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/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..aac7789 --- /dev/null +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift @@ -0,0 +1,139 @@ +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 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. + /// + /// 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() + } + 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/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/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) + } +} 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() + } +}