From 8bb0aedd26fcc62fbfb7c9b65502557bc96f80bc Mon Sep 17 00:00:00 2001 From: "supasai(boy)" Date: Wed, 28 Oct 2026 21:39:22 +0700 Subject: [PATCH 1/7] test(ios): a letterboxed phone should claim the shared terminal's size A phone sharing a terminal with the same user's Mac under the default fit-everyone policy gets min(Mac, phone) and shows an empty band. These cases expect the phone to switch that terminal to follow the latest device and count itself, once, unless the user turned the preference off or already chose a policy or counts override, or the grid already fills the phone. Adds the preference key and the store's defaults seam the tests inject. --- .../MobileTerminalFillScreenPreference.swift | 22 +++ .../MobileShellComposite.swift | 3 + ...leShellRenderGridLivenessTestSupport.swift | 16 +- .../TerminalFillScreenClaimTests.swift | 158 ++++++++++++++++++ 4 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalFillScreenPreference.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalFillScreenPreference.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalFillScreenPreference.swift new file mode 100644 index 000000000000..cf9729536513 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalFillScreenPreference.swift @@ -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 + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 990c818867a5..aa92e8171223 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1705,6 +1705,9 @@ 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 /// 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. diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index f7833d7f4e51..049b3f136caf 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -34,6 +34,8 @@ actor LivenessHostRouter { var stackAccessToken: String? var deviceKind: String? var deviceName: String? + var policyMode: String? + var countsOverride: Bool? } private var recorded: [RecordedRequest] = [] @@ -137,7 +139,9 @@ actor LivenessHostRouter { attachToken: String? = nil, stackAccessToken: String? = nil, deviceKind: String? = nil, - deviceName: String? = nil + deviceName: String? = nil, + policyMode: String? = nil, + countsOverride: Bool? = nil ) { recorded.append(RecordedRequest( method: method, @@ -154,7 +158,9 @@ actor LivenessHostRouter { attachToken: attachToken, stackAccessToken: stackAccessToken, deviceKind: deviceKind, - deviceName: deviceName + deviceName: deviceName, + policyMode: policyMode, + countsOverride: countsOverride )) resumeSatisfiedCountWaiters() } @@ -765,6 +771,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 @@ -898,7 +906,9 @@ 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, + 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 diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift new file mode 100644 index 000000000000..9ad0bd687504 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift @@ -0,0 +1,158 @@ +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 switches that terminal to follow the +// latest device and counts itself, once, so it fills while in use. + +private let surfaceID = "live-terminal" +private let phoneID = "mobile:phone" +private let phoneViewport = (cols: 72, rows: 60) + +/// 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", + "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 letterboxedPhoneClaimsLatestAndCountsOnce() 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)) + #expect(await router.requests(for: "mobile.terminal.size_policy.set").map(\.policyMode) == ["latest"]) + 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() + } + + @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() + } +} From e4ecd4935554e8cf1f8477ea38f665cc8bb0cc00 Mon Sep 17 00:00:00 2001 From: "supasai(boy)" Date: Wed, 28 Oct 2026 21:43:35 +0700 Subject: [PATCH 2/7] feat(ios): a letterboxed phone takes the size of a terminal it shares Under the default fit-everyone policy a phone next to the same user's Mac gets min(Mac, phone) and shows an empty band under a full-screen agent. When a size state shows this phone letterboxed under the untouched default, with no counts choice of its own, the phone counts itself and switches the terminal to follow the latest device, once per terminal. The grid then fills whichever device was used last, through RPCs the Mac already accepts. TerminalSizingState.isFillScreenClaimable(by:) holds the decision; the cmux.mobile.fillScreenWhenShared preference (default on) turns it off. --- .../MobileShellComposite+TerminalSizing.swift | 25 +++++++++ .../MobileShellComposite.swift | 6 ++ .../TerminalSizingState+FillScreenClaim.swift | 22 ++++++++ ...minalSizingStateFillScreenClaimTests.swift | 55 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift create mode 100644 Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift index 7fc7ffcaa2ee..0a5fec51212d 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift @@ -1,3 +1,4 @@ +internal import CMUXMobileCore internal import CmuxMobileDiagnostics internal import CmuxMobileRPC public import CmuxMobileShellModel @@ -99,6 +100,30 @@ 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 under the default + /// policy: follow the latest device, with this phone counted, so the grid + /// fills whichever device was used last. + private func claimTerminalFillScreenIfNeeded(surfaceID: String) { + guard !terminalFillScreenClaimedSurfaceIDs.contains(surfaceID), + let surface = terminalSizingBySurfaceID[surfaceID], + surface.state?.isFillScreenClaimable( + by: surface.selfParticipantID ?? defaultTerminalParticipantID + ) == true, + MobileTerminalFillScreenPreference(defaults: terminalFillScreenDefaults).isEnabled + else { return } + terminalFillScreenClaimedSurfaceIDs.insert(surfaceID) + Task { + // Counts first: under fit-everyone it leaves the grid unchanged, so + // the phone never briefly shows the Mac's grid between the two. + guard await setTerminalCountsOverride(true, surfaceID: surfaceID), + await setTerminalSizePolicy(.latest, surfaceID: surfaceID) else { + terminalFillScreenClaimedSurfaceIDs.remove(surfaceID) + return + } + } } /// Applies a `detached` push. diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index aa92e8171223..d595ef3f5220 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1708,6 +1708,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// 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 = [] /// 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. diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift new file mode 100644 index 000000000000..3202729adb35 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift @@ -0,0 +1,22 @@ +public import CmuxTerminalSizing + +extension TerminalSizingState { + /// Whether a phone may take over this terminal's size so the grid fills it. + /// + /// Under the default fit-everyone policy the grid is the smallest attached + /// viewport, so a phone next to a shorter Mac pane letterboxes. Following + /// the latest device with the phone counted gives the grid to whichever + /// device was used last. Only the untouched default qualifies, never a + /// policy or counts choice someone made. + /// - Parameter participantID: The phone's participant id. + /// - Returns: `true` when the policy is the default, the phone has a + /// viewport and no counts override, and the grid is smaller than that + /// viewport in some dimension. + public func isFillScreenClaimable(by participantID: String) -> Bool { + guard policy == .fitEveryone, + let phone = participant(participantID)?.participant, + phone.countsOverride == nil, + let viewport = phone.viewport else { return false } + return cols < viewport.cols || rows < viewport.rows + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift new file mode 100644 index 000000000000..07f4d6e686bc --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift @@ -0,0 +1,55 @@ +import CmuxMobileShellModel +import CmuxTerminalSizing +import Testing + +private let phoneID = "mobile:phone" + +private func state( + grid: TerminalGridSize = TerminalGridSize(cols: 72, rows: 44), + policy: TerminalSizingPolicy = .fitEveryone, + phone: TerminalSizingParticipant? = TerminalSizingParticipant( + id: phoneID, + userID: "u_maya", + deviceKind: .iphone, + viewport: TerminalGridSize(cols: 72, rows: 60) + ) +) -> TerminalSizingState { + let mac = TerminalSizingParticipant( + id: "c3", + userID: "u_maya", + deviceKind: .mac, + viewport: TerminalGridSize(cols: 120, rows: 44) + ) + return TerminalSizingState( + generation: 1, + cols: grid.cols, + rows: grid.rows, + reason: .smallest, + owners: ["c3"], + policy: policy, + participants: ([mac] + [phone].compactMap { $0 }) + .map { TerminalSizingParticipantState(participant: $0, counts: true) } + ) +} + +@Suite struct TerminalSizingStateFillScreenClaimTests { + @Test func letterboxedInEitherDimensionIsClaimable() { + #expect(state().isFillScreenClaimable(by: phoneID)) + #expect(state(grid: TerminalGridSize(cols: 60, rows: 60)).isFillScreenClaimable(by: phoneID)) + } + + @Test func gridThatFillsThePhoneIsNotClaimable() { + #expect(!state(grid: TerminalGridSize(cols: 72, rows: 60)).isFillScreenClaimable(by: phoneID)) + } + + @Test func fixedPolicyIsNotClaimable() { + let fixed = TerminalSizingPolicy(mode: .fixed, fixed: TerminalGridSize(cols: 60, rows: 30)) + #expect(!state(policy: fixed).isFillScreenClaimable(by: phoneID)) + } + + @Test func phoneMissingFromStateOrWithoutViewportIsNotClaimable() { + #expect(!state(phone: nil).isFillScreenClaimable(by: phoneID)) + let noViewport = TerminalSizingParticipant(id: phoneID, deviceKind: .iphone) + #expect(!state(phone: noViewport).isFillScreenClaimable(by: phoneID)) + } +} From 677e356f441cb32008a8c834be9ba9dcebb43b33 Mon Sep 17 00:00:00 2001 From: "supasai(boy)" Date: Wed, 28 Oct 2026 21:51:39 +0700 Subject: [PATCH 3/7] feat(ios): a Settings toggle for filling the screen in shared terminals Settings > Terminal gets "Fill Screen in Shared Terminals", on by default. It writes cmux.mobile.fillScreenWhenShared, which the shell reads before it claims a letterboxed terminal. Strings in all nine locales. --- .../MobileDisplaySettings.swift | 12 ++++++++ .../MobileSettingsView.swift | 16 +++++++++++ .../Resources/Localizable.xcstrings | 28 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 41e7ec9c04a3..f6e8f12fa244 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -128,6 +128,17 @@ public final class MobileDisplaySettings { } } + /// Whether the phone takes over the size of a terminal it shares with + /// the Mac when the shared grid letterboxes it. Defaults to + /// `true`. Mutating this + /// writes through to the injected ``UserDefaults`` under the shared + /// preference key the shell reads when a size state arrives. + public var fillScreenWhenShared: Bool { + didSet { + defaults.set(fillScreenWhenShared, forKey: MobileTerminalFillScreenPreference.defaultsKey) + } + } + /// Whether cmux emits app-owned haptic feedback. Defaults to `true`. /// This is the sole observed writer for the persisted preference; haptic /// emitters read the same defaults store through ``haptics``. @@ -254,6 +265,7 @@ public final class MobileDisplaySettings { self.handoffTerminalSizingWhenInactive = defaults.object( forKey: Self.handoffTerminalSizingWhenInactiveKey ) as? Bool ?? false + self.fillScreenWhenShared = MobileTerminalFillScreenPreference(defaults: defaults).isEnabled self.hapticFeedbackEnabled = haptics.isEnabled self.terminalScrollbackRows = MobileTerminalScrollbackPreference.resolve(from: defaults) let storedPreviewLines = defaults.object(forKey: Self.workspacePreviewLineCountKey) as? Int diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 3c1d5b97acaf..70b5adf6a59d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -323,6 +323,22 @@ struct MobileSettingsView: View { } .accessibilityIdentifier("MobileSettingsHandoffTerminalSizingToggle") + Toggle(isOn: $displaySettings.fillScreenWhenShared) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.string( + "mobile.settings.fillScreenWhenShared", + defaultValue: "Fill Screen in Shared Terminals" + )) + Text(L10n.string( + "mobile.settings.fillScreenWhenShared.description", + defaultValue: "When a terminal is also open on your Mac, size it for the device you used last instead of fitting both." + )) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("MobileSettingsFillScreenWhenSharedToggle") + Button { showingShortcuts = true } label: { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index c22295e36d5f..0b4afde319ef 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -22580,6 +22580,34 @@ } } }, + "mobile.settings.fillScreenWhenShared": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Fill Screen in Shared Terminals"}}, + "ja": {"stringUnit": {"state": "translated", "value": "共有ターミナルで画面いっぱいに表示"}}, + "de": {"stringUnit": {"state": "translated", "value": "Bildschirm in geteilten Terminals füllen"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Remplir l’écran dans les terminaux partagés"}}, + "ar": {"stringUnit": {"state": "translated", "value": "ملء الشاشة في الطرفيات المشتركة"}}, + "es": {"stringUnit": {"state": "translated", "value": "Llenar la pantalla en terminales compartidos"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "在共用終端機中填滿螢幕"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "在共享终端中填满屏幕"}}, + "ko": {"stringUnit": {"state": "translated", "value": "공유 터미널에서 화면 채우기"}} + } + }, + "mobile.settings.fillScreenWhenShared.description": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "When a terminal is also open on your Mac, size it for the device you used last instead of fitting both."}}, + "ja": {"stringUnit": {"state": "translated", "value": "ターミナルがMacでも開いているときは、両方に合わせる代わりに最後に使ったデバイスに合わせてサイズを決めます。"}}, + "de": {"stringUnit": {"state": "translated", "value": "Wenn ein Terminal auch auf Ihrem Mac geöffnet ist, richtet sich seine Größe nach dem zuletzt verwendeten Gerät statt nach beiden."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Lorsqu’un terminal est aussi ouvert sur votre Mac, l’adapter au dernier appareil utilisé plutôt qu’aux deux."}}, + "ar": {"stringUnit": {"state": "translated", "value": "عندما تكون الطرفية مفتوحة أيضًا على جهاز Mac، يُضبط حجمها وفق آخر جهاز استخدمته بدلًا من ملاءمة الجهازين."}}, + "es": {"stringUnit": {"state": "translated", "value": "Cuando un terminal también está abierto en tu Mac, ajustar su tamaño al último dispositivo que usaste en lugar de adaptarlo a ambos."}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "終端機也在 Mac 上開啟時,依你最後使用的裝置調整大小,而不是同時符合兩者。"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "终端也在 Mac 上打开时,按你最后使用的设备调整大小,而不是同时适配两者。"}}, + "ko": {"stringUnit": {"state": "translated", "value": "터미널이 Mac에서도 열려 있으면 두 기기에 모두 맞추는 대신 마지막으로 사용한 기기에 맞춰 크기를 조정합니다."}} + } + }, "mobile.settings.handoffTerminalSizing": { "extractionState": "manual", "localizations": { From 43b16fd3646c69f62135b22ca44cccc08bd06716 Mon Sep 17 00:00:00 2001 From: "supasai(boy)" Date: Thu, 29 Oct 2026 22:36:29 +0700 Subject: [PATCH 4/7] test(ios): a shared terminal's phone claims priority, not follow latest Follow latest resizes the grid under the phone on any Mac keystroke, and the phone's render stalls mid-stream on that resize. The claim should put this phone first in a priority policy, move build 7's follow-latest claims over, and hand the grid back while the phone is inactive by default. --- ...leShellRenderGridLivenessTestSupport.swift | 4 ++ .../TerminalFillScreenClaimTests.swift | 29 +++++++-- ...minalSizingStateFillScreenClaimTests.swift | 61 ++++++++++++++----- .../MobileDisplaySettingsTests.swift | 6 +- 4 files changed, 78 insertions(+), 22 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 049b3f136caf..fa91a4862548 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -35,6 +35,7 @@ actor LivenessHostRouter { var deviceKind: String? var deviceName: String? var policyMode: String? + var policyPriority: [String]? var countsOverride: Bool? } @@ -141,6 +142,7 @@ actor LivenessHostRouter { deviceKind: String? = nil, deviceName: String? = nil, policyMode: String? = nil, + policyPriority: [String]? = nil, countsOverride: Bool? = nil ) { recorded.append(RecordedRequest( @@ -160,6 +162,7 @@ actor LivenessHostRouter { deviceKind: deviceKind, deviceName: deviceName, policyMode: policyMode, + policyPriority: policyPriority, countsOverride: countsOverride )) resumeSatisfiedCountWaiters() @@ -908,6 +911,7 @@ actor LivenessTransport: CmxByteTransport, CmxByteTransportLivenessObserving { deviceKind: params?["device_kind"] 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 diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift index 9ad0bd687504..999bc6898106 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift @@ -8,12 +8,14 @@ import Testing // 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 switches that terminal to follow the -// latest device and counts itself, once, so it fills while in use. +// 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 @@ -61,6 +63,7 @@ private func sizeStateEnvelope( ["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()], ], @@ -89,14 +92,16 @@ private func claimed(_ router: LivenessHostRouter, atLeast count: Int = 1) async @MainActor @Suite struct TerminalFillScreenClaimTests { - @Test func letterboxedPhoneClaimsLatestAndCountsOnce() async throws { + @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)) - #expect(await router.requests(for: "mobile.terminal.size_policy.set").map(\.policyMode) == ["latest"]) + 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") @@ -117,9 +122,25 @@ private func claimed(_ router: LivenessHostRouter, atLeast count: Int = 1) async 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: .priority, priority: [phonePriorityKey]), TerminalSizingPolicy(mode: .smallest, priority: ["u_maya/mac"]), ]) func userChosenPolicyIsLeftAlone(policy: TerminalSizingPolicy) async throws { diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift index 07f4d6e686bc..9d7a1754a281 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift @@ -3,16 +3,26 @@ import CmuxTerminalSizing import Testing private let phoneID = "mobile:phone" +private let phoneFirst = TerminalSizingPolicy(mode: .priority, priority: ["u_maya/iphone/phone-device"]) -private func state( - grid: TerminalGridSize = TerminalGridSize(cols: 72, rows: 44), - policy: TerminalSizingPolicy = .fitEveryone, - phone: TerminalSizingParticipant? = TerminalSizingParticipant( +private func phone( + viewport: TerminalGridSize? = TerminalGridSize(cols: 72, rows: 60), + countsOverride: Bool? = nil +) -> TerminalSizingParticipant { + TerminalSizingParticipant( id: phoneID, userID: "u_maya", deviceKind: .iphone, - viewport: TerminalGridSize(cols: 72, rows: 60) + deviceID: "phone-device", + viewport: viewport, + countsOverride: countsOverride ) +} + +private func state( + grid: TerminalGridSize = TerminalGridSize(cols: 72, rows: 44), + policy: TerminalSizingPolicy = .fitEveryone, + phone: TerminalSizingParticipant? = phone() ) -> TerminalSizingState { let mac = TerminalSizingParticipant( id: "c3", @@ -33,23 +43,42 @@ private func state( } @Suite struct TerminalSizingStateFillScreenClaimTests { - @Test func letterboxedInEitherDimensionIsClaimable() { - #expect(state().isFillScreenClaimable(by: phoneID)) - #expect(state(grid: TerminalGridSize(cols: 60, rows: 60)).isFillScreenClaimable(by: phoneID)) + @Test func letterboxedInEitherDimensionPutsThisPhoneFirst() { + #expect(state().fillScreenPolicy(for: phoneID) == phoneFirst) + #expect(state(grid: TerminalGridSize(cols: 60, rows: 60)).fillScreenPolicy(for: phoneID) == phoneFirst) + } + + @Test func gridThatFillsThePhoneUnderTheDefaultIsLeftAlone() { + #expect(state(grid: TerminalGridSize(cols: 72, rows: 60)).fillScreenPolicy(for: phoneID) == nil) + } + + /// Build 7 set follow-latest with the phone counted. Any Mac keystroke then + /// resized the grid under the phone, so that claim moves to priority even + /// while the phone currently fills. + @Test func followLatestWithThisPhoneCountedMovesToPriority() { + let claimed = state( + grid: TerminalGridSize(cols: 72, rows: 60), + policy: .latest, + phone: phone(countsOverride: true) + ) + #expect(claimed.fillScreenPolicy(for: phoneID) == phoneFirst) } - @Test func gridThatFillsThePhoneIsNotClaimable() { - #expect(!state(grid: TerminalGridSize(cols: 72, rows: 60)).isFillScreenClaimable(by: phoneID)) + @Test func followLatestWithoutTheCountsClaimIsLeftAlone() { + #expect(state(policy: .latest).fillScreenPolicy(for: phoneID) == nil) + #expect(state(policy: .latest, phone: phone(countsOverride: false)).fillScreenPolicy(for: phoneID) == nil) } - @Test func fixedPolicyIsNotClaimable() { + @Test func otherPoliciesAndCountsChoicesAreLeftAlone() { let fixed = TerminalSizingPolicy(mode: .fixed, fixed: TerminalGridSize(cols: 60, rows: 30)) - #expect(!state(policy: fixed).isFillScreenClaimable(by: phoneID)) + #expect(state(policy: fixed).fillScreenPolicy(for: phoneID) == nil) + #expect(state(policy: phoneFirst).fillScreenPolicy(for: phoneID) == nil) + #expect(state(phone: phone(countsOverride: true)).fillScreenPolicy(for: phoneID) == nil) + #expect(state(phone: phone(countsOverride: false)).fillScreenPolicy(for: phoneID) == nil) } - @Test func phoneMissingFromStateOrWithoutViewportIsNotClaimable() { - #expect(!state(phone: nil).isFillScreenClaimable(by: phoneID)) - let noViewport = TerminalSizingParticipant(id: phoneID, deviceKind: .iphone) - #expect(!state(phone: noViewport).isFillScreenClaimable(by: phoneID)) + @Test func phoneMissingFromStateOrWithoutViewportIsLeftAlone() { + #expect(state(phone: nil).fillScreenPolicy(for: phoneID) == nil) + #expect(state(phone: phone(viewport: nil)).fillScreenPolicy(for: phoneID) == nil) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 917d1f9395d2..ee2819034ab7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -115,10 +115,12 @@ import Testing #expect(!MobileDisplaySettings(defaults: defaults).useLegacyTerminalSizing) } - @Test func handoffTerminalSizingDefaultsToOffWithoutAWrite() throws { + /// On by default so a phone that put itself first in a priority policy + /// returns the grid to the Mac once it goes to the background. + @Test func handoffTerminalSizingDefaultsToOnWithoutAWrite() throws { let defaults = try makeDefaults("handoffTerminalSizingDefaults") let settings = MobileDisplaySettings(defaults: defaults) - #expect(!settings.handoffTerminalSizingWhenInactive) + #expect(settings.handoffTerminalSizingWhenInactive) #expect(defaults.object(forKey: "cmux.mobile.handoffTerminalSizingWhenInactive") == nil) } From 5629e6208a114ab7d0f2c040641744dba3f8fc3e Mon Sep 17 00:00:00 2001 From: "supasai(boy)" Date: Thu, 29 Oct 2026 22:39:36 +0700 Subject: [PATCH 5/7] fix(ios): a shared terminal's phone claims priority instead of follow latest Follow latest gave the grid back to the Mac on any Mac keystroke, including cmux send. The resize landed mid-stream and the phone went blank, then showed a stale 97x29 scaled grid until the next phone input. The phone now counts itself and comes first in a priority policy, keyed to its own device, so Mac input no longer resizes the grid while the phone shows the terminal. Build 7's follow-latest claims (latest with the phone counted) move over. Hand Off Terminal Sizing When Inactive now defaults to on, so the Mac takes the grid back when the phone goes to the background. --- .../MobileShellComposite+TerminalSizing.swift | 14 ++++----- .../TerminalSizingState+FillScreenClaim.swift | 30 ++++++++++--------- .../MobileDisplaySettings.swift | 7 +++-- .../MobileSettingsView.swift | 2 +- .../Resources/Localizable.xcstrings | 18 +++++------ 5 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift index 0a5fec51212d..10dbeb259c81 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift @@ -103,15 +103,15 @@ extension MobileShellComposite { claimTerminalFillScreenIfNeeded(surfaceID: surfaceID) } - /// Lets a letterboxed phone take a terminal it shares under the default - /// policy: follow the latest device, with this phone counted, so the grid - /// fills whichever device was used last. + /// 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 !terminalFillScreenClaimedSurfaceIDs.contains(surfaceID), let surface = terminalSizingBySurfaceID[surfaceID], - surface.state?.isFillScreenClaimable( - by: surface.selfParticipantID ?? defaultTerminalParticipantID - ) == true, + let policy = surface.state?.fillScreenPolicy( + for: surface.selfParticipantID ?? defaultTerminalParticipantID + ), MobileTerminalFillScreenPreference(defaults: terminalFillScreenDefaults).isEnabled else { return } terminalFillScreenClaimedSurfaceIDs.insert(surfaceID) @@ -119,7 +119,7 @@ extension MobileShellComposite { // Counts first: under fit-everyone it leaves the grid unchanged, so // the phone never briefly shows the Mac's grid between the two. guard await setTerminalCountsOverride(true, surfaceID: surfaceID), - await setTerminalSizePolicy(.latest, surfaceID: surfaceID) else { + await setTerminalSizePolicy(policy, surfaceID: surfaceID) else { terminalFillScreenClaimedSurfaceIDs.remove(surfaceID) return } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift index 3202729adb35..497eb8f65c1c 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift @@ -1,22 +1,24 @@ public import CmuxTerminalSizing extension TerminalSizingState { - /// Whether a phone may take over this terminal's size so the grid fills it. + /// The policy that lets a phone 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. Following - /// the latest device with the phone counted gives the grid to whichever - /// device was used last. Only the untouched default qualifies, never a - /// policy or counts choice someone made. + /// viewport, so a phone next to a shorter Mac pane letterboxes. Putting this + /// phone first in a priority policy gives it the grid while it counts, and + /// the Mac takes it back once the phone leaves or hands off. Follow latest + /// is not enough: any Mac input resizes the grid under the phone, so a + /// follow-latest policy with this phone counted (build 7's claim) moves + /// over too. Every other policy or counts choice is someone's decision. /// - Parameter participantID: The phone's participant id. - /// - Returns: `true` when the policy is the default, the phone has a - /// viewport and no counts override, and the grid is smaller than that - /// viewport in some dimension. - public func isFillScreenClaimable(by participantID: String) -> Bool { - guard policy == .fitEveryone, - let phone = participant(participantID)?.participant, - phone.countsOverride == nil, - let viewport = phone.viewport else { return false } - return cols < viewport.cols || rows < viewport.rows + public func fillScreenPolicy(for participantID: String) -> TerminalSizingPolicy? { + guard let phone = participant(participantID)?.participant, + let viewport = phone.viewport else { return nil } + let letterboxed = cols < viewport.cols || rows < viewport.rows + let defaultLetterbox = policy == .fitEveryone && phone.countsOverride == nil && letterboxed + let followLatestClaim = policy == .latest && phone.countsOverride == true + guard defaultLetterbox || followLatestClaim else { return nil } + return TerminalSizingPolicy(mode: .priority, priority: [phone.priorityKey]) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index f6e8f12fa244..5e0f0881dbe7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -117,8 +117,9 @@ public final class MobileDisplaySettings { } /// Whether an inactive iPhone releases its terminal viewport so a Mac can - /// take the grid. Defaults to `false`, preserving the host policy and the - /// last confirmed grid across a background/foreground transition. + /// take the grid. Defaults to `true`: a phone that put itself first in a + /// priority policy would otherwise keep the Mac at its size while in the + /// background. The cost is one resize when the phone comes back. public var handoffTerminalSizingWhenInactive: Bool { didSet { defaults.set( @@ -264,7 +265,7 @@ public final class MobileDisplaySettings { self.useLegacyTerminalSizing = defaults.object(forKey: Self.useLegacyTerminalSizingKey) as? Bool ?? false self.handoffTerminalSizingWhenInactive = defaults.object( forKey: Self.handoffTerminalSizingWhenInactiveKey - ) as? Bool ?? false + ) as? Bool ?? true self.fillScreenWhenShared = MobileTerminalFillScreenPreference(defaults: defaults).isEnabled self.hapticFeedbackEnabled = haptics.isEnabled self.terminalScrollbackRows = MobileTerminalScrollbackPreference.resolve(from: defaults) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 70b5adf6a59d..9e773ed4a25c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -331,7 +331,7 @@ struct MobileSettingsView: View { )) Text(L10n.string( "mobile.settings.fillScreenWhenShared.description", - defaultValue: "When a terminal is also open on your Mac, size it for the device you used last instead of fitting both." + defaultValue: "When a terminal is also open on your Mac, size it for this iPhone while you view it. The Mac gets its size back when you leave the terminal." )) .font(.caption) .foregroundStyle(.secondary) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index 0b4afde319ef..7b7bb53add6d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -22597,15 +22597,15 @@ "mobile.settings.fillScreenWhenShared.description": { "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "When a terminal is also open on your Mac, size it for the device you used last instead of fitting both."}}, - "ja": {"stringUnit": {"state": "translated", "value": "ターミナルがMacでも開いているときは、両方に合わせる代わりに最後に使ったデバイスに合わせてサイズを決めます。"}}, - "de": {"stringUnit": {"state": "translated", "value": "Wenn ein Terminal auch auf Ihrem Mac geöffnet ist, richtet sich seine Größe nach dem zuletzt verwendeten Gerät statt nach beiden."}}, - "fr": {"stringUnit": {"state": "translated", "value": "Lorsqu’un terminal est aussi ouvert sur votre Mac, l’adapter au dernier appareil utilisé plutôt qu’aux deux."}}, - "ar": {"stringUnit": {"state": "translated", "value": "عندما تكون الطرفية مفتوحة أيضًا على جهاز Mac، يُضبط حجمها وفق آخر جهاز استخدمته بدلًا من ملاءمة الجهازين."}}, - "es": {"stringUnit": {"state": "translated", "value": "Cuando un terminal también está abierto en tu Mac, ajustar su tamaño al último dispositivo que usaste en lugar de adaptarlo a ambos."}}, - "zh-Hant": {"stringUnit": {"state": "translated", "value": "終端機也在 Mac 上開啟時,依你最後使用的裝置調整大小,而不是同時符合兩者。"}}, - "zh-Hans": {"stringUnit": {"state": "translated", "value": "终端也在 Mac 上打开时,按你最后使用的设备调整大小,而不是同时适配两者。"}}, - "ko": {"stringUnit": {"state": "translated", "value": "터미널이 Mac에서도 열려 있으면 두 기기에 모두 맞추는 대신 마지막으로 사용한 기기에 맞춰 크기를 조정합니다."}} + "en": {"stringUnit": {"state": "translated", "value": "When a terminal is also open on your Mac, size it for this iPhone while you view it. The Mac gets its size back when you leave the terminal."}}, + "ja": {"stringUnit": {"state": "translated", "value": "ターミナルがMacでも開いているときは、表示中はこのiPhoneに合わせてサイズを決めます。ターミナルを離れるとMacのサイズに戻ります。"}}, + "de": {"stringUnit": {"state": "translated", "value": "Wenn ein Terminal auch auf Ihrem Mac geöffnet ist, richtet sich seine Größe nach diesem iPhone, solange Sie es ansehen. Der Mac erhält seine Größe zurück, wenn Sie das Terminal verlassen."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Lorsqu’un terminal est aussi ouvert sur votre Mac, l’adapter à cet iPhone tant que vous l’affichez. Le Mac retrouve sa taille quand vous quittez le terminal."}}, + "ar": {"stringUnit": {"state": "translated", "value": "عندما تكون الطرفية مفتوحة أيضًا على جهاز Mac، يُضبط حجمها وفق هذا الـ iPhone أثناء عرضها. يستعيد Mac حجمه عند مغادرة الطرفية."}}, + "es": {"stringUnit": {"state": "translated", "value": "Cuando un terminal también está abierto en tu Mac, ajustar su tamaño a este iPhone mientras lo ves. El Mac recupera su tamaño cuando sales del terminal."}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "終端機也在 Mac 上開啟時,在你檢視期間依這部 iPhone 調整大小。離開終端機後,Mac 會恢復原本的大小。"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "终端也在 Mac 上打开时,在你查看期间按这部 iPhone 调整大小。离开终端后,Mac 会恢复原来的大小。"}}, + "ko": {"stringUnit": {"state": "translated", "value": "터미널이 Mac에서도 열려 있으면 보는 동안 이 iPhone에 맞춰 크기를 조정합니다. 터미널을 나가면 Mac이 원래 크기로 돌아갑니다."}} } }, "mobile.settings.handoffTerminalSizing": { From c358d12d249cf6ab0df76799db56f7b30416593b Mon Sep 17 00:00:00 2001 From: "supasai(boy)" Date: Thu, 29 Oct 2026 23:52:17 +0700 Subject: [PATCH 6/7] test(ios): a phone back from a hand-off counts itself toward size again Seen on device with build 8: after Home and back, the Mac listed the phone with counts=false and kept its own 97x29 grid under the phone's priority claim. The reattached phone had lost its counts override, and the claimed-set guard stopped the phone from sending it again. --- .../TerminalFillScreenClaimTests.swift | 25 +++++++++++++- ...minalSizingStateFillScreenClaimTests.swift | 33 ++++++++++++------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift index 999bc6898106..0c6fa884913f 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift @@ -122,6 +122,30 @@ private func claimed(_ router: LivenessHostRouter, atLeast count: Int = 1) async 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) @@ -140,7 +164,6 @@ private func claimed(_ router: LivenessHostRouter, atLeast count: Int = 1) async @Test(arguments: [ TerminalSizingPolicy.latest, TerminalSizingPolicy(mode: .priority, priority: ["u_maya/mac"]), - TerminalSizingPolicy(mode: .priority, priority: [phonePriorityKey]), TerminalSizingPolicy(mode: .smallest, priority: ["u_maya/mac"]), ]) func userChosenPolicyIsLeftAlone(policy: TerminalSizingPolicy) async throws { diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift index 9d7a1754a281..0f744598694a 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift @@ -4,6 +4,7 @@ import Testing private let phoneID = "mobile:phone" private let phoneFirst = TerminalSizingPolicy(mode: .priority, priority: ["u_maya/iphone/phone-device"]) +private let claimFirst = TerminalFillScreenClaim(countsThisPhone: true, policy: phoneFirst) private func phone( viewport: TerminalGridSize? = TerminalGridSize(cols: 72, rows: 60), @@ -44,12 +45,12 @@ private func state( @Suite struct TerminalSizingStateFillScreenClaimTests { @Test func letterboxedInEitherDimensionPutsThisPhoneFirst() { - #expect(state().fillScreenPolicy(for: phoneID) == phoneFirst) - #expect(state(grid: TerminalGridSize(cols: 60, rows: 60)).fillScreenPolicy(for: phoneID) == phoneFirst) + #expect(state().fillScreenClaim(for: phoneID) == claimFirst) + #expect(state(grid: TerminalGridSize(cols: 60, rows: 60)).fillScreenClaim(for: phoneID) == claimFirst) } @Test func gridThatFillsThePhoneUnderTheDefaultIsLeftAlone() { - #expect(state(grid: TerminalGridSize(cols: 72, rows: 60)).fillScreenPolicy(for: phoneID) == nil) + #expect(state(grid: TerminalGridSize(cols: 72, rows: 60)).fillScreenClaim(for: phoneID) == nil) } /// Build 7 set follow-latest with the phone counted. Any Mac keystroke then @@ -61,24 +62,32 @@ private func state( policy: .latest, phone: phone(countsOverride: true) ) - #expect(claimed.fillScreenPolicy(for: phoneID) == phoneFirst) + #expect(claimed.fillScreenClaim(for: phoneID) == TerminalFillScreenClaim(countsThisPhone: false, policy: phoneFirst)) + } + + /// Back from a hand-off the phone reattaches without its counts override, + /// so under its own priority claim it stops counting and letterboxes. + @Test func ownPriorityClaimWithoutCountsCountsAgain() { + let returned = state(policy: phoneFirst) + #expect(returned.fillScreenClaim(for: phoneID) == TerminalFillScreenClaim(countsThisPhone: true, policy: nil)) + #expect(state(policy: phoneFirst, phone: phone(countsOverride: true)).fillScreenClaim(for: phoneID) == nil) + #expect(state(policy: phoneFirst, phone: phone(countsOverride: false)).fillScreenClaim(for: phoneID) == nil) } @Test func followLatestWithoutTheCountsClaimIsLeftAlone() { - #expect(state(policy: .latest).fillScreenPolicy(for: phoneID) == nil) - #expect(state(policy: .latest, phone: phone(countsOverride: false)).fillScreenPolicy(for: phoneID) == nil) + #expect(state(policy: .latest).fillScreenClaim(for: phoneID) == nil) + #expect(state(policy: .latest, phone: phone(countsOverride: false)).fillScreenClaim(for: phoneID) == nil) } @Test func otherPoliciesAndCountsChoicesAreLeftAlone() { let fixed = TerminalSizingPolicy(mode: .fixed, fixed: TerminalGridSize(cols: 60, rows: 30)) - #expect(state(policy: fixed).fillScreenPolicy(for: phoneID) == nil) - #expect(state(policy: phoneFirst).fillScreenPolicy(for: phoneID) == nil) - #expect(state(phone: phone(countsOverride: true)).fillScreenPolicy(for: phoneID) == nil) - #expect(state(phone: phone(countsOverride: false)).fillScreenPolicy(for: phoneID) == nil) + #expect(state(policy: fixed).fillScreenClaim(for: phoneID) == nil) + #expect(state(phone: phone(countsOverride: true)).fillScreenClaim(for: phoneID) == nil) + #expect(state(phone: phone(countsOverride: false)).fillScreenClaim(for: phoneID) == nil) } @Test func phoneMissingFromStateOrWithoutViewportIsLeftAlone() { - #expect(state(phone: nil).fillScreenPolicy(for: phoneID) == nil) - #expect(state(phone: phone(viewport: nil)).fillScreenPolicy(for: phoneID) == nil) + #expect(state(phone: nil).fillScreenClaim(for: phoneID) == nil) + #expect(state(phone: phone(viewport: nil)).fillScreenClaim(for: phoneID) == nil) } } From 60024f064c50f78c39118bb8a93d73ead7eb9682 Mon Sep 17 00:00:00 2001 From: "supasai(boy)" Date: Thu, 29 Oct 2026 23:54:03 +0700 Subject: [PATCH 7/7] fix(ios): a phone back from a hand-off counts itself toward size again The claim decision now returns what to send. Under this phone's own priority policy with no counts choice, which is how the Mac sees a phone reattach after Hand Off, the phone re-sends counts_override true and leaves the policy alone. That repeat is guarded only while its request is in flight; the policy claim stays once per terminal. --- .../MobileShellComposite+TerminalSizing.swift | 23 +++++++--- .../MobileShellComposite.swift | 2 + .../TerminalSizingState+FillScreenClaim.swift | 46 ++++++++++++++----- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift index 10dbeb259c81..86babb7e76f9 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift @@ -107,22 +107,33 @@ extension MobileShellComposite { /// 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 !terminalFillScreenClaimedSurfaceIDs.contains(surfaceID), - let surface = terminalSizingBySurfaceID[surfaceID], - let policy = surface.state?.fillScreenPolicy( + guard let surface = terminalSizingBySurfaceID[surfaceID], + let claim = surface.state?.fillScreenClaim( for: surface.selfParticipantID ?? defaultTerminalParticipantID ), MobileTerminalFillScreenPreference(defaults: terminalFillScreenDefaults).isEnabled else { return } - terminalFillScreenClaimedSurfaceIDs.insert(surfaceID) + 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. - guard await setTerminalCountsOverride(true, surfaceID: surfaceID), - await setTerminalSizePolicy(policy, surfaceID: surfaceID) else { + if claim.countsThisPhone, !(await setTerminalCountsOverride(true, surfaceID: surfaceID)) { terminalFillScreenClaimedSurfaceIDs.remove(surfaceID) return } + if !(await setTerminalSizePolicy(policy, surfaceID: surfaceID)) { + terminalFillScreenClaimedSurfaceIDs.remove(surfaceID) + } } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d595ef3f5220..299b6505a6de 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1714,6 +1714,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // Fit everyone, with no counts choice, is claimed again; persist the ids // per Mac if that shows up. @ObservationIgnored var terminalFillScreenClaimedSurfaceIDs: Set = [] + /// Terminals whose phone is re-sending its counts claim after a hand-off. + @ObservationIgnored var terminalFillScreenRecountingSurfaceIDs: Set = [] /// 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. diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift index 497eb8f65c1c..90f4145d0703 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift @@ -1,24 +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 { - /// The policy that lets a phone fill its screen in this terminal, or `nil` + /// 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 gives it the grid while it counts, and - /// the Mac takes it back once the phone leaves or hands off. Follow latest - /// is not enough: any Mac input resizes the grid under the phone, so a - /// follow-latest policy with this phone counted (build 7's claim) moves - /// over too. Every other policy or counts choice is someone's decision. + /// 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 fillScreenPolicy(for participantID: String) -> TerminalSizingPolicy? { + 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 - let defaultLetterbox = policy == .fitEveryone && phone.countsOverride == nil && letterboxed - let followLatestClaim = policy == .latest && phone.countsOverride == true - guard defaultLetterbox || followLatestClaim else { return nil } - return TerminalSizingPolicy(mode: .priority, priority: [phone.priorityKey]) + 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 + } } }