From f8fdf9851724d5435c0971e7fd2535e6951fd572 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:48:56 -0700 Subject: [PATCH] iPad: hardware keyboard, pointer, and a camera-free pairing path (Phase 3) Make the remote feel native with a Magic Keyboard / trackpad, and give iPad a pairing path that survives a blocked camera. Keyboard shortcuts (mirroring the Mac's .commands): - Cmd+Return sends in the session follow-up composer and the start-chat composer (plain Return stays a newline in the multiline fields). - On the approval card, Return allows and Esc denies -- the approval bar replaces the composer, so Return is unclaimed there. Allow stays gated on the biometric. Pointer: - .hoverEffect on the diff file-list rows (native List rows already hover). Camera-free pairing (ManualPairingView): - "Enter code manually" in both the pairing intro and Settings opens a sheet to paste the Mac's nucleic://pair?d=... code, parsed with the same PairingPayload(qrString:) the scanner uses (+ a Paste-from-clipboard button). The iPad scanner can't run while mirrored to an external display or in some Stage Manager states; this always works. Note: the Mac's RemoteAccessView currently shows only the QR, so a follow-up host change is needed to surface a copyable pairing link for this to be end-to-end. Builds clean. Co-Authored-By: Claude Opus 4.8 --- .../Views/ApprovalCardView.swift | 4 + .../NucleicRemote/Views/Composer.swift | 1 + .../Views/SessionDetailView.swift | 4 + .../NucleicRemote/Views/SettingsView.swift | 100 +++++++++++++++++- 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index 8bdbad9..0926b72 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -70,6 +70,9 @@ struct ApprovalCardView: View { Text("Deny").frame(maxWidth: .infinity) } .buttonStyle(.bordered) + // Hardware-keyboard shortcuts for the defining interaction: Esc denies, Return + // allows (the approval bar replaces the composer, so Return is unclaimed here). + .keyboardShortcut(.cancelAction) Button { store.respond(approval, .allow()) @@ -78,6 +81,7 @@ struct ApprovalCardView: View { } .buttonStyle(.borderedProminent) .disabled(!allowEnabled) + .keyboardShortcut(.defaultAction) } // A destructive action (rm, force-push, reset --hard, …) offers no remembered diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 8d8d5f0..89b2157 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -92,6 +92,7 @@ struct StartChatComposer: View { Image(systemName: "arrow.up.circle.fill").font(.title) } .disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + .keyboardShortcut(.return, modifiers: .command) } if !store.canControl { Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 5cad92b..9ccf742 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -223,6 +223,9 @@ struct SessionDetailView: View { .font(.title2) } .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive) + // Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac — + // plain Return stays newline in the multiline field. + .keyboardShortcut(.return, modifiers: .command) } } } @@ -408,6 +411,7 @@ private struct SplitDiffView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .hoverEffect(.highlight) } if diff.truncated { TruncatedPatchNote().padding(12) } } diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 8c563ad..cfb2fbd 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -1,10 +1,12 @@ import SwiftUI import NucleicProtocol import NucleicTailnet +import UIKit struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false + @State private var showManualPair = false @State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? "" @FocusState private var tailscaleKeyFocused: Bool @AppStorage("nucleic.showRawEvents") private var showRaw = false @@ -133,6 +135,11 @@ struct SettingsView: View { } label: { Label("Pair with a Mac", systemImage: "qrcode.viewfinder") } + Button { + showManualPair = true + } label: { + Label("Enter code manually", systemImage: "keyboard") + } if store.isPaired { Button(role: .destructive) { store.unpair() } label: { Label("Unpair this device", systemImage: "minus.circle") @@ -149,6 +156,12 @@ struct SettingsView: View { store.pair(with: payload) } } + .sheet(isPresented: $showManualPair) { + ManualPairingView { payload in + showManualPair = false + store.pair(with: payload) + } + } } } } @@ -157,12 +170,13 @@ struct SettingsView: View { struct PairingIntroView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false + @State private var showManualPair = false var body: some View { VStack(spacing: 24) { Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint) Text("Pair with your Mac").font(.title2.weight(.semibold)) - Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.") + Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the pairing code beneath it manually.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) @@ -173,6 +187,15 @@ struct PairingIntroView: View { } .buttonStyle(.borderedProminent) + // A camera-free path — the iPad's scanner can't run while the app is mirrored to an + // external display or in some Stage Manager states; pasting the code always works. + Button { + showManualPair = true + } label: { + Label("Enter code manually", systemImage: "keyboard") + } + .buttonStyle(.bordered) + // Feedback after a scan: the whole UI is still the intro until pairing completes, so // without this a failed or in-flight pair looks like "nothing happened." pairingStatus @@ -201,6 +224,12 @@ struct PairingIntroView: View { store.pair(with: payload) } } + .sheet(isPresented: $showManualPair) { + ManualPairingView { payload in + showManualPair = false + store.pair(with: payload) + } + } } /// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting @@ -239,3 +268,72 @@ struct PairingIntroView: View { } } } + +/// A camera-free pairing path (UX_IOS §7): paste the Mac's `nucleic://pair?d=…` code instead of +/// scanning its QR. The iPad's camera can be blocked while mirrored to an external display or in +/// some Stage Manager states, where the scanner's `couldNotStart` path leaves you stuck — this +/// always works. Parses with the same `PairingPayload(qrString:)` the scanner uses. +struct ManualPairingView: View { + let onEntered: (PairingPayload) -> Void + @Environment(\.dismiss) private var dismiss + @State private var code = "" + @State private var error: String? + @FocusState private var fieldFocused: Bool + + var body: some View { + NavigationStack { + Form { + Section { + TextField("nucleic://pair?d=…", text: $code, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .font(.callout.monospaced()) + .lineLimit(2...6) + .focused($fieldFocused) + .onChange(of: code) { error = nil } + } header: { + Text("Pairing code") + } footer: { + Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the pairing link shown beneath the QR code, then paste it here.") + } + if let error { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.callout).foregroundStyle(.red) + } + Button { + submit() + } label: { + Label("Pair", systemImage: "link").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .keyboardShortcut(.defaultAction) + } + .navigationTitle("Enter pairing code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button { + if let pasted = UIPasteboard.general.string { code = pasted } + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + .disabled(!UIPasteboard.general.hasStrings) + } + } + .onAppear { fieldFocused = true } + } + } + + private func submit() { + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard let payload = try? PairingPayload(qrString: trimmed) else { + error = "That isn't a valid Nucleic pairing code. Copy the link beneath the QR on your Mac." + return + } + onEntered(payload) + dismiss() + } +}