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 <[email protected]>
This commit is contained in:
2026-07-04 00:48:56 -07:00
co-authored by Claude Opus 4.8
parent 15ea0f1dd2
commit f8fdf98517
4 changed files with 108 additions and 1 deletions
@@ -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
@@ -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)
@@ -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) }
}
@@ -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()
}
}