diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b18eba..f00a0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,37 @@ 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. +- 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 a simulator down from the menu. +- 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. +- 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. 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/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index d77781e..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) @@ -138,6 +137,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 +169,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) @@ -222,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 { @@ -258,54 +287,55 @@ 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: { - 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: { - 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 - 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)") } } }, @@ -313,117 +343,106 @@ 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: { + 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 - 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 - 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 @@ -474,21 +493,17 @@ 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 { - 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)") } } }, @@ -496,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( @@ -525,13 +558,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 +631,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 +832,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..92e9248 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) } @@ -495,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 c396871..c4bc9aa 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) @@ -223,32 +229,17 @@ 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 + /// opened for the boot that ended can be opened again. + public var onReattached: ((String) -> Void)? public var openUDIDs: [String] { Array(controllers.keys) } @@ -257,26 +248,24 @@ 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? { - controllers.first { $0.value.window?.isKeyWindow == true }?.key ?? 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) + 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 } - /// 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(), @@ -318,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 { @@ -343,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) @@ -370,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 @@ -393,13 +372,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/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/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) 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() {} +} 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"] 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() {} +} diff --git a/scripts/release-notes.py b/scripts/release-notes.py index 70d5bbf..dbaaf25 100755 --- a/scripts/release-notes.py +++ b/scripts/release-notes.py @@ -92,9 +92,8 @@ 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.""") +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