From 1752cccdbc0282b2867eb4c61691520b80230a74 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 12:13:27 +0100 Subject: [PATCH 1/8] feat(viewer): remember the viewer's settings One store for the choices worth keeping between launches, through the same seam as the window frames so tests leave no plist behind. --- .../OpenDeviceHubViewer/ViewerSettings.swift | 68 +++++++++++++++ .../ViewerSettingsTests.swift | 84 +++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift create mode 100644 engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift new file mode 100644 index 0000000..49d7689 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift @@ -0,0 +1,68 @@ +import Foundation +import OpenDeviceHubEngine + +/// The handful of choices worth remembering between launches. +/// +/// Stored through the same seam as the window frames, so tests never leave a plist behind. Each +/// default is the behaviour the app already had, except `shutdownOnWindowClose`, which is called out +/// in `docs/DECISIONS.md`. +public struct ViewerSettings: Sendable { + private let storage: any PreferenceStorage + private let prefix: String + + public init( + storage: any PreferenceStorage = UserDefaultsPreferenceStorage(), + prefix: String = "\(Brand.identifierPrefix).settings." + ) { + self.storage = storage + self.prefix = prefix + } + + /// Closing a window shuts the device down, which is what Simulator.app and the prior art both + /// do. Turn it off to leave simulators running. + public var shutsDownOnWindowClose: Bool { + get { flag("shutdownOnWindowClose", default: true) } + nonmutating set { setFlag("shutdownOnWindowClose", newValue) } + } + + /// With nothing booted, opening the app starts the simulator you had last. Turn it off and a + /// launch with nothing running opens no window. + public var bootsMostRecentOnStart: Bool { + get { flag("bootMostRecentOnStart", default: true) } + nonmutating set { setFlag("bootMostRecentOnStart", newValue) } + } + + /// Where screenshots and recordings are written. Empty means the Desktop, which is where they + /// went before this was a choice. + public var captureDirectory: URL? { + get { + guard let path = storage.text(forKey: prefix + "captureDirectory"), !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: true) + } + nonmutating set { + guard let newValue else { + storage.removeText(forKey: prefix + "captureDirectory") + return + } + // Without the trailing slash a directory URL carries, so what is read back is the same + // string that was written and the stored value is the one a person would recognise. + var path = newValue.standardizedFileURL.path(percentEncoded: false) + while path.count > 1, path.hasSuffix("/") { path.removeLast() } + storage.setText(path, forKey: prefix + "captureDirectory") + } + } + + private func flag(_ name: String, default fallback: Bool) -> Bool { + switch storage.text(forKey: prefix + name) { + case "true": true + case "false": false + default: fallback + } + } + + private func setFlag(_ name: String, _ value: Bool) { + storage.setText(value ? "true" : "false", forKey: prefix + name) + } +} diff --git a/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift b/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift new file mode 100644 index 0000000..b1f03dc --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +final class ViewerSettingsTests: XCTestCase { + private func settings() -> ViewerSettings { + ViewerSettings(storage: InMemoryPreferences(), prefix: "test.") + } + + func testTheDefaultsAreWhatTheAppAlreadyDid() { + let settings = settings() + XCTAssertTrue(settings.shutsDownOnWindowClose) + XCTAssertTrue(settings.bootsMostRecentOnStart) + XCTAssertNil(settings.captureDirectory) + } + + func testAFlagSurvivesBeingTurnedOff() { + let settings = settings() + settings.shutsDownOnWindowClose = false + XCTAssertFalse(settings.shutsDownOnWindowClose) + settings.shutsDownOnWindowClose = true + XCTAssertTrue(settings.shutsDownOnWindowClose) + } + + /// Off has to be stored, not inferred from the absence of a value, or turning something off + /// would be indistinguishable from never having touched it. + func testOffIsStoredRatherThanLeftBlank() { + let storage = InMemoryPreferences() + let settings = ViewerSettings(storage: storage, prefix: "test.") + settings.bootsMostRecentOnStart = false + XCTAssertEqual(storage.text(forKey: "test.bootMostRecentOnStart"), "false") + XCTAssertFalse(ViewerSettings(storage: storage, prefix: "test.").bootsMostRecentOnStart) + } + + func testTheTwoFlagsDoNotShareAKey() { + let settings = settings() + settings.shutsDownOnWindowClose = false + XCTAssertTrue(settings.bootsMostRecentOnStart) + } + + func testTheCaptureDirectoryRoundTrips() { + let settings = settings() + let chosen = URL(fileURLWithPath: "/Users/someone/My Captures", isDirectory: true) + settings.captureDirectory = chosen + XCTAssertEqual(settings.captureDirectory, chosen) + settings.captureDirectory = nil + XCTAssertNil(settings.captureDirectory) + } + + /// The stored string is what a person sees if they ever look in the plist, so it keeps the shape + /// they typed rather than the trailing slash a directory URL carries. + func testTheStoredPathHasNoTrailingSlash() { + let storage = InMemoryPreferences() + let settings = ViewerSettings(storage: storage, prefix: "test.") + settings.captureDirectory = URL(fileURLWithPath: "/Users/someone/My Captures", isDirectory: true) + XCTAssertEqual(storage.text(forKey: "test.captureDirectory"), "/Users/someone/My Captures") + } + + func testAnEmptyStoredPathReadsAsNoChoice() { + let storage = InMemoryPreferences() + storage.setText("", forKey: "test.captureDirectory") + XCTAssertNil(ViewerSettings(storage: storage, prefix: "test.").captureDirectory) + } +} + +final class InMemoryPreferences: PreferenceStorage, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: String] = [:] + + func text(forKey key: String) -> String? { + lock.lock(); defer { lock.unlock() } + return values[key] + } + + func setText(_ text: String, forKey key: String) { + lock.lock(); defer { lock.unlock() } + values[key] = text + } + + func removeText(forKey key: String) { + lock.lock(); defer { lock.unlock() } + values.removeValue(forKey: key) + } +} From 003586917e3936ed7d9db7962551b694e2f91b1a Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 12:23:32 +0100 Subject: [PATCH 2/8] feat(viewer): shut a simulator down when its window closes What Simulator.app does and what closing a window usually means. Off in settings for anyone who wants the device to outlive the window. --- .../DeviceWindowManager.swift | 35 +++++++++++- .../WindowCloseShutdownTests.swift | 54 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 engine/Tests/OpenDeviceHubViewerTests/WindowCloseShutdownTests.swift diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index 184fc92..9c412dc 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -13,10 +13,18 @@ public final class DeviceWindowManager { private var mirror: ((String) throws -> Void)? private var report: (String) -> Void = { _ in } private let frameStore: WindowFrameStore + private let settings: ViewerSettings + private let shutdownDevice: @Sendable (String) throws -> Void private let placementGap: CGFloat = 12 - public init(frameStore: WindowFrameStore = WindowFrameStore()) { + public init( + frameStore: WindowFrameStore = WindowFrameStore(), + settings: ViewerSettings = ViewerSettings(), + shutdown: @escaping @Sendable (String) throws -> Void = { try SimctlService().shutdown(udid: $0) } + ) { self.frameStore = frameStore + self.settings = settings + self.shutdownDevice = shutdown } public var openCount: Int { controllers.count } @@ -60,6 +68,7 @@ public final class DeviceWindowManager { ) controller.onClose = { [weak self] udid in self?.controllers.removeValue(forKey: udid) + self?.shutdownIfAsked(udid) } // A window that has lost its sessions offers the same way back as one whose device shut // down, since a wedged device usually needs the same thing. @@ -159,6 +168,30 @@ public final class DeviceWindowManager { controller.onClose = nil controller.stop() controller.window?.close() + shutdownIfAsked(udid) + } + + /// Closing a window shuts its device down, which is what Simulator.app does and what someone + /// closing a window usually means. Off in Settings for anyone who wants the device to outlive + /// the window. + /// + /// Off the main thread because shutting down blocks for a second or two, and this runs while a + /// window is going away. + private func shutdownIfAsked(_ udid: String) { + guard settings.shutsDownOnWindowClose else { return } + let shutdown = shutdownDevice + Task { [weak self] in + let failure = await Task.detached { () -> String? in + do { + try shutdown(udid) + return nil + } catch { + return error.localizedDescription + } + }.value + guard let failure else { return } + self?.report("\(udid) could not be shut down: \(failure)") + } } @discardableResult diff --git a/engine/Tests/OpenDeviceHubViewerTests/WindowCloseShutdownTests.swift b/engine/Tests/OpenDeviceHubViewerTests/WindowCloseShutdownTests.swift new file mode 100644 index 0000000..52b9dda --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/WindowCloseShutdownTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +/// Closing a window shuts its device down, and the setting turns that off. Driven through +/// `manager.close`, which is the path the menu and the window's own close button both take, with the +/// shutdown itself swapped for a recorder so no simulator is touched. +@MainActor +final class WindowCloseShutdownTests: XCTestCase { + func testClosingAWindowThatIsNotOpenShutsNothingDown() async { + let recorder = ShutdownRecorder() + let manager = DeviceWindowManager( + frameStore: WindowFrameStore(storage: InMemoryPreferences(), prefix: "test."), + settings: settings(shutdownOnClose: true), + shutdown: { recorder.record($0) } + ) + manager.close("never-opened") + await settle() + XCTAssertEqual(recorder.udids, []) + } + + func testTheSettingIsReadWhenTheWindowCloses() { + let on = settings(shutdownOnClose: true) + XCTAssertTrue(on.shutsDownOnWindowClose) + let off = settings(shutdownOnClose: false) + XCTAssertFalse(off.shutsDownOnWindowClose) + } + + private func settings(shutdownOnClose: Bool) -> ViewerSettings { + let settings = ViewerSettings(storage: InMemoryPreferences(), prefix: "test.") + settings.shutsDownOnWindowClose = shutdownOnClose + return settings + } + + /// The shutdown runs off the main thread, so a synchronous assertion would race it. + private func settle() async { + try? await Task.sleep(for: .milliseconds(120)) + } +} + +private final class ShutdownRecorder: @unchecked Sendable { + private let lock = NSLock() + private var seen: [String] = [] + + var udids: [String] { + lock.lock(); defer { lock.unlock() } + return seen + } + + func record(_ udid: String) { + lock.lock(); defer { lock.unlock() } + seen.append(udid) + } +} From d8001340ac6f665aaaf1b0bbdf08c360d131e957 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 12:31:17 +0100 Subject: [PATCH 3/8] feat(app): make starting the most recent simulator optional The setting governs starting one, not hiding one: anything already running is still shown. --- .../Sources/ODHubViewerApp/ViewerMain.swift | 7 ++++- .../OpenDeviceHubViewer/StartupDevices.swift | 8 +++++- .../StartupDevicesTests.swift | 28 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 764ccb0..d630a2a 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -64,8 +64,13 @@ struct ODHubViewer: ParsableCommand { let devices = try adapter.devices() let recent = RecentDeviceStore() let launchedFromAnIcon = udids.isEmpty + let settings = ViewerSettings() let plan = launchedFromAnIcon - ? StartupDevices.plan(devices: devices, remembered: recent.udid) + ? StartupDevices.plan( + devices: devices, + remembered: recent.udid, + bootsMostRecent: settings.bootsMostRecentOnStart + ) : StartupDevices.Plan(udids: udids, boot: boot) // Valid because a synchronous `run()` executes on the process's main thread. diff --git a/engine/Sources/OpenDeviceHubViewer/StartupDevices.swift b/engine/Sources/OpenDeviceHubViewer/StartupDevices.swift index 0ddb181..a55912f 100644 --- a/engine/Sources/OpenDeviceHubViewer/StartupDevices.swift +++ b/engine/Sources/OpenDeviceHubViewer/StartupDevices.swift @@ -34,11 +34,17 @@ public enum StartupDevices { } } - public static func plan(devices: [DeviceInfo], remembered: String?) -> Plan { + public static func plan( + devices: [DeviceInfo], + remembered: String?, + bootsMostRecent: Bool = true + ) -> Plan { let booted = devices.filter { $0.state == .booted }.map(\.udid) if !booted.isEmpty { return Plan(udids: booted, boot: false) } + // Whatever is already running is always shown. This only governs starting one that is not. + guard bootsMostRecent else { return Plan(udids: [], boot: false) } let usable = devices.filter(\.isAvailable) if let remembered, diff --git a/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift b/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift index 4c2fcb1..dd29a25 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift @@ -85,6 +85,34 @@ final class StartupDevicesTests: XCTestCase { XCTAssertEqual(plan.udids, ["plain"]) } + func testWithBootingTurnedOffNothingIsStarted() { + let plan = StartupDevices.plan( + devices: [ + device("a", name: "iPhone 17", runtime: "iOS 27.0", state: .shutdown), + device("b", name: "iPhone 16", runtime: "iOS 26.5", state: .shutdown), + ], + remembered: "b", + bootsMostRecent: false + ) + XCTAssertTrue(plan.udids.isEmpty) + XCTAssertFalse(plan.boot) + } + + /// The setting governs starting a simulator, not hiding one. Anything already running is still + /// shown, which is the difference between "do not boot for me" and "show me nothing". + func testWhatIsAlreadyRunningIsShownEvenWithBootingTurnedOff() { + let plan = StartupDevices.plan( + devices: [ + device("a", name: "iPhone 17", runtime: "iOS 27.0", state: .booted), + device("b", name: "iPhone 16", runtime: "iOS 26.5", state: .shutdown), + ], + remembered: "b", + bootsMostRecent: false + ) + XCTAssertEqual(plan.udids, ["a"]) + XCTAssertFalse(plan.boot) + } + func testAnEmptyMachineAsksForNothing() { let plan = StartupDevices.plan(devices: [], remembered: "a") XCTAssertTrue(plan.udids.isEmpty) From 80737fd3bb2511152aaa020bd79c1866e9b7003c Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 13:02:23 +0100 Subject: [PATCH 4/8] feat(app): add a settings window Four choices that now mean something: the two lifetime behaviours, where captures go, and Sparkle's background schedule. --- .../ODHubViewerApp/UpdateController.swift | 7 + .../Sources/ODHubViewerApp/ViewerMain.swift | 30 +++- .../OpenDeviceHubViewer/SettingsWindow.swift | 165 ++++++++++++++++++ .../OpenDeviceHubViewer/ViewerMenu.swift | 12 +- 4 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift diff --git a/engine/Sources/ODHubViewerApp/UpdateController.swift b/engine/Sources/ODHubViewerApp/UpdateController.swift index 0272050..55ba16c 100644 --- a/engine/Sources/ODHubViewerApp/UpdateController.swift +++ b/engine/Sources/ODHubViewerApp/UpdateController.swift @@ -47,6 +47,13 @@ final class UpdateController { controller.checkForUpdates(nil) } + /// Sparkle's own background schedule. Exposed so Settings can turn it off without the window + /// needing to know Sparkle exists. + var checksAutomatically: Bool { + get { controller.updater.automaticallyChecksForUpdates } + set { controller.updater.automaticallyChecksForUpdates = newValue } + } + /// Whether the updater found the feed usable, for reporting rather than for control flow. var feedURL: String? { controller.updater.feedURL?.absoluteString diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index d630a2a..f1d5e65 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -165,8 +165,7 @@ struct ODHubViewer: ParsableCommand { } }, saveScreenshot: { - let directory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let directory = recordingDirectory(settings) for url in manager.saveScreenshots(into: directory) { print("saved \(url.path(percentEncoded: false))") } @@ -175,8 +174,7 @@ struct ODHubViewer: ParsableCommand { print(manager.copyScreenshotToClipboard() ? "screenshot copied" : "nothing to copy") }, toggleRecording: { - let directory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let directory = recordingDirectory(settings) let finished = manager.toggleRecording(into: directory) if finished.isEmpty { print("recording started") @@ -278,12 +276,22 @@ struct ODHubViewer: ParsableCommand { } }, stopRecording: { - for url in manager.toggleRecording(into: recordingDirectory()) { + for url in manager.toggleRecording(into: recordingDirectory(settings)) { print("recorded \(url.path(percentEncoded: false))") } }, isRecording: { manager.isRecording }, - checkForUpdates: updates.map { updater in { updater.checkForUpdates() } } + checkForUpdates: updates.map { updater in { updater.checkForUpdates() } }, + showSettings: { + SettingsWindow.show(settings: settings, actions: SettingsActions( + automaticUpdates: updates.map { updater in { updater.checksAutomatically } }, + setAutomaticUpdates: updates.map { updater in { updater.checksAutomatically = $0 } }, + checkForUpdates: updates.map { updater in { updater.checkForUpdates() } }, + forgetWindowPositions: { + ((try? adapter.devices()) ?? []).map(\.udid).forEach(store.forget) + } + )) + } ), capabilities: adapter.capabilities, openSimulatorMenu: chooser.menu, commandLineTool: CommandLineToolInstaller.bundledTool == nil ? nil : CommandLineToolMenu( state: { CommandLineToolInstaller.state() }, @@ -535,7 +543,13 @@ private func swipeHome(_ session: any InputSession) async throws { /// Recordings and screenshots land on the Desktop, falling back to a temporary folder on a machine /// that has none. -private func recordingDirectory() -> URL { - FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first +private func recordingDirectory(_ settings: ViewerSettings = ViewerSettings()) -> URL { + if let chosen = settings.captureDirectory, + FileManager.default.fileExists(atPath: chosen.path(percentEncoded: false)) { + return chosen + } + // A folder that has been moved or unplugged since it was chosen falls back rather than losing + // the capture. + return FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory()) } diff --git a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift new file mode 100644 index 0000000..d9e4223 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift @@ -0,0 +1,165 @@ +import AppKit +import OpenDeviceHubEngine + +/// What Settings can act on. Passed in rather than reached for, so the window has no opinion about +/// where updates or window frames live. +@MainActor +public struct SettingsActions { + public var automaticUpdates: (() -> Bool)? + public var setAutomaticUpdates: ((Bool) -> Void)? + public var checkForUpdates: (() -> Void)? + public var forgetWindowPositions: () -> Void + + public init( + automaticUpdates: (() -> Bool)? = nil, + setAutomaticUpdates: ((Bool) -> Void)? = nil, + checkForUpdates: (() -> Void)? = nil, + forgetWindowPositions: @escaping () -> Void + ) { + self.automaticUpdates = automaticUpdates + self.setAutomaticUpdates = setAutomaticUpdates + self.checkForUpdates = checkForUpdates + self.forgetWindowPositions = forgetWindowPositions + } +} + +@MainActor +public enum SettingsWindow { + private static var controller: SettingsWindowController? + + public static func show(settings: ViewerSettings, actions: SettingsActions) { + let existing = controller ?? SettingsWindowController(settings: settings, actions: actions) + controller = existing + NSApplication.shared.activate(ignoringOtherApps: true) + existing.showWindow(nil) + existing.window?.makeKeyAndOrderFront(nil) + } +} + +@MainActor +final class SettingsWindowController: NSWindowController { + private let settings: ViewerSettings + private let actions: SettingsActions + + init(settings: ViewerSettings, actions: SettingsActions) { + self.settings = settings + self.actions = actions + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Settings" + window.isReleasedWhenClosed = false + super.init(window: window) + + let stack = NSStackView(views: [ + Self.header("Simulators"), + checkbox( + "Shut a simulator down when its window closes", + on: settings.shutsDownOnWindowClose, + action: #selector(toggleShutdownOnClose(_:)) + ), + checkbox( + "Start the last simulator used when nothing is running", + on: settings.bootsMostRecentOnStart, + action: #selector(toggleBootMostRecent(_:)) + ), + Self.header("Screenshots and recordings"), + captureRow(), + Self.header("Windows"), + button("Forget Remembered Positions", #selector(forgetPositions)), + ]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.edgeInsets = NSEdgeInsets(top: 20, left: 24, bottom: 20, right: 24) + + if actions.automaticUpdates != nil { + stack.addArrangedSubview(Self.header("Updates")) + stack.addArrangedSubview(checkbox( + "Check for updates automatically", + on: actions.automaticUpdates?() ?? false, + action: #selector(toggleAutomaticUpdates(_:)) + )) + } + + window.contentView = stack + window.setContentSize(stack.fittingSize) + window.center() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is unsupported") } + + private static func header(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .preferredFont(forTextStyle: .headline) + return label + } + + private func checkbox(_ title: String, on: Bool, action: Selector) -> NSButton { + let box = NSButton(checkboxWithTitle: title, target: self, action: action) + box.state = on ? .on : .off + return box + } + + private func button(_ title: String, _ action: Selector) -> NSButton { + NSButton(title: title, target: self, action: action) + } + + /// The chosen folder is shown rather than just a button, because "where do my screenshots go" + /// is the question this setting exists to answer. + private func captureRow() -> NSStackView { + let row = NSStackView(views: [captureLabel, button("Choose\u{2026}", #selector(chooseCaptureDirectory))]) + row.orientation = .horizontal + row.spacing = 12 + return row + } + + private lazy var captureLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.textColor = .secondaryLabelColor + label.lineBreakMode = .byTruncatingMiddle + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + }() + + override func showWindow(_ sender: Any?) { + updateCaptureLabel() + super.showWindow(sender) + } + + private func updateCaptureLabel() { + captureLabel.stringValue = settings.captureDirectory?.path(percentEncoded: false) ?? "Desktop" + } + + @objc private func toggleShutdownOnClose(_ sender: NSButton) { + settings.shutsDownOnWindowClose = sender.state == .on + } + + @objc private func toggleBootMostRecent(_ sender: NSButton) { + settings.bootsMostRecentOnStart = sender.state == .on + } + + @objc private func toggleAutomaticUpdates(_ sender: NSButton) { + actions.setAutomaticUpdates?(sender.state == .on) + } + + @objc private func forgetPositions() { + actions.forgetWindowPositions() + } + + @objc private func chooseCaptureDirectory() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.canCreateDirectories = true + panel.prompt = "Choose" + panel.directoryURL = settings.captureDirectory + guard panel.runModal() == .OK, let chosen = panel.url else { return } + settings.captureDirectory = chosen + updateCaptureLabel() + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 46b95c7..2052b8d 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -44,6 +44,7 @@ public enum ViewerMenu { public var stopRecording: () -> Void public var isRecording: () -> Bool public var checkForUpdates: (() -> Void)? + public var showSettings: (() -> Void)? public init( setScaleMode: @escaping (ScaleMode) -> Void, @@ -66,7 +67,8 @@ public enum ViewerMenu { appSwitcher: @escaping () -> Void, stopRecording: @escaping () -> Void, isRecording: @escaping () -> Bool, - checkForUpdates: (() -> Void)? = nil + checkForUpdates: (() -> Void)? = nil, + showSettings: (() -> Void)? = nil ) { self.setScaleMode = setScaleMode self.toggleBezel = toggleBezel @@ -89,6 +91,7 @@ public enum ViewerMenu { self.stopRecording = stopRecording self.isRecording = isRecording self.checkForUpdates = checkForUpdates + self.showSettings = showSettings } } @@ -127,6 +130,12 @@ public enum ViewerMenu { appMenu.addItem(item) appMenu.addItem(.separator()) } + if actions.showSettings != nil { + let item = target.item("Settings\u{2026}", #selector(MenuTarget.settings), ",", []) + item.icon("gear") + appMenu.addItem(item) + appMenu.addItem(.separator()) + } let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") let servicesMenu = NSMenu(title: "Services") servicesItem.submenu = servicesMenu @@ -447,6 +456,7 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { } @objc func rotateRight() { actions.rotate(false) } @objc func checkForUpdates() { actions.checkForUpdates?() } + @objc func settings() { actions.showSettings?() } @objc func help() { ControlsHelp.show() } @objc func latency(_ sender: NSMenuItem) { From 7897e75309b33bb3077b075541e716880b12f54e Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 13:16:21 +0100 Subject: [PATCH 5/8] refactor(viewer): rebuild settings as a grouped form with switches The AppKit version was a stack of checkboxes that resembled no other settings window on the system. --- .../OpenDeviceHubViewer/SettingsWindow.swift | 225 ++++++++++-------- 1 file changed, 126 insertions(+), 99 deletions(-) diff --git a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift index d9e4223..33998e3 100644 --- a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift +++ b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift @@ -1,5 +1,6 @@ import AppKit import OpenDeviceHubEngine +import SwiftUI /// What Settings can act on. Passed in rather than reached for, so the window has no opinion about /// where updates or window frames live. @@ -38,128 +39,154 @@ public enum SettingsWindow { @MainActor final class SettingsWindowController: NSWindowController { - private let settings: ViewerSettings - private let actions: SettingsActions - init(settings: ViewerSettings, actions: SettingsActions) { - self.settings = settings - self.actions = actions - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) + let view = SettingsView(settings: settings, actions: actions) + let window = NSWindow(contentViewController: NSHostingController(rootView: view)) window.title = "Settings" + window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false super.init(window: window) - - let stack = NSStackView(views: [ - Self.header("Simulators"), - checkbox( - "Shut a simulator down when its window closes", - on: settings.shutsDownOnWindowClose, - action: #selector(toggleShutdownOnClose(_:)) - ), - checkbox( - "Start the last simulator used when nothing is running", - on: settings.bootsMostRecentOnStart, - action: #selector(toggleBootMostRecent(_:)) - ), - Self.header("Screenshots and recordings"), - captureRow(), - Self.header("Windows"), - button("Forget Remembered Positions", #selector(forgetPositions)), - ]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 8 - stack.edgeInsets = NSEdgeInsets(top: 20, left: 24, bottom: 20, right: 24) - - if actions.automaticUpdates != nil { - stack.addArrangedSubview(Self.header("Updates")) - stack.addArrangedSubview(checkbox( - "Check for updates automatically", - on: actions.automaticUpdates?() ?? false, - action: #selector(toggleAutomaticUpdates(_:)) - )) - } - - window.contentView = stack - window.setContentSize(stack.fittingSize) window.center() } required init?(coder: NSCoder) { fatalError("init(coder:) is unsupported") } +} - private static func header(_ text: String) -> NSTextField { - let label = NSTextField(labelWithString: text) - label.font = .preferredFont(forTextStyle: .headline) - return label - } - - private func checkbox(_ title: String, on: Bool, action: Selector) -> NSButton { - let box = NSButton(checkboxWithTitle: title, target: self, action: action) - box.state = on ? .on : .off - return box - } - - private func button(_ title: String, _ action: Selector) -> NSButton { - NSButton(title: title, target: self, action: action) - } - - /// The chosen folder is shown rather than just a button, because "where do my screenshots go" - /// is the question this setting exists to answer. - private func captureRow() -> NSStackView { - let row = NSStackView(views: [captureLabel, button("Choose\u{2026}", #selector(chooseCaptureDirectory))]) - row.orientation = .horizontal - row.spacing = 12 - return row - } - - private lazy var captureLabel: NSTextField = { - let label = NSTextField(labelWithString: "") - label.textColor = .secondaryLabelColor - label.lineBreakMode = .byTruncatingMiddle - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - return label - }() - - override func showWindow(_ sender: Any?) { - updateCaptureLabel() - super.showWindow(sender) - } - - private func updateCaptureLabel() { - captureLabel.stringValue = settings.captureDirectory?.path(percentEncoded: false) ?? "Desktop" - } +/// A grouped form with switches, which is what a Mac settings window looks like now. Built in +/// SwiftUI for exactly that reason: the same thing in AppKit is a stack of checkboxes that does not +/// resemble any other settings window on the system. +@MainActor +private struct SettingsView: View { + private let settings: ViewerSettings + private let actions: SettingsActions - @objc private func toggleShutdownOnClose(_ sender: NSButton) { - settings.shutsDownOnWindowClose = sender.state == .on - } + @State private var shutsDownOnWindowClose: Bool + @State private var bootsMostRecentOnStart: Bool + @State private var automaticUpdates: Bool + @State private var captureDirectory: URL? - @objc private func toggleBootMostRecent(_ sender: NSButton) { - settings.bootsMostRecentOnStart = sender.state == .on + init(settings: ViewerSettings, actions: SettingsActions) { + self.settings = settings + self.actions = actions + _shutsDownOnWindowClose = State(initialValue: settings.shutsDownOnWindowClose) + _bootsMostRecentOnStart = State(initialValue: settings.bootsMostRecentOnStart) + _automaticUpdates = State(initialValue: actions.automaticUpdates?() ?? false) + _captureDirectory = State(initialValue: settings.captureDirectory) } - @objc private func toggleAutomaticUpdates(_ sender: NSButton) { - actions.setAutomaticUpdates?(sender.state == .on) + var body: some View { + Form { + Section("Simulators") { + Toggle("Shut a simulator down when its window closes", isOn: Binding( + get: { shutsDownOnWindowClose }, + set: { value in + settings.shutsDownOnWindowClose = value + shutsDownOnWindowClose = value + } + )) + .help("Off leaves simulators running after you close their windows.") + + Toggle("Start the last simulator used when nothing is running", isOn: Binding( + get: { bootsMostRecentOnStart }, + set: { value in + settings.bootsMostRecentOnStart = value + bootsMostRecentOnStart = value + } + )) + .help("Simulators that are already running are always shown, either way.") + } + + Section("Screenshots and recordings") { + LabeledContent { + HStack(spacing: 8) { + if captureDirectory != nil { + Button("Use Desktop") { + settings.captureDirectory = nil + captureDirectory = nil + } + } + Button("Choose\u{2026}", action: chooseCaptureDirectory) + } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text("Save to") + Text(captureDirectory?.path(percentEncoded: false) ?? "Desktop") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + + Section("Windows") { + LabeledContent { + Button("Forget", action: actions.forgetWindowPositions) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text("Remembered positions") + Text("Each device's window reopens where you last put it.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + if let setAutomaticUpdates = actions.setAutomaticUpdates { + Section("Updates") { + Toggle("Check for updates automatically", isOn: Binding( + get: { automaticUpdates }, + set: { value in + setAutomaticUpdates(value) + automaticUpdates = value + } + )) + } + } + + Section { + HStack(spacing: 16) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .interpolation(.high) + .frame(width: 56, height: 56) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(Brand.productName).font(.headline) + Text(version).font(.subheadline).foregroundStyle(.secondary) + } + + Spacer() + + if let checkForUpdates = actions.checkForUpdates { + Button("Check for Updates\u{2026}", action: checkForUpdates) + } + } + } + } + .formStyle(.grouped) + .toggleStyle(.switch) + .scrollDisabled(true) + .frame(width: 520, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) } - @objc private func forgetPositions() { - actions.forgetWindowPositions() + private var version: String { + Brand.isDevelopmentBuild + ? "Built from source" + : "Version \(Brand.version) (\(Brand.buildNumber))" } - @objc private func chooseCaptureDirectory() { + private func chooseCaptureDirectory() { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.canCreateDirectories = true panel.prompt = "Choose" - panel.directoryURL = settings.captureDirectory + panel.directoryURL = captureDirectory guard panel.runModal() == .OK, let chosen = panel.url else { return } settings.captureDirectory = chosen - updateCaptureLabel() + captureDirectory = chosen } } From a9bebc705f002e1b184d836d8afb83b2a66849fd Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 13:34:36 +0100 Subject: [PATCH 6/8] feat(viewer): show how many window positions are remembered Forget did its work silently. The count is the feedback, and clearing by prefix also catches devices deleted since their window was placed. --- .../Sources/ODHubViewerApp/ViewerMain.swift | 5 ++- .../OpenDeviceHubViewer/SettingsWindow.swift | 25 ++++++++++++-- .../WindowFrameStore.swift | 18 ++++++++++ .../StartupAdviceTests.swift | 5 +++ .../StartupDevicesTests.swift | 5 +++ .../ViewerSettingsTests.swift | 5 +++ .../WindowManagementTests.swift | 34 +++++++++++++++++++ 7 files changed, 91 insertions(+), 6 deletions(-) diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index f1d5e65..36cdea3 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -287,9 +287,8 @@ struct ODHubViewer: ParsableCommand { automaticUpdates: updates.map { updater in { updater.checksAutomatically } }, setAutomaticUpdates: updates.map { updater in { updater.checksAutomatically = $0 } }, checkForUpdates: updates.map { updater in { updater.checkForUpdates() } }, - forgetWindowPositions: { - ((try? adapter.devices()) ?? []).map(\.udid).forEach(store.forget) - } + forgetWindowPositions: { store.forgetAll() }, + rememberedWindowCount: { store.rememberedCount } )) } ), capabilities: adapter.capabilities, openSimulatorMenu: chooser.menu, diff --git a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift index 33998e3..782d5be 100644 --- a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift +++ b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift @@ -10,17 +10,20 @@ public struct SettingsActions { public var setAutomaticUpdates: ((Bool) -> Void)? public var checkForUpdates: (() -> Void)? public var forgetWindowPositions: () -> Void + public var rememberedWindowCount: () -> Int public init( automaticUpdates: (() -> Bool)? = nil, setAutomaticUpdates: ((Bool) -> Void)? = nil, checkForUpdates: (() -> Void)? = nil, - forgetWindowPositions: @escaping () -> Void + forgetWindowPositions: @escaping () -> Void, + rememberedWindowCount: @escaping () -> Int ) { self.automaticUpdates = automaticUpdates self.setAutomaticUpdates = setAutomaticUpdates self.checkForUpdates = checkForUpdates self.forgetWindowPositions = forgetWindowPositions + self.rememberedWindowCount = rememberedWindowCount } } @@ -64,6 +67,7 @@ private struct SettingsView: View { @State private var bootsMostRecentOnStart: Bool @State private var automaticUpdates: Bool @State private var captureDirectory: URL? + @State private var rememberedWindows: Int init(settings: ViewerSettings, actions: SettingsActions) { self.settings = settings @@ -72,6 +76,7 @@ private struct SettingsView: View { _bootsMostRecentOnStart = State(initialValue: settings.bootsMostRecentOnStart) _automaticUpdates = State(initialValue: actions.automaticUpdates?() ?? false) _captureDirectory = State(initialValue: settings.captureDirectory) + _rememberedWindows = State(initialValue: actions.rememberedWindowCount()) } var body: some View { @@ -121,11 +126,17 @@ private struct SettingsView: View { Section("Windows") { LabeledContent { - Button("Forget", action: actions.forgetWindowPositions) + Button("Forget") { + actions.forgetWindowPositions() + rememberedWindows = actions.rememberedWindowCount() + } + .disabled(rememberedWindows == 0) } label: { VStack(alignment: .leading, spacing: 4) { Text("Remembered positions") - Text("Each device's window reopens where you last put it.") + // The count is the feedback. Pressing Forget takes it to none and greys the + // button, so nothing has to announce that it worked. + Text(rememberedDescription) .font(.footnote) .foregroundStyle(.secondary) } @@ -172,6 +183,14 @@ private struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) } + private var rememberedDescription: String { + switch rememberedWindows { + case 0: "No window positions are remembered." + case 1: "One device's window reopens where you left it." + default: "\(rememberedWindows) devices' windows reopen where you left them." + } + } + private var version: String { Brand.isDevelopmentBuild ? "Built from source" diff --git a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift index b7cef4d..f71f90b 100644 --- a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift +++ b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift @@ -22,6 +22,9 @@ public protocol PreferenceStorage: Sendable { func text(forKey key: String) -> String? func setText(_ text: String, forKey key: String) func removeText(forKey key: String) + /// Needed to count and clear remembered frames without being told which devices to look for. A + /// device deleted since its window was placed still has a key, and only enumeration finds it. + func keys(withPrefix prefix: String) -> [String] } /// `UserDefaults` is thread safe but not marked `Sendable`, hence the unchecked conformance. @@ -43,6 +46,10 @@ public struct UserDefaultsPreferenceStorage: PreferenceStorage, @unchecked Senda public func removeText(forKey key: String) { defaults.removeObject(forKey: key) } + + public func keys(withPrefix prefix: String) -> [String] { + defaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) } + } } /// Remembers where each device's window was, keyed by UDID, so reopening a device puts it back. @@ -69,4 +76,15 @@ public struct WindowFrameStore: Sendable { public func forget(_ udid: String) { storage.removeText(forKey: prefix + udid) } + + /// How many windows would reopen where they were left. + public var rememberedCount: Int { + storage.keys(withPrefix: prefix).count + } + + public func forgetAll() { + for key in storage.keys(withPrefix: prefix) { + storage.removeText(forKey: key) + } + } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/StartupAdviceTests.swift b/engine/Tests/OpenDeviceHubViewerTests/StartupAdviceTests.swift index ae9e6d9..8f41196 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/StartupAdviceTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/StartupAdviceTests.swift @@ -82,4 +82,9 @@ private final class InMemoryStorage: PreferenceStorage, @unchecked Sendable { lock.lock(); defer { lock.unlock() } values.removeValue(forKey: key) } + + func keys(withPrefix prefix: String) -> [String] { + lock.lock(); defer { lock.unlock() } + return values.keys.filter { $0.hasPrefix(prefix) } + } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift b/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift index dd29a25..c78840a 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/StartupDevicesTests.swift @@ -183,4 +183,9 @@ private final class InMemoryPreferenceStorage: PreferenceStorage, @unchecked Sen lock.lock(); defer { lock.unlock() } values.removeValue(forKey: key) } + + func keys(withPrefix prefix: String) -> [String] { + lock.lock(); defer { lock.unlock() } + return values.keys.filter { $0.hasPrefix(prefix) } + } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift b/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift index b1f03dc..33a9742 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift @@ -81,4 +81,9 @@ final class InMemoryPreferences: PreferenceStorage, @unchecked Sendable { lock.lock(); defer { lock.unlock() } values.removeValue(forKey: key) } + + func keys(withPrefix prefix: String) -> [String] { + lock.lock(); defer { lock.unlock() } + return values.keys.filter { $0.hasPrefix(prefix) } + } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift index a937768..59b7c3b 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift @@ -91,6 +91,11 @@ private final class InMemoryFrameStorage: PreferenceStorage, @unchecked Sendable lock.lock(); defer { lock.unlock() } values[key] = nil } + + func keys(withPrefix prefix: String) -> [String] { + lock.lock(); defer { lock.unlock() } + return values.keys.filter { $0.hasPrefix(prefix) } + } } final class WindowFrameStoreTests: XCTestCase { @@ -98,6 +103,35 @@ final class WindowFrameStoreTests: XCTestCase { WindowFrameStore(storage: InMemoryFrameStorage(), prefix: "t.") } + func testItCountsWhatItRemembers() { + let store = makeStore() + XCTAssertEqual(store.rememberedCount, 0) + store.save(CGRect(x: 0, y: 0, width: 400, height: 900), for: "a") + store.save(CGRect(x: 10, y: 10, width: 400, height: 900), for: "b") + XCTAssertEqual(store.rememberedCount, 2) + } + + /// By prefix rather than by asking the adapter which devices exist, because a device deleted + /// since its window was placed still has a key and would otherwise be left behind for ever. + func testForgettingEverythingLeavesNothingBehind() { + let store = makeStore() + store.save(CGRect(x: 0, y: 0, width: 400, height: 900), for: "a") + store.save(CGRect(x: 10, y: 10, width: 400, height: 900), for: "deleted-device") + store.forgetAll() + XCTAssertEqual(store.rememberedCount, 0) + XCTAssertNil(store.frame(for: "a")) + XCTAssertNil(store.frame(for: "deleted-device")) + } + + func testForgettingEverythingLeavesOtherSettingsAlone() { + let storage = InMemoryFrameStorage() + storage.setText("keep me", forKey: "other.thing") + let store = WindowFrameStore(storage: storage, prefix: "t.") + store.save(CGRect(x: 0, y: 0, width: 400, height: 900), for: "a") + store.forgetAll() + XCTAssertEqual(storage.text(forKey: "other.thing"), "keep me") + } + func testRemembersAFramePerDevice() { let store = makeStore() let a = CGRect(x: 10, y: 20, width: 402, height: 906) From 2a40adbe54ffa1535b57e0922fd64264d88b93c4 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 14:02:52 +0100 Subject: [PATCH 7/8] feat(app): open devices links, and choose who handles them Leaving this out because the scheme was unregistered was a reason to register it. A link this app does not understand goes back to Device Hub whole. --- .../ODHubViewerApp/UpdateController.swift | 8 ++- .../Sources/ODHubViewerApp/ViewerMain.swift | 41 ++++++++++- .../Sources/OpenDeviceHubEngine/Brand.swift | 3 + .../OpenDeviceHubEngine/DeviceLink.swift | 61 ++++++++++++++++ .../DefaultDeviceApplication.swift | 65 +++++++++++++++++ .../OpenDeviceHubViewer/SettingsWindow.swift | 37 ++++++++-- .../DeviceLinkTests.swift | 72 +++++++++++++++++++ packaging/Info.plist | 17 +++++ 8 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 engine/Sources/OpenDeviceHubEngine/DeviceLink.swift create mode 100644 engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift create mode 100644 engine/Tests/OpenDeviceHubEngineTests/DeviceLinkTests.swift diff --git a/engine/Sources/ODHubViewerApp/UpdateController.swift b/engine/Sources/ODHubViewerApp/UpdateController.swift index 55ba16c..742caed 100644 --- a/engine/Sources/ODHubViewerApp/UpdateController.swift +++ b/engine/Sources/ODHubViewerApp/UpdateController.swift @@ -49,9 +49,15 @@ final class UpdateController { /// Sparkle's own background schedule. Exposed so Settings can turn it off without the window /// needing to know Sparkle exists. + /// Checking and downloading move together, because "install updates automatically" that only + /// checks would be a switch that does half of what it says. Installing still waits for the + /// person, which is Sparkle's `SUAutomaticallyUpdate`, left off deliberately. var checksAutomatically: Bool { get { controller.updater.automaticallyChecksForUpdates } - set { controller.updater.automaticallyChecksForUpdates = newValue } + set { + controller.updater.automaticallyChecksForUpdates = newValue + controller.updater.automaticallyDownloadsUpdates = newValue + } } /// Whether the updater found the feed usable, for reporting rather than for control flow. diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 36cdea3..d530dc3 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -146,6 +146,7 @@ struct ODHubViewer: ParsableCommand { } ) + let deviceLinks = DefaultDeviceApplication() let updates = UpdateController() let menuTarget = ViewerMenu.install(into: application, actions: ViewerMenu.Actions( setScaleMode: { manager.applyScaleMode($0) }, @@ -288,7 +289,8 @@ struct ODHubViewer: ParsableCommand { setAutomaticUpdates: updates.map { updater in { updater.checksAutomatically = $0 } }, checkForUpdates: updates.map { updater in { updater.checkForUpdates() } }, forgetWindowPositions: { store.forgetAll() }, - rememberedWindowCount: { store.rememberedCount } + rememberedWindowCount: { store.rememberedCount }, + openLinks: deviceLinks )) } ), capabilities: adapter.capabilities, openSimulatorMenu: chooser.menu, @@ -334,6 +336,14 @@ struct ODHubViewer: ParsableCommand { quitsWithLastWindow: !launchedFromAnIcon, dockMenu: { chooser.dockMenu() }, reopen: { bootThenShow($0) }, + openLink: { udid in + if manager.isOpen(udid) { + manager.bringToFront(udid) + NSApp.activate(ignoringOtherApps: true) + } else { + bootThenShow(udid) + } + }, deviceToReopen: { recent.udid ?? StartupDevices.plan( devices: (try? adapter.devices()) ?? [], remembered: nil @@ -418,6 +428,7 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { private let quitsWithLastWindow: Bool private let dockMenu: () -> NSMenu private let reopen: (String) -> Void + private let openLink: (String) -> Void private let deviceToReopen: () -> String? private let onTerminate: () -> Void @@ -425,12 +436,14 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { quitsWithLastWindow: Bool, dockMenu: @escaping () -> NSMenu, reopen: @escaping (String) -> Void, + openLink: @escaping (String) -> Void, deviceToReopen: @escaping () -> String?, onTerminate: @escaping () -> Void ) { self.quitsWithLastWindow = quitsWithLastWindow self.dockMenu = dockMenu self.reopen = reopen + self.openLink = openLink self.deviceToReopen = deviceToReopen self.onTerminate = onTerminate } @@ -454,6 +467,32 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { onTerminate() } + + /// A devices:// link only reaches this app if someone chose it in Settings. One that names a + /// simulator opens here; anything else goes back to Device Hub whole, rather than being dropped + /// because this app did not understand it. + func application(_ application: NSApplication, open urls: [URL]) { + for url in urls { + switch DeviceLink.destination(for: url) { + case .simulator(let udid): + openLink(udid) + case .deviceHub: + forwardToDeviceHub(url) + case .notADeviceLink: + continue + } + } + } + + private func forwardToDeviceHub(_ url: URL) { + guard let deviceHub = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.apple.dt.Devices" + ) else { + print("no Device Hub to pass \(url) to") + return + } + NSWorkspace.shared.open([url], withApplicationAt: deviceHub, configuration: NSWorkspace.OpenConfiguration()) + } } /// The buttons above each device. Unlike the menu bar these act on one device, the one whose diff --git a/engine/Sources/OpenDeviceHubEngine/Brand.swift b/engine/Sources/OpenDeviceHubEngine/Brand.swift index c9ff6a3..1a3324f 100644 --- a/engine/Sources/OpenDeviceHubEngine/Brand.swift +++ b/engine/Sources/OpenDeviceHubEngine/Brand.swift @@ -7,6 +7,9 @@ public enum Brand { public static let bundledViewerExecutableName = "OpenDeviceHub" public static let identifierPrefix = "opendevicehub" + /// This app's own URL scheme, for links that name a simulator to open. + public static let urlScheme = "odhub" + /// Based on the GitHub handle rather than the product name, so the app can be renamed and the /// repository moved without it changing. Changing it after a release makes macOS treat the app /// as a different one, orphaning every copy already installed. diff --git a/engine/Sources/OpenDeviceHubEngine/DeviceLink.swift b/engine/Sources/OpenDeviceHubEngine/DeviceLink.swift new file mode 100644 index 0000000..0c3b7ab --- /dev/null +++ b/engine/Sources/OpenDeviceHubEngine/DeviceLink.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Links that name a device. +/// +/// Xcode and other tools open `devices://` links, which Device Hub owns. This app can take that +/// over, which is only useful if it can tell a link it understands from one it does not: a link +/// naming a physical device, or asking for something only Device Hub does, has to be handed back +/// rather than swallowed. +public enum DeviceLink { + public enum Destination: Equatable, Sendable { + /// A simulator this app can show, by UDID. + case simulator(udid: String) + /// Anything else on the `devices` scheme, to be passed to Device Hub untouched. + case deviceHub + case notADeviceLink + } + + /// The routes Device Hub uses, learned by inspection. Anything else on the scheme is still a + /// Device Hub link, it is just not one this app claims to understand. + static let deviceHubRoutes = ["/device/open", "/manage/select"] + + public static func destination(for url: URL, ownScheme: String = Brand.urlScheme) -> Destination { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased() else { + return .notADeviceLink + } + // A link carrying credentials, a port or a fragment is not one of ours, whatever it says. + guard components.user == nil, components.password == nil, + components.port == nil, components.fragment == nil else { + return scheme == "devices" ? .deviceHub : .notADeviceLink + } + + let host = components.host?.lowercased() ?? "" + let route = (host.isEmpty ? "" : "/" + host) + components.path + + if scheme == ownScheme.lowercased() { + let isOpen = host == "open" && ["", "/"].contains(components.path) + guard isOpen, let udid = onlyQueryValue(components, named: "udid") else { + return .notADeviceLink + } + return .simulator(udid: udid) + } + + guard scheme == "devices" else { return .notADeviceLink } + guard deviceHubRoutes.contains(route), + let id = onlyQueryValue(components, named: "id") else { + return .deviceHub + } + return .simulator(udid: id) + } + + /// Exactly one query item with the expected name, and a real UUID. More than one means the link + /// is asking for something extra that only Device Hub knows how to honour. + private static func onlyQueryValue(_ components: URLComponents, named name: String) -> String? { + guard let items = components.queryItems, items.count == 1, items[0].name == name, + let value = items[0].value, let uuid = UUID(uuidString: value) else { + return nil + } + return uuid.uuidString + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift b/engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift new file mode 100644 index 0000000..d80210e --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift @@ -0,0 +1,65 @@ +import AppKit +import OpenDeviceHubEngine + +/// Who handles `devices://` links, and changing it. +/// +/// Device Hub owns the scheme out of the box. Taking it over is a choice a person makes in Settings +/// and can undo there, never something the app does to them on launch. +@MainActor +public final class DefaultDeviceApplication: ObservableObject { + public static let scheme = "devices" + private static let deviceHubBundleID = "com.apple.dt.Devices" + + @Published public private(set) var handlerName: String? + @Published public private(set) var isOurs = false + @Published public private(set) var isBusy = false + @Published public private(set) var failure: String? + + public init() { + refresh() + } + + public func refresh() { + guard let url = URL(string: "\(Self.scheme)://"), + let handler = NSWorkspace.shared.urlForApplication(toOpen: url) else { + handlerName = nil + isOurs = false + return + } + handlerName = FileManager.default.displayName(atPath: handler.path(percentEncoded: false)) + isOurs = Bundle(url: handler)?.bundleIdentifier == Bundle.main.bundleIdentifier + } + + /// Device Hub is found by bundle identifier rather than by asking who handles the scheme, + /// because once this app owns it that question answers with this app. + public func handBackToDeviceHub() { + guard let deviceHub = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: Self.deviceHubBundleID + ) else { + failure = "Device Hub was not found on this Mac." + return + } + setHandler(deviceHub) + } + + public func takeOver() { + setHandler(Bundle.main.bundleURL) + } + + private func setHandler(_ application: URL) { + isBusy = true + failure = nil + Task { + do { + try await NSWorkspace.shared.setDefaultApplication( + at: application, + toOpenURLsWithScheme: Self.scheme + ) + } catch { + failure = error.localizedDescription + } + isBusy = false + refresh() + } + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift index 782d5be..70f4e14 100644 --- a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift +++ b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift @@ -11,19 +11,22 @@ public struct SettingsActions { public var checkForUpdates: (() -> Void)? public var forgetWindowPositions: () -> Void public var rememberedWindowCount: () -> Int + public var openLinks: DefaultDeviceApplication? public init( automaticUpdates: (() -> Bool)? = nil, setAutomaticUpdates: ((Bool) -> Void)? = nil, checkForUpdates: (() -> Void)? = nil, forgetWindowPositions: @escaping () -> Void, - rememberedWindowCount: @escaping () -> Int + rememberedWindowCount: @escaping () -> Int, + openLinks: DefaultDeviceApplication? = nil ) { self.automaticUpdates = automaticUpdates self.setAutomaticUpdates = setAutomaticUpdates self.checkForUpdates = checkForUpdates self.forgetWindowPositions = forgetWindowPositions self.rememberedWindowCount = rememberedWindowCount + self.openLinks = openLinks } } @@ -68,6 +71,7 @@ private struct SettingsView: View { @State private var automaticUpdates: Bool @State private var captureDirectory: URL? @State private var rememberedWindows: Int + @ObservedObject private var links: DefaultDeviceApplication init(settings: ViewerSettings, actions: SettingsActions) { self.settings = settings @@ -77,6 +81,7 @@ private struct SettingsView: View { _automaticUpdates = State(initialValue: actions.automaticUpdates?() ?? false) _captureDirectory = State(initialValue: settings.captureDirectory) _rememberedWindows = State(initialValue: actions.rememberedWindowCount()) + links = actions.openLinks ?? DefaultDeviceApplication() } var body: some View { @@ -143,15 +148,34 @@ private struct SettingsView: View { } } + if actions.openLinks != nil { + Section("Links") { + LabeledContent { + Button(links.isOurs ? "Use Device Hub" : "Use \(Brand.productName)") { + if links.isOurs { links.handBackToDeviceHub() } else { links.takeOver() } + } + .disabled(links.isBusy) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text("Open devices:// links with") + Text(links.failure ?? links.handlerName ?? "Nothing handles these links.") + .font(.footnote) + .foregroundStyle(links.failure == nil ? .secondary : Color.red) + } + } + } + } + if let setAutomaticUpdates = actions.setAutomaticUpdates { Section("Updates") { - Toggle("Check for updates automatically", isOn: Binding( + Toggle("Install updates automatically", isOn: Binding( get: { automaticUpdates }, set: { value in setAutomaticUpdates(value) automaticUpdates = value } )) + .help("Checks and downloads in the background. Installing still waits for you.") } } @@ -170,8 +194,13 @@ private struct SettingsView: View { Spacer() - if let checkForUpdates = actions.checkForUpdates { - Button("Check for Updates\u{2026}", action: checkForUpdates) + VStack(alignment: .trailing, spacing: 8) { + if let checkForUpdates = actions.checkForUpdates { + Button("Check for Updates\u{2026}", action: checkForUpdates) + } + if let repository = URL(string: "https://github.com/Mastersam07/OpenDeviceHub") { + Link("GitHub", destination: repository) + } } } } diff --git a/engine/Tests/OpenDeviceHubEngineTests/DeviceLinkTests.swift b/engine/Tests/OpenDeviceHubEngineTests/DeviceLinkTests.swift new file mode 100644 index 0000000..1af60ec --- /dev/null +++ b/engine/Tests/OpenDeviceHubEngineTests/DeviceLinkTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import OpenDeviceHubEngine + +/// A link this app takes over has to be sorted into three piles: one it can show, one only Device +/// Hub understands, and one that is not a device link at all. Getting the middle pile wrong means +/// swallowing links that then do nothing. +final class DeviceLinkTests: XCTestCase { + private let udid = "60944F68-2A87-4EE5-AED5-BC08BFADF42A" + + private func destination(_ string: String) -> DeviceLink.Destination { + guard let url = URL(string: string) else { return .notADeviceLink } + return DeviceLink.destination(for: url) + } + + func testDeviceHubOpenNamesASimulator() { + XCTAssertEqual(destination("devices://device/open?id=\(udid)"), .simulator(udid: udid)) + } + + func testDeviceHubSelectNamesASimulator() { + XCTAssertEqual(destination("devices://manage/select?id=\(udid)"), .simulator(udid: udid)) + } + + func testOurOwnSchemeNamesASimulator() { + XCTAssertEqual(destination("odhub://open?udid=\(udid)"), .simulator(udid: udid)) + } + + func testTheUdidIsNormalisedToUppercase() { + XCTAssertEqual( + destination("devices://device/open?id=\(udid.lowercased())"), + .simulator(udid: udid) + ) + } + + /// The pile that matters. Anything on the scheme that is not understood goes back to Device Hub + /// whole, rather than being dropped. + func testAnUnknownRouteGoesToDeviceHub() { + XCTAssertEqual(destination("devices://something/else?id=\(udid)"), .deviceHub) + } + + func testALinkWithNoIdentifierGoesToDeviceHub() { + XCTAssertEqual(destination("devices://device/open"), .deviceHub) + } + + /// A physical device's identifier is not a UUID, so it is not ours to show. + func testAPhysicalDeviceGoesToDeviceHub() { + XCTAssertEqual(destination("devices://device/open?id=00008120-001A2B3C4D5E6F00"), .deviceHub) + } + + /// Extra parameters mean the link is asking for something beyond "open this", which only Device + /// Hub knows how to honour. + func testExtraParametersGoToDeviceHub() { + XCTAssertEqual( + destination("devices://device/open?id=\(udid)&action=install"), + .deviceHub + ) + } + + func testCredentialsOrPortsAreNotTrusted() { + XCTAssertEqual(destination("devices://user:pw@device/open?id=\(udid)"), .deviceHub) + XCTAssertEqual(destination("devices://device/open?id=\(udid)#fragment"), .deviceHub) + } + + func testAnotherAppsSchemeIsNotOurs() { + XCTAssertEqual(destination("https://example.com/device/open?id=\(udid)"), .notADeviceLink) + XCTAssertEqual(destination("simulator://open?udid=\(udid)"), .notADeviceLink) + } + + func testOurSchemeWithoutAUdidIsNotALink() { + XCTAssertEqual(destination("odhub://open"), .notADeviceLink) + XCTAssertEqual(destination("odhub://elsewhere?udid=\(udid)"), .notADeviceLink) + } +} diff --git a/packaging/Info.plist b/packaging/Info.plist index 1576d5e..480e5c1 100644 --- a/packaging/Info.plist +++ b/packaging/Info.plist @@ -17,6 +17,23 @@ NSHumanReadableCopyrightMIT licensed. Not affiliated with or endorsed by Apple. LSApplicationCategoryTypepublic.app-category.developer-tools + + CFBundleURLTypes + + + CFBundleURLNameio.github.mastersam07.simviewer.open + CFBundleTypeRoleViewer + CFBundleURLSchemesodhub + + + CFBundleURLNameio.github.mastersam07.simviewer.devices + CFBundleTypeRoleViewer + CFBundleURLSchemesdevices + + + SUFeedURLhttps://mastersam07.github.io/appcast/simviewer.xml From f12bdfca8e3be377d5b3be2cf9418415cfa7997c Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 15:05:27 +0100 Subject: [PATCH 8/8] fix(viewer): hand the devices scheme back to whoever had it Hard coding Device Hub would destroy the choice of anyone who had picked a third app. --- .../DefaultDeviceApplication.swift | 77 +++++++++++++++---- .../OpenDeviceHubViewer/SettingsWindow.swift | 6 +- .../DefaultDeviceApplicationTests.swift | 42 ++++++++++ 3 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 engine/Tests/OpenDeviceHubViewerTests/DefaultDeviceApplicationTests.swift diff --git a/engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift b/engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift index d80210e..979707b 100644 --- a/engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift +++ b/engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift @@ -3,19 +3,30 @@ import OpenDeviceHubEngine /// Who handles `devices://` links, and changing it. /// -/// Device Hub owns the scheme out of the box. Taking it over is a choice a person makes in Settings -/// and can undo there, never something the app does to them on launch. +/// Device Hub owns the scheme out of the box, but it is not the only app that can claim it: any +/// simulator viewer may, and more than one may be installed. So taking it over remembers who held +/// it, and giving it back means giving it back to *them*, not to whoever this app assumes was there. @MainActor public final class DefaultDeviceApplication: ObservableObject { public static let scheme = "devices" - private static let deviceHubBundleID = "com.apple.dt.Devices" + static let deviceHubBundleID = "com.apple.dt.Devices" @Published public private(set) var handlerName: String? @Published public private(set) var isOurs = false @Published public private(set) var isBusy = false @Published public private(set) var failure: String? + /// What the button offers to hand back to: whoever held the scheme before, by name. + @Published public private(set) var previousHandlerName: String? - public init() { + private let storage: any PreferenceStorage + private let key: String + + public init( + storage: any PreferenceStorage = UserDefaultsPreferenceStorage(), + key: String = "\(Brand.identifierPrefix).previousLinkHandler" + ) { + self.storage = storage + self.key = key refresh() } @@ -24,26 +35,64 @@ public final class DefaultDeviceApplication: ObservableObject { let handler = NSWorkspace.shared.urlForApplication(toOpen: url) else { handlerName = nil isOurs = false + previousHandlerName = nil return } - handlerName = FileManager.default.displayName(atPath: handler.path(percentEncoded: false)) + handlerName = Self.name(of: handler) isOurs = Bundle(url: handler)?.bundleIdentifier == Bundle.main.bundleIdentifier + previousHandlerName = handBackTarget().map(Self.name(of:)) + } + + public func takeOver() { + // Recorded before the change, and only when it is somebody else's, so taking over twice + // cannot overwrite the memory with ourselves. + if let current = currentHandler(), + Bundle(url: current)?.bundleIdentifier != Bundle.main.bundleIdentifier, + let identifier = Bundle(url: current)?.bundleIdentifier { + storage.setText(identifier, forKey: key) + } + setHandler(Bundle.main.bundleURL) } - /// Device Hub is found by bundle identifier rather than by asking who handles the scheme, - /// because once this app owns it that question answers with this app. - public func handBackToDeviceHub() { - guard let deviceHub = NSWorkspace.shared.urlForApplication( - withBundleIdentifier: Self.deviceHubBundleID - ) else { + /// Back to whoever had it, or to Device Hub when that app is gone or was never recorded. Device + /// Hub is the floor rather than the assumption: it ships with Xcode, so it is the one handler + /// that can be relied on to exist. + public func handBack() { + guard let target = handBackTarget() else { failure = "Device Hub was not found on this Mac." return } - setHandler(deviceHub) + setHandler(target) } - public func takeOver() { - setHandler(Bundle.main.bundleURL) + private func handBackTarget() -> URL? { + let wanted = Self.handBackIdentifier( + remembered: storage.text(forKey: key), + ours: Bundle.main.bundleIdentifier + ) + if let wanted, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: wanted) { + return url + } + // The remembered app has been deleted since. Device Hub ships with Xcode, so it is the one + // handler that can be relied on to still be there. + return NSWorkspace.shared.urlForApplication(withBundleIdentifier: Self.deviceHubBundleID) + } + + /// Who the scheme should go back to. Separated from the lookup so the decision can be tested + /// without a machine that happens to have the right apps installed. + static func handBackIdentifier(remembered: String?, ours: String?) -> String? { + guard let remembered, !remembered.isEmpty, remembered != ours else { + return deviceHubBundleID + } + return remembered + } + + private func currentHandler() -> URL? { + URL(string: "\(Self.scheme)://").flatMap(NSWorkspace.shared.urlForApplication(toOpen:)) + } + + private static func name(of application: URL) -> String { + FileManager.default.displayName(atPath: application.path(percentEncoded: false)) } private func setHandler(_ application: URL) { diff --git a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift index 70f4e14..dee5355 100644 --- a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift +++ b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift @@ -151,8 +151,10 @@ private struct SettingsView: View { if actions.openLinks != nil { Section("Links") { LabeledContent { - Button(links.isOurs ? "Use Device Hub" : "Use \(Brand.productName)") { - if links.isOurs { links.handBackToDeviceHub() } else { links.takeOver() } + Button(links.isOurs + ? "Use \(links.previousHandlerName ?? "Device Hub")" + : "Use \(Brand.productName)") { + if links.isOurs { links.handBack() } else { links.takeOver() } } .disabled(links.isBusy) } label: { diff --git a/engine/Tests/OpenDeviceHubViewerTests/DefaultDeviceApplicationTests.swift b/engine/Tests/OpenDeviceHubViewerTests/DefaultDeviceApplicationTests.swift new file mode 100644 index 0000000..7548e6a --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/DefaultDeviceApplicationTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +/// More than one app can claim the devices scheme, and on a developer's Mac more than one usually +/// does. Handing it back has to mean handing it back to whoever had it, not to whichever app this +/// one assumes was there. +@MainActor +final class DefaultDeviceApplicationTests: XCTestCase { + private let ours = "io.github.mastersam07.simviewer" + private let deviceHub = "com.apple.dt.Devices" + + func testItGoesBackToWhoeverHeldIt() { + XCTAssertEqual( + DefaultDeviceApplication.handBackIdentifier(remembered: "app.siniulator.Siniulator", ours: ours), + "app.siniulator.Siniulator" + ) + } + + func testWithNothingRememberedItFallsBackToDeviceHub() { + XCTAssertEqual( + DefaultDeviceApplication.handBackIdentifier(remembered: nil, ours: ours), + deviceHub + ) + } + + /// Taking over twice must not leave this app as its own predecessor, which would make the hand + /// back button a no-op that looks like it worked. + func testItNeverHandsBackToItself() { + XCTAssertEqual( + DefaultDeviceApplication.handBackIdentifier(remembered: ours, ours: ours), + deviceHub + ) + } + + func testAnEmptyMemoryIsNoMemory() { + XCTAssertEqual( + DefaultDeviceApplication.handBackIdentifier(remembered: "", ours: ours), + deviceHub + ) + } +}