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