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)
|
Text("Deny").frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.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 {
|
Button {
|
||||||
store.respond(approval, .allow())
|
store.respond(approval, .allow())
|
||||||
@@ -78,6 +81,7 @@ struct ApprovalCardView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(!allowEnabled)
|
.disabled(!allowEnabled)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A destructive action (rm, force-push, reset --hard, …) offers no remembered
|
// 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)
|
Image(systemName: "arrow.up.circle.fill").font(.title)
|
||||||
}
|
}
|
||||||
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
}
|
}
|
||||||
if !store.canControl {
|
if !store.canControl {
|
||||||
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ struct SessionDetailView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
}
|
}
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
.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())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.hoverEffect(.highlight)
|
||||||
}
|
}
|
||||||
if diff.truncated { TruncatedPatchNote().padding(12) }
|
if diff.truncated { TruncatedPatchNote().padding(12) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
import NucleicTailnet
|
import NucleicTailnet
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
|
@State private var showManualPair = false
|
||||||
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
||||||
@FocusState private var tailscaleKeyFocused: Bool
|
@FocusState private var tailscaleKeyFocused: Bool
|
||||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||||
@@ -133,6 +135,11 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
|
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
showManualPair = true
|
||||||
|
} label: {
|
||||||
|
Label("Enter code manually", systemImage: "keyboard")
|
||||||
|
}
|
||||||
if store.isPaired {
|
if store.isPaired {
|
||||||
Button(role: .destructive) { store.unpair() } label: {
|
Button(role: .destructive) { store.unpair() } label: {
|
||||||
Label("Unpair this device", systemImage: "minus.circle")
|
Label("Unpair this device", systemImage: "minus.circle")
|
||||||
@@ -149,6 +156,12 @@ struct SettingsView: View {
|
|||||||
store.pair(with: payload)
|
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 {
|
struct PairingIntroView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
|
@State private var showManualPair = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
|
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
|
||||||
Text("Pair with your Mac").font(.title2.weight(.semibold))
|
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)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
@@ -173,6 +187,15 @@ struct PairingIntroView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.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
|
// 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."
|
// without this a failed or in-flight pair looks like "nothing happened."
|
||||||
pairingStatus
|
pairingStatus
|
||||||
@@ -201,6 +224,12 @@ struct PairingIntroView: View {
|
|||||||
store.pair(with: payload)
|
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
|
/// 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