Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -606,6 +607,7 @@
MOBB0006 /* MobileBridgeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeListener.swift; sourceTree = "<group>"; };
MOBB0008 /* MobileBridgeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeSession.swift; sourceTree = "<group>"; };
MOBB0010 /* MobileBridgePush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePush.swift; sourceTree = "<group>"; };
MOBB0012 /* MobileBridgePairingCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePairingCode.swift; sourceTree = "<group>"; };
A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = "<group>"; };
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
};
Expand Down
78 changes: 73 additions & 5 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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コードをスキャン」ボタンでこれをスキャンしてください。"
}
}
}
Expand All @@ -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コードをスキャンするか、ペイロードとトークンを手動で入力してください。"
}
}
}
Expand Down
23 changes: 21 additions & 2 deletions Sources/MobileBridge/MobileBridgeListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions Sources/MobileBridge/MobileBridgePairingCode.swift
Original file line number Diff line number Diff line change
@@ -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=<ticket>&k=<token>`
/// 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)
}
}
Loading
Loading