From 9e517a5a65d05944d1f4ce9c8ed796b4ad0ddbe7 Mon Sep 17 00:00:00 2001 From: Jostein Haugland Dyrseth Date: Fri, 9 Oct 2026 17:46:50 +0200 Subject: [PATCH 1/3] feat(viewer): add a header-free desktop focus mode --- CHANGELOG.md | 6 +++ README.md | 9 +++++ .../Sources/ODHubViewerApp/ViewerMain.swift | 5 +++ .../DevicePresentationView.swift | 5 ++- .../OpenDeviceHubViewer/DeviceWindow.swift | 9 +++++ .../DeviceWindowController.swift | 38 ++++++++++++++++++- .../PresentationLayout.swift | 11 +++++- .../OpenDeviceHubViewer/ViewerMenu.swift | 8 ++++ .../MenuSettingsTests.swift | 17 +++++++++ .../PresentationLayoutTests.swift | 11 ++++++ 10 files changed, 115 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00a0e2..52d34bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ The appcast generator reads this file. Each release is a `## ` heading, and everything under it until the next heading becomes that version's release notes in the update feed. +## Unreleased + +- Window → Focus Mode (⇧⌘F) hides the header and fits the device into the desktop, + without entering full screen. Toggle it off to restore the previous window; ⌥⌘-drag + moves the focused window. Menus, device shortcuts and the separate bezel toggle remain available. + ## 0.2.0 - The camera control works on every iPhone that has one, not only the iPhone Duo, and Device, diff --git a/README.md b/README.md index b186aca..0ade296 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,15 @@ options. +## Focus Mode + +Choose **Window → Focus Mode** (⇧⌘F) to hide the header and fill the available desktop +without entering macOS full screen. The phone keeps its proportions, with a small resize +margin. Toggle it again to restore the previous window. Hold ⌥⌘ and drag to move the +window; device controls remain in the menus and their usual keyboard shortcuts work. +Hide device bezels separately with ⌘B for more screen space. Focus Mode is per window +and is not saved across launches. Fixed scale presets resume after leaving Focus Mode. + ## Current limitations - **The windows are invisible to accessibility**: VoiceOver, window managers and scripting tools see the app and its menus but not its windows. diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 2efb26f..5da3525 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -256,6 +256,10 @@ struct ODHubViewer: ParsableCommand { guard let target = front() else { return } target.controller.setKeepOnTop(!target.controller.isKeptOnTop) }, + toggleFocusMode: { + guard let target = front() else { return } + target.controller.setFocused(!target.controller.isFocused) + }, pasteToDevice: { guard let target = front(), let text = NSPasteboard.general.string(forType: .string) else { return } @@ -522,6 +526,7 @@ struct ODHubViewer: ParsableCommand { case .latencyOverlay: return controller.isLatencyOverlayVisible case .bezel: return controller.isBezelEnabled case .keepOnTop: return controller.isKeptOnTop + case .focusMode: return controller.isFocused case .increasedContrast: if let known = controller.increasesContrast { return known } let read = SimctlService().increasesContrast(udid: target.udid) diff --git a/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift b/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift index 961a0ba..21cf920 100644 --- a/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift +++ b/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift @@ -10,6 +10,9 @@ public final class DevicePresentationView: NSView { public let model: DuoModelView? private var isFullScreen = false + public var isFocused = false { + didSet { bar.isHidden = isFocused; needsLayout = true } + } public init(bar: DeviceControlBar, chrome: DeviceChromeView, model: DuoModelView? = nil) { self.bar = bar @@ -37,7 +40,7 @@ public final class DevicePresentationView: NSView { } public var currentLayout: PresentationLayout { - PresentationLayout(contentSize: bounds.size, isFullScreen: isFullScreen) + PresentationLayout(contentSize: bounds.size, isFullScreen: isFullScreen, isFocused: isFocused) } /// The content covers the whole window, including the few points AppKit keeps at the edges for diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift index c91298c..f8888c4 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift @@ -14,7 +14,16 @@ final class DeviceWindow: NSWindow { /// A drag that starts on one of the device's corners resizes the window, keeping the device's /// shape. AppKit's own edge resize still works; this is the handle that is easy to hit. + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + override func sendEvent(_ event: NSEvent) { + if event.type == .leftMouseDown, + event.modifierFlags.contains([.command, .option]), + (contentView as? DevicePresentationView)?.isFocused == true { + performDrag(with: event) + return + } if resize != nil { switch event.type { case .leftMouseDragged: diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 92e9248..5f34b8d 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -65,6 +65,39 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate private var parallelOffset = CGSize(width: 0.12, height: 0) private var dragEdge: TouchEvent.Edge = .none private var orientation: DeviceOrientation = .portrait + public private(set) var isFocused = false + private var frameBeforeFocus: CGRect? + private var styleBeforeFocus: NSWindow.StyleMask? + private var toolbarBeforeFocus: NSToolbar? + + /// A reversible, ordinary desktop window with no header and only its resize margin. + public func setFocused(_ focused: Bool) { + guard let window, focused != isFocused, + !window.styleMask.contains(.fullScreen) else { return } + isFocused = focused + presentationView.isFocused = focused + if focused { + frameBeforeFocus = window.frame + styleBeforeFocus = window.styleMask + toolbarBeforeFocus = window.toolbar + window.toolbar = nil + window.styleMask = [.borderless, .resizable, .miniaturizable] + window.contentResizeIncrements = NSSize(width: 1, height: 1) + if let visible = (window.screen ?? NSScreen.main)?.visibleFrame { + window.setFrame(visible, display: true) + } + } else { + if let styleBeforeFocus { window.styleMask = styleBeforeFocus } + window.toolbar = toolbarBeforeFocus + toolbarBeforeFocus = nil + if let frameBeforeFocus { window.setFrame(frameBeforeFocus, display: true) } + frameBeforeFocus = nil + styleBeforeFocus = nil + } + window.makeKeyAndOrderFront(nil) + presentationView.needsLayout = true + } + private var scaleMode: ScaleMode private let deviceName: String private var pendingSend: Task? @@ -352,6 +385,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate @discardableResult public func applyScaleMode(_ mode: ScaleMode) -> ScaleApplication { scaleMode = mode + if isFocused { presentationView.needsLayout = true; return .noFixedSize } guard let window else { return .unavailable } let device = DeviceMetrics( pixelSize: session.pixelSize, @@ -626,7 +660,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate /// Fit mode has no size of its own, so a sideways turn reshapes the window itself, keeping the /// device at the size it is drawn. A foldable's viewport is never reshaped. private func reshape(from previous: DeviceOrientation) { - guard scaleMode == .fit, modelView == nil, let window, + guard !isFocused, scaleMode == .fit, modelView == nil, let window, !window.styleMask.contains(.fullScreen) else { return } let content = PresentationLayout.contentSizeTurned( from: window.contentRect(forFrameRect: window.frame).size, @@ -698,7 +732,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate } public func rememberFrame() { - guard tracksFrameChanges, + guard !isFocused, tracksFrameChanges, let window, !window.styleMask.contains(.fullScreen) else { return } frameStore.save(uprightFrame(of: window), for: frameKey) diff --git a/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift b/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift index 3558519..9486784 100644 --- a/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift +++ b/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift @@ -49,9 +49,18 @@ public struct PresentationLayout: Equatable { /// Full screen hands the window to AppKit, which puts its own chrome at the top, so the bar /// squares off and the device takes the rest. - public init(contentSize: CGSize, isFullScreen: Bool = false) { + public init(contentSize: CGSize, isFullScreen: Bool = false, isFocused: Bool = false) { let width = max(contentSize.width, 0) let height = max(contentSize.height, 0) + if isFocused { + bar = .zero + cornerRadius = 0 + let border = Self.resizeBorder + device = CGRect(x: border, y: border, + width: max(width - border * 2, 0), + height: max(height - border * 2, 0)) + return + } bar = CGRect( x: 0, y: max(height - Self.barHeight, 0), diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 16c7b7f..ea8e1cf 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -32,12 +32,14 @@ public enum ViewerMenu { case latencyOverlay case bezel case keepOnTop + case focusMode } public struct Actions { public var setScaleMode: (ScaleMode) -> Void public var toggleBezel: () -> Void public var toggleKeepOnTop: () -> Void + public var toggleFocusMode: () -> Void public var pasteToDevice: () -> Void public var toggleAppearance: () -> Void public var saveScreenshot: () -> Void @@ -88,6 +90,7 @@ public enum ViewerMenu { setScaleMode: @escaping (ScaleMode) -> Void, toggleBezel: @escaping () -> Void, toggleKeepOnTop: @escaping () -> Void, + toggleFocusMode: @escaping () -> Void = {}, pasteToDevice: @escaping () -> Void, toggleAppearance: @escaping () -> Void, saveScreenshot: @escaping () -> Void, @@ -134,6 +137,7 @@ public enum ViewerMenu { self.setScaleMode = setScaleMode self.toggleBezel = toggleBezel self.toggleKeepOnTop = toggleKeepOnTop + self.toggleFocusMode = toggleFocusMode self.pasteToDevice = pasteToDevice self.toggleAppearance = toggleAppearance self.saveScreenshot = saveScreenshot @@ -457,6 +461,9 @@ public enum ViewerMenu { windowMenu.addItem(.separator()) windowMenu.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") .keyEquivalentModifierMask = [.control, .command] + let focus = target.item("Focus Mode", #selector(MenuTarget.focusMode), "f", [.command, .shift]) + target.track(focus, as: .focusMode) + windowMenu.addItem(focus) let bezel = target.item("Show Device Bezels", #selector(MenuTarget.bezel), "b", []) target.track(bezel, as: .bezel) windowMenu.addItem(bezel) @@ -689,6 +696,7 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { ]) } + @objc func focusMode() { actions.toggleFocusMode() } @objc func bezel() { actions.toggleBezel() } @objc func keepOnTop() { actions.toggleKeepOnTop() } @objc func saveScreenshot() { actions.saveScreenshot() } diff --git a/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift index 86b6189..20c788c 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift @@ -7,6 +7,22 @@ import OpenDeviceHubEngine final class MenuSettingsTests: XCTestCase { private var previousMenu: NSMenu? + func testFocusModeCanBeFoundAndItsCheckmarkFollowsTheFrontDevice() throws { + var focused = false + var harness = MenuHarness() + harness.isOn = { $0 == .focusMode && focused } + let target = harness.install() + let (item, _) = try XCTUnwrap(MenuHarness.item(titled: "Focus Mode")) + XCTAssertTrue(target.validateMenuItem(item)) + XCTAssertEqual(item.state, .off) + XCTAssertEqual(item.keyEquivalent, "f") + XCTAssertEqual(item.keyEquivalentModifierMask, [.command, .shift]) + focused = true + XCTAssertTrue(target.validateMenuItem(item)) + XCTAssertEqual(item.state, .on) + } + + override func setUp() async throws { previousMenu = NSApplication.shared.mainMenu } @@ -78,6 +94,7 @@ final class MenuSettingsTests: XCTestCase { .latencyOverlay: "Show Click to Frame Latency", .bezel: "Show Device Bezels", .keepOnTop: "Stay On Top", + .focusMode: "Focus Mode", ] XCTAssertEqual(Set(titles.keys), Set(ViewerMenu.DeviceSetting.allCases)) var inFront: Set = [] diff --git a/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift b/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift index 1e336e7..0c2ff31 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift @@ -2,6 +2,17 @@ import XCTest @testable import OpenDeviceHubViewer final class PresentationLayoutTests: XCTestCase { + func testFocusModeReclaimsHeaderAndMarginsWhileKeepingResizeEdges() { + let size = CGSize(width: 1024, height: 644) + let normal = PresentationLayout(contentSize: size) + let focused = PresentationLayout(contentSize: size, isFocused: true) + XCTAssertEqual(focused.bar, .zero) + XCTAssertEqual(focused.device, CGRect(x: 5, y: 5, width: 1014, height: 634)) + XCTAssertGreaterThan(focused.device.height, normal.device.height) + let tiny = PresentationLayout(contentSize: CGSize(width: 2, height: 2), isFocused: true) + XCTAssertEqual(tiny.device.size, .zero) + } + private let device = CGSize(width: 402, height: 874) func testTheBarFloatsInsetFromEveryEdgeItTouches() { From 35bae0c0ad5fc5c240a6ca4958e68627ca895d37 Mon Sep 17 00:00:00 2001 From: Jostein Haugland Dyrseth Date: Fri, 9 Oct 2026 17:51:52 +0200 Subject: [PATCH 2/3] feat(viewer): remember focus mode per simulator --- CHANGELOG.md | 2 ++ README.md | 5 +++-- .../DeviceWindowController.swift | 5 +++++ .../WindowFrameStore.swift | 13 ++++++++++- .../WindowManagementTests.swift | 22 +++++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d34bf..644263f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ under it until the next heading becomes that version's release notes in the upda ## Unreleased +- Remember Focus Mode per simulator across launches while preserving the normal window frame. + - Window → Focus Mode (⇧⌘F) hides the header and fits the device into the desktop, without entering full screen. Toggle it off to restore the previous window; ⌥⌘-drag moves the focused window. Menus, device shortcuts and the separate bezel toggle remain available. diff --git a/README.md b/README.md index 0ade296..6931e61 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,9 @@ Choose **Window → Focus Mode** (⇧⌘F) to hide the header and fill the avail without entering macOS full screen. The phone keeps its proportions, with a small resize margin. Toggle it again to restore the previous window. Hold ⌥⌘ and drag to move the window; device controls remain in the menus and their usual keyboard shortcuts work. -Hide device bezels separately with ⌘B for more screen space. Focus Mode is per window -and is not saved across launches. Fixed scale presets resume after leaving Focus Mode. +Hide device bezels separately with ⌘B for more screen space. Focus Mode is remembered per simulator across launches, together with its normal +window frame. Toggle it off to restore that frame. Focus Mode uses fit sizing; leave +it before choosing a fixed scale preset. ## Current limitations - **The windows are invisible to accessibility**: VoiceOver, window managers and scripting tools see diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 5f34b8d..6a63745 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -78,6 +78,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate presentationView.isFocused = focused if focused { frameBeforeFocus = window.frame + frameStore.save(uprightFrame(of: window), for: frameKey) styleBeforeFocus = window.styleMask toolbarBeforeFocus = window.toolbar window.toolbar = nil @@ -94,6 +95,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate frameBeforeFocus = nil styleBeforeFocus = nil } + frameStore.setFocused(focused, for: frameKey) window.makeKeyAndOrderFront(nil) presentationView.needsLayout = true } @@ -724,6 +726,9 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate // A unified toolbar makes the title bar taller, which would otherwise come out of the // device's own height, so the window is sized again now that it is there. applyScaleMode(scaleMode) + // Restore after toolbar installation and normal window placement, so leaving Focus Mode + // has a complete toolbar and a normal frame to return to. + if frameStore.isFocused(for: frameKey) { setFocused(true) } } /// Offers a finished recording for dragging out of the window. diff --git a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift index f71f90b..0df9076 100644 --- a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift +++ b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift @@ -73,13 +73,24 @@ public struct WindowFrameStore: Sendable { storage.setText(WindowFrameCodec.encode(frame), forKey: prefix + udid) } + public func isFocused(for udid: String) -> Bool { + storage.text(forKey: prefix + "focus." + udid) == "true" + } + + public func setFocused(_ focused: Bool, for udid: String) { + let key = prefix + "focus." + udid + if focused { storage.setText("true", forKey: key) } + else { storage.removeText(forKey: key) } + } + public func forget(_ udid: String) { + setFocused(false, for: udid) storage.removeText(forKey: prefix + udid) } /// How many windows would reopen where they were left. public var rememberedCount: Int { - storage.keys(withPrefix: prefix).count + storage.keys(withPrefix: prefix).filter { !$0.hasPrefix(prefix + "focus.") }.count } public func forgetAll() { diff --git a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift index 59b7c3b..aa5e65a 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift @@ -103,6 +103,28 @@ final class WindowFrameStoreTests: XCTestCase { WindowFrameStore(storage: InMemoryFrameStorage(), prefix: "t.") } + func testFocusAndNormalFrameSurviveRelaunchIndependentlyPerDevice() { + let storage = InMemoryFrameStorage() + let store = WindowFrameStore(storage: storage, prefix: "t.") + let normal = CGRect(x: 20, y: 30, width: 400, height: 600) + store.save(normal, for: "A") + store.setFocused(true, for: "A") + let restored = WindowFrameStore(storage: storage, prefix: "t.") + XCTAssertTrue(restored.isFocused(for: "A")) + XCTAssertFalse(restored.isFocused(for: "B")) + XCTAssertEqual(restored.frame(for: "A"), normal) + XCTAssertEqual(restored.rememberedCount, 1) + restored.setFocused(false, for: "A") + XCTAssertFalse(store.isFocused(for: "A")) + XCTAssertEqual(store.frame(for: "A"), normal) + store.setFocused(true, for: "A") + store.forget("A") + XCTAssertFalse(store.isFocused(for: "A")) + store.setFocused(true, for: "B") + store.forgetAll() + XCTAssertFalse(store.isFocused(for: "B")) + } + func testItCountsWhatItRemembers() { let store = makeStore() XCTAssertEqual(store.rememberedCount, 0) From f19271149b2c993f929eb926cd05542169de3064 Mon Sep 17 00:00:00 2001 From: Jostein Haugland Dyrseth Date: Fri, 9 Oct 2026 19:27:33 +0200 Subject: [PATCH 3/3] feat(viewer): share focus mode across simulators --- CHANGELOG.md | 4 ++- README.md | 5 ++-- .../Sources/ODHubViewerApp/ViewerMain.swift | 5 ++-- .../DeviceWindowController.swift | 8 ++--- .../DeviceWindowManager.swift | 12 ++++++++ .../WindowFrameStore.swift | 24 ++++++++++----- .../FrontDeviceTests.swift | 29 ++++++++++++++++++ .../WindowManagementTests.swift | 30 ++++++++++++------- 8 files changed, 89 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 644263f..dba1f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ under it until the next heading becomes that version's release notes in the upda ## Unreleased -- Remember Focus Mode per simulator across launches while preserving the normal window frame. +- Remember Focus Mode globally across launches and apply toggles to all open simulators. + New devices follow the shared preference; normal window frames remain per device. + Previously enabled per-device focus preferences migrate automatically. - Window → Focus Mode (⇧⌘F) hides the header and fits the device into the desktop, without entering full screen. Toggle it off to restore the previous window; ⌥⌘-drag diff --git a/README.md b/README.md index 6931e61..feb3cef 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,9 @@ Choose **Window → Focus Mode** (⇧⌘F) to hide the header and fill the avail without entering macOS full screen. The phone keeps its proportions, with a small resize margin. Toggle it again to restore the previous window. Hold ⌥⌘ and drag to move the window; device controls remain in the menus and their usual keyboard shortcuts work. -Hide device bezels separately with ⌘B for more screen space. Focus Mode is remembered per simulator across launches, together with its normal -window frame. Toggle it off to restore that frame. Focus Mode uses fit sizing; leave +Hide device bezels separately with ⌘B for more screen space. Focus Mode is shared by all simulator windows and remembered across launches. +Toggling it updates all open devices; new devices follow the same preference. Each +device keeps its own normal window frame for toggling back. Focus Mode uses fit sizing; leave it before choosing a fixed scale preset. ## Current limitations diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 5da3525..74e5b4d 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -257,8 +257,7 @@ struct ODHubViewer: ParsableCommand { target.controller.setKeepOnTop(!target.controller.isKeptOnTop) }, toggleFocusMode: { - guard let target = front() else { return } - target.controller.setFocused(!target.controller.isFocused) + manager.toggleFocusMode() }, pasteToDevice: { guard let target = front(), @@ -526,7 +525,7 @@ struct ODHubViewer: ParsableCommand { case .latencyOverlay: return controller.isLatencyOverlayVisible case .bezel: return controller.isBezelEnabled case .keepOnTop: return controller.isKeptOnTop - case .focusMode: return controller.isFocused + case .focusMode: return manager.isFocused case .increasedContrast: if let known = controller.increasesContrast { return known } let read = SimctlService().increasesContrast(udid: target.udid) diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 6a63745..fc3b993 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -71,7 +71,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate private var toolbarBeforeFocus: NSToolbar? /// A reversible, ordinary desktop window with no header and only its resize margin. - public func setFocused(_ focused: Bool) { + public func setFocused(_ focused: Bool, activate: Bool = true) { guard let window, focused != isFocused, !window.styleMask.contains(.fullScreen) else { return } isFocused = focused @@ -95,8 +95,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate frameBeforeFocus = nil styleBeforeFocus = nil } - frameStore.setFocused(focused, for: frameKey) - window.makeKeyAndOrderFront(nil) + if activate { window.makeKeyAndOrderFront(nil) } presentationView.needsLayout = true } @@ -728,7 +727,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate applyScaleMode(scaleMode) // Restore after toolbar installation and normal window placement, so leaving Focus Mode // has a complete toolbar and a normal frame to return to. - if frameStore.isFocused(for: frameKey) { setFocused(true) } + if frameStore.isFocused { setFocused(true) } } /// Offers a finished recording for dragging out of the window. @@ -775,6 +774,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate (window as? DeviceWindow)?.constrainsToScreen = false presentationView.setFullScreen(false) toolbar?.setFoldModes(visible: false) + setFocused(frameStore.isFocused, activate: false) // The device may have been turned while full screen, where the window is not resized, so // the shape it comes back to is whatever the current orientation asks for. applyScaleMode(scaleMode) diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index c4bc9aa..1fa1c34 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -30,6 +30,18 @@ public final class DeviceWindowManager { public var openCount: Int { controllers.count } + public var isFocused: Bool { frameStore.isFocused } + + public func toggleFocusMode() { + let focused = !isFocused + frameStore.setFocused(focused) + let front = NSApp.keyWindow + for controller in controllers.values { + controller.setFocused(focused, activate: false) + } + front?.makeKey() + } + /// Current screenshot preference, read by per-window toolbar actions at invocation time. public var savesScreenshotsToClipboard: Bool { settings.savesScreenshotsToClipboard } diff --git a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift index 0df9076..ee497bf 100644 --- a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift +++ b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift @@ -73,18 +73,26 @@ public struct WindowFrameStore: Sendable { storage.setText(WindowFrameCodec.encode(frame), forKey: prefix + udid) } - public func isFocused(for udid: String) -> Bool { - storage.text(forKey: prefix + "focus." + udid) == "true" + /// Shared across devices. Migrate an enabled legacy device preference once. + public var isFocused: Bool { + let key = prefix + "focus.enabled" + if let saved = storage.text(forKey: key) { return saved == "true" } + let enabled = storage.keys(withPrefix: prefix + "focus.").contains { + storage.text(forKey: $0) == "true" + } + setFocused(enabled) + return enabled } - public func setFocused(_ focused: Bool, for udid: String) { - let key = prefix + "focus." + udid - if focused { storage.setText("true", forKey: key) } - else { storage.removeText(forKey: key) } + public func setFocused(_ focused: Bool) { + for key in storage.keys(withPrefix: prefix + "focus.") { + storage.removeText(forKey: key) + } + storage.setText(focused ? "true" : "false", forKey: prefix + "focus.enabled") } public func forget(_ udid: String) { - setFocused(false, for: udid) + storage.removeText(forKey: prefix + "focus." + udid) storage.removeText(forKey: prefix + udid) } @@ -94,7 +102,7 @@ public struct WindowFrameStore: Sendable { } public func forgetAll() { - for key in storage.keys(withPrefix: prefix) { + for key in storage.keys(withPrefix: prefix) where !key.hasPrefix(prefix + "focus.") { storage.removeText(forKey: key) } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift b/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift index 38c22d4..4efec9b 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift @@ -42,6 +42,35 @@ final class FrontDeviceTests: XCTestCase { XCTAssertEqual(manager.frontmostUDID, "second") } + func testFocusToggleUpdatesAllWindowsAndNewDevicesFollowIt() throws { + func installToolbar(_ controller: DeviceWindowController) { + controller.setToolbarActions(DeviceToolbarActions( + goHome: {}, saveScreenshot: {}, stopRecording: {}, rotate: { _ in } + )) + } + let first = try open("first") + let second = try open("second") + installToolbar(first) + installToolbar(second) + let firstFrame = try XCTUnwrap(first.window).frame + let secondFrame = try XCTUnwrap(second.window).frame + manager.toggleFocusMode() + XCTAssertTrue(manager.isFocused) + XCTAssertTrue(first.isFocused) + XCTAssertTrue(second.isFocused) + let third = try open("third") + installToolbar(third) + XCTAssertTrue(third.isFocused, "new devices must follow the shared preference") + manager.toggleFocusMode() + XCTAssertFalse(first.isFocused) + XCTAssertFalse(second.isFocused) + XCTAssertFalse(third.isFocused) + XCTAssertEqual(first.window?.frame, firstFrame) + XCTAssertEqual(second.window?.frame, secondFrame) + XCTAssertNotNil(first.window?.toolbar) + XCTAssertNotNil(second.window?.toolbar) + } + private func open(_ udid: String) throws -> DeviceWindowController { try manager.open( device: DeviceInfo( diff --git a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift index aa5e65a..db1ec43 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift @@ -103,26 +103,36 @@ final class WindowFrameStoreTests: XCTestCase { WindowFrameStore(storage: InMemoryFrameStorage(), prefix: "t.") } - func testFocusAndNormalFrameSurviveRelaunchIndependentlyPerDevice() { + func testGlobalFocusSurvivesRelaunchWithoutChangingDeviceFrames() { let storage = InMemoryFrameStorage() let store = WindowFrameStore(storage: storage, prefix: "t.") let normal = CGRect(x: 20, y: 30, width: 400, height: 600) store.save(normal, for: "A") - store.setFocused(true, for: "A") + store.setFocused(true) let restored = WindowFrameStore(storage: storage, prefix: "t.") - XCTAssertTrue(restored.isFocused(for: "A")) - XCTAssertFalse(restored.isFocused(for: "B")) + XCTAssertTrue(restored.isFocused) XCTAssertEqual(restored.frame(for: "A"), normal) XCTAssertEqual(restored.rememberedCount, 1) - restored.setFocused(false, for: "A") - XCTAssertFalse(store.isFocused(for: "A")) + restored.setFocused(false) + XCTAssertFalse(store.isFocused) XCTAssertEqual(store.frame(for: "A"), normal) - store.setFocused(true, for: "A") + store.setFocused(true) store.forget("A") - XCTAssertFalse(store.isFocused(for: "A")) - store.setFocused(true, for: "B") + XCTAssertTrue(store.isFocused) store.forgetAll() - XCTAssertFalse(store.isFocused(for: "B")) + XCTAssertTrue(store.isFocused, "resetting window positions must not reset global focus") + } + + func testLegacyFocusMigratesAndExplicitOffWins() { + let storage = InMemoryFrameStorage() + storage.setText("true", forKey: "t.focus.A") + let store = WindowFrameStore(storage: storage, prefix: "t.") + XCTAssertTrue(store.isFocused) + XCTAssertNil(storage.text(forKey: "t.focus.A")) + store.setFocused(false) + storage.setText("true", forKey: "t.focus.B") + XCTAssertFalse(store.isFocused) + XCTAssertEqual(store.rememberedCount, 0) } func testItCountsWhatItRemembers() {