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+TerminalSizing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalSizing.swift index 7fc7ffcaa2ee..86babb7e76f9 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,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. diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 990c818867a5..299b6505a6de 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -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 = [] + /// 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/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index f7833d7f4e51..fa91a4862548 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -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] = [] @@ -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, @@ -154,7 +160,10 @@ actor LivenessHostRouter { attachToken: attachToken, stackAccessToken: stackAccessToken, deviceKind: deviceKind, - deviceName: deviceName + deviceName: deviceName, + policyMode: policyMode, + policyPriority: policyPriority, + countsOverride: countsOverride )) resumeSatisfiedCountWaiters() } @@ -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 @@ -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 diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift new file mode 100644 index 000000000000..0c6fa884913f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalFillScreenClaimTests.swift @@ -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() + } +} 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..90f4145d0703 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalSizingState+FillScreenClaim.swift @@ -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 + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift new file mode 100644 index 000000000000..0f744598694a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalSizingStateFillScreenClaimTests.swift @@ -0,0 +1,93 @@ +import CmuxMobileShellModel +import CmuxTerminalSizing +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), + countsOverride: Bool? = nil +) -> TerminalSizingParticipant { + TerminalSizingParticipant( + id: phoneID, + userID: "u_maya", + deviceKind: .iphone, + 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", + 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 letterboxedInEitherDimensionPutsThisPhoneFirst() { + #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)).fillScreenClaim(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.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).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).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).fillScreenClaim(for: phoneID) == nil) + #expect(state(phone: phone(viewport: nil)).fillScreenClaim(for: phoneID) == nil) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 41e7ec9c04a3..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( @@ -128,6 +129,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``. @@ -253,7 +265,8 @@ 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) 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..9e773ed4a25c 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 this iPhone while you view it. The Mac gets its size back when you leave the terminal." + )) + .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..7b7bb53add6d 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 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": { "extractionState": "manual", "localizations": { 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) }