From efaa75800de580de768b590f37cf2e0c6c57d4bf Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 21:16:22 +0100 Subject: [PATCH 01/10] chore(app): raise the version to 0.2.0 The release notes' closing line no longer calls the release the first one, since it is composed for every version. --- CHANGELOG.md | 15 +++++++++++++++ VERSION | 2 +- scripts/release-notes.py | 6 +++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b18eba..3ea2f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ 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. +## 0.2.0 + +- The camera control works on every iPhone that has one, not only the iPhone Duo, and Device, + Camera Control presses it. The item appears only for devices with the button. The simulator has + no camera, so the device takes a screenshot. +- Choosing cover, half open or fully open on the iPhone Duo folds it smoothly to that angle instead + of jumping, unless Reduce Motion is on. The device stays inside the window in every pose, + standing ones included. +- The iPhone Duo's window moves to the screen the device is using sooner after a fold. +- Recording the iPhone Duo records the screen it is using, follows it through a fold, and keeps the + picture the right way up. +- A window opens the way its device is already turned, instead of assuming it is upright. +- The disk image opens as an installer window: drag the app onto Applications. The mounted disk + shows the app's icon. + ## 0.1.0 First release. diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/scripts/release-notes.py b/scripts/release-notes.py index 740bc93..24de8f4 100755 --- a/scripts/release-notes.py +++ b/scripts/release-notes.py @@ -92,9 +92,9 @@ def main() -> int: ## Tell me what breaks -This is the first release and it has been used by one person on one Mac. If something does not work, -please [open an issue]({REPOSITORY}/issues/new/choose): `odhub doctor` prints almost -everything the bug form asks for.""") +This is an early pre-release. If something does not work, please +[open an issue]({REPOSITORY}/issues/new/choose): `odhub doctor` prints almost everything the bug +form asks for.""") return 0 From 4589a72b01184d176d560d2c5d67ba81ba50bddf Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 21:24:41 +0100 Subject: [PATCH 02/10] chore(scripts): stop calling the release notes' release a pre-release --- scripts/release-notes.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/release-notes.py b/scripts/release-notes.py index 24de8f4..3b90aa3 100755 --- a/scripts/release-notes.py +++ b/scripts/release-notes.py @@ -92,9 +92,8 @@ def main() -> int: ## Tell me what breaks -This is an early pre-release. If something does not work, please -[open an issue]({REPOSITORY}/issues/new/choose): `odhub doctor` prints almost everything the bug -form asks for.""") +If something does not work, please [open an issue]({REPOSITORY}/issues/new/choose): +`odhub doctor` prints almost everything the bug form asks for.""") return 0 From 2ef2bfab3d9da8236c7d31c88a88e0ec4a5a109b Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 22:05:11 +0100 Subject: [PATCH 03/10] docs: add shut down, favorites and capture settings to the changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea2f24..50b89b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,20 @@ under it until the next heading becomes that version's release notes in the upda - Recording the iPhone Duo records the screen it is using, follows it through a fold, and keeps the picture the right way up. - A window opens the way its device is already turned, instead of assuming it is upright. +- Device, Shut Down shuts the open simulators down from the menu. +- Favorite locations: save named coordinates in Settings and choose them from Features, Location. +- Screenshots and recordings each have their own folder in Settings, starting from the folder you + had already chosen, and Save Screen can put the picture on the clipboard instead of in a file. +- Clipboard sync can be turned on or off in Settings as well as from the Edit menu, and the two stay + in step. +- The screen shown while a device is shut down or starting stays inside its rounded corners. +- The Settings window can be resized, and scrolls. - The disk image opens as an installer window: drag the app onto Applications. The mounted disk shows the app's icon. +Shut Down, favorite locations and the capture and clipboard settings were contributed by +@stephen-zeng. + ## 0.1.0 First release. From 2225fc4cc50a61bcaef58d820c48583debbeb3e2 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 22:58:09 +0100 Subject: [PATCH 04/10] fix(viewer): reconnect everything a window holds when its device restarts Reattaching only reopened the main picture, so a restarted iPhone Duo kept its hinge control, hinge readings, cover picture and touch targeting on the boot that had ended: the fold buttons stopped reaching the device and its inner screen stayed black. The adapter now forgets its cached connections, the window takes a fresh cover and retargeting, and the app reconnects the hinge at the fold the window showed and restarts the clipboard sync. --- .../Sources/ODHubViewerApp/ViewerMain.swift | 145 ++++++++++++------ .../Adapter/CoreSimulatorAdapter.swift | 6 + .../Adapter/SimulatorAdapter.swift | 4 + .../DeviceWindowController.swift | 17 +- .../DeviceWindowManager.swift | 24 ++- .../ReattachTests.swift | 99 ++++++++++++ 6 files changed, 238 insertions(+), 57 deletions(-) create mode 100644 engine/Tests/OpenDeviceHubViewerTests/ReattachTests.swift diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index d77781e..eb4bd18 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -138,6 +138,25 @@ struct ODHubViewer: ParsableCommand { } } + // Opens unfolded unless told otherwise, which is where the device's own tooling starts. + let followFold: @MainActor (String, DeviceWindowController, Double?) -> Void = { udid, controller, angle in + let angle = angle ?? DeviceControlBar.FoldMode.fullyOpen.angle + controller.showHingeAngle(angle) + controller.onHingeAngle = { angle in foldables.setAngle(angle, for: udid) } + controller.onFoldPreset = { angle in foldables.setAngle(angle, for: udid, eased: true) } + foldables.setAngle(angle, for: udid) + foldables.follow( + udid, + nudges: controller.screenChanges, + onPanel: { [weak controller] panel in + controller?.setActivePanel(screenID: panel.displayID) + }, + onHinge: { [weak controller] degrees in + controller?.showHingeAngle(degrees) + } + ) + } + let show: @MainActor (String, Bool) throws -> Void = { udid, allowBoot in let current = try adapter.devices() try self.open( @@ -151,27 +170,22 @@ struct ODHubViewer: ParsableCommand { ) recent.remember(udid) pasteboard.adopt(udid) - - // Opens unfolded, which is where the device's own tooling starts. if let controller = manager.controller(for: udid), controller.foldsAtHinge { - let angle = foldables.angle(for: udid) ?? DeviceControlBar.FoldMode.fullyOpen.angle - controller.showHingeAngle(angle) - controller.onHingeAngle = { angle in foldables.setAngle(angle, for: udid) } - controller.onFoldPreset = { angle in foldables.setAngle(angle, for: udid, eased: true) } - foldables.setAngle(angle, for: udid) - foldables.follow( - udid, - nudges: controller.screenChanges, - onPanel: { [weak controller] panel in - controller?.setActivePanel(screenID: panel.displayID) - }, - onHinge: { [weak controller] degrees in - controller?.showHingeAngle(degrees) - } - ) + followFold(udid, controller, foldables.angle(for: udid)) } } + // The hinge, its readings and the clipboard connection all belong to the boot that + // ended, so they are opened again. The window keeps the fold it was showing. + manager.onReattached = { udid in + pasteboard.forget(udid) + pasteboard.adopt(udid) + guard let controller = manager.controller(for: udid), controller.foldsAtHinge else { return } + let angle = foldables.angle(for: udid) + foldables.forget(udid) + followFold(udid, controller, angle) + } + for udid in plan.udids { do { try show(udid, plan.boot) @@ -525,13 +539,8 @@ struct ODHubViewer: ParsableCommand { manager.follow( notifier, attach: { udid in - let panels = (try? adapter.panels(udid)) ?? [] - let session = try adapter.openDisplay(udid, panel: panels.first { $0.name == "Unfolded" }) - session.setBezelEnabled(bezel) - return DeviceAttachment( - session: session, - input: try? adapter.openInput(udid) - ) + adapter.forgetConnections(udid) + return try openSessions(udid, adapter: adapter, bezel: bezel).attachment }, boot: { udid in try SimctlService().boot(udid: udid) }, // Only when no simulator was named. `odhub view ` asked for one window and @@ -603,46 +612,29 @@ struct ODHubViewer: ParsableCommand { device = try adapter.devices().first { $0.udid == device.udid } ?? device } - // More than one built in screen means a hinge between them. - let panels = (try? adapter.panels(device.udid)) ?? [] - let foldsAtHinge = panels.count > 1 - let unfolded = foldsAtHinge ? panels.first { $0.name == "Unfolded" } : nil - let coverPanel = foldsAtHinge ? panels.first { $0.name == "Cover" } : nil - let panel = unfolded - let session = try adapter.openDisplay(device.udid, panel: panel) - session.setBezelEnabled(bezel) - var cover: FoldableCover? - if let coverPanel, let coverSession = try? adapter.openDisplay(device.udid, panel: coverPanel) { - coverSession.setBezelEnabled(bezel) - cover = FoldableCover(panel: coverPanel, session: coverSession) - } - let input: (any InputSession)? - do { - input = try adapter.openInput(device.udid, screenID: panel?.screenID ?? 0) - } catch { - input = nil - print("\(device.name): clicking will not send taps, \(error.localizedDescription)") - } - let retarget: ((Int) -> Void)? = (input as? PanelInputSession).map { targeted in - { targeted.setTarget(screenID: $0) } + let opened = try openSessions(device.udid, adapter: adapter, bezel: bezel) + if let failure = opened.inputFailure { + print("\(device.name): clicking will not send taps, \(failure)") } + let panel = opened.unfolded + let session = opened.session let orientation = DevicectlService().orientation(udid: device.udid) ?? .portrait let controller = try manager.open( device: device, session: session, - input: input, + input: opened.input, scaleMode: scale, bezelEnabled: bezel, keepOnTop: keepOnTop, showFPS: fps, - foldsAtHinge: foldsAtHinge, + foldsAtHinge: opened.foldsAtHinge, // The device type's chrome names only the cover's body, so the panel's own is used. chrome: panel?.chromeIdentifier.flatMap { ChromeLocator.chrome(identifier: $0) }, panelNativeRotation: panel?.nativeRotation ?? 0, - unfoldedPanel: unfolded, - cover: cover, - retarget: retarget, + unfoldedPanel: panel, + cover: opened.cover, + retarget: opened.retarget, orientation: orientation ) // The model turns the picture itself; setting this too turns the unfolded panel twice. @@ -821,6 +813,57 @@ private func installToolbar( )) } +/// What a window is opened with, and opened with again when its device comes back from a reboot. +@MainActor +private struct DeviceSessions { + let foldsAtHinge: Bool + let unfolded: DevicePanel? + let session: any DisplaySession + let cover: FoldableCover? + let input: (any InputSession)? + let inputFailure: String? + let retarget: ((Int) -> Void)? + + var attachment: DeviceAttachment { + DeviceAttachment(session: session, input: input, cover: cover, retarget: retarget) + } +} + +@MainActor +private func openSessions(_ udid: String, adapter: any SimulatorAdapter, bezel: Bool) throws -> DeviceSessions { + // More than one built in screen means a hinge between them. + let panels = (try? adapter.panels(udid)) ?? [] + let foldsAtHinge = panels.count > 1 + let unfolded = foldsAtHinge ? panels.first { $0.name == "Unfolded" } : nil + let coverPanel = foldsAtHinge ? panels.first { $0.name == "Cover" } : nil + let session = try adapter.openDisplay(udid, panel: unfolded) + session.setBezelEnabled(bezel) + var cover: FoldableCover? + if let coverPanel, let coverSession = try? adapter.openDisplay(udid, panel: coverPanel) { + coverSession.setBezelEnabled(bezel) + cover = FoldableCover(panel: coverPanel, session: coverSession) + } + var input: (any InputSession)? + var inputFailure: String? + do { + input = try adapter.openInput(udid, screenID: unfolded?.screenID ?? 0) + } catch { + inputFailure = error.localizedDescription + } + let retarget: ((Int) -> Void)? = (input as? PanelInputSession).map { targeted in + { targeted.setTarget(screenID: $0) } + } + return DeviceSessions( + foldsAtHinge: foldsAtHinge, + unfolded: unfolded, + session: session, + cover: cover, + input: input, + inputFailure: inputFailure, + retarget: retarget + ) +} + /// Off the main thread, because every one of these blocks for a second or more and they run from a /// menu. A failure is printed rather than swallowed. @MainActor diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift index 4710bbd..50613af 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift @@ -219,6 +219,12 @@ public final class CoreSimulatorAdapter: SimulatorAdapter, @unchecked Sendable { return feature } + public func forgetConnections(_ udid: String) { + lock.lock() + defer { lock.unlock() } + coreDeviceFeatures = coreDeviceFeatures.filter { !$0.key.hasPrefix("\(udid)/") } + } + public func openInput(_ udid: String) throws -> any InputSession { lock.lock() defer { lock.unlock() } diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift index 449361d..c724e7d 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift @@ -186,6 +186,8 @@ public protocol SimulatorAdapter: Sendable { func openFoldableControl(_ udid: String) throws -> any HingeControl /// One of the guest's CoreDevice features, the plane that answers what the guest is doing. func openCoreDevice(_ udid: String, service: String) throws -> CoreDeviceFeature + /// Lets go of every connection kept open to a device, which a reboot leaves pointing at nothing. + func forgetConnections(_ udid: String) /// What the guest says about its screens, the only honest answer to which panel is in use. func displayReport(_ udid: String) async throws -> DisplayReport func motionCapabilities(_ udid: String) async throws -> MotionCapabilities @@ -238,6 +240,8 @@ extension SimulatorAdapter { throw EngineError.capabilityUnavailable(name: "CoreDevice \(service) on \(udid)") } + public func forgetConnections(_ udid: String) {} + public func displayReport(_ udid: String) async throws -> DisplayReport { let feature = try openCoreDevice(udid, service: CoreDeviceFeature.displayInfoService) return try DisplayReport.parse(try await feature.perform(action: CoreDeviceFeature.displayInfoAction)) diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 8674339..6ee838a 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -69,9 +69,9 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate private let deviceName: String private var pendingSend: Task? private let unfoldedPanel: DevicePanel? - private let cover: FoldableCover? + private var cover: FoldableCover? private let recording = RecordingSlot() - private let retarget: ((Int) -> Void)? + private var retarget: ((Int) -> Void)? private var coverFrameTask: Task? private var activeScreenID: Int private var bezelEnabled: Bool @@ -263,16 +263,27 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate /// The device is back. The window keeps its size, position and orientation, so a reboot looks /// like the screen coming back on rather than a new window. - public func reattach(session: any DisplaySession, input: (any InputSession)?) { + /// A foldable's cover picture and its touch targeting belong to the boot that ended too, so + /// they come back with the rest. + public func reattach( + session: any DisplaySession, + input: (any InputSession)?, + cover: FoldableCover? = nil, + retarget: ((Int) -> Void)? = nil + ) { guard !isStopped else { return } closeSessions() self.session = session self.input = input + if let cover { self.cover = cover } + if let retarget { self.retarget = retarget } session.setBezelEnabled(bezelEnabled) chromeView.overlay = nil overlay = nil toolbar?.setEnabled(true) startConsumingFrames() + startConsumingCoverFrames() + self.retarget?(activeScreenID) applyScaleMode(scaleMode) } diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index c396871..bb38554 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -167,8 +167,14 @@ public final class DeviceWindowManager { case .reattach: do { let attachment = try attach(change.udid) - controller.reattach(session: attachment.session, input: attachment.input) + controller.reattach( + session: attachment.session, + input: attachment.input, + cover: attachment.cover, + retarget: attachment.retarget + ) report("\(controller.deviceTitle) reattached") + onReattached?(change.udid) } catch { let reason = DetachReason.failed(error.localizedDescription) controller.detach(reason: reason) @@ -249,6 +255,9 @@ public final class DeviceWindowManager { /// The UDIDs of every open window, so a menu action can reach all of them. /// Called after a window has closed, so anything held per device can be let go of. public var onDeviceClosed: ((String) -> Void)? + /// Called once a window has fresh sessions after its device came back, so whatever else was + /// opened for the boot that ended can be opened again. + public var onReattached: ((String) -> Void)? public var openUDIDs: [String] { Array(controllers.keys) } @@ -393,13 +402,22 @@ public final class DeviceWindowManager { } } -/// The pair of sessions a window needs, so reattaching after a reboot is one call. +/// The sessions a window needs, so reattaching after a reboot is one call. public struct DeviceAttachment { public let session: any DisplaySession public let input: (any InputSession)? + public let cover: FoldableCover? + public let retarget: ((Int) -> Void)? - public init(session: any DisplaySession, input: (any InputSession)?) { + public init( + session: any DisplaySession, + input: (any InputSession)?, + cover: FoldableCover? = nil, + retarget: ((Int) -> Void)? = nil + ) { self.session = session self.input = input + self.cover = cover + self.retarget = retarget } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/ReattachTests.swift b/engine/Tests/OpenDeviceHubViewerTests/ReattachTests.swift new file mode 100644 index 0000000..4d3bf79 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/ReattachTests.swift @@ -0,0 +1,99 @@ +import AppKit +import XCTest +@testable import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +@MainActor +final class ReattachTests: XCTestCase { + func testARestartReopensEverythingTheWindowWasGiven() async throws { + let notifier = ScriptedNotifier() + let manager = DeviceWindowManager( + frameStore: WindowFrameStore(storage: InMemoryPreferences(), prefix: "test."), + settings: ViewerSettings(storage: InMemoryPreferences(), prefix: "test."), + shutdown: { _ in } + ) + let device = DeviceInfo( + udid: "restarting", + name: "iPhone 17", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", + runtimeName: "iOS 27.0", + state: .booted, + isAvailable: true + ) + let controller = try manager.open( + device: device, + session: BlankSession(), + input: nil, + scaleMode: .fit, + bezelEnabled: true, + keepOnTop: false, + showFPS: false + ) + defer { manager.close(device.udid) } + + var attached = 0 + var retargeted: [Int] = [] + var reattached: [String] = [] + manager.onReattached = { reattached.append($0) } + manager.follow( + notifier, + attach: { _ in + attached += 1 + return DeviceAttachment( + session: BlankSession(), + input: nil, + retarget: { retargeted.append($0) } + ) + }, + boot: { _ in } + ) + + notifier.send(device.udid, .shutdown, after: .booted) + try await settle { controller.isDetached } + XCTAssertTrue(controller.isDetached) + + notifier.send(device.udid, .booted, after: .booting) + try await settle { !reattached.isEmpty } + XCTAssertFalse(controller.isDetached, "the window stayed in its shut down state") + XCTAssertEqual(attached, 1) + XCTAssertEqual(retargeted, [0], "touches were not pointed at the screen in use") + XCTAssertEqual(reattached, [device.udid], "nothing else was told the device came back") + } + + private func settle(_ done: () -> Bool) async throws { + for _ in 0..<50 where !done() { + try await Task.sleep(for: .milliseconds(20)) + } + } +} + +private final class ScriptedNotifier: DeviceNotifier, @unchecked Sendable { + let changes: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + var escaping: AsyncStream.Continuation! + changes = AsyncStream { escaping = $0 } + continuation = escaping + } + + func send(_ udid: String, _ state: DeviceState, after previous: DeviceState) { + continuation.yield(DeviceStateChange(udid: udid, state: state, previousState: previous)) + } + + func close() { + continuation.finish() + } +} + +private final class BlankSession: DisplaySession, @unchecked Sendable { + let frames = AsyncStream { $0.finish() } + let screenChanges = AsyncStream { $0.finish() } + let pixelSize = CGSize(width: 1206, height: 2622) + let pointScale: CGFloat = 3 + let pixelsPerInch: CGFloat? = 460 + let supportsBezel = false + func setBezelEnabled(_ enabled: Bool) {} + func close() {} +} From cf5344310683d2729b387447fd128e1004cffcf6 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 22:58:09 +0100 Subject: [PATCH 05/10] test(viewer): wait for the camera control's screenshot rather than a fixed time --- .../FoldableDriveTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/engine/Tests/OpenDeviceHubIntegrationTests/FoldableDriveTests.swift b/engine/Tests/OpenDeviceHubIntegrationTests/FoldableDriveTests.swift index 00a9814..20544a6 100644 --- a/engine/Tests/OpenDeviceHubIntegrationTests/FoldableDriveTests.swift +++ b/engine/Tests/OpenDeviceHubIntegrationTests/FoldableDriveTests.swift @@ -350,8 +350,12 @@ final class FoldableDriveTests: XCTestCase { // The guest has no camera to open, so its camera control takes a screenshot of itself. let shots = photos() try await press(.cameraControl) - try await settle(3) - print("RESULT the camera control took the camera roll from \(shots) to \(photos()) pictures") + // The guest can take several seconds over a screenshot, the first after a boot especially. + let pressed = ContinuousClock.now + while photos() == shots, ContinuousClock.now - pressed < .seconds(15) { + try await Task.sleep(for: .milliseconds(100)) + } + print("RESULT the camera control took the camera roll from \(shots) to \(photos()) pictures after \(ContinuousClock.now - pressed)") XCTAssertEqual(photos(), shots + 1, "the camera control did not take a screenshot in the guest") try await press(.lock) From 7613f7c0c766104e4d12a7af628a798e02d34979 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 22:58:10 +0100 Subject: [PATCH 06/10] docs: note the restart fix in the changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b89b0..62aa7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ under it until the next heading becomes that version's release notes in the upda - Recording the iPhone Duo records the screen it is using, follows it through a fold, and keeps the picture the right way up. - A window opens the way its device is already turned, instead of assuming it is upright. +- Restarting a device no longer leaves its window half connected. The iPhone Duo's fold buttons, + cover screen and touches come back with the fold it was showing, and the clipboard sync picks up + again. - Device, Shut Down shuts the open simulators down from the menu. - Favorite locations: save named coordinates in Settings and choose them from Features, Location. - Screenshots and recordings each have their own folder in Settings, starting from the folder you From ff4f9e782f6c7840a893474327539067c5fd365e Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 23:28:23 +0100 Subject: [PATCH 07/10] fix(app): act on the simulator in front for device menu actions Restart, Shut Down, Erase, the hardware buttons, rotation, Shake, location and text size reached every open simulator, so restarting one restarted them all. With Settings or an alert in front, the device in front is now the highest device window rather than whichever the dictionary listed first. --- .../Sources/ODHubViewerApp/ViewerMain.swift | 131 ++++++++---------- .../DeviceWindowManager.swift | 11 +- .../FrontDeviceTests.swift | 75 ++++++++++ 3 files changed, 145 insertions(+), 72 deletions(-) create mode 100644 engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index eb4bd18..d87c30b 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -292,11 +292,9 @@ struct ODHubViewer: ParsableCommand { } }, shake: { - let simctl = SimctlService() - for udid in manager.openUDIDs { - do { try simctl.shake(udid: udid) } catch { - print("shake failed: \(error.localizedDescription)") - } + guard let udid = manager.frontmostUDID else { return } + do { try SimctlService().shake(udid: udid) } catch { + print("shake failed: \(error.localizedDescription)") } }, toggleSlowAnimations: { @@ -309,17 +307,17 @@ struct ODHubViewer: ParsableCommand { }, toggleLatencyOverlay: { manager.toggleLatencyOverlay() }, pressButton: { button in - for udid in manager.openUDIDs where button != .cameraControl || manager.hasCameraControl(udid) { - Task { - do { - guard let session = manager.controller(for: udid)?.inputSession - else { return } - try await session.button(button, phase: .down) - try await Task.sleep(for: .milliseconds(15)) - try await session.button(button, phase: .up) - } catch { - print("\(button) failed: \(error.localizedDescription)") - } + guard let udid = manager.frontmostUDID, + button != .cameraControl || manager.hasCameraControl(udid) else { return } + Task { + do { + guard let session = manager.controller(for: udid)?.inputSession + else { return } + try await session.button(button, phase: .down) + try await Task.sleep(for: .milliseconds(15)) + try await session.button(button, phase: .up) + } catch { + print("\(button) failed: \(error.localizedDescription)") } } }, @@ -327,44 +325,39 @@ struct ODHubViewer: ParsableCommand { manager.frontmostUDID.map { manager.hasCameraControl($0) } ?? false }, rotate: { left in - for udid in manager.openUDIDs { - guard let controller = manager.controller(for: udid) else { continue } - let next = left - ? controller.currentOrientation.rotatedLeft - : controller.currentOrientation.rotatedRight - turn(next, udid) - } + guard let udid = manager.frontmostUDID, + let controller = manager.controller(for: udid) else { return } + let next = left + ? controller.currentOrientation.rotatedLeft + : controller.currentOrientation.rotatedRight + turn(next, udid) }, restart: { - for udid in manager.openUDIDs { - runOnEveryDevice("restart", udid) { try SimctlService().restart(udid: $0) } - } + guard let udid = manager.frontmostUDID else { return } + runOnEveryDevice("restart", udid) { try SimctlService().restart(udid: $0) } }, shutdown: { - for udid in manager.openUDIDs { - runOnEveryDevice("shutdown", udid) { try SimctlService().shutdown(udid: $0) } - } + guard let udid = manager.frontmostUDID else { return } + runOnEveryDevice("shutdown", udid) { try SimctlService().shutdown(udid: $0) } }, erase: { // Destructive and not undoable, so it asks, names the device, and Erase is not // the default button. - for udid in manager.openUDIDs { - guard let controller = manager.controller(for: udid) else { continue } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Erase \(controller.deviceTitle)?" - alert.informativeText = "Every app, setting and file on this simulator is deleted. This cannot be undone, and the device is left shut down." - alert.addButton(withTitle: "Cancel") - alert.addButton(withTitle: "Erase") - guard alert.runModal() == .alertSecondButtonReturn else { continue } - runOnEveryDevice("erase", udid) { try SimctlService().erase(udid: $0) } - } + guard let udid = manager.frontmostUDID, + let controller = manager.controller(for: udid) else { return } + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Erase \(controller.deviceTitle)?" + alert.informativeText = "Every app, setting and file on this simulator is deleted. This cannot be undone, and the device is left shut down." + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Erase") + guard alert.runModal() == .alertSecondButtonReturn else { return } + runOnEveryDevice("erase", udid) { try SimctlService().erase(udid: $0) } }, stepTextSize: { step in - for udid in manager.openUDIDs { - runOnEveryDevice("text size", udid) { - try SimctlService().stepContentSize(step, udid: $0) - } + guard let udid = manager.frontmostUDID else { return } + runOnEveryDevice("text size", udid) { + try SimctlService().stepContentSize(step, udid: $0) } }, toggleIncreaseContrast: { @@ -384,38 +377,35 @@ struct ODHubViewer: ParsableCommand { } }, setLocation: { scenario in - for udid in manager.openUDIDs { - runOnEveryDevice("location", udid) { device in - if let scenario { - try SimctlService().runLocation(scenario, udid: device) - } else { - try SimctlService().clearLocation(udid: device) - } + guard let udid = manager.frontmostUDID else { return } + runOnEveryDevice("location", udid) { device in + if let scenario { + try SimctlService().runLocation(scenario, udid: device) + } else { + try SimctlService().clearLocation(udid: device) } } }, setCustomLocation: { - guard let point = CustomLocationPrompt.ask() else { return } - for udid in manager.openUDIDs { - runOnEveryDevice("location", udid) { - try SimctlService().setLocation( - latitude: point.latitude, - longitude: point.longitude, - udid: $0 - ) - } + guard let udid = manager.frontmostUDID, + let point = CustomLocationPrompt.ask() else { return } + runOnEveryDevice("location", udid) { + try SimctlService().setLocation( + latitude: point.latitude, + longitude: point.longitude, + udid: $0 + ) } }, locationFavorites: { settings.locationFavorites }, setFavoriteLocation: { favorite in - for udid in manager.openUDIDs { - runOnEveryDevice("location", udid) { - try SimctlService().setLocation( - latitude: favorite.latitude, - longitude: favorite.longitude, - udid: $0 - ) - } + guard let udid = manager.frontmostUDID else { return } + runOnEveryDevice("location", udid) { + try SimctlService().setLocation( + latitude: favorite.latitude, + longitude: favorite.longitude, + udid: $0 + ) } }, toggleKeyboardInput: { enabled in @@ -488,9 +478,8 @@ struct ODHubViewer: ParsableCommand { ), settings: settings) }, setOrientation: { orientation in - for udid in manager.openUDIDs { - turn(orientation, udid) - } + guard let udid = manager.frontmostUDID else { return } + turn(orientation, udid) }, appSwitcher: { for udid in manager.openUDIDs { diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index bb38554..5e14b32 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -267,8 +267,17 @@ public final class DeviceWindowManager { deviceTypes[udid].map { DeviceTypeProfile.hasCameraControl(deviceType: $0) } ?? false } /// The device the user is looking at, which is where an action that can only land on one goes. + /// With Settings or an alert in front, that is the device window highest in the stack. public var frontmostUDID: String? { - controllers.first { $0.value.window?.isKeyWindow == true }?.key ?? controllers.keys.first + if let key = controllers.first(where: { $0.value.window?.isKeyWindow == true }) { + return key.key + } + for window in NSApplication.shared.orderedWindows { + if let shown = controllers.first(where: { $0.value.window === window }) { + return shown.key + } + } + return controllers.keys.first } diff --git a/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift b/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift new file mode 100644 index 0000000..38c22d4 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift @@ -0,0 +1,75 @@ +import AppKit +import XCTest +@testable import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +@MainActor +final class FrontDeviceTests: XCTestCase { + private var manager: DeviceWindowManager! + + override func setUp() async throws { + manager = DeviceWindowManager( + frameStore: WindowFrameStore(storage: InMemoryPreferences(), prefix: "test."), + settings: ViewerSettings(storage: InMemoryPreferences(), prefix: "test."), + shutdown: { _ in } + ) + } + + override func tearDown() async throws { + for udid in manager.openUDIDs { manager.close(udid) } + manager = nil + } + + func testWithSettingsInFrontTheHighestDeviceWindowIsTheDeviceInFront() throws { + let first = try open("first") + let second = try open("second") + let settings = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + settings.isReleasedWhenClosed = false + settings.makeKeyAndOrderFront(nil) + defer { settings.close() } + + first.window?.orderFront(nil) + settings.orderFront(nil) + XCTAssertEqual(manager.frontmostUDID, "first") + + second.window?.orderFront(nil) + settings.orderFront(nil) + XCTAssertEqual(manager.frontmostUDID, "second") + } + + private func open(_ udid: String) throws -> DeviceWindowController { + try manager.open( + device: DeviceInfo( + udid: udid, + name: "iPhone 17", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", + runtimeName: "iOS 27.0", + state: .booted, + isAvailable: true + ), + session: QuietSession(), + input: nil, + scaleMode: .fit, + bezelEnabled: true, + keepOnTop: false, + showFPS: false + ) + } +} + +private final class QuietSession: DisplaySession, @unchecked Sendable { + let frames = AsyncStream { $0.finish() } + let screenChanges = AsyncStream { $0.finish() } + let pixelSize = CGSize(width: 1206, height: 2622) + let pointScale: CGFloat = 3 + let pixelsPerInch: CGFloat? = 460 + let supportsBezel = false + func setBezelEnabled(_ enabled: Bool) {} + func close() {} +} From c8a6424389c7ee91de91da6332c8a1f920e380ac Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 27 Sep 2026 23:28:23 +0100 Subject: [PATCH 08/10] docs: note the device menu scope in the changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62aa7b0..81e3492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ under it until the next heading becomes that version's release notes in the upda - Restarting a device no longer leaves its window half connected. The iPhone Duo's fold buttons, cover screen and touches come back with the fold it was showing, and the clipboard sync picks up again. -- Device, Shut Down shuts the open simulators down from the menu. +- Device, Shut Down shuts a simulator down from the menu. +- Restart, Shut Down, Erase, the hardware buttons, rotation, Shake, location and text size act on + the simulator in front, not on every open one. - Favorite locations: save named coordinates in Settings and choose them from Features, Location. - Screenshots and recordings each have their own folder in Settings, starting from the folder you had already chosen, and Save Screen can put the picture on the clipboard instead of in a file. From d27665e479aee998e4642939d6ca538d8d4c0822 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Mon, 28 Sep 2026 00:06:26 +0100 Subject: [PATCH 09/10] fix(app): act on the simulator in front for every menu action Paste, Send Pasteboard, Save Screen, Record Screen, App Switcher, appearance, contrast, iCloud sync, the debug items, the keyboard options and the window options still reached every open simulator, and their ticks were one app wide value. Each now acts on the device in front, and each tick reads that device's own setting whenever the menu is shown. --- .../Sources/ODHubViewerApp/ViewerMain.swift | 160 +++++++++++------- .../DeviceWindowController.swift | 11 +- .../DeviceWindowManager.swift | 53 +----- .../PasteboardSyncController.swift | 7 +- .../OpenDeviceHubViewer/ViewerMenu.swift | 85 ++++++---- .../MenuHarness.swift | 9 +- .../MenuSettingsTests.swift | 39 +++++ .../PasteboardSyncControllerTests.swift | 6 +- 8 files changed, 211 insertions(+), 159 deletions(-) diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index d87c30b..2efb26f 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -78,7 +78,6 @@ struct ODHubViewer: ParsableCommand { let application = NSApplication.shared application.setActivationPolicy(.regular) - var slowAnimations = false let store = WindowFrameStore() if resetWindowPosition { plan.udids.forEach(store.forget) @@ -236,35 +235,51 @@ struct ODHubViewer: ParsableCommand { let deviceLinks = DefaultDeviceApplication() let updates = UpdateController() + // Every menu action lands on the device the user is looking at, as in Simulator.app. + let front: @MainActor () -> (udid: String, controller: DeviceWindowController)? = { + guard let udid = manager.frontmostUDID, + let controller = manager.controller(for: udid) else { return nil } + return (udid, controller) + } let menuTarget = ViewerMenu.install(into: application, actions: ViewerMenu.Actions( - setScaleMode: { manager.applyScaleMode($0) }, - toggleBezel: { manager.toggleBezel() }, - toggleKeepOnTop: { manager.toggleKeepOnTop() }, - pasteToDevice: { - guard let text = NSPasteboard.general.string(forType: .string) else { return } - let simctl = SimctlService() - for udid in manager.openUDIDs { - try? simctl.pasteboardCopy(text, udid: udid) + setScaleMode: { mode in + guard let target = front() else { return } + if case .largerThanScreen(let size) = target.controller.applyScaleMode(mode) { + print("\(target.controller.deviceTitle): \(mode.displayName) needs \(Int(size.width))x\(Int(size.height)) points, which is larger than this display.") } }, - setAppearance: { appearance in + toggleBezel: { + guard let target = front() else { return } + target.controller.setBezelEnabled(!target.controller.isBezelEnabled) + }, + toggleKeepOnTop: { + guard let target = front() else { return } + target.controller.setKeepOnTop(!target.controller.isKeptOnTop) + }, + pasteToDevice: { + guard let target = front(), + let text = NSPasteboard.general.string(forType: .string) else { return } + try? SimctlService().pasteboardCopy(text, udid: target.udid) + }, + toggleAppearance: { + guard let target = front() else { return } let simctl = SimctlService() - for udid in manager.openUDIDs { - try? simctl.setAppearance(appearance, udid: udid) - } + let current = (try? simctl.appearance(udid: target.udid)) ?? nil + try? simctl.setAppearance(current == .dark ? .light : .dark, udid: target.udid) }, saveScreenshot: { + guard let target = front() else { return } if settings.savesScreenshotsToClipboard { - print(manager.copyScreenshotToClipboard() ? "screenshot saved to clipboard" : "nothing to save") + print(manager.copyScreenshotToClipboard(only: target.udid) ? "screenshot saved to clipboard" : "nothing to save") } else { - present(manager.saveScreenshots(into: CaptureStaging.directory())) + present(manager.saveScreenshots(into: CaptureStaging.directory(), only: target.udid)) } }, copyScreenshot: { print(manager.copyScreenshotToClipboard() ? "screenshot copied" : "nothing to copy") }, toggleRecording: { - let finished = manager.toggleRecording(into: CaptureStaging.directory()) + let finished = manager.toggleRecording(into: CaptureStaging.directory(), only: front()?.udid) if finished.isEmpty { print("recording started") } else { @@ -272,24 +287,21 @@ struct ODHubViewer: ParsableCommand { } }, simulateMemoryWarning: { - for udid in manager.openUDIDs { - do { - try adapter.simulateMemoryWarning(udid) - print("sent a memory warning to \(udid)") - } catch { - print("memory warning failed: \(error.localizedDescription)") - } + guard let target = front() else { return } + do { + try adapter.simulateMemoryWarning(target.udid) + print("sent a memory warning to \(target.udid)") + } catch { + print("memory warning failed: \(error.localizedDescription)") } }, openSystemLog: { - for udid in manager.openUDIDs { - NSWorkspace.shared.open(SimctlService.systemLogDirectory(udid: udid)) - } + guard let target = front() else { return } + NSWorkspace.shared.open(SimctlService.systemLogDirectory(udid: target.udid)) }, openAppData: { - for udid in manager.openUDIDs { - NSWorkspace.shared.open(SimctlService.deviceDataDirectory(udid: udid)) - } + guard let target = front() else { return } + NSWorkspace.shared.open(SimctlService.deviceDataDirectory(udid: target.udid)) }, shake: { guard let udid = manager.frontmostUDID else { return } @@ -298,14 +310,20 @@ struct ODHubViewer: ParsableCommand { } }, toggleSlowAnimations: { - slowAnimations.toggle() - let simctl = SimctlService() - for udid in manager.openUDIDs { - try? simctl.setSlowAnimations(slowAnimations, udid: udid) + guard let target = front() else { return } + let wanted = !target.controller.slowAnimations + do { + try SimctlService().setSlowAnimations(wanted, udid: target.udid) + target.controller.slowAnimations = wanted + print("slow animations \(wanted ? "on" : "off")") + } catch { + print("slow animations failed: \(error.localizedDescription)") } - print("slow animations \(slowAnimations ? "on" : "off")") }, - toggleLatencyOverlay: { manager.toggleLatencyOverlay() }, + toggleLatencyOverlay: { + guard let target = front() else { return } + target.controller.setLatencyOverlayVisible(!target.controller.isLatencyOverlayVisible) + }, pressButton: { button in guard let udid = manager.frontmostUDID, button != .cameraControl || manager.hasCameraControl(udid) else { return } @@ -361,19 +379,18 @@ struct ODHubViewer: ParsableCommand { } }, toggleIncreaseContrast: { + guard let target = front() else { return } let simctl = SimctlService() - for udid in manager.openUDIDs { - let wanted = !simctl.increasesContrast(udid: udid) - runOnEveryDevice("increase contrast", udid) { - try simctl.setIncreaseContrast(wanted, udid: $0) - } + let wanted = !simctl.increasesContrast(udid: target.udid) + target.controller.increasesContrast = wanted + runOnEveryDevice("increase contrast", target.udid) { + try simctl.setIncreaseContrast(wanted, udid: $0) } }, triggerICloudSync: { - for udid in manager.openUDIDs { - runOnEveryDevice("iCloud sync", udid) { - try SimctlService().triggerICloudSync(udid: $0) - } + guard let target = front() else { return } + runOnEveryDevice("iCloud sync", target.udid) { + try SimctlService().triggerICloudSync(udid: $0) } }, setLocation: { scenario in @@ -409,25 +426,23 @@ struct ODHubViewer: ParsableCommand { } }, toggleKeyboardInput: { enabled in - for udid in manager.openUDIDs { - manager.controller(for: udid)?.sendsKeyboardInput = enabled - } + front()?.controller.sendsKeyboardInput = enabled }, toggleHardwareKeyboard: { enabled in - for udid in manager.openUDIDs { - runOnEveryDevice("hardware keyboard", udid) { - try adapter.setHardwareKeyboardEnabled(enabled, udid: $0) - } + guard let target = front() else { return } + target.controller.hasHardwareKeyboard = enabled + runOnEveryDevice("hardware keyboard", target.udid) { + try adapter.setHardwareKeyboardEnabled(enabled, udid: $0) } }, matchKeyboardLanguage: { matching in + guard let target = front() else { return } + target.controller.matchesKeyboardLanguage = matching // Off leaves the guest on whatever it had: there is no "stop matching" call, so // turning it back on is what re-applies the Mac's language. guard matching, let language = KeyboardLanguage.current() else { return } - for udid in manager.openUDIDs { - runOnEveryDevice("keyboard language", udid) { - try adapter.setKeyboardLanguage(language, udid: $0) - } + runOnEveryDevice("keyboard language", target.udid) { + try adapter.setKeyboardLanguage(language, udid: $0) } }, toggleAutomaticPasteboardSync: { enabled in @@ -482,16 +497,13 @@ struct ODHubViewer: ParsableCommand { turn(orientation, udid) }, appSwitcher: { - for udid in manager.openUDIDs { - guard let controller = manager.controller(for: udid), - let session = controller.inputSession else { continue } - let turn = controller.layoutTurn - Task { - do { - try await SystemGesture.appSwitcher(on: session, turn: turn) - } catch { - print("app switcher failed: \(error.localizedDescription)") - } + guard let target = front(), let session = target.controller.inputSession else { return } + let turn = target.controller.layoutTurn + Task { + do { + try await SystemGesture.appSwitcher(on: session, turn: turn) + } catch { + print("app switcher failed: \(error.localizedDescription)") } } }, @@ -499,6 +511,24 @@ struct ODHubViewer: ParsableCommand { present(manager.toggleRecording(into: CaptureStaging.directory())) }, isRecording: { manager.isRecording }, + isOn: { setting in + guard let target = front() else { return false } + let controller = target.controller + switch setting { + case .keyboardInput: return controller.sendsKeyboardInput + case .hardwareKeyboard: return controller.hasHardwareKeyboard + case .keyboardLanguage: return controller.matchesKeyboardLanguage + case .slowAnimations: return controller.slowAnimations + case .latencyOverlay: return controller.isLatencyOverlayVisible + case .bezel: return controller.isBezelEnabled + case .keepOnTop: return controller.isKeptOnTop + case .increasedContrast: + if let known = controller.increasesContrast { return known } + let read = SimctlService().increasesContrast(udid: target.udid) + controller.increasesContrast = read + return read + } + }, checkForUpdates: updates.map { updater in { updater.checkForUpdates() } }, showSettings: { SettingsWindow.show(settings: settings, actions: SettingsActions( diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 6ee838a..92e9248 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -506,13 +506,22 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate tracksFrameChanges = true } - /// The most recent frame as a PNG, matching whatever the window is showing including the bezel. /// Whether the Mac's keystrokes reach this device. public var sendsKeyboardInput: Bool { get { screenView.sendsKeyboardInput } set { screenView.sendsKeyboardInput = newValue } } + /// What the menu last set on this device, for the ticks it shows. The guest does not report + /// these cheaply, so each starts as the app leaves a device it opens: keyboard on, language + /// matched, animations at normal speed. + public var hasHardwareKeyboard = true + public var matchesKeyboardLanguage = true + public var slowAnimations = false + /// Read from the guest the first time the menu needs it, then kept in step with each change. + public var increasesContrast: Bool? + + /// The most recent frame as a PNG, matching whatever the window is showing including the bezel. public func screenshotPNG() -> Data? { // The flat body around a foldable's panel is a phone bezel round a landscape picture. if let modelView, chromeView.hasChrome, let png = modelView.screenshotPNG() { diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index 5e14b32..c4bc9aa 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -229,30 +229,12 @@ public final class DeviceWindowManager { } } - @discardableResult - public func applyScaleMode(_ mode: ScaleMode) -> [String: ScaleApplication] { - controllers.mapValues { $0.applyScaleMode(mode) } - } - private func placedFrames(excluding udid: String) -> [CGRect] { controllers .filter { $0.key != udid } .compactMap { $0.value.window?.frame } } - public func setKeepOnTop(_ enabled: Bool) { - for controller in controllers.values { - controller.setKeepOnTop(enabled) - } - } - - public func setBezelEnabled(_ enabled: Bool) { - for controller in controllers.values { - controller.setBezelEnabled(enabled) - } - } - - /// The UDIDs of every open window, so a menu action can reach all of them. /// Called after a window has closed, so anything held per device can be let go of. public var onDeviceClosed: ((String) -> Void)? /// Called once a window has fresh sessions after its device came back, so whatever else was @@ -266,6 +248,7 @@ public final class DeviceWindowManager { public func hasCameraControl(_ udid: String) -> Bool { deviceTypes[udid].map { DeviceTypeProfile.hasCameraControl(deviceType: $0) } ?? false } + /// The device the user is looking at, which is where an action that can only land on one goes. /// With Settings or an alert in front, that is the device window highest in the stack. public var frontmostUDID: String? { @@ -280,21 +263,9 @@ public final class DeviceWindowManager { return controllers.keys.first } - - public func toggleBezel() { - let enabled = controllers.values.first?.isBezelEnabled ?? true - setBezelEnabled(!enabled) - } - - public func toggleKeepOnTop() { - let enabled = controllers.values.first?.isKeptOnTop ?? false - setKeepOnTop(!enabled) - } - - /// Writes a PNG of every open device into `directory`, returning the files written. + /// Writes a PNG per open device into `directory`, or for just one when a udid is given, and + /// returns the files written. @discardableResult - /// Writes a PNG per open device, or for just one when a udid is given, which is what the - /// button above a single window needs. public func saveScreenshots( into directory: URL, date: Date = Date(), @@ -336,9 +307,10 @@ public final class DeviceWindowManager { public var isRecording: Bool { !recorders.isEmpty || !selfRecording.isEmpty } - /// Starts or stops recording every open device. Returns the files finished by a stop. + /// Stops every recording, or starts one on each open device, or on just `only`. Returns the + /// files finished by a stop. @discardableResult - public func toggleRecording(into directory: URL, date: Date = Date()) -> [URL] { + public func toggleRecording(into directory: URL, date: Date = Date(), only chosen: String? = nil) -> [URL] { guard !isRecording else { var finished: [URL] = [] for (udid, recorder) in recorders { @@ -361,7 +333,7 @@ public final class DeviceWindowManager { return finished } - for (udid, controller) in controllers { + for (udid, controller) in controllers where chosen == nil || udid == chosen { let name = ScreenshotWriter.fileName(deviceName: controller.deviceTitle, date: date) .replacingOccurrences(of: ".png", with: ".mov") let url = directory.appending(path: name) @@ -388,17 +360,6 @@ public final class DeviceWindowManager { } } - public func setLatencyOverlayVisible(_ visible: Bool) { - for controller in controllers.values { - controller.setLatencyOverlayVisible(visible) - } - } - - public func toggleLatencyOverlay() { - let visible = controllers.values.first?.isLatencyOverlayVisible ?? false - setLatencyOverlayVisible(!visible) - } - public func closeAll() { followTask?.cancel() followTask = nil diff --git a/engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift b/engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift index 6b6d301..4deb883 100644 --- a/engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift +++ b/engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift @@ -45,11 +45,10 @@ public final class PasteboardSyncController { sessions[udid] = nil } - /// The Mac's clipboard to every open device, since a copy is not device specific. + /// The Mac's clipboard to the device in front, like every other menu action. public func send() { - for udid in devices() { - session(for: udid)?.send() - } + guard let udid = frontmost() else { return } + session(for: udid)?.send() } /// The frontmost device's clipboard to the Mac. Every device at once would leave whichever diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 9cc4c31..16c7b7f 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -22,12 +22,24 @@ public struct CommandLineToolMenu { /// Builds the application menu. Shortcuts follow the classic Simulator where the action exists. @MainActor public enum ViewerMenu { + /// Something the device in front has on or off, shown as a tick beside its item. + public enum DeviceSetting: Sendable, CaseIterable { + case keyboardInput + case hardwareKeyboard + case keyboardLanguage + case increasedContrast + case slowAnimations + case latencyOverlay + case bezel + case keepOnTop + } + public struct Actions { public var setScaleMode: (ScaleMode) -> Void public var toggleBezel: () -> Void public var toggleKeepOnTop: () -> Void public var pasteToDevice: () -> Void - public var setAppearance: (SimctlService.Appearance) -> Void + public var toggleAppearance: () -> Void public var saveScreenshot: () -> Void public var copyScreenshot: () -> Void public var toggleRecording: () -> Void @@ -68,6 +80,7 @@ public enum ViewerMenu { public var appSwitcher: () -> Void public var stopRecording: () -> Void public var isRecording: () -> Bool + public var isOn: (DeviceSetting) -> Bool public var checkForUpdates: (() -> Void)? public var showSettings: (() -> Void)? @@ -76,7 +89,7 @@ public enum ViewerMenu { toggleBezel: @escaping () -> Void, toggleKeepOnTop: @escaping () -> Void, pasteToDevice: @escaping () -> Void, - setAppearance: @escaping (SimctlService.Appearance) -> Void, + toggleAppearance: @escaping () -> Void, saveScreenshot: @escaping () -> Void, copyScreenshot: @escaping () -> Void, toggleRecording: @escaping () -> Void, @@ -114,6 +127,7 @@ public enum ViewerMenu { appSwitcher: @escaping () -> Void, stopRecording: @escaping () -> Void, isRecording: @escaping () -> Bool, + isOn: @escaping (DeviceSetting) -> Bool = { _ in false }, checkForUpdates: (() -> Void)? = nil, showSettings: (() -> Void)? = nil ) { @@ -121,7 +135,7 @@ public enum ViewerMenu { self.toggleBezel = toggleBezel self.toggleKeepOnTop = toggleKeepOnTop self.pasteToDevice = pasteToDevice - self.setAppearance = setAppearance + self.toggleAppearance = toggleAppearance self.saveScreenshot = saveScreenshot self.copyScreenshot = copyScreenshot self.toggleRecording = toggleRecording @@ -159,6 +173,7 @@ public enum ViewerMenu { self.appSwitcher = appSwitcher self.stopRecording = stopRecording self.isRecording = isRecording + self.isOn = isOn self.checkForUpdates = checkForUpdates self.showSettings = showSettings } @@ -355,7 +370,7 @@ public enum ViewerMenu { "k", [.command, .option] ) - sendKeys.state = .on + target.track(sendKeys, as: .keyboardInput) inputMenu.addItem(sendKeys) inputItem.submenu = inputMenu ioMenu.addItem(inputItem) @@ -368,7 +383,7 @@ public enum ViewerMenu { "k", [.command, .shift] ) - hardware.state = .on + target.track(hardware, as: .hardwareKeyboard) disable(hardware, unless: capabilities.contains(.hardwareKeyboard), reason: "not available on this Xcode") let sameLanguage = target.item( "Use the Same Keyboard Language as macOS", @@ -376,7 +391,7 @@ public enum ViewerMenu { "", [] ) - sameLanguage.state = .on + target.track(sameLanguage, as: .keyboardLanguage) disable(sameLanguage, unless: capabilities.contains(.hardwareKeyboard), reason: "not available on this Xcode") keyboardMenu.addItem(sameLanguage) keyboardMenu.addItem(hardware) @@ -392,7 +407,9 @@ public enum ViewerMenu { let featuresItem = NSMenuItem() let featuresMenu = NSMenu(title: "Features") featuresMenu.addItem(target.item("Toggle Appearance", #selector(MenuTarget.appearance), "a", [.command, .shift])) - featuresMenu.addItem(target.item("Toggle Increase Contrast", #selector(MenuTarget.increaseContrast(_:)), "", [])) + let contrast = target.item("Toggle Increase Contrast", #selector(MenuTarget.increaseContrast(_:)), "", []) + target.track(contrast, as: .increasedContrast) + featuresMenu.addItem(contrast) featuresMenu.addItem(.separator()) featuresMenu.addItem(target.item("Increase Preferred Text Size", #selector(MenuTarget.textSizeUp), "+", [.command, .option])) featuresMenu.addItem(target.item("Decrease Preferred Text Size", #selector(MenuTarget.textSizeDown), "-", [.command, .option])) @@ -418,6 +435,7 @@ public enum ViewerMenu { debugMenu.addItem(memoryWarning) let slowAnimations = target.item("Slow Animations", #selector(MenuTarget.slowAnimations(_:)), "", []) + target.track(slowAnimations, as: .slowAnimations) disable(slowAnimations, unless: capabilities.contains(.slowAnimations), reason: "not available on this Xcode") debugMenu.addItem(slowAnimations) @@ -425,7 +443,9 @@ public enum ViewerMenu { debugMenu.addItem(target.item("Open System Log\u{2026}", #selector(MenuTarget.systemLog), "/", [])) debugMenu.addItem(target.item("Open App Data in Finder", #selector(MenuTarget.appData), "", [])) debugMenu.addItem(.separator()) - debugMenu.addItem(target.item("Show Click to Frame Latency", #selector(MenuTarget.latency(_:)), "l", [.command, .shift])) + let latency = target.item("Show Click to Frame Latency", #selector(MenuTarget.latency(_:)), "l", [.command, .shift]) + target.track(latency, as: .latencyOverlay) + debugMenu.addItem(latency) debugItem.submenu = debugMenu bar.addItem(debugItem) @@ -437,8 +457,12 @@ public enum ViewerMenu { windowMenu.addItem(.separator()) windowMenu.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") .keyEquivalentModifierMask = [.control, .command] - windowMenu.addItem(target.item("Show Device Bezels", #selector(MenuTarget.bezel), "b", [])) - windowMenu.addItem(target.item("Stay On Top", #selector(MenuTarget.keepOnTop), "t", [])) + let bezel = target.item("Show Device Bezels", #selector(MenuTarget.bezel), "b", []) + target.track(bezel, as: .bezel) + windowMenu.addItem(bezel) + let keepOnTop = target.item("Stay On Top", #selector(MenuTarget.keepOnTop), "t", []) + target.track(keepOnTop, as: .keepOnTop) + windowMenu.addItem(keepOnTop) windowMenu.addItem(.separator()) let scaleShortcuts: [(ScaleMode, String)] = [ (.physicalSize, "1"), (.pointAccurate, "2"), (.pixelAccurate, "3"), (.fit, "4"), @@ -515,13 +539,7 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { private let commandLineTool: CommandLineToolMenu? private weak var commandLineToolItem: NSMenuItem? private weak var stopRecordingItem: NSMenuItem? - private var isDark = false - private var isSlowAnimations = false - private var isLatencyVisible = false - private var isIncreasedContrast = false - private var sendsKeyboardInput = true - private var hasHardwareKeyboard = true - private var matchesKeyboardLanguage = true + private var settingItems: [ObjectIdentifier: ViewerMenu.DeviceSetting] = [:] private weak var getPasteboardItem: NSMenuItem? private weak var sendPasteboardItem: NSMenuItem? private weak var syncPasteboardItem: NSMenuItem? @@ -709,11 +727,14 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { } @objc func newSimulator() { actions.newSimulator?() } - /// Both start on, because that is what the app does before anyone touches the menu. + /// The tick is read from the device in front whenever the menu is shown, since each device + /// keeps its own. + func track(_ item: NSMenuItem, as setting: ViewerMenu.DeviceSetting) { + settingItems[ObjectIdentifier(item)] = setting + } + @objc func keyboardInput(_ sender: NSMenuItem) { - sendsKeyboardInput.toggle() - sender.state = sendsKeyboardInput ? .on : .off - actions.toggleKeyboardInput(sendsKeyboardInput) + actions.toggleKeyboardInput(!actions.isOn(.keyboardInput)) } /// Read from the setting each time, because Settings changes it as well as this menu. @@ -735,15 +756,11 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { @objc func sendPasteboard() { actions.sendPasteboard() } @objc func matchKeyboardLanguage(_ sender: NSMenuItem) { - matchesKeyboardLanguage.toggle() - sender.state = matchesKeyboardLanguage ? .on : .off - actions.matchKeyboardLanguage(matchesKeyboardLanguage) + actions.matchKeyboardLanguage(!actions.isOn(.keyboardLanguage)) } @objc func hardwareKeyboard(_ sender: NSMenuItem) { - hasHardwareKeyboard.toggle() - sender.state = hasHardwareKeyboard ? .on : .off - actions.toggleHardwareKeyboard(hasHardwareKeyboard) + actions.toggleHardwareKeyboard(!actions.isOn(.hardwareKeyboard)) } @objc func locationScenario(_ sender: NSMenuItem) { @@ -753,8 +770,6 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { } @objc func increaseContrast(_ sender: NSMenuItem) { - isIncreasedContrast.toggle() - sender.state = isIncreasedContrast ? .on : .off actions.toggleIncreaseContrast() } @objc func stopRecording() { actions.stopRecording() } @@ -764,6 +779,9 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { } public func validateMenuItem(_ item: NSMenuItem) -> Bool { + if let setting = settingItems[ObjectIdentifier(item)] { + item.state = actions.isOn(setting) ? .on : .off + } if item === stopRecordingItem { return actions.isRecording() } if item === syncPasteboardItem { item.state = syncsPasteboard ? .on : .off @@ -780,21 +798,14 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { @objc func help() { ControlsHelp.show() } @objc func latency(_ sender: NSMenuItem) { - isLatencyVisible.toggle() - sender.state = isLatencyVisible ? .on : .off actions.toggleLatencyOverlay() } @objc func slowAnimations(_ sender: NSMenuItem) { - isSlowAnimations.toggle() - sender.state = isSlowAnimations ? .on : .off actions.toggleSlowAnimations() } @objc func copyScreenshot() { actions.copyScreenshot() } @objc func paste() { actions.pasteToDevice() } - @objc func appearance() { - isDark.toggle() - actions.setAppearance(isDark ? .dark : .light) - } + @objc func appearance() { actions.toggleAppearance() } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/MenuHarness.swift b/engine/Tests/OpenDeviceHubViewerTests/MenuHarness.swift index e3fd1b7..decfdcb 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/MenuHarness.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/MenuHarness.swift @@ -12,6 +12,8 @@ struct MenuHarness { var toggleAutomaticPasteboardSync: (Bool) -> Void = { _ in } var locationFavorites: () -> [LocationFavorite] = { [] } var setFavoriteLocation: (LocationFavorite) -> Void = { _ in } + var toggleKeyboardInput: (Bool) -> Void = { _ in } + var isOn: (ViewerMenu.DeviceSetting) -> Bool = { _ in false } @discardableResult func install() -> MenuTarget { @@ -22,7 +24,7 @@ struct MenuHarness { toggleBezel: {}, toggleKeepOnTop: {}, pasteToDevice: {}, - setAppearance: { _ in }, + toggleAppearance: {}, saveScreenshot: {}, copyScreenshot: {}, toggleRecording: {}, @@ -45,7 +47,7 @@ struct MenuHarness { setCustomLocation: {}, locationFavorites: locationFavorites, setFavoriteLocation: setFavoriteLocation, - toggleKeyboardInput: { _ in }, + toggleKeyboardInput: toggleKeyboardInput, toggleHardwareKeyboard: { _ in }, matchKeyboardLanguage: { _ in }, toggleAutomaticPasteboardSync: toggleAutomaticPasteboardSync, @@ -58,7 +60,8 @@ struct MenuHarness { setOrientation: { _ in }, appSwitcher: {}, stopRecording: {}, - isRecording: { false } + isRecording: { false }, + isOn: isOn ), capabilities: [.hardwareButtons, .touch, .rotation, .shake, .pasteboardSync] ) diff --git a/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift index 119a819..86b6189 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift @@ -68,6 +68,45 @@ final class MenuSettingsTests: XCTestCase { XCTAssertEqual(chosen, [lagos]) } + func testEachTickShowsTheDeviceInFront() throws { + let titles: [ViewerMenu.DeviceSetting: String] = [ + .keyboardInput: "Send Keyboard Input to Device", + .hardwareKeyboard: "Connect Hardware Keyboard", + .keyboardLanguage: "Use the Same Keyboard Language as macOS", + .increasedContrast: "Toggle Increase Contrast", + .slowAnimations: "Slow Animations", + .latencyOverlay: "Show Click to Frame Latency", + .bezel: "Show Device Bezels", + .keepOnTop: "Stay On Top", + ] + XCTAssertEqual(Set(titles.keys), Set(ViewerMenu.DeviceSetting.allCases)) + var inFront: Set = [] + var harness = MenuHarness() + harness.isOn = { inFront.contains($0) } + let target = harness.install() + + for (setting, title) in titles { + let (item, _) = try XCTUnwrap(MenuHarness.item(titled: title), title) + inFront = [setting] + _ = target.validateMenuItem(item) + XCTAssertEqual(item.state, .on, "\(title) missed the device in front having it on") + inFront = [] + _ = target.validateMenuItem(item) + XCTAssertEqual(item.state, .off, "\(title) missed the device in front having it off") + } + } + + func testTogglingKeyboardInputFlipsTheDeviceInFront() throws { + var sent: [Bool] = [] + var harness = MenuHarness() + harness.isOn = { $0 != .keyboardInput } + harness.toggleKeyboardInput = { sent.append($0) } + let target = harness.install() + let (item, _) = try XCTUnwrap(MenuHarness.item(titled: "Send Keyboard Input to Device")) + target.keyboardInput(item) + XCTAssertEqual(sent, [true], "the front device had keyboard input off, so it should turn on") + } + func testShutDownSitsAfterRestart() throws { var shutDowns = 0 var harness = MenuHarness() diff --git a/engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift b/engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift index 40e8400..e89c7a6 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift @@ -56,16 +56,15 @@ final class PasteboardSyncControllerTests: XCTestCase { XCTAssertEqual(sessions["b"]?.isAutomatic, true) } - func testSendingReachesEveryOpenDeviceAndGettingOnlyTheFrontOne() { + func testSendingAndGettingReachOnlyTheFrontDevice() { open = ["a", "b"] front = "b" let controller = makeController(isAutomatic: true) controller.send() controller.get() - XCTAssertEqual(sessions["a"]?.sends, 1) + XCTAssertNil(sessions["a"], "a device behind was sent the clipboard") XCTAssertEqual(sessions["b"]?.sends, 1) - XCTAssertEqual(sessions["a"]?.gets, 0) XCTAssertEqual(sessions["b"]?.gets, 1) } @@ -94,6 +93,7 @@ final class PasteboardSyncControllerTests: XCTestCase { func testForgettingADeviceStopsItsSyncAndDropsTheConnection() { open = ["a"] + front = "a" let controller = makeController(isAutomatic: true) controller.adopt("a") let session = sessions["a"] From 3c975e3f0a2f47bbc17b0940abf08ef025dfbc3a Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Mon, 28 Sep 2026 00:06:26 +0100 Subject: [PATCH 10/10] docs: widen the changelog note on menu scope --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e3492..f00a0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,8 @@ under it until the next heading becomes that version's release notes in the upda cover screen and touches come back with the fold it was showing, and the clipboard sync picks up again. - Device, Shut Down shuts a simulator down from the menu. -- Restart, Shut Down, Erase, the hardware buttons, rotation, Shake, location and text size act on - the simulator in front, not on every open one. +- Every menu action acts on the simulator in front, not on every open one, and ticks such as Stay + On Top, Slow Animations and Connect Hardware Keyboard show that simulator's own setting. - Favorite locations: save named coordinates in Settings and choose them from Features, Location. - Screenshots and recordings each have their own folder in Settings, starting from the folder you had already chosen, and Save Screen can put the picture on the clipboard instead of in a file.