Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// Whether the phone takes over a terminal it shares with the same user's Mac
/// so the grid fills the phone instead of fitting both screens. Shared by the
/// writer (the iOS Settings screen) and the reader (the shell composite when a
/// size state arrives).
public struct MobileTerminalFillScreenPreference {
/// UserDefaults key, written by Settings and read when a size state arrives.
public static let defaultsKey = "cmux.mobile.fillScreenWhenShared"

private let defaults: UserDefaults

/// Creates a reader backed by the supplied defaults store.
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

/// The stored preference, `true` when nothing is stored.
public var isEnabled: Bool {
defaults.object(forKey: Self.defaultsKey) as? Bool ?? true
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
internal import CMUXMobileCore
internal import CmuxMobileDiagnostics
internal import CmuxMobileRPC
public import CmuxMobileShellModel
Expand Down Expand Up @@ -99,6 +100,41 @@ extension MobileShellComposite {
// `.reassertViewport` is carried by `viewportReassertGeneration`,
// which the mounted surface observes.
terminalSizingBySurfaceID[surfaceID] = surface
claimTerminalFillScreenIfNeeded(surfaceID: surfaceID)
}

/// Lets a letterboxed phone take a terminal it shares: this phone counts
/// and comes first in a priority policy, so the grid fills it while it
/// shows the terminal and returns to the Mac when it leaves.
private func claimTerminalFillScreenIfNeeded(surfaceID: String) {
guard let surface = terminalSizingBySurfaceID[surfaceID],
let claim = surface.state?.fillScreenClaim(
for: surface.selfParticipantID ?? defaultTerminalParticipantID
),
MobileTerminalFillScreenPreference(defaults: terminalFillScreenDefaults).isEnabled
else { return }
guard let policy = claim.policy else {
// Back from a hand-off: counting again repeats on every return, so
// only a request in flight blocks it.
guard terminalFillScreenRecountingSurfaceIDs.insert(surfaceID).inserted else { return }
Task {
_ = await setTerminalCountsOverride(true, surfaceID: surfaceID)
terminalFillScreenRecountingSurfaceIDs.remove(surfaceID)
}
return
}
guard terminalFillScreenClaimedSurfaceIDs.insert(surfaceID).inserted else { return }
Task {
// Counts first: under fit-everyone it leaves the grid unchanged, so
// the phone never briefly shows the Mac's grid between the two.
if claim.countsThisPhone, !(await setTerminalCountsOverride(true, surfaceID: surfaceID)) {
terminalFillScreenClaimedSurfaceIDs.remove(surfaceID)
return
}
if !(await setTerminalSizePolicy(policy, surfaceID: surfaceID)) {
terminalFillScreenClaimedSurfaceIDs.remove(surfaceID)
}
}
}

/// Applies a `detached` push.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1705,6 +1705,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// Shared sizing state per terminal surface (size state, self participant,
/// attachment). See `MobileShellComposite+TerminalSizing.swift`.
var terminalSizingBySurfaceID: [String: MobileTerminalSizingSurface] = [:]
/// Where the fill-screen-when-shared preference is read. Tests inject a
/// suite-scoped `UserDefaults`.
@ObservationIgnored var terminalFillScreenDefaults: UserDefaults = .standard
/// Terminals this phone already claimed to fill its screen. Never cleared,
/// so a policy the user picks afterwards in the size sheet stays.
// debt: in memory only. After a relaunch a terminal the user set back to
// Fit everyone, with no counts choice, is claimed again; persist the ids
// per Mac if that shows up.
@ObservationIgnored var terminalFillScreenClaimedSurfaceIDs: Set<String> = []
/// Terminals whose phone is re-sending its counts claim after a hand-off.
@ObservationIgnored var terminalFillScreenRecountingSurfaceIDs: Set<String> = []
/// Monotonic viewport fences scoped to the Mac app instance that consumes
/// them. Warm Iroh focus swaps keep both peer connections alive, so their
/// counters must survive independently for the signed-in account lifetime.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ actor LivenessHostRouter {
var stackAccessToken: String?
var deviceKind: String?
var deviceName: String?
var policyMode: String?
var policyPriority: [String]?
var countsOverride: Bool?
}

private var recorded: [RecordedRequest] = []
Expand Down Expand Up @@ -137,7 +140,10 @@ actor LivenessHostRouter {
attachToken: String? = nil,
stackAccessToken: String? = nil,
deviceKind: String? = nil,
deviceName: String? = nil
deviceName: String? = nil,
policyMode: String? = nil,
policyPriority: [String]? = nil,
countsOverride: Bool? = nil
) {
recorded.append(RecordedRequest(
method: method,
Expand All @@ -154,7 +160,10 @@ actor LivenessHostRouter {
attachToken: attachToken,
stackAccessToken: stackAccessToken,
deviceKind: deviceKind,
deviceName: deviceName
deviceName: deviceName,
policyMode: policyMode,
policyPriority: policyPriority,
countsOverride: countsOverride
))
resumeSatisfiedCountWaiters()
}
Expand Down Expand Up @@ -765,6 +774,8 @@ actor LivenessHostRouter {
var result: [String: Any] = [:]
if let viewportReport = viewportEffectiveGridOverride ?? viewportReport { result["columns"] = viewportReport.columns; result["rows"] = viewportReport.rows }
return try? Self.resultFrame(id: id, result: result)
case "mobile.terminal.size_policy.set":
return try? Self.resultFrame(id: id, result: [:])
case "terminal.input":
let terminalSequence = terminalInputSequences.isEmpty
? 100
Expand Down Expand Up @@ -898,7 +909,10 @@ actor LivenessTransport: CmxByteTransport, CmxByteTransportLivenessObserving {
attachToken: auth?["attach_token"] as? String,
stackAccessToken: auth?["stack_access_token"] as? String,
deviceKind: params?["device_kind"] as? String,
deviceName: params?["device_name"] as? String
deviceName: params?["device_name"] as? String,
policyMode: (params?["policy"] as? [String: Any])?["mode"] as? String,
policyPriority: (params?["policy"] as? [String: Any])?["priority"] as? [String],
countsOverride: params?["counts_override"] as? Bool
)
// Answer each request concurrently so one held response cannot
// head-of-line block later RPCs, matching the Mac host's
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import CMUXMobileCore
import CmuxMobileRPC
import CmuxMobileShellModel
import CmuxTerminalSizing
import Foundation
import Testing
@testable import CmuxMobileShell

// A phone sharing a terminal with the same user's Mac under the default
// fit-everyone policy gets min(Mac, phone) and letterboxes. With the
// fill-screen preference on, the phone counts itself and puts itself first in
// a priority policy, once, so it fills while it shows the terminal. Follow
// latest is not enough: any Mac input resizes the grid under the phone.

private let surfaceID = "live-terminal"
private let phoneID = "mobile:phone"
private let phoneViewport = (cols: 72, rows: 60)
private let phonePriorityKey = "u_maya/iphone/phone-device"

/// A connected phone with the terminal mounted at `phoneViewport`. The
/// collector keeps the surface mounted; dropping it clears the reported
/// viewport the counts report needs.
@MainActor
private func makeStore(
router: LivenessHostRouter,
fillScreen: Bool? = nil
) async throws -> (MobileShellComposite, OutputCollector) {
let store = try await makeConnectedStore(router: router, box: TransportBox(), clock: TestClock())
let defaults = try #require(UserDefaults(suiteName: "fill-screen-\(UUID().uuidString)"))
if let fillScreen {
defaults.set(fillScreen, forKey: MobileTerminalFillScreenPreference.defaultsKey)
}
store.terminalFillScreenDefaults = defaults
await router.enqueueReplayTexts(["cold-replay", "initial-viewport-replay"])
let output = OutputCollector()
output.mount(store: store, surfaceID: surfaceID)
#expect(try await pollUntil { output.lines.count == 1 }, "cold replay")
_ = await store.updateTerminalViewport(
surfaceID: surfaceID,
columns: phoneViewport.cols,
rows: phoneViewport.rows
)
#expect(try await pollUntil { output.lines.count == 2 }, "viewport replay")
return (store, output)
}

@MainActor
private func sizeStateEnvelope(
grid: (cols: Int, rows: Int) = (72, 44),
policy: TerminalSizingPolicy = .fitEveryone,
phoneCountsOverride: Bool? = nil
) throws -> MobileEventEnvelope {
let policyObject = try JSONSerialization.jsonObject(with: JSONEncoder().encode(policy))
let payload: [String: Any] = [
"surface_id": surfaceID,
"self_participant_id": phoneID,
"state": [
"generation": 7, "cols": grid.cols, "rows": grid.rows,
"reason": policy.mode.rawValue,
"owners": [phoneID, "c3"],
"policy": policyObject,
"participants": [
["id": "c3", "user_id": "u_maya", "device_kind": "mac", "device_name": "Mac Studio",
"viewport": ["cols": 120, "rows": 44], "counts": true],
["id": phoneID, "user_id": "u_maya", "device_kind": "iphone", "device_name": "iPhone",
"device_id": "phone-device",
"viewport": ["cols": phoneViewport.cols, "rows": phoneViewport.rows], "counts": true,
"counts_override": phoneCountsOverride.map { $0 as Any } ?? NSNull()],
],
] as [String: Any],
]
return MobileEventEnvelope(
topic: MobileShellComposite.terminalSizeStateTopic,
payloadJSON: try JSONSerialization.data(withJSONObject: payload),
streamID: nil
)
}

private func countsOverrideReports(_ router: LivenessHostRouter) async -> [Bool] {
await router.requests(for: "mobile.terminal.viewport").compactMap(\.countsOverride)
}

/// Waits briefly for a claim that must not happen.
private func claimed(_ router: LivenessHostRouter, atLeast count: Int = 1) async -> Bool {
await router.waitForCount(
of: "mobile.terminal.size_policy.set",
atLeast: count,
timeoutNanoseconds: 300_000_000,
recordIssueOnTimeout: false
)
}

@MainActor
@Suite struct TerminalFillScreenClaimTests {
@Test func letterboxedPhoneClaimsPriorityAndCountsOnce() async throws {
let router = LivenessHostRouter()
let (store, output) = try await makeStore(router: router)

store.handleTerminalSizeStateEvent(try sizeStateEnvelope())

#expect(await router.waitForCount(of: "mobile.terminal.size_policy.set", atLeast: 1))
let claims = await router.requests(for: "mobile.terminal.size_policy.set")
#expect(claims.map(\.policyMode) == ["priority"])
#expect(claims.map(\.policyPriority) == [[phonePriorityKey]])
let overrideSent = try await pollUntil { await countsOverrideReports(router) == [true] }
#expect(overrideSent, "the phone must count itself toward size")

store.handleTerminalSizeStateEvent(try sizeStateEnvelope())
#expect(!(await claimed(router, atLeast: 2)), "a repeated push must not claim again")
#expect(await countsOverrideReports(router) == [true])
output.unmount()
}

@Test func disabledPreferenceNeverClaims() async throws {
let router = LivenessHostRouter()
let (store, output) = try await makeStore(router: router, fillScreen: false)

store.handleTerminalSizeStateEvent(try sizeStateEnvelope())

#expect(!(await claimed(router)))
#expect(await countsOverrideReports(router).isEmpty)
output.unmount()
}

/// A backgrounded phone hands its viewport back, and the Mac treats its
/// return as a fresh attach without the counts override. Under its own
/// priority claim the phone then does not count, and the Mac keeps the
/// grid, so the phone counts itself again without resending the policy.
@Test func phoneBackFromHandOffCountsItselfAgain() async throws {
let router = LivenessHostRouter()
let (store, output) = try await makeStore(router: router)
let phoneFirst = TerminalSizingPolicy(mode: .priority, priority: [phonePriorityKey])

store.handleTerminalSizeStateEvent(try sizeStateEnvelope())
#expect(await router.waitForCount(of: "mobile.terminal.size_policy.set", atLeast: 1))
#expect(try await pollUntil { await countsOverrideReports(router) == [true] })

store.handleTerminalSizeStateEvent(
try sizeStateEnvelope(grid: phoneViewport, policy: phoneFirst, phoneCountsOverride: true)
)
store.handleTerminalSizeStateEvent(try sizeStateEnvelope(grid: (120, 44), policy: phoneFirst))

let recounted = try await pollUntil { await countsOverrideReports(router) == [true, true] }
#expect(recounted, "the returning phone must count itself again")
#expect(!(await claimed(router, atLeast: 2)), "the policy is already this phone's")
output.unmount()
}

@Test func followLatestClaimFromBuildSevenMovesToPriority() async throws {
let router = LivenessHostRouter()
let (store, output) = try await makeStore(router: router)

store.handleTerminalSizeStateEvent(
try sizeStateEnvelope(grid: phoneViewport, policy: .latest, phoneCountsOverride: true)
)

#expect(await router.waitForCount(of: "mobile.terminal.size_policy.set", atLeast: 1))
let claims = await router.requests(for: "mobile.terminal.size_policy.set")
#expect(claims.map(\.policyMode) == ["priority"])
#expect(claims.map(\.policyPriority) == [[phonePriorityKey]])
output.unmount()
}

@Test(arguments: [
TerminalSizingPolicy.latest,
TerminalSizingPolicy(mode: .priority, priority: ["u_maya/mac"]),
TerminalSizingPolicy(mode: .smallest, priority: ["u_maya/mac"]),
])
func userChosenPolicyIsLeftAlone(policy: TerminalSizingPolicy) async throws {
let router = LivenessHostRouter()
let (store, output) = try await makeStore(router: router)

store.handleTerminalSizeStateEvent(try sizeStateEnvelope(policy: policy))

#expect(!(await claimed(router)))
#expect(await countsOverrideReports(router).isEmpty)
output.unmount()
}

@Test(arguments: [true, false])
func userChosenCountsOverrideIsLeftAlone(countsOverride: Bool) async throws {
let router = LivenessHostRouter()
let (store, output) = try await makeStore(router: router)

store.handleTerminalSizeStateEvent(try sizeStateEnvelope(phoneCountsOverride: countsOverride))

#expect(!(await claimed(router)))
#expect(await countsOverrideReports(router).isEmpty)
output.unmount()
}

@Test func phoneThatAlreadyFillsNeverClaims() async throws {
let router = LivenessHostRouter()
let (store, output) = try await makeStore(router: router)

store.handleTerminalSizeStateEvent(try sizeStateEnvelope(grid: phoneViewport))

#expect(!(await claimed(router)))
#expect(await countsOverrideReports(router).isEmpty)
output.unmount()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
public import CmuxTerminalSizing

/// What a phone sends so it fills its screen in a shared terminal.
public struct TerminalFillScreenClaim: Equatable, Sendable {
/// Whether to report `counts_override: true` for this phone first.
public var countsThisPhone: Bool
/// The policy to set, or `nil` when the policy is already this phone's.
public var policy: TerminalSizingPolicy?

public init(countsThisPhone: Bool, policy: TerminalSizingPolicy?) {
self.countsThisPhone = countsThisPhone
self.policy = policy
}
}

extension TerminalSizingState {
/// What this phone must send to fill its screen in this terminal, or `nil`
/// to leave the terminal alone.
///
/// Under the default fit-everyone policy the grid is the smallest attached
/// viewport, so a phone next to a shorter Mac pane letterboxes. Putting this
/// phone first in a priority policy, with the phone counted, gives it the
/// grid while it shows the terminal; the Mac takes it back once the phone
/// leaves or hands off. Three states qualify:
/// - the default policy letterboxing a phone with no counts choice;
/// - follow latest with this phone counted (build 7's claim), because any
/// Mac input resized the grid under the phone;
/// - this phone's own priority claim without a counts choice, which is how
/// the Mac sees a phone back from a hand-off.
/// Every other policy or counts choice is someone's decision.
/// - Parameter participantID: The phone's participant id.
public func fillScreenClaim(for participantID: String) -> TerminalFillScreenClaim? {
guard let phone = participant(participantID)?.participant,
let viewport = phone.viewport else { return nil }
let phoneFirst = TerminalSizingPolicy(mode: .priority, priority: [phone.priorityKey])
let letterboxed = cols < viewport.cols || rows < viewport.rows
switch phone.countsOverride {
case nil where policy == .fitEveryone && letterboxed:
return TerminalFillScreenClaim(countsThisPhone: true, policy: phoneFirst)
case nil where policy == phoneFirst:
return TerminalFillScreenClaim(countsThisPhone: true, policy: nil)
case true? where policy == .latest:
return TerminalFillScreenClaim(countsThisPhone: false, policy: phoneFirst)
default:
return nil
}
}
}
Loading