From 3c369bd90d434f7a196b9805c6c27172dd4ed813 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 21:40:07 +0100 Subject: [PATCH 1/2] feat(adapter): sync the clipboard with a booted device The automatic direction carries the Mac's copies to the device. The device's copies only come back when asked for, so the connection also reconciles the two sides, keeping whichever is newer rather than letting a pull overwrite a copy the user just made on the Mac. --- .../Adapter/Capabilities.swift | 1 + .../Adapter/CoreSimulatorAdapter.swift | 43 +++++ .../Adapter/PasteboardBridge.swift | 136 ++++++++++++++ .../Adapter/PrivateSymbolProbe.swift | 6 + .../Adapter/SimulatorAdapter.swift | 17 ++ .../Xcode/FrameworkLoader.swift | 11 ++ .../include/OpenDeviceHubPrivate.h | 18 ++ .../PasteboardBridgeTests.swift | 169 ++++++++++++++++++ 8 files changed, 401 insertions(+) create mode 100644 engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift create mode 100644 engine/Tests/OpenDeviceHubIntegrationTests/PasteboardBridgeTests.swift diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift index be5c28c..7b1867b 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift @@ -18,6 +18,7 @@ public struct Capabilities: OptionSet, Sendable, Hashable { public static let rotation = Capabilities(rawValue: 1 << 10) public static let deviceNotifications = Capabilities(rawValue: 1 << 11) public static let hardwareKeyboard = Capabilities(rawValue: 1 << 12) + public static let pasteboardSync = Capabilities(rawValue: 1 << 13) } extension Capabilities { diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift index 8269eb9..560ebf6 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift @@ -188,6 +188,43 @@ public final class CoreSimulatorAdapter: SimulatorAdapter, @unchecked Sendable { } } + /// A connection that keeps the device's clipboard and the Mac's in step. + /// + /// The caller holds the result for as long as the device is on screen: the autosync runs on this + /// connection, so releasing it stops the syncing. + /// + /// The port is looked up here rather than stored anywhere, because a mach port name means + /// nothing outside the process that asked for it. Verified on Xcode 27 (27A266a) and Xcode 26.6. + public func openPasteboard(_ udid: String) throws -> any PasteboardSession { + lock.lock() + defer { lock.unlock() } + + let device = try rawDevice(udid) + guard DeviceState.from(state: device.state, stateString: device.stateString ?? "") == .booted else { + throw EngineError.deviceNotBooted(udid: udid) + } + guard (device as AnyObject).responds(to: NSSelectorFromString("lookup:error:")) else { + throw EngineError.symbolNotFound( + name: "-[SimDevice lookup:error:]", + framework: PrivateFramework.coreSimulator.rawValue + ) + } + _ = try FrameworkLoader.load(.simPasteboardPlus, from: xcode) + // Asked of the listener class rather than written down here, so a rename in a later Xcode is + // followed rather than guessed at. + guard let service = PasteboardBridge.serviceName() else { + throw EngineError.symbolNotFound( + name: "+[SimPasteboardInterfaceListener machServiceName]", + framework: PrivateFramework.simPasteboardPlus.rawValue + ) + } + let port = device.lookup(service, error: nil) + guard port != 0 else { + throw EngineError.capabilityUnavailable(name: "pasteboard sync on \(udid)") + } + return try PasteboardBridge(port: port) + } + public func setOrientation(_ orientation: DeviceOrientation, udid: String) throws { lock.lock() defer { lock.unlock() } @@ -310,6 +347,12 @@ public final class CoreSimulatorAdapter: SimulatorAdapter, @unchecked Sendable { ) != nil { capabilities.insert(.hardwareKeyboard) } + // The interface class only exists once SimPasteboardPlus has been loaded, which happens on + // first use, so the flag turns on the route rather than promising the class is resident. + if let device = NSClassFromString("SimDevice"), + class_getInstanceMethod(device, NSSelectorFromString("lookup:error:")) != nil { + capabilities.insert(.pasteboardSync) + } if let deviceSet = NSClassFromString("SimDeviceSet"), class_getInstanceMethod( deviceSet, NSSelectorFromString("registerNotificationHandlerOnQueue:handler:") diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift new file mode 100644 index 0000000..5ff9fb5 --- /dev/null +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift @@ -0,0 +1,136 @@ +import AppKit +import Foundation +import OpenDeviceHubPrivate + +/// Keeps a device's clipboard and the Mac's in step. +/// +/// One connection per device, held for as long as the device is shown. The automatic syncing runs on +/// the connection, so letting go of it stops the syncing. +/// +/// Only one of the two directions is automatic. Measured on Xcode 27 (27A266a) against a booted +/// device, reading the other side back with `simctl pbpaste` and `pbcopy` each time: +/// +/// - `enableRemoteAutosync` carries the Mac's copies to the device on its own, but only while the +/// run loop turns, which an app does and a test process has to be made to do. +/// - Nothing carries the device's copies back, with or without a delegate. `pull` is the only way, +/// and it has to be asked for. +public final class PasteboardBridge: PasteboardSession, @unchecked Sendable { + private let interface: any ODHSimPasteboardInterface + private let pasteboard: NSPasteboard + private let lock = NSLock() + private var isSyncing = false + private var lastSeenChangeCount: Int + + /// The service the guest publishes. Asked of the listener class rather than written down here, so + /// a rename in a later Xcode is followed rather than guessed at. + static func serviceName() -> String? { + guard let listener = NSClassFromString("SimPasteboardPlus.SimPasteboardInterfaceListener") + ?? NSClassFromString("SimPasteboardInterfaceListener") else { + return nil + } + let selector = NSSelectorFromString("machServiceName") + guard (listener as AnyObject).responds(to: selector) else { return nil } + return (listener as AnyObject).perform(selector)?.takeUnretainedValue() as? String + } + + init(port: UInt32, pasteboard: NSPasteboard = .general) throws { + self.pasteboard = pasteboard + self.lastSeenChangeCount = pasteboard.changeCount + + guard let interfaceClass = NSClassFromString("SimPasteboardPlus.SimPasteboardInterface") + ?? NSClassFromString("SimPasteboardInterface") else { + throw EngineError.symbolNotFound( + name: "SimPasteboardInterface", + framework: PrivateFramework.simPasteboardPlus.rawValue + ) + } + let selector = NSSelectorFromString( + "initWithConnectingToPort:managingPasteboard:delegate:delegateQueue:" + ) + guard let allocated = (interfaceClass as AnyObject).perform(NSSelectorFromString("alloc"))? + .takeUnretainedValue(), + allocated.responds(to: selector) else { + throw EngineError.symbolNotFound( + name: "-[SimPasteboardInterface \(NSStringFromSelector(selector))]", + framework: PrivateFramework.simPasteboardPlus.rawValue + ) + } + // Four arguments, so `perform` cannot be used. The signature was read off the runtime: + // `@44@0:8I16@20@28@36`, an unsigned int port then three objects. The result comes back + // unmanaged because an initialiser hands over ownership, which `alloc` above does not. + // + // No delegate and no queue. Both are optional, and the delegate only reports that the + // interface became active and that the sync state changed, neither of which says the device's + // clipboard moved. A delegate would also commit us to passing a dispatch queue here: an + // `OperationQueue` crashes when the interface dispatches to it. + typealias Initialiser = @convention(c) ( + AnyObject, Selector, UInt32, AnyObject?, AnyObject?, AnyObject? + ) -> Unmanaged? + let imp = unsafeBitCast(allocated.method(for: selector), to: Initialiser.self) + guard let made = imp(allocated, selector, port, pasteboard, nil, nil) else { + throw EngineError.privateCall( + symbol: "initWithConnectingToPort:managingPasteboard:delegate:delegateQueue:", + message: "the pasteboard interface could not connect to the device" + ) + } + // A Swift class does not declare conformance to an Objective-C protocol written here, so a + // conditional cast would fail even though every selector is present. + interface = unsafeBitCast( + made.takeRetainedValue(), + to: (any ODHSimPasteboardInterface).self + ) + } + + /// Whether the Mac's copies are reaching the device on their own. + public var isAutomatic: Bool { + lock.lock() + defer { lock.unlock() } + return isSyncing + } + + public func setAutomatic(_ enabled: Bool) { + lock.lock() + defer { lock.unlock() } + guard isSyncing != enabled else { return } + isSyncing = enabled + if enabled { + interface.enableRemoteAutosync() + } else { + interface.disableRemoteAutosync() + } + lastSeenChangeCount = pasteboard.changeCount + } + + /// The Mac's clipboard to the device. + public func send() { + lock.lock() + defer { lock.unlock() } + interface.push() + lastSeenChangeCount = pasteboard.changeCount + } + + /// The device's clipboard to the Mac. + public func get() { + lock.lock() + defer { lock.unlock() } + interface.pull() + lastSeenChangeCount = pasteboard.changeCount + } + + /// Brings the device's clipboard back to the Mac, unless the Mac's is the newer of the two. + /// + /// Called when the app stops being frontmost, which is when a copy made inside the device is + /// about to be pasted somewhere else. The check matters: an unconditional pull would overwrite + /// something the user copied on the Mac a moment earlier, before the automatic sync had carried + /// it the other way. + public func reconcile() { + lock.lock() + defer { lock.unlock() } + if pasteboard.changeCount != lastSeenChangeCount { + interface.push() + } else { + interface.pull() + } + lastSeenChangeCount = pasteboard.changeCount + } +} diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/PrivateSymbolProbe.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/PrivateSymbolProbe.swift index 2cd6a72..04993b9 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/PrivateSymbolProbe.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/PrivateSymbolProbe.swift @@ -55,11 +55,17 @@ public enum PrivateSymbolProbe { (.classSymbol, "SimHIDCaptureManager"), ] + public static let simPasteboardPlusSymbols: [(PrivateSymbolKind, String)] = [ + (.classSymbol, "SimPasteboardInterface"), + (.classSymbol, "SimPasteboardInterfaceListener"), + ] + public static func symbols(for framework: PrivateFramework) -> [(PrivateSymbolKind, String)] { switch framework { case .coreSimulator: coreSimulatorSymbols case .coreSimDeviceIO: coreSimDeviceIOSymbols case .simulatorKit: simulatorKitSymbols + case .simPasteboardPlus: simPasteboardPlusSymbols } } diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift index dc5e203..04d338e 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift @@ -130,6 +130,21 @@ public protocol InputSession: AnyObject, Sendable { func close() } +/// A live connection that keeps a device's clipboard and the Mac's in step. +/// +/// Held for as long as the device is on screen: the automatic syncing runs on the connection, so +/// letting go of it stops the syncing. +public protocol PasteboardSession: Sendable { + var isAutomatic: Bool { get } + func setAutomatic(_ enabled: Bool) + /// The Mac's clipboard to the device. + func send() + /// The device's clipboard to the Mac. + func get() + /// Brings the device's clipboard back to the Mac, unless the Mac's is the newer of the two. + func reconcile() +} + public protocol SimulatorAdapter: Sendable { var xcode: XcodeInstall { get } var capabilities: Capabilities { get } @@ -144,6 +159,8 @@ public protocol SimulatorAdapter: Sendable { func setHardwareKeyboardEnabled(_ enabled: Bool, udid: String) throws /// Points the guest's keyboard at a language, for example "en-US". func setKeyboardLanguage(_ language: String, udid: String) throws + /// Opens the clipboard link to a booted device. + func openPasteboard(_ udid: String) throws -> any PasteboardSession /// Watches every device in the set, so a window learns that its device has gone or come back /// without asking. func watchDeviceStates() throws -> any DeviceNotifier diff --git a/engine/Sources/OpenDeviceHubEngine/Xcode/FrameworkLoader.swift b/engine/Sources/OpenDeviceHubEngine/Xcode/FrameworkLoader.swift index 07d92af..2c3be4e 100644 --- a/engine/Sources/OpenDeviceHubEngine/Xcode/FrameworkLoader.swift +++ b/engine/Sources/OpenDeviceHubEngine/Xcode/FrameworkLoader.swift @@ -4,6 +4,7 @@ public enum PrivateFramework: String, Sendable, Hashable, CaseIterable { case coreSimulator = "CoreSimulator" case coreSimDeviceIO = "CoreSimDeviceIO" case simulatorKit = "SimulatorKit" + case simPasteboardPlus = "SimPasteboardPlus" /// Probed in order. CoreSimulator lives outside Xcode; SimulatorKit moved from the developer /// directory into the app bundle's SharedFrameworks in Xcode 27. @@ -24,6 +25,16 @@ public enum PrivateFramework: String, Sendable, Hashable, CaseIterable { .appending(path: "Library/PrivateFrameworks/\(suffix)") .path(percentEncoded: false), ] + case .simPasteboardPlus: + // Inside CoreSimulator's own bundle rather than beside it, which is why it is not a + // variation of the two paths above. + let suffix = "CoreSimulator.framework/Frameworks/SimPasteboardPlus.framework/SimPasteboardPlus" + return [ + "/Library/Developer/PrivateFrameworks/\(suffix)", + install.developerDir + .appending(path: "Library/PrivateFrameworks/\(suffix)") + .path(percentEncoded: false), + ] case .simulatorKit: return [ install.appRoot diff --git a/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h b/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h index 8d9bed4..01ddd60 100644 --- a/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h +++ b/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h @@ -124,6 +124,24 @@ completion:(void (^_Nonnull)(NSError *_Nullable))completion; @end +/// The pasteboard bridge between the Mac and a booted device, from `SimPasteboardPlus`, which lives +/// inside CoreSimulator's own bundle. The port comes from `-[SimDevice lookup:error:]` with the +/// service name `SimPasteboardInterfaceListener` reports. Verified on Xcode 27 (27A266a). +@protocol ODHSimPasteboardInterface +/// `managingPasteboard` is the `NSPasteboard` to keep in step. Typed `id` so this header does not +/// pull in AppKit. +- (instancetype _Nullable)initWithConnectingToPort:(unsigned int)port + managingPasteboard:(id _Nullable)pasteboard + delegate:(id _Nullable)delegate + delegateQueue:(id _Nullable)queue; +/// The Mac's clipboard to the device. +- (void)push; +/// The device's clipboard to the Mac. +- (void)pull; +- (void)enableRemoteAutosync; +- (void)disableRemoteAutosync; +@end + @protocol ODHSimServiceContext - (nullable id)defaultDeviceSetWithError:(NSError *_Nullable *_Nullable)error; @end diff --git a/engine/Tests/OpenDeviceHubIntegrationTests/PasteboardBridgeTests.swift b/engine/Tests/OpenDeviceHubIntegrationTests/PasteboardBridgeTests.swift new file mode 100644 index 0000000..5a0b161 --- /dev/null +++ b/engine/Tests/OpenDeviceHubIntegrationTests/PasteboardBridgeTests.swift @@ -0,0 +1,169 @@ +import AppKit +import XCTest +import OpenDeviceHubEngine + +final class PasteboardBridgeTests: XCTestCase { + private func makeAdapter() throws -> any SimulatorAdapter { + try AdapterFactory.make(for: XcodeLocator.locate()) + } + + private func bootedDevice() throws -> DeviceInfo { + guard let booted = try makeAdapter().devices().first(where: { $0.state == .booted }) else { + throw XCTSkip("no booted simulator, boot one to run this test") + } + return booted + } + + /// The automatic direction only moves while the run loop turns, so a test that slept instead + /// would report a working feature as broken. + private func spin(_ seconds: TimeInterval) { + RunLoop.current.run(until: Date().addingTimeInterval(seconds)) + } + + private func setMacClipboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + func testOpensAPasteboardConnectionOnABootedDevice() throws { + try IntegrationGate.requireEnabled() + let session = try makeAdapter().openPasteboard(bootedDevice().udid) + XCTAssertFalse(session.isAutomatic) + } + + func testRefusesAShutdownDevice() throws { + try IntegrationGate.requireEnabled() + let adapter = try makeAdapter() + guard let shutdown = try adapter.devices().first(where: { $0.state == .shutdown }) else { + throw XCTSkip("every simulator is booted") + } + XCTAssertThrowsError(try adapter.openPasteboard(shutdown.udid)) { error in + guard case EngineError.deviceNotBooted = error else { + return XCTFail("wrong error: \(error)") + } + } + } + + /// Read back with `simctl pbpaste` rather than by trusting the call that wrote it. + func testSendCarriesTheMacClipboardToTheDevice() throws { + try IntegrationGate.requireEnabled() + let udid = try bootedDevice().udid + let session = try makeAdapter().openPasteboard(udid) + + let sent = "odhub-send-\(UUID().uuidString)" + setMacClipboard(sent) + session.send() + spin(1) + + XCTAssertEqual(try simctlPaste(udid: udid), sent) + } + + func testGetCarriesTheDeviceClipboardToTheMac() throws { + try IntegrationGate.requireEnabled() + let udid = try bootedDevice().udid + let session = try makeAdapter().openPasteboard(udid) + + let copied = "odhub-get-\(UUID().uuidString)" + setMacClipboard("something else") + try simctlCopy(copied, udid: udid) + session.get() + spin(1) + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), copied) + } + + func testAutomaticSyncCarriesLaterMacCopies() throws { + try IntegrationGate.requireEnabled() + let udid = try bootedDevice().udid + let session = try makeAdapter().openPasteboard(udid) + session.setAutomatic(true) + XCTAssertTrue(session.isAutomatic) + defer { session.setAutomatic(false) } + spin(1) + + // Copied after the syncing was turned on and never pushed by hand, so arriving proves the + // sync is running rather than that a one off push happened. + let copied = "odhub-auto-\(UUID().uuidString)" + setMacClipboard(copied) + spin(3) + + XCTAssertEqual(try simctlPaste(udid: udid), copied) + } + + func testTurningTheSyncOffStopsIt() throws { + try IntegrationGate.requireEnabled() + let udid = try bootedDevice().udid + let session = try makeAdapter().openPasteboard(udid) + session.setAutomatic(true) + spin(1) + session.setAutomatic(false) + XCTAssertFalse(session.isAutomatic) + + let ignored = "odhub-off-\(UUID().uuidString)" + setMacClipboard(ignored) + spin(3) + + XCTAssertNotEqual(try simctlPaste(udid: udid), ignored) + } + + func testReconcileBringsTheDeviceClipboardBack() throws { + try IntegrationGate.requireEnabled() + let udid = try bootedDevice().udid + let session = try makeAdapter().openPasteboard(udid) + session.send() + + let onTheDevice = "odhub-back-\(UUID().uuidString)" + try simctlCopy(onTheDevice, udid: udid) + session.reconcile() + spin(1) + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), onTheDevice) + } + + /// The guard that stops a pull from eating a copy the user made on the Mac a moment earlier. + func testReconcileKeepsANewerMacCopy() throws { + try IntegrationGate.requireEnabled() + let udid = try bootedDevice().udid + let session = try makeAdapter().openPasteboard(udid) + + try simctlCopy("older-on-the-device", udid: udid) + let newerOnTheMac = "odhub-newer-\(UUID().uuidString)" + setMacClipboard(newerOnTheMac) + session.reconcile() + spin(1) + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), newerOnTheMac) + XCTAssertEqual(try simctlPaste(udid: udid), newerOnTheMac) + } + + private func simctlPaste(udid: String) throws -> String { + try simctl(["pbpaste", udid]) + } + + private func simctlCopy(_ value: String, udid: String) throws { + _ = try simctl(["pbcopy", udid], input: value) + } + + @discardableResult + private func simctl(_ arguments: [String], input: String? = nil) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl"] + arguments + process.environment = ProcessInfo.processInfo.environment + let output = Pipe() + process.standardOutput = output + process.standardError = Pipe() + if let input { + let pipe = Pipe() + process.standardInput = pipe + try process.run() + pipe.fileHandleForWriting.write(Data(input.utf8)) + pipe.fileHandleForWriting.closeFile() + } else { + try process.run() + } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(decoding: data, as: UTF8.self) + } +} From 15067c3d6d603e43f838f3aca8bc8ab81c86514d Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 22:49:40 +0100 Subject: [PATCH 2/2] feat(app): keep the clipboard in step with the device One connection per open device, kept while the window is up, since the automatic syncing runs on it. The sync is on by default and remembered. Only the Mac to device direction is automatic, so the device's clipboard is fetched when the app stops being frontmost, which is when a copy made inside a device is about to be pasted elsewhere. Get and Send Pasteboard are the manual halves and are greyed while the sync is on, as in Simulator.app. --- CHANGELOG.md | 5 +- .../Sources/ODHubViewerApp/ViewerMain.swift | 30 ++++- .../Adapter/PasteboardBridge.swift | 15 ++- .../DeviceWindowManager.swift | 9 ++ .../PasteboardSyncController.swift | 76 +++++++++++ .../OpenDeviceHubViewer/ViewerMenu.swift | 63 ++++++++- .../OpenDeviceHubViewer/ViewerSettings.swift | 7 + .../PasteboardSyncControllerTests.swift | 122 ++++++++++++++++++ 8 files changed, 315 insertions(+), 12 deletions(-) create mode 100644 engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift create mode 100644 engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index abde5cb..a6b2a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,9 @@ First release. where they were. - Click to tap, drag, two finger pinch and rotate, keyboard input, hardware buttons, and the swipe up from the bottom edge that goes home or opens the app switcher. -- Rotation, screenshots, screen recording, drag and drop onto a device, and the clipboard shared - with the Mac. +- Rotation, screenshots, screen recording, drag and drop onto a device, and the clipboard kept in + step with the Mac: copies carry over on their own, and a copy made inside a device is there when + you switch to another app. - A window follows its device: it shows when the device has shut down, offers a reboot, and reattaches on its own when the device comes back. - The menu bar follows the simulator it replaces: the same menus in the same order, the same diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 2b8bfd9..086dfe2 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -85,6 +85,14 @@ struct ODHubViewer: ParsableCommand { } let manager = DeviceWindowManager(frameStore: store) + let pasteboard = PasteboardSyncController( + isAutomatic: settings.syncsPasteboard, + devices: { manager.openUDIDs }, + frontmost: { manager.frontmostUDID }, + open: { try adapter.openPasteboard($0) } + ) + manager.onDeviceClosed = { pasteboard.forget($0) } + let previews = CapturePreviewPresenter(report: { print($0) }) let present: @MainActor ([URL]) -> Void = { urls in let destination = recordingDirectory(settings) @@ -109,6 +117,7 @@ struct ODHubViewer: ParsableCommand { present: present ) recent.remember(udid) + pasteboard.adopt(udid) } for udid in plan.udids { @@ -346,6 +355,13 @@ struct ODHubViewer: ParsableCommand { } } }, + toggleAutomaticPasteboardSync: { enabled in + settings.syncsPasteboard = enabled + pasteboard.setAutomatic(enabled) + }, + getPasteboard: { pasteboard.get() }, + sendPasteboard: { pasteboard.send() }, + syncsPasteboard: { settings.syncsPasteboard }, newSimulator: { let simctl = SimctlService() NewSimulatorPanel.show(actions: NewSimulatorActions( @@ -459,7 +475,8 @@ struct ODHubViewer: ParsableCommand { remembered: nil ).udids.first }, onTerminate: { manager.closeAll() }, - settlePreviews: { previews.settleEverything() } + settlePreviews: { previews.settleEverything() }, + onResignActive: { pasteboard.reconcileOnResignActive() } ) // NSApplication holds its delegate weakly, and nothing else refers to these objects // once the run loop starts, so without this ARC releases them and the display sessions @@ -544,6 +561,7 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { private let deviceToReopen: () -> String? private let onTerminate: () -> Void private let settlePreviews: () -> Void + private let onResignActive: () -> Void init( quitsWithLastWindow: Bool, @@ -552,7 +570,8 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { openLink: @escaping (String) -> Void, deviceToReopen: @escaping () -> String?, onTerminate: @escaping () -> Void, - settlePreviews: @escaping () -> Void + settlePreviews: @escaping () -> Void, + onResignActive: @escaping () -> Void ) { self.quitsWithLastWindow = quitsWithLastWindow self.dockMenu = dockMenu @@ -561,6 +580,13 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { self.deviceToReopen = deviceToReopen self.onTerminate = onTerminate self.settlePreviews = settlePreviews + self.onResignActive = onResignActive + } + + /// The automatic sync only carries the Mac's copies to the device, so a copy made inside a device + /// is fetched when the user switches away, which is when they are about to paste it elsewhere. + func applicationWillResignActive(_ notification: Notification) { + onResignActive() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift index 5ff9fb5..aac7789 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift @@ -117,20 +117,23 @@ public final class PasteboardBridge: PasteboardSession, @unchecked Sendable { lastSeenChangeCount = pasteboard.changeCount } - /// Brings the device's clipboard back to the Mac, unless the Mac's is the newer of the two. + /// Brings the device's clipboard back to the Mac without losing a copy made on the Mac. /// /// Called when the app stops being frontmost, which is when a copy made inside the device is - /// about to be pasted somewhere else. The check matters: an unconditional pull would overwrite - /// something the user copied on the Mac a moment earlier, before the automatic sync had carried - /// it the other way. + /// about to be pasted somewhere else. + /// + /// A Mac copy that has not been carried over yet is pushed first, so the pull that follows cannot + /// overwrite it: after the push both sides hold it, and the pull is then a no-op. Whichever side + /// was copied on last wins, in either order. The push cannot be skipped by watching the Mac's + /// change count alone, because the automatic sync pushes inside the private interface, where + /// there is nothing to observe. public func reconcile() { lock.lock() defer { lock.unlock() } if pasteboard.changeCount != lastSeenChangeCount { interface.push() - } else { - interface.pull() } + interface.pull() lastSeenChangeCount = pasteboard.changeCount } } diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index 9c412dc..b099088 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -69,6 +69,7 @@ public final class DeviceWindowManager { controller.onClose = { [weak self] udid in self?.controllers.removeValue(forKey: udid) self?.shutdownIfAsked(udid) + self?.onDeviceClosed?(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. @@ -218,9 +219,17 @@ public final class DeviceWindowManager { } /// The UDIDs of every open window, so a menu action can reach all of them. + /// Called after a window has closed, so anything held per device can be let go of. + public var onDeviceClosed: ((String) -> Void)? + public var openUDIDs: [String] { Array(controllers.keys) } public func controller(for udid: String) -> DeviceWindowController? { controllers[udid] } + /// The device the user is looking at, which is where an action that can only land on one goes. + 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 diff --git a/engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift b/engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift new file mode 100644 index 0000000..6b6d301 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/PasteboardSyncController.swift @@ -0,0 +1,76 @@ +import AppKit +import OpenDeviceHubEngine + +/// Holds one clipboard connection per open device. +/// +/// The connection is what the automatic syncing runs on, so these are kept for as long as the device +/// is on screen rather than opened per action. A device whose connection cannot be opened is skipped: +/// a clipboard that will not sync is not a reason to refuse to show a device. +@MainActor +public final class PasteboardSyncController { + private let open: (String) throws -> any PasteboardSession + private let devices: () -> [String] + private let frontmost: () -> String? + private var sessions: [String: any PasteboardSession] = [:] + + public private(set) var isAutomatic: Bool + + public init( + isAutomatic: Bool, + devices: @escaping () -> [String], + frontmost: @escaping () -> String?, + open: @escaping (String) throws -> any PasteboardSession + ) { + self.isAutomatic = isAutomatic + self.devices = devices + self.frontmost = frontmost + self.open = open + } + + public func setAutomatic(_ enabled: Bool) { + isAutomatic = enabled + for udid in devices() { + session(for: udid)?.setAutomatic(enabled) + } + } + + /// Starts syncing a device that has just appeared, if the sync is on. + public func adopt(_ udid: String) { + guard isAutomatic else { return } + session(for: udid)?.setAutomatic(true) + } + + public func forget(_ udid: String) { + sessions[udid]?.setAutomatic(false) + sessions[udid] = nil + } + + /// The Mac's clipboard to every open device, since a copy is not device specific. + public func send() { + for udid in devices() { + session(for: udid)?.send() + } + } + + /// The frontmost device's clipboard to the Mac. Every device at once would leave whichever + /// happened to be last, which is not a decision to make on the user's behalf. + public func get() { + guard let udid = frontmost() else { return } + session(for: udid)?.get() + } + + /// Called when the app stops being frontmost, which is when a copy made inside a device is about + /// to be pasted somewhere else. The automatic sync only carries the Mac's copies to the device, + /// so without this a device copy would never arrive. + public func reconcileOnResignActive() { + guard isAutomatic, let udid = frontmost() else { return } + session(for: udid)?.reconcile() + } + + private func session(for udid: String) -> (any PasteboardSession)? { + if let existing = sessions[udid] { return existing } + guard let opened = try? open(udid) else { return nil } + sessions[udid] = opened + return opened + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 0d0382e..bd9ddfc 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -49,6 +49,11 @@ public enum ViewerMenu { public var toggleKeyboardInput: (Bool) -> Void public var toggleHardwareKeyboard: (Bool) -> Void public var matchKeyboardLanguage: (Bool) -> Void + public var toggleAutomaticPasteboardSync: (Bool) -> Void + public var getPasteboard: () -> Void + public var sendPasteboard: () -> Void + /// Whether the sync starts on, which is remembered between launches. + public var syncsPasteboard: () -> Bool public var newSimulator: (() -> Void)? public var setOrientation: (DeviceOrientation) -> Void public var appSwitcher: () -> Void @@ -84,6 +89,10 @@ public enum ViewerMenu { toggleKeyboardInput: @escaping (Bool) -> Void, toggleHardwareKeyboard: @escaping (Bool) -> Void, matchKeyboardLanguage: @escaping (Bool) -> Void, + toggleAutomaticPasteboardSync: @escaping (Bool) -> Void, + getPasteboard: @escaping () -> Void, + sendPasteboard: @escaping () -> Void, + syncsPasteboard: @escaping () -> Bool, newSimulator: (() -> Void)? = nil, setOrientation: @escaping (DeviceOrientation) -> Void, appSwitcher: @escaping () -> Void, @@ -118,6 +127,10 @@ public enum ViewerMenu { self.toggleKeyboardInput = toggleKeyboardInput self.toggleHardwareKeyboard = toggleHardwareKeyboard self.matchKeyboardLanguage = matchKeyboardLanguage + self.toggleAutomaticPasteboardSync = toggleAutomaticPasteboardSync + self.getPasteboard = getPasteboard + self.sendPasteboard = sendPasteboard + self.syncsPasteboard = syncsPasteboard self.newSimulator = newSimulator self.setOrientation = setOrientation self.appSwitcher = appSwitcher @@ -225,6 +238,30 @@ public enum ViewerMenu { let pasteItem = target.item("Paste", #selector(MenuTarget.paste), "v", []) pasteItem.icon("doc.on.clipboard") editMenu.addItem(pasteItem) + editMenu.addItem(.separator()) + + let syncItem = target.item( + "Automatically Sync Pasteboard", + #selector(MenuTarget.automaticPasteboardSync(_:)), + "", + [] + ) + syncItem.icon("clipboard") + syncItem.state = target.syncsPasteboard ? .on : .off + let getItem = target.item("Get Pasteboard", #selector(MenuTarget.getPasteboard), "", []) + let sendItem = target.item("Send Pasteboard", #selector(MenuTarget.sendPasteboard), "", []) + for item in [syncItem, getItem, sendItem] { + disable( + item, + unless: capabilities.contains(.pasteboardSync), + reason: "not available on this Xcode" + ) + editMenu.addItem(item) + } + // Both are the manual halves of the sync, so they are redundant while it is on. Simulator.app + // greys them for the same reason. + target.trackPasteboardItems(get: getItem, send: sendItem) + editMenu.addItem(.separator()) editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") editItem.submenu = editMenu @@ -457,6 +494,9 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { private var sendsKeyboardInput = true private var hasHardwareKeyboard = true private var matchesKeyboardLanguage = true + private weak var getPasteboardItem: NSMenuItem? + private weak var sendPasteboardItem: NSMenuItem? + private lazy var syncsPasteboardNow = actions.syncsPasteboard() init(actions: ViewerMenu.Actions, commandLineTool: CommandLineToolMenu? = nil) { self.actions = actions @@ -561,6 +601,22 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { actions.toggleKeyboardInput(sendsKeyboardInput) } + var syncsPasteboard: Bool { syncsPasteboardNow } + + func trackPasteboardItems(get: NSMenuItem, send: NSMenuItem) { + getPasteboardItem = get + sendPasteboardItem = send + } + + @objc func automaticPasteboardSync(_ sender: NSMenuItem) { + syncsPasteboardNow.toggle() + sender.state = syncsPasteboardNow ? .on : .off + actions.toggleAutomaticPasteboardSync(syncsPasteboardNow) + } + + @objc func getPasteboard() { actions.getPasteboard() } + @objc func sendPasteboard() { actions.sendPasteboard() } + @objc func matchKeyboardLanguage(_ sender: NSMenuItem) { matchesKeyboardLanguage.toggle() sender.state = matchesKeyboardLanguage ? .on : .off @@ -591,8 +647,11 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { } public func validateMenuItem(_ item: NSMenuItem) -> Bool { - guard item === stopRecordingItem else { return item.action != nil } - return actions.isRecording() + if item === stopRecordingItem { return actions.isRecording() } + if item === getPasteboardItem || item === sendPasteboardItem { + return item.action != nil && !syncsPasteboardNow + } + return item.action != nil } @objc func rotateRight() { actions.rotate(false) } @objc func checkForUpdates() { actions.checkForUpdates?() } diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift index 7b8471e..9e8eeb8 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift @@ -25,6 +25,13 @@ public struct ViewerSettings: Sendable { nonmutating set { setFlag("shutdownOnWindowClose", newValue) } } + /// Copies made on the Mac reach the device on their own, and a copy made in the device comes + /// back when you switch away from the app. On by default, as in Simulator.app. + public var syncsPasteboard: Bool { + get { flag("syncsPasteboard", default: true) } + nonmutating set { setFlag("syncsPasteboard", 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 { diff --git a/engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift b/engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift new file mode 100644 index 0000000..40e8400 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/PasteboardSyncControllerTests.swift @@ -0,0 +1,122 @@ +import XCTest +import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +private final class FakeSession: PasteboardSession, @unchecked Sendable { + var isAutomatic = false + var sends = 0 + var gets = 0 + var reconciles = 0 + + func setAutomatic(_ enabled: Bool) { isAutomatic = enabled } + func send() { sends += 1 } + func get() { gets += 1 } + func reconcile() { reconciles += 1 } +} + +@MainActor +final class PasteboardSyncControllerTests: XCTestCase { + private var sessions: [String: FakeSession] = [:] + private var opened: [String] = [] + private var open: [String] = [] + private var front: String? + + private func makeController(isAutomatic: Bool) -> PasteboardSyncController { + PasteboardSyncController( + isAutomatic: isAutomatic, + devices: { self.open }, + frontmost: { self.front }, + open: { udid in + self.opened.append(udid) + let session = FakeSession() + self.sessions[udid] = session + return session + } + ) + } + + func testAdoptingStartsTheSyncOnlyWhenItIsOn() { + open = ["a"] + let off = makeController(isAutomatic: false) + off.adopt("a") + XCTAssertTrue(opened.isEmpty) + + let on = makeController(isAutomatic: true) + on.adopt("a") + XCTAssertEqual(sessions["a"]?.isAutomatic, true) + } + + func testTurningItOnReachesEveryOpenDevice() { + open = ["a", "b"] + let controller = makeController(isAutomatic: false) + controller.setAutomatic(true) + + XCTAssertTrue(controller.isAutomatic) + XCTAssertEqual(sessions["a"]?.isAutomatic, true) + XCTAssertEqual(sessions["b"]?.isAutomatic, true) + } + + func testSendingReachesEveryOpenDeviceAndGettingOnlyTheFrontOne() { + open = ["a", "b"] + front = "b" + let controller = makeController(isAutomatic: true) + controller.send() + controller.get() + + XCTAssertEqual(sessions["a"]?.sends, 1) + XCTAssertEqual(sessions["b"]?.sends, 1) + XCTAssertEqual(sessions["a"]?.gets, 0) + XCTAssertEqual(sessions["b"]?.gets, 1) + } + + func testOneConnectionPerDeviceHoweverManyActions() { + open = ["a"] + let controller = makeController(isAutomatic: true) + controller.send() + controller.send() + front = "a" + controller.get() + + XCTAssertEqual(opened, ["a"]) + } + + func testResignActiveOnlyReconcilesWhileTheSyncIsOn() { + open = ["a"] + front = "a" + let off = makeController(isAutomatic: false) + off.reconcileOnResignActive() + XCTAssertTrue(opened.isEmpty) + + let on = makeController(isAutomatic: true) + on.reconcileOnResignActive() + XCTAssertEqual(sessions["a"]?.reconciles, 1) + } + + func testForgettingADeviceStopsItsSyncAndDropsTheConnection() { + open = ["a"] + let controller = makeController(isAutomatic: true) + controller.adopt("a") + let session = sessions["a"] + controller.forget("a") + + XCTAssertEqual(session?.isAutomatic, false) + controller.send() + XCTAssertEqual(opened, ["a", "a"]) + } + + /// A device whose clipboard cannot be reached still shows, so the failure has to be swallowed. + func testADeviceThatCannotConnectIsSkipped() { + open = ["a"] + front = "a" + let controller = PasteboardSyncController( + isAutomatic: true, + devices: { self.open }, + frontmost: { self.front }, + open: { _ in throw EngineError.capabilityUnavailable(name: "pasteboard sync") } + ) + controller.setAutomatic(true) + controller.send() + controller.get() + controller.reconcileOnResignActive() + } +}