From 7b2fc1deb231e30bafde0ebfc8ea4093d0c5be18 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 30 Jul 2026 00:02:47 -0300 Subject: [PATCH] feat: pair a phone by scanning one code instead of copying two Setting up the phone meant copying two long opaque strings from Mac Settings into two fields on the phone, with no QR code and a five minute window that gave no hint it was running out. That was the biggest thing standing between a new tester and a working companion. Now the Mac shows a QR code and one combined link, with a live countdown, and the phone can scan it with the camera or take a single paste. The old two-field path is still there behind a disclosure, so nothing breaks if the camera is unavailable or a tester prefers pasting. Also adds a README with the setup steps, including the two easy things to get wrong: the Mac toggle is off by default, and both devices have to be on the same iCloud account for background notifications. The combined code carries a version marker that both sides check, so a newer Mac cannot quietly hand an older phone a format it would misread. The phone ships through TestFlight and will lag the Mac app, so that skew is the normal case rather than an edge one. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 78 +++++++- .../MobileBridge/MobileBridgeListener.swift | 23 ++- .../MobileBridgePairingCode.swift | 69 +++++++ Sources/SettingsView.swift | 148 +++++++++++--- .../ProgramaSpike/AppStore.swift | 25 ++- .../ProgramaSpike/PairConnectView.swift | 65 ++++++- .../ProgramaSpike/PairingCode.swift | 39 ++++ .../ProgramaSpike/QRScannerView.swift | 183 ++++++++++++++++++ ios/ProgramaSpike/README.md | 53 +++++ ios/ProgramaSpike/project.yml | 3 + 11 files changed, 647 insertions(+), 43 deletions(-) create mode 100644 Sources/MobileBridge/MobileBridgePairingCode.swift create mode 100644 ios/ProgramaSpike/ProgramaSpike/PairingCode.swift create mode 100644 ios/ProgramaSpike/ProgramaSpike/QRScannerView.swift create mode 100644 ios/ProgramaSpike/README.md diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 3cfde3f9..022cb356 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -232,6 +232,7 @@ MOBB0005 /* MobileBridgeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0006 /* MobileBridgeListener.swift */; }; MOBB0007 /* MobileBridgeSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0008 /* MobileBridgeSession.swift */; }; MOBB0009 /* MobileBridgePush.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0010 /* MobileBridgePush.swift */; }; + MOBB0011 /* MobileBridgePairingCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0012 /* MobileBridgePairingCode.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; MOBB1003 /* IrohLib in Frameworks */ = {isa = PBXBuildFile; productRef = MOBB1002 /* IrohLib */; }; @@ -606,6 +607,7 @@ MOBB0006 /* MobileBridgeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeListener.swift; sourceTree = ""; }; MOBB0008 /* MobileBridgeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeSession.swift; sourceTree = ""; }; MOBB0010 /* MobileBridgePush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePush.swift; sourceTree = ""; }; + MOBB0012 /* MobileBridgePairingCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePairingCode.swift; sourceTree = ""; }; A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; @@ -1018,6 +1020,7 @@ MOBB0006 /* MobileBridgeListener.swift */, MOBB0008 /* MobileBridgeSession.swift */, MOBB0010 /* MobileBridgePush.swift */, + MOBB0012 /* MobileBridgePairingCode.swift */, RVPN00000000000000000001 /* ReviewComment.swift */, RVPN00000000000000000003 /* ReviewCommentSerializer.swift */, RVPN00000000000000000005 /* ReviewDiffParser.swift */, @@ -1504,6 +1507,7 @@ MOBB0005 /* MobileBridgeListener.swift in Sources */, MOBB0007 /* MobileBridgeSession.swift in Sources */, MOBB0009 /* MobileBridgePush.swift in Sources */, + MOBB0011 /* MobileBridgePairingCode.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1a28122a..62a9848f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -10207,6 +10207,23 @@ } } }, + "settings.phone.pair.copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー" + } + } + } + }, "settings.phone.pair.error": { "extractionState": "manual", "localizations": { @@ -10224,19 +10241,70 @@ } } }, - "settings.phone.pair.expiry": { + "settings.phone.pair.expired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expired. Start a new pairing." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効期限が切れました。新しいペア設定を開始してください。" + } + } + } + }, + "settings.phone.pair.expiresIn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Single use. Expires in %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1回限り。%@ で失効します。" + } + } + } + }, + "settings.phone.pair.manualFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't scan? Paste the payload and token manually" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンできない場合は、ペイロードとトークンを手動で貼り付け" + } + } + } + }, + "settings.phone.pair.scanLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Single use. Expires in 5 minutes." + "value": "Scan this with the Programa iOS app's “Scan QR Code” button." } }, "ja": { "stringUnit": { "state": "translated", - "value": "1回限り。5分で失効します。" + "value": "Programa iOSアプリの「QRコードをスキャン」ボタンでこれをスキャンしてください。" } } } @@ -10247,13 +10315,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Opens a single-use, 5-minute pairing window. Enter the payload and token in the Programa iOS app." + "value": "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or enter the payload and token manually." } }, "ja": { "stringUnit": { "state": "translated", - "value": "5分間有効な、1回限りのペア設定ウィンドウを開きます。Programa iOSアプリにペイロードとトークンを入力してください。" + "value": "5分間有効な、1回限りのペア設定ウィンドウを開きます。Programa iOSアプリでQRコードをスキャンするか、ペイロードとトークンを手動で入力してください。" } } } diff --git a/Sources/MobileBridge/MobileBridgeListener.swift b/Sources/MobileBridge/MobileBridgeListener.swift index 99d6880d..6220ebc1 100644 --- a/Sources/MobileBridge/MobileBridgeListener.swift +++ b/Sources/MobileBridge/MobileBridgeListener.swift @@ -9,6 +9,24 @@ private let mobileBridgeALPN = Data("programa/mobile-bridge/1".utf8) private let mobileBridgePairingWindowDuration: Duration = .seconds(300) +private extension Duration { + /// `Duration` has no direct `TimeInterval` conversion; used to derive a + /// wall-clock `Date` expiry from `mobileBridgePairingWindowDuration` for + /// display in Settings, without hardcoding the 300s figure a second time. + var timeIntervalValue: TimeInterval { + let parts = components + return TimeInterval(parts.seconds) + TimeInterval(parts.attoseconds) / 1e18 + } +} + +/// Everything Settings needs to show a pairing invitation: the two payloads +/// to transfer plus the wall-clock deadline for the live countdown. +struct MobileBridgePairingInfo: Sendable { + let ticket: String + let token: String + let expiresAt: Date +} + /// Owns the in-process iroh endpoint that lets a paired iPhone reach this /// Mac's terminal control dispatch without the user ever running the /// `tools/mobile-spike bridge` CLI in a terminal. Ported from @@ -84,7 +102,7 @@ final class MobileBridgeListener: @unchecked Sendable { /// the endpoint isn't bound yet (mode just enabled, still connecting to /// relays) -- the caller should show a brief error and let the user /// retry. - func beginPairing() async -> (ticket: String, token: String)? { + func beginPairing() async -> MobileBridgePairingInfo? { stateLock.lock() let ep = endpoint stateLock.unlock() @@ -93,13 +111,14 @@ final class MobileBridgeListener: @unchecked Sendable { let tokenBytes = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) }) let tokenString = MobileBridgeBase64URL.encode(tokenBytes) let window = MobileBridgePairingWindow(token: Data(tokenString.utf8), duration: mobileBridgePairingWindowDuration) + let expiresAt = Date().addingTimeInterval(mobileBridgePairingWindowDuration.timeIntervalValue) stateLock.lock() pairingWindow = window stateLock.unlock() guard let ticket = try? EndpointTicket.fromAddr(addr: ep.addr()) else { return nil } - return (ticket: ticket.description, token: tokenString) + return MobileBridgePairingInfo(ticket: ticket.description, token: tokenString, expiresAt: expiresAt) } /// Revokes a previously paired device immediately. diff --git a/Sources/MobileBridge/MobileBridgePairingCode.swift b/Sources/MobileBridge/MobileBridgePairingCode.swift new file mode 100644 index 00000000..c75dc9aa --- /dev/null +++ b/Sources/MobileBridge/MobileBridgePairingCode.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Combines the mobile-bridge pairing ticket + token into a single URL so a +/// tester can transfer both by scanning one QR code (or pasting one string) +/// instead of hand-copying two long opaque strings between devices. +/// +/// `programa-pair` (not the bare `programa`) was chosen as the scheme after +/// confirming no `CFBundleURLTypes`/URL scheme is registered anywhere in this +/// repo today (`Resources/Info.plist`, `Sources/`, `ios/ProgramaSpike/project.yml`) -- +/// either name would have been free, but the more specific scheme makes the +/// pairing URL self-describing and leaves `programa://` open for some other +/// future purpose without a collision. +/// +/// Kept in sync by hand with +/// `ios/ProgramaSpike/ProgramaSpike/PairingCode.swift` -- the two app +/// targets share no module, so there is no compiler-enforced tie between +/// them. Any format change here must be mirrored there. +enum MobileBridgePairingCode { + static let scheme = "programa-pair" + private static let host = "pair" + + /// The only format version this build understands. Bumping it is a breaking + /// change for every already-installed phone: the companion ships through + /// TestFlight and lags the Mac app, so a Mac that emits `v=2` while a phone + /// still understands `v=1` must be rejected loudly rather than mis-parsed. + /// Ship phone-side support for a new version BEFORE the Mac starts emitting it. + static let currentVersion = "1" + + /// Builds the combined `programa-pair://pair?v=1&t=&k=` + /// URL. `URLComponents` percent-encodes both query values, so neither + /// the iroh ticket nor the base64url token needs manual escaping. + static func makeURL(ticket: String, token: String) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = host + components.queryItems = [ + URLQueryItem(name: "v", value: currentVersion), + URLQueryItem(name: "t", value: ticket), + URLQueryItem(name: "k", value: token), + ] + return components.url + } + + struct Parsed { + let ticket: String + let token: String + } + + /// Parses a combined pairing code back into its ticket/token. Returns + /// `nil` for anything that isn't a well-formed `programa-pair://` URL + /// with both `t` and `k` present -- callers should fall back to treating + /// the input as a bare ticket in that case. + static func parse(_ string: String) -> Parsed? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard let components = URLComponents(string: trimmed), + components.scheme?.lowercased() == scheme + else { return nil } + guard let items = components.queryItems else { return nil } + // Reject an unrecognised version rather than reading `t`/`k` out of a + // format we do not actually understand. A missing `v` is also rejected: + // every code this app has ever emitted carries one. + guard items.first(where: { $0.name == "v" })?.value == currentVersion else { return nil } + guard + let ticket = items.first(where: { $0.name == "t" })?.value, !ticket.isEmpty, + let token = items.first(where: { $0.name == "k" })?.value, !token.isEmpty + else { return nil } + return Parsed(ticket: ticket, token: token) + } +} diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 84e2700a..154876ed 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -3,6 +3,8 @@ import SwiftUI import Darwin import Bonsplit import UniformTypeIdentifiers +import CoreImage +import CoreImage.CIFilterBuiltins struct SettingsView: View { @@ -95,6 +97,7 @@ struct SettingsView: View { @State private var mobileBridgePairedDevices: [MobileBridgeTrustedDevice] = [] @State private var mobileBridgePairingTicket: String? @State private var mobileBridgePairingToken: String? + @State private var mobileBridgePairingExpiresAt: Date? @State private var mobileBridgePairingErrorMessage: String? @State private var isPairingMobileBridgeDevice = false @@ -1379,9 +1382,11 @@ struct SettingsView: View { if let pairing = await MobileBridgeListener.shared.beginPairing() { mobileBridgePairingTicket = pairing.ticket mobileBridgePairingToken = pairing.token + mobileBridgePairingExpiresAt = pairing.expiresAt } else { mobileBridgePairingTicket = nil mobileBridgePairingToken = nil + mobileBridgePairingExpiresAt = nil mobileBridgePairingErrorMessage = String( localized: "settings.phone.pair.error", defaultValue: "Could not start pairing. The phone companion may still be connecting — try again in a moment." @@ -1407,6 +1412,44 @@ struct SettingsView: View { return String(localized: "settings.phone.devices.pairedOn", defaultValue: "Paired \(formattedDate)") } + /// Renders `string` as a fixed-size QR code using CoreImage's built-in + /// generator (no third-party dependency). Regenerated on demand rather + /// than cached in `@State` -- this only runs while the pairing card is + /// visible in Settings, not on any hot path. + private func mobileBridgeQRImage(for string: String) -> NSImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let outputImage = filter.outputImage else { return nil } + + let targetSize: CGFloat = 512 + let scale = targetSize / outputImage.extent.width + let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + + let rep = NSCIImageRep(ciImage: scaled) + let image = NSImage(size: rep.size) + image.addRepresentation(rep) + return image + } + + private func mobileBridgePairingRemainingSeconds(at date: Date) -> Int? { + guard let expiresAt = mobileBridgePairingExpiresAt else { return nil } + return max(0, Int(expiresAt.timeIntervalSince(date).rounded(.up))) + } + + private func mobileBridgePairingIsExpired(at date: Date) -> Bool { + (mobileBridgePairingRemainingSeconds(at: date) ?? 0) <= 0 + } + + private func mobileBridgePairingCountdownLabel(at date: Date) -> String { + guard let remaining = mobileBridgePairingRemainingSeconds(at: date) else { return "" } + if remaining <= 0 { + return String(localized: "settings.phone.pair.expired", defaultValue: "Expired. Start a new pairing.") + } + let timeString = String(format: "%d:%02d", remaining / 60, remaining % 60) + return String(localized: "settings.phone.pair.expiresIn", defaultValue: "Single use. Expires in \(timeString).") + } + @ViewBuilder private var phoneSection: some View { SettingsSectionHeader(title: String(localized: "settings.section.phone", defaultValue: "Phone")) @@ -1430,7 +1473,7 @@ struct SettingsView: View { SettingsCardRow( String(localized: "settings.phone.pair.title", defaultValue: "Pair a Device"), - subtitle: String(localized: "settings.phone.pair.subtitle", defaultValue: "Opens a single-use, 5-minute pairing window. Enter the payload and token in the Programa iOS app.") + subtitle: String(localized: "settings.phone.pair.subtitle", defaultValue: "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or enter the payload and token manually.") ) { Button(String(localized: "settings.phone.pair.button", defaultValue: "Pair a Device…")) { beginMobileBridgePairing() @@ -1441,36 +1484,81 @@ struct SettingsView: View { .accessibilityIdentifier("MobileBridgePairButton") } - if let ticket = mobileBridgePairingTicket, let token = mobileBridgePairingToken { + if let ticket = mobileBridgePairingTicket, let token = mobileBridgePairingToken, + let pairingURL = MobileBridgePairingCode.makeURL(ticket: ticket, token: token) { SettingsCardDivider() - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.phone.pair.ticketLabel", defaultValue: "Pairing Payload")) - .font(.system(size: 12, weight: .semibold)) - Text(ticket) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - - Text(String(localized: "settings.phone.pair.tokenLabel", defaultValue: "Pairing Token")) - .font(.system(size: 12, weight: .semibold)) - Text(token) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - - Text(String(localized: "settings.phone.pair.expiry", defaultValue: "Single use. Expires in 5 minutes.")) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 14) { + if let qrImage = mobileBridgeQRImage(for: pairingURL.absoluteString) { + Image(nsImage: qrImage) + .interpolation(.none) + .resizable() + .frame(width: 168, height: 168) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .accessibilityIdentifier("MobileBridgePairingQRCode") + } + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.phone.pair.scanLabel", defaultValue: "Scan this with the Programa iOS app's \u{201c}Scan QR Code\u{201d} button.")) + .font(.system(size: 12, weight: .semibold)) + + Text(pairingURL.absoluteString) + .font(.system(size: 10, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + + HStack(spacing: 10) { + Button(String(localized: "settings.phone.pair.copy", defaultValue: "Copy")) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(pairingURL.absoluteString, forType: .string) + } + .buttonStyle(.bordered) + .controlSize(.small) + + TimelineView(.periodic(from: mobileBridgePairingExpiresAt ?? Date(), by: 1)) { context in + Text(mobileBridgePairingCountdownLabel(at: context.date)) + .font(.caption) + .foregroundStyle(mobileBridgePairingIsExpired(at: context.date) ? .red : .secondary) + } + } + } + } + + DisclosureGroup(String(localized: "settings.phone.pair.manualFallback", defaultValue: "Can't scan? Paste the payload and token manually")) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.phone.pair.ticketLabel", defaultValue: "Pairing Payload")) + .font(.system(size: 12, weight: .semibold)) + Text(ticket) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + + Text(String(localized: "settings.phone.pair.tokenLabel", defaultValue: "Pairing Token")) + .font(.system(size: 12, weight: .semibold)) + Text(token) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + .padding(.top, 6) + } } .padding(.horizontal, 14) .padding(.vertical, 10) diff --git a/ios/ProgramaSpike/ProgramaSpike/AppStore.swift b/ios/ProgramaSpike/ProgramaSpike/AppStore.swift index 2df72e1c..ed7a90d8 100644 --- a/ios/ProgramaSpike/ProgramaSpike/AppStore.swift +++ b/ios/ProgramaSpike/ProgramaSpike/AppStore.swift @@ -147,15 +147,36 @@ final class AppStore { // MARK: - User actions func connectManually() async { - let ticket = pairingTicketDraft.trimmingCharacters(in: .whitespacesAndNewlines) + var ticket = pairingTicketDraft.trimmingCharacters(in: .whitespacesAndNewlines) + var token = pairingTokenDraft.trimmingCharacters(in: .whitespacesAndNewlines) + // A combined `programa-pair://` code pasted straight into the + // legacy ticket field is handled here rather than sent to iroh as a + // malformed ticket, which would otherwise fail with an opaque error. + if let parsed = PairingCode.parse(ticket) { + ticket = parsed.ticket + if token.isEmpty { token = parsed.token } + } guard !ticket.isEmpty else { return } - let token = pairingTokenDraft.trimmingCharacters(in: .whitespacesAndNewlines) PairingStore.saveTicket(ticket) currentTicket = ticket + pairingTicketDraft = ticket pairingTokenDraft = "" await attemptConnect(ticket: ticket, token: token.isEmpty ? nil : token) } + /// Fills the legacy ticket/token fields from a combined `programa-pair://` + /// code -- scanned via `QRScannerView` or pasted into the "Pairing code" + /// field. Returns `false` (leaving the legacy fields untouched) if `raw` + /// isn't a recognised combined code, so the caller can show a clear + /// error instead of silently doing nothing. + @discardableResult + func applyPairingCode(_ raw: String) -> Bool { + guard let parsed = PairingCode.parse(raw) else { return false } + pairingTicketDraft = parsed.ticket + pairingTokenDraft = parsed.token + return true + } + func manualResync() async { do { try await resyncAll() diff --git a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift index 816aa92c..f39cd1d9 100644 --- a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift +++ b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift @@ -1,13 +1,48 @@ import SwiftUI -/// Screen 1: paste the ticket, paste the token (first time only), connect. +/// Screen 1: scan or paste the pairing code shown on Programa's Mac +/// Settings ▸ Phone screen, then connect. The legacy separate ticket/token +/// fields stay as a fallback for testers who can't scan or whose combined +/// code paste didn't parse. struct PairConnectView: View { @Bindable var store: AppStore + @State private var showScanner = false + @State private var pairingCodeDraft = "" + @State private var pairingCodeError: String? + var body: some View { NavigationStack { Form { - Section("Pairing ticket") { + Section("Pairing code") { + Button { + showScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + } + + TextField( + "Or paste the code from Programa ▸ Settings ▸ Phone", + text: $pairingCodeDraft, + axis: .vertical + ) + .lineLimit(1 ... 4) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button("Use This Code") { + applyPairingCodeDraft() + } + .disabled(pairingCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if let pairingCodeError { + Text(pairingCodeError) + .font(.footnote) + .foregroundStyle(.red) + } + } + + Section("Advanced: paste ticket and token separately") { TextField( "Paste the ticket from Programa's pairing screen", text: $store.pairingTicketDraft, @@ -16,9 +51,7 @@ struct PairConnectView: View { .lineLimit(3 ... 8) .textInputAutocapitalization(.never) .autocorrectionDisabled() - } - Section("Pairing token") { TextField("Only needed the first time", text: $store.pairingTokenDraft) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -68,7 +101,31 @@ struct PairConnectView: View { } } .navigationTitle("Connect to Programa") + .sheet(isPresented: $showScanner) { + QRScannerView { code in + handleScannedCode(code) + } + } + } + } + + private func applyPairingCodeDraft() { + let trimmed = pairingCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard store.applyPairingCode(trimmed) else { + pairingCodeError = "That doesn't look like a Programa pairing code. Scan the QR code, or paste the ticket and token below instead." + return + } + pairingCodeError = nil + Task { await store.connectManually() } + } + + private func handleScannedCode(_ code: String) { + guard store.applyPairingCode(code) else { + pairingCodeError = "That QR code wasn't a valid Programa pairing code." + return } + pairingCodeError = nil + Task { await store.connectManually() } } } diff --git a/ios/ProgramaSpike/ProgramaSpike/PairingCode.swift b/ios/ProgramaSpike/ProgramaSpike/PairingCode.swift new file mode 100644 index 00000000..023262f7 --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/PairingCode.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Mirrors `Sources/MobileBridge/MobileBridgePairingCode.swift` on the macOS +/// app. Kept in sync by hand -- this iOS target and the macOS app share no +/// module, so there is no compiler-enforced tie between the two copies. Any +/// format change here must be mirrored there. +enum PairingCode { + static let scheme = "programa-pair" + + /// Must match `MobileBridgePairingCode.currentVersion` on the Mac. This app + /// ships through TestFlight and so lags the Mac app: if a newer Mac starts + /// emitting a version this build does not know, rejecting is correct — the + /// alternative is reading `t`/`k` out of a format that may have moved. + static let currentVersion = "1" + + struct Parsed { + let ticket: String + let token: String + } + + /// Parses a combined `programa-pair://pair?v=1&t=&k=` + /// code (scanned or pasted) back into its ticket/token. Returns `nil` + /// for anything that isn't a well-formed code with both `t` and `k` + /// present -- callers should fall back to treating the input as a bare + /// ticket in that case. + static func parse(_ string: String) -> Parsed? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard let components = URLComponents(string: trimmed), + components.scheme?.lowercased() == scheme + else { return nil } + guard let items = components.queryItems else { return nil } + guard items.first(where: { $0.name == "v" })?.value == currentVersion else { return nil } + guard + let ticket = items.first(where: { $0.name == "t" })?.value, !ticket.isEmpty, + let token = items.first(where: { $0.name == "k" })?.value, !token.isEmpty + else { return nil } + return Parsed(ticket: ticket, token: token) + } +} diff --git a/ios/ProgramaSpike/ProgramaSpike/QRScannerView.swift b/ios/ProgramaSpike/ProgramaSpike/QRScannerView.swift new file mode 100644 index 00000000..2254b4ec --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/QRScannerView.swift @@ -0,0 +1,183 @@ +import AVFoundation +import SwiftUI + +/// A sheet that scans a QR code with `AVCaptureMetadataOutput` and calls +/// `onCode` with the decoded string on the first successful scan. Uses +/// `AVCaptureMetadataOutput` rather than `DataScannerViewController` -- +/// simpler API, needs only the one `NSCameraUsageDescription` key, and +/// avoids `DataScannerViewController`'s own separate availability checks. +/// +/// Degrades gracefully rather than crashing when no camera is available +/// (the simulator, or a device whose camera failed to initialize), and +/// surfaces a clear message rather than a silent dead end when the user has +/// denied camera permission. +struct QRScannerView: View { + let onCode: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + @State private var cameraUnavailable = false + + var body: some View { + NavigationStack { + content + .navigationTitle("Scan QR Code") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + @ViewBuilder + private var content: some View { + switch authorizationStatus { + case .authorized: + if cameraUnavailable { + unavailableView( + message: "No camera is available on this device. Paste the pairing code instead." + ) + } else { + QRCaptureRepresentable( + onCode: { code in + onCode(code) + dismiss() + }, + onUnavailable: { cameraUnavailable = true } + ) + .ignoresSafeArea() + } + case .notDetermined: + ProgressView() + .task { await requestAccess() } + case .denied, .restricted: + unavailableView( + message: "Camera access is off for Programa. Enable it in Settings ▸ Programa to scan the pairing code, or paste it instead." + ) + @unknown default: + unavailableView(message: "Camera unavailable. Paste the pairing code instead.") + } + } + + private func requestAccess() async { + let granted = await AVCaptureDevice.requestAccess(for: .video) + authorizationStatus = granted ? .authorized : .denied + } + + @ViewBuilder + private func unavailableView(message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "camera.fill") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text(message) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 24) + } + } +} + +/// Wraps a bare `AVCaptureSession` in a `UIViewControllerRepresentable`. +private struct QRCaptureRepresentable: UIViewControllerRepresentable { + let onCode: (String) -> Void + let onUnavailable: () -> Void + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + controller.onCode = onCode + controller.onUnavailable = onUnavailable + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} +} + +// `@preconcurrency` on the delegate conformance: the callback is delivered +// on `.main` (set explicitly below via `setMetadataObjectsDelegate(_:queue:)`), +// so it is genuinely main-actor-safe even though the protocol itself +// predates Swift concurrency and isn't annotated as such. +private final class QRScannerViewController: UIViewController, @preconcurrency AVCaptureMetadataOutputObjectsDelegate { + var onCode: ((String) -> Void)? + var onUnavailable: (() -> Void)? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var didEmit = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + configureSession() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard previewLayer != nil, !session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { [session] in + session.startRunning() + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + guard session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { [session] in + session.stopRunning() + } + } + + /// No camera hardware (the simulator) or a camera that fails to open + /// both land here -- `onUnavailable()` is the one path back to a + /// non-crashing UI state either way. + private func configureSession() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) + else { + onUnavailable?() + return + } + session.addInput(input) + + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { + onUnavailable?() + return + } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.addSublayer(layer) + previewLayer = layer + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !didEmit else { return } + guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + object.type == .qr, + let value = object.stringValue + else { return } + didEmit = true + session.stopRunning() + onCode?(value) + } +} + +#Preview { + QRScannerView(onCode: { _ in }) +} diff --git a/ios/ProgramaSpike/README.md b/ios/ProgramaSpike/README.md new file mode 100644 index 00000000..93c70ec9 --- /dev/null +++ b/ios/ProgramaSpike/README.md @@ -0,0 +1,53 @@ +# Programa iOS companion — tester setup + +This is the iPhone companion app for Programa. It connects directly to a Mac +running Programa over a private peer-to-peer link (via iroh) — pairing works +over the internet through iroh's relay network, not just when both devices +are on the same Wi-Fi. + +## Setup + +1. On the Mac, open Programa and go to **Settings ▸ Phone**. +2. Set **Mobile Companion** from `Off` to `Paired Devices Only`. It is `Off` + by default, so no phone can connect until you do this. +3. Click **Pair a Device…**. This opens a single-use, 5-minute pairing + window and shows a QR code plus a live countdown. +4. On the iPhone, open the Programa app and tap **Scan QR Code** on the + pairing screen. +5. Point the camera at the QR code on the Mac. The app fills in the pairing + details automatically and connects. + +If scanning isn't possible (no camera access, a Simulator build, or the scan +doesn't work), you have two fallbacks on the same screen: + +- Paste the full pairing code text from under the Mac's QR code into the + **Pairing code** field and tap **Use This Code**. +- Expand **Can't scan? Paste the payload and token manually** on the Mac and + copy the two values into the **Advanced** section of the iOS pairing + screen separately. + +Once a device pairs successfully it's remembered — the pairing window is +single-use, but reconnecting later doesn't require re-pairing or a new code. + +## iCloud requirement for notifications + +Both the Mac and the iPhone must be **signed into the same iCloud account** +for background notifications (Live Activities, "an agent needs you" alerts) +to arrive. The app has no way to detect a mismatched Apple ID between the two +devices — if notifications never show up, check the Apple ID on both devices +first. + +## Building locally + +```bash +cd ios/ProgramaSpike +xcodegen generate +cd ../.. +xcodebuild -project ios/ProgramaSpike/ProgramaSpike.xcodeproj \ + -scheme ProgramaSpike -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 16' build +``` + +Camera-based scanning has no effect in the Simulator (no camera hardware) — +the **Scan QR Code** button degrades gracefully and shows a message instead +of crashing; use the paste fallback when testing there. diff --git a/ios/ProgramaSpike/project.yml b/ios/ProgramaSpike/project.yml index b3f8bf02..627d304e 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -85,6 +85,9 @@ targets: # so the connection falls back to a relay even on the same Wi-Fi. # Measured: phone reported `relay` until this key was added. NSLocalNetworkUsageDescription: "Programa connects directly to your Mac on the same network instead of routing through a relay." + # Used only by the pairing screen's "Scan QR Code" button, to read the + # pairing code shown in Programa's Mac Settings ▸ Phone screen. + NSCameraUsageDescription: "Programa uses the camera to scan the pairing QR code shown in Programa's Mac Settings ▸ Phone screen." # Required for ActivityKit -- without this the app can request a Live # Activity but the system silently refuses to start it. NSSupportsLiveActivities: true