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
1 change: 1 addition & 0 deletions Packages/macOS/CmuxNext/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,7 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [
"CmuxNextImportSuggestions",
"CmuxNextLayout",
"CmuxNextMacScreen",
"CmuxNextMobile",
"CmuxNextNowPlaying",
"CmuxNextPalette",
"CmuxNextProcessEnvironment",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ public struct MobileHostLaunch: Sendable, Equatable {
line(["type": "acpmux", "url": url.absoluteString, "token": token])
}

/// The control line that hands hostd each paired phone's own acpmux credential, by phone
/// device id (`phone_credentials`): that phone's acpmux channel presents it, so its
/// Full-access setting can cover it. Carries secrets: stdin only, never logged.
public static func phoneCredentialsLine(_ credentials: [String: String]) -> Data {
anyLine(["type": "phone_credentials", "credentials": credentials])
}

/// The control line that tells hostd which phones this Mac approved, and
/// which identity keys the user refused (`pairing.rs`).
public static func devicesLine(_ devices: MobileHostPairedDevices) -> Data {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,26 @@ public struct MobileHostPairedDevices: Sendable, Equatable, Codable {
devices.removeAll { $0.id == id }
}

/// The acpmux pairing name of phone `id`'s own credential (acpmux role `phone`): what
/// Settings > Devices joins the phone's row to for its Full-access setting
/// (`DeviceDirectory.phoneRecordName`, kept equal by a test).
public static func acpmuxRecordName(_ id: String) -> String {
"phone-" + String(id.lowercased().filter { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }.prefix(74))
}

/// The approvers acpmux gets (`_acpmux/approvers_set`).
/// The pairing of `phone`, a phone of the account, if it is paired here (by its link id).
public func pairing(of phone: MobileHostKnownPhone) -> MobileHostPairedDevice? {
devices.first { phone.owns($0.id) }
}

/// The paired phones (link ids) that are among the account's `phones`: only these get an
/// acpmux credential, so a phone removed from the account while this Mac was not running
/// (still paired here, with no row in Settings > Devices) never holds Full access.
public func listed(in phones: [MobileHostKnownPhone]) -> Set<String> {
Set(devices.map(\.id).filter { id in phones.contains { $0.owns(id) } })
}

public func approversParams(macDevice: String) -> [String: Any] {
["macDevice": macDevice,
"approvers": devices.map { ["id": $0.id, "publicKey": $0.approvalKey, "name": $0.name] }]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ public actor MobileHostSupervisor {
private let pairingContinuation: AsyncStream<MobileHostEvent>.Continuation
/// The last `devices` line: sent to each new hostd and on every change.
private var devicesLine: Data?
/// The last `phone_credentials` line (secrets, memory only): sent to each new hostd and on
/// every change.
private var phoneCredentialsLine: Data?
private let clock: any Clock<Duration>
private let now: @Sendable () -> Date
private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "mobile.cmux-mobile-host")
Expand Down Expand Up @@ -147,6 +150,14 @@ public actor MobileHostSupervisor {
process?.send(line)
}

/// Each paired phone's own acpmux credential: kept for each new hostd and sent to the
/// running one now (the phone's next acpmux channel presents it).
public func setPhoneCredentials(_ credentials: [String: String]) {
let line = MobileHostLaunch.phoneCredentialsLine(credentials)
phoneCredentialsLine = line
process?.send(line)
}

/// Shows a phase without running hostd (for example, not signed in).
public func show(_ phase: MobileHostState.Phase) {
publish { $0.reset(to: phase) }
Expand Down Expand Up @@ -272,6 +283,7 @@ public actor MobileHostSupervisor {
lastToken = nil
if let identity = inputs.macIdentity { child.send(MobileHostLaunch.macIdentityLine(identity)) }
if let devicesLine { child.send(devicesLine) }
if let phoneCredentialsLine { child.send(phoneCredentialsLine) }
await sendToken(child, inputs: inputs)
await sendAcpmux(child, inputs: inputs)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ extension IPhoneHostService {
/// live link the list does not have yet. Only a known phone can be removed.
/// The phones Settings > Devices lists (plans/cmux-next/add-device-DESIGN.md 2.4): each one the
/// account knows, and whether it has a live link now. Remove is ``removePhone(id:)``.
/// A paired phone's row is its link id, the id its acpmux record is named for (its Full-access
/// switch); Remove takes any of its ids.
public var devicePhones: [(id: String, name: String, connected: Bool)] {
guard allowed else { return [] }
return knownPhones.map { phone in (phone.id, phone.name, state.phones.contains { phone.owns($0.phoneDeviceID) }) }
return knownPhones.map { phone in
(paired?.pairing(of: phone)?.id ?? phone.id, phone.name, state.phones.contains { phone.owns($0.phoneDeviceID) })
}
}

private var phoneRows: [JSONValue] {
Expand All @@ -66,7 +70,7 @@ extension IPhoneHostService {
let link = links.first { phone.owns($0.phoneDeviceID) }
// Paired here (cmux-next-ios verify/phone-approvals.md): its links and Face ID approvals
// count; Remove also forgets the pairing.
let pairing = paired?.devices.first { phone.owns($0.id) }
let pairing = paired?.pairing(of: phone)
return ["id": .string(phone.id), "title": .string(phone.name),
"subtitle": .string(link.map(Self.subtitle) ?? IPhoneHostStrings.notConnected),
"connected": .bool(link != nil), "removable": .bool(true),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextAgentPane
import CmuxNextDesign
import CmuxNextMobile
import Foundation
Expand Down Expand Up @@ -36,7 +37,7 @@ extension IPhoneHostService {
if paired?.userID != account?.userID { resetPrompts() }
paired = account.map { file.load(userID: $0.userID) }
if approverSync == nil, let environment = dependencies?.acpmuxEnvironment {
approverSync = MobileHostApproverSync(environment: environment)
approverSync = makeApproverSync(environment)
}
pushPaired()
}
Expand Down Expand Up @@ -209,6 +210,17 @@ extension IPhoneHostService {
pushPaired()
}

/// The approver sync, which also hands hostd each phone's own acpmux credential.
func makeApproverSync(_ environment: AcpmuxEnvironment) -> MobileHostApproverSync {
let sync = MobileHostApproverSync(environment: environment)
let supervisor = supervisor
sync.phoneCredentialsChanged = { credentials in
// task-owner: one hop to the supervisor actor; setPhoneCredentials replaces the whole set
Task { await supervisor.setPhoneCredentials(credentials) }
}
return sync
}

/// hostd's `devices` line and acpmux's approvers follow the list.
private func pushPaired() {
let list = paired ?? MobileHostPairedDevices(userID: "")
Expand All @@ -218,5 +230,15 @@ extension IPhoneHostService {
guard let directory = dependencies?.stateDirectory,
let macDevice = try? MobileHostDeviceIdentity(directory: directory).deviceID() else { return }
approverSync?.update(list.approversParams(macDevice: macDevice))
pushListedPhones()
}

/// The paired phones the account lists get their own acpmux credential (their Full-access
/// setting); every other phone's record is revoked. Until the account's list is read (or
/// connections are off), none gets one and none is revoked for being unlisted.
func pushListedPhones() {
let paired = paired ?? MobileHostPairedDevices(userID: "")
approverSync?.updatePhones(paired: Set(paired.devices.map(\.id)),
listed: phonesKnown ? paired.listed(in: knownPhones) : nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ public final class IPhoneHostService {
private(set) var state = MobileHostState()
/// The account's phones on the mobile service (Settings > Mobile's list with Remove).
private(set) var knownPhones: [MobileHostKnownPhone] = []
/// Which phones may hold an acpmux credential is known: ``knownPhones`` is the signed-in
/// account's list, read since it signed in, or iPhone connections are off (none may).
@ObservationIgnored private(set) var phonesKnown = false
/// The setting has been read once (``allowed`` false before that is not "off").
@ObservationIgnored private var allowedRead = false
/// Why the last list read or removal failed, shown under the list; nil when it worked.
private(set) var phonesError: String?
/// The phones the signed-in user allowed on this Mac (cmux-next-ios verify/phone-approvals.md).
Expand Down Expand Up @@ -174,8 +179,11 @@ public final class IPhoneHostService {
listing?.cancel()
guard allowed, account != nil, let dependencies else {
knownPhones = []
// Connections turned off: no phone holds a credential and their records end. Before the
// setting or the account is read, which phones may is not known yet.
phonesKnown = allowedRead && !allowed && account != nil
phonesError = nil
return
return pushListedPhones()
}
let directory = MobileHostDeviceDirectory(baseURL: dependencies.baseURL)
// task-owner: stored in `listing`; a newer read cancels it
Expand All @@ -184,7 +192,9 @@ public final class IPhoneHostService {
let phones = try await directory.phones(token: dependencies.accessToken(false))
guard !Task.isCancelled else { return }
self?.knownPhones = phones
self?.phonesKnown = true
self?.phonesError = nil
self?.pushListedPhones()
} catch {
guard !Task.isCancelled else { return }
self?.phonesError = IPhoneHostStrings.listFailed
Expand All @@ -196,14 +206,15 @@ public final class IPhoneHostService {
/// hostd, and re-reads the list. A phone still signed in to the account registers again
/// when it next connects.
public func removePhone(id: String) async {
guard let dependencies, let phone = knownPhones.first(where: { $0.id == id }) else { return }
guard let dependencies, let phone = knownPhones.first(where: { $0.id == id || $0.owns(id) }) else { return }
do {
try await MobileHostDeviceDirectory(baseURL: dependencies.baseURL).remove(phone, token: dependencies.accessToken(false))
await supervisor.drop(phoneDeviceIDs: phone.deviceIDs)
// Its pairing goes too: its links and Face ID approvals stop at once.
for deviceID in phone.deviceIDs { revokePhone(id: deviceID) }
knownPhones.removeAll { $0.id == id }
knownPhones.removeAll { $0.id == phone.id }
phonesError = nil
pushListedPhones()
} catch {
logger.error("iPhone removal failed: \(String(describing: error), privacy: .public)")
phonesError = IPhoneHostStrings.removeFailed
Expand All @@ -223,6 +234,7 @@ public final class IPhoneHostService {
clearRemoval()
}
self.account = account
phonesKnown = false
loadPaired(signedOut: signedOut)
reconcile()
refreshPhones()
Expand Down Expand Up @@ -252,7 +264,7 @@ public final class IPhoneHostService {
}) {
guard let self else { return }
if approverSync == nil, !folders.isEmpty, let environment = dependencies?.acpmuxEnvironment {
approverSync = MobileHostApproverSync(environment: environment)
approverSync = makeApproverSync(environment)
}
approverSync?.updateWorkspaceRoots(folders)
}
Expand All @@ -277,7 +289,7 @@ public final class IPhoneHostService {
continue
}
if approverSync == nil, let environment = dependencies?.acpmuxEnvironment {
approverSync = MobileHostApproverSync(environment: environment)
approverSync = makeApproverSync(environment)
}
approverSync?.updateNewChatPicks(picks.params)
}
Expand Down Expand Up @@ -321,7 +333,13 @@ public final class IPhoneHostService {
}

private func setAllowed(_ allowed: Bool) {
guard allowed != self.allowed else { return }
let first = !allowedRead
allowedRead = true
guard allowed != self.allowed else {
// Read off at launch: the phones' records end too (``refreshPhones()``).
if first { refreshPhones() }
return
}
// Turning the setting on again is the person allowing connections again.
if allowed, removed { clearRemoval() }
self.allowed = allowed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import os
/// (``AcpmuxCheckedLine``); only the person may set approvers.
/// - acpmux keeps approvers in memory only, so a restarted daemon starts with
/// none: the connection stays open, and each new one sends the list again.
/// - Nothing to sync (no paired phones) and no connection: it does not connect.
/// - Nothing to sync (no paired phones) and no connection: it does not connect, once it has
/// listed acpmux's phone records (``MobileHostPhoneCredentials``).
/// - Each paired phone the account lists gets its own acpmux credential
/// (``MobileHostPhoneCredentials``), handed to hostd, so the phone's Full-access setting
/// (Settings > Devices, off by default) can cover it.
@MainActor
final class MobileHostApproverSync {
private let environment: AcpmuxEnvironment
Expand All @@ -36,7 +40,14 @@ final class MobileHostApproverSync {
private var picksClearPending = false
private var needed: Bool {
hasApprovers || workspaceRoots?.isEmpty == false || rootsClearPending || newChatPicks != nil || picksClearPending
|| credentials.needsConnection
}
/// Hands hostd every phone's credential, by phone device id, whenever the set changes.
var phoneCredentialsChanged: (@MainActor ([String: String]) -> Void)?
/// The phones' own acpmux credentials and their records' revocations.
private var credentials = MobileHostPhoneCredentials()
/// The credentials hostd holds now.
private var handedTokens: [String: String] = [:]
private var nextID = 10
private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "app.iphone.approvers")

Expand All @@ -59,6 +70,14 @@ final class MobileHostApproverSync {
}
}

/// The paired phones (link ids) and, once the account's list is read, the ones it lists
/// (``MobileHostPairedDevices/listed(in:)``): each listed one gets its own credential; one that
/// left has its record revoked.
func updatePhones(paired: Set<String>, listed: Set<String>?) {
run(credentials.update(paired: paired, listed: listed, ids: &nextID))
if !proven, connection == nil, reconnect == nil, needed { connect() }
}

/// The folders of the workspaces the daemon lists now (each tab's folder). acpmux lets
/// a remote connection (a phone, through cmux-mobile-host) start sessions there, under its own
/// guards: never `/`, the home folder or above it; symlinks resolved.
Expand Down Expand Up @@ -96,6 +115,7 @@ final class MobileHostApproverSync {
connection?.cancel()
connection = nil
proven = false
credentials.disconnected()
}

private func connect() {
Expand Down Expand Up @@ -140,9 +160,20 @@ final class MobileHostApproverSync {
return lost(line)
}
proven = true
backoff.reset()
// A refusal retried on this connection keeps the backoff growing.
if !credentials.refused { backoff.reset() }
run(credentials.connected(ids: &nextID))
send()
default:
if let requests = credentials.reply(id: id, message, ids: &nextID) {
guard credentials.refused else { return run(requests) }
let error = message["error"] as? [String: Any]
logger.error("acpmux refused a phone credential request: \(String(describing: error?["message"]), privacy: .public); retrying")
// A new connection retries it, after the backoff.
run([])
line.cancel()
return lost(line)
}
if let error = message["error"] as? [String: Any] {
logger.error("acpmux refused a person update: \(String(describing: error["message"]), privacy: .public)")
} else {
Expand All @@ -160,6 +191,18 @@ final class MobileHostApproverSync {
connection.send(Self.frame(id: nextID, method: "_acpmux/approvers_set", params: params))
}

/// Sends the credential requests (only on the proven connection), then hands hostd the
/// credentials whenever they changed.
private func run(_ requests: [MobileHostPhoneCredentials.Request]) {
if let connection, proven {
for request in requests { connection.send(Self.frame(id: request.id, method: request.method, params: request.params)) }
}
let tokens = credentials.tokens
guard tokens != handedTokens else { return }
handedTokens = tokens
phoneCredentialsChanged?(tokens)
}

private func sendRoots() {
guard let connection, proven, let workspaceRoots else { return }
nextID += 1
Expand All @@ -178,6 +221,7 @@ final class MobileHostApproverSync {
guard connection === line else { return }
connection = nil
proven = false
credentials.disconnected()
if needed { scheduleReconnect() }
}

Expand Down
Loading
Loading