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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user