Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -109,6 +117,7 @@ struct ODHubViewer: ParsableCommand {
present: present
)
recent.remember(udid)
pasteboard.adopt(udid)
}

for udid in plan.udids {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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:")
Expand Down
139 changes: 139 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Adapter/PasteboardBridge.swift
Original file line number Diff line number Diff line change
@@ -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<AnyObject>?
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
17 changes: 17 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Xcode/FrameworkLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NSObject>
/// `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 <NSObject>
- (nullable id<ODHSimDeviceSet>)defaultDeviceSetWithError:(NSError *_Nullable *_Nullable)error;
@end
Expand Down
Loading
Loading