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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,37 @@
The appcast generator reads this file. Each release is a `## <version>` heading, and everything
under it until the next heading becomes that version's release notes in the update feed.

## 0.2.0

- The camera control works on every iPhone that has one, not only the iPhone Duo, and Device,
Camera Control presses it. The item appears only for devices with the button. The simulator has
no camera, so the device takes a screenshot.
- Choosing cover, half open or fully open on the iPhone Duo folds it smoothly to that angle instead
of jumping, unless Reduce Motion is on. The device stays inside the window in every pose,
standing ones included.
- The iPhone Duo's window moves to the screen the device is using sooner after a fold.
- Recording the iPhone Duo records the screen it is using, follows it through a fold, and keeps the
picture the right way up.
- A window opens the way its device is already turned, instead of assuming it is upright.
- Restarting a device no longer leaves its window half connected. The iPhone Duo's fold buttons,
cover screen and touches come back with the fold it was showing, and the clipboard sync picks up
again.
- Device, Shut Down shuts a simulator down from the menu.
- Every menu action acts on the simulator in front, not on every open one, and ticks such as Stay
On Top, Slow Animations and Connect Hardware Keyboard show that simulator's own setting.
- Favorite locations: save named coordinates in Settings and choose them from Features, Location.
- Screenshots and recordings each have their own folder in Settings, starting from the folder you
had already chosen, and Save Screen can put the picture on the clipboard instead of in a file.
- Clipboard sync can be turned on or off in Settings as well as from the Edit menu, and the two stay
in step.
- The screen shown while a device is shut down or starting stays inside its rounded corners.
- The Settings window can be resized, and scrolls.
- The disk image opens as an installer window: drag the app onto Applications. The mounted disk
shows the app's icon.

Shut Down, favorite locations and the capture and clipboard settings were contributed by
@stephen-zeng.

## 0.1.0

First release.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.2.0
436 changes: 249 additions & 187 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ public final class CoreSimulatorAdapter: SimulatorAdapter, @unchecked Sendable {
return feature
}

public func forgetConnections(_ udid: String) {
lock.lock()
defer { lock.unlock() }
coreDeviceFeatures = coreDeviceFeatures.filter { !$0.key.hasPrefix("\(udid)/") }
}

public func openInput(_ udid: String) throws -> any InputSession {
lock.lock()
defer { lock.unlock() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ public protocol SimulatorAdapter: Sendable {
func openFoldableControl(_ udid: String) throws -> any HingeControl
/// One of the guest's CoreDevice features, the plane that answers what the guest is doing.
func openCoreDevice(_ udid: String, service: String) throws -> CoreDeviceFeature
/// Lets go of every connection kept open to a device, which a reboot leaves pointing at nothing.
func forgetConnections(_ udid: String)
/// What the guest says about its screens, the only honest answer to which panel is in use.
func displayReport(_ udid: String) async throws -> DisplayReport
func motionCapabilities(_ udid: String) async throws -> MotionCapabilities
Expand Down Expand Up @@ -238,6 +240,8 @@ extension SimulatorAdapter {
throw EngineError.capabilityUnavailable(name: "CoreDevice \(service) on \(udid)")
}

public func forgetConnections(_ udid: String) {}

public func displayReport(_ udid: String) async throws -> DisplayReport {
let feature = try openCoreDevice(udid, service: CoreDeviceFeature.displayInfoService)
return try DisplayReport.parse(try await feature.perform(action: CoreDeviceFeature.displayInfoAction))
Expand Down
28 changes: 24 additions & 4 deletions engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
private let deviceName: String
private var pendingSend: Task<Void, Never>?
private let unfoldedPanel: DevicePanel?
private let cover: FoldableCover?
private var cover: FoldableCover?
private let recording = RecordingSlot()
private let retarget: ((Int) -> Void)?
private var retarget: ((Int) -> Void)?
private var coverFrameTask: Task<Void, Never>?
private var activeScreenID: Int
private var bezelEnabled: Bool
Expand Down Expand Up @@ -263,16 +263,27 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate

/// The device is back. The window keeps its size, position and orientation, so a reboot looks
/// like the screen coming back on rather than a new window.
public func reattach(session: any DisplaySession, input: (any InputSession)?) {
/// A foldable's cover picture and its touch targeting belong to the boot that ended too, so
/// they come back with the rest.
public func reattach(
session: any DisplaySession,
input: (any InputSession)?,
cover: FoldableCover? = nil,
retarget: ((Int) -> Void)? = nil
) {
guard !isStopped else { return }
closeSessions()
self.session = session
self.input = input
if let cover { self.cover = cover }
if let retarget { self.retarget = retarget }
session.setBezelEnabled(bezelEnabled)
chromeView.overlay = nil
overlay = nil
toolbar?.setEnabled(true)
startConsumingFrames()
startConsumingCoverFrames()
self.retarget?(activeScreenID)
applyScaleMode(scaleMode)
}

Expand Down Expand Up @@ -495,13 +506,22 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
tracksFrameChanges = true
}

/// The most recent frame as a PNG, matching whatever the window is showing including the bezel.
/// Whether the Mac's keystrokes reach this device.
public var sendsKeyboardInput: Bool {
get { screenView.sendsKeyboardInput }
set { screenView.sendsKeyboardInput = newValue }
}

/// What the menu last set on this device, for the ticks it shows. The guest does not report
/// these cheaply, so each starts as the app leaves a device it opens: keyboard on, language
/// matched, animations at normal speed.
public var hasHardwareKeyboard = true
public var matchesKeyboardLanguage = true
public var slowAnimations = false
/// Read from the guest the first time the menu needs it, then kept in step with each change.
public var increasesContrast: Bool?

/// The most recent frame as a PNG, matching whatever the window is showing including the bezel.
public func screenshotPNG() -> Data? {
// The flat body around a foldable's panel is a phone bezel round a landscape picture.
if let modelView, chromeView.hasChrome, let png = modelView.screenshotPNG() {
Expand Down
88 changes: 38 additions & 50 deletions engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,14 @@ public final class DeviceWindowManager {
case .reattach:
do {
let attachment = try attach(change.udid)
controller.reattach(session: attachment.session, input: attachment.input)
controller.reattach(
session: attachment.session,
input: attachment.input,
cover: attachment.cover,
retarget: attachment.retarget
)
report("\(controller.deviceTitle) reattached")
onReattached?(change.udid)
} catch {
let reason = DetachReason.failed(error.localizedDescription)
controller.detach(reason: reason)
Expand Down Expand Up @@ -223,32 +229,17 @@ public final class DeviceWindowManager {
}
}

@discardableResult
public func applyScaleMode(_ mode: ScaleMode) -> [String: ScaleApplication] {
controllers.mapValues { $0.applyScaleMode(mode) }
}

private func placedFrames(excluding udid: String) -> [CGRect] {
controllers
.filter { $0.key != udid }
.compactMap { $0.value.window?.frame }
}

public func setKeepOnTop(_ enabled: Bool) {
for controller in controllers.values {
controller.setKeepOnTop(enabled)
}
}

public func setBezelEnabled(_ enabled: Bool) {
for controller in controllers.values {
controller.setBezelEnabled(enabled)
}
}

/// The UDIDs of every open window, so a menu action can reach all of them.
/// Called after a window has closed, so anything held per device can be let go of.
public var onDeviceClosed: ((String) -> Void)?
/// Called once a window has fresh sessions after its device came back, so whatever else was
/// opened for the boot that ended can be opened again.
public var onReattached: ((String) -> Void)?

public var openUDIDs: [String] { Array(controllers.keys) }

Expand All @@ -257,26 +248,24 @@ public final class DeviceWindowManager {
public func hasCameraControl(_ udid: String) -> Bool {
deviceTypes[udid].map { DeviceTypeProfile.hasCameraControl(deviceType: $0) } ?? false
}

/// The device the user is looking at, which is where an action that can only land on one goes.
/// With Settings or an alert in front, that is the device window highest in the stack.
public var frontmostUDID: String? {
controllers.first { $0.value.window?.isKeyWindow == true }?.key ?? controllers.keys.first
}


public func toggleBezel() {
let enabled = controllers.values.first?.isBezelEnabled ?? true
setBezelEnabled(!enabled)
}

public func toggleKeepOnTop() {
let enabled = controllers.values.first?.isKeptOnTop ?? false
setKeepOnTop(!enabled)
if let key = controllers.first(where: { $0.value.window?.isKeyWindow == true }) {
return key.key
}
for window in NSApplication.shared.orderedWindows {
if let shown = controllers.first(where: { $0.value.window === window }) {
return shown.key
}
}
return controllers.keys.first
}

/// Writes a PNG of every open device into `directory`, returning the files written.
/// Writes a PNG per open device into `directory`, or for just one when a udid is given, and
/// returns the files written.
@discardableResult
/// Writes a PNG per open device, or for just one when a udid is given, which is what the
/// button above a single window needs.
public func saveScreenshots(
into directory: URL,
date: Date = Date(),
Expand Down Expand Up @@ -318,9 +307,10 @@ public final class DeviceWindowManager {

public var isRecording: Bool { !recorders.isEmpty || !selfRecording.isEmpty }

/// Starts or stops recording every open device. Returns the files finished by a stop.
/// Stops every recording, or starts one on each open device, or on just `only`. Returns the
/// files finished by a stop.
@discardableResult
public func toggleRecording(into directory: URL, date: Date = Date()) -> [URL] {
public func toggleRecording(into directory: URL, date: Date = Date(), only chosen: String? = nil) -> [URL] {
guard !isRecording else {
var finished: [URL] = []
for (udid, recorder) in recorders {
Expand All @@ -343,7 +333,7 @@ public final class DeviceWindowManager {
return finished
}

for (udid, controller) in controllers {
for (udid, controller) in controllers where chosen == nil || udid == chosen {
let name = ScreenshotWriter.fileName(deviceName: controller.deviceTitle, date: date)
.replacingOccurrences(of: ".png", with: ".mov")
let url = directory.appending(path: name)
Expand All @@ -370,17 +360,6 @@ public final class DeviceWindowManager {
}
}

public func setLatencyOverlayVisible(_ visible: Bool) {
for controller in controllers.values {
controller.setLatencyOverlayVisible(visible)
}
}

public func toggleLatencyOverlay() {
let visible = controllers.values.first?.isLatencyOverlayVisible ?? false
setLatencyOverlayVisible(!visible)
}

public func closeAll() {
followTask?.cancel()
followTask = nil
Expand All @@ -393,13 +372,22 @@ public final class DeviceWindowManager {
}
}

/// The pair of sessions a window needs, so reattaching after a reboot is one call.
/// The sessions a window needs, so reattaching after a reboot is one call.
public struct DeviceAttachment {
public let session: any DisplaySession
public let input: (any InputSession)?
public let cover: FoldableCover?
public let retarget: ((Int) -> Void)?

public init(session: any DisplaySession, input: (any InputSession)?) {
public init(
session: any DisplaySession,
input: (any InputSession)?,
cover: FoldableCover? = nil,
retarget: ((Int) -> Void)? = nil
) {
self.session = session
self.input = input
self.cover = cover
self.retarget = retarget
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,10 @@ public final class PasteboardSyncController {
sessions[udid] = nil
}

/// The Mac's clipboard to every open device, since a copy is not device specific.
/// The Mac's clipboard to the device in front, like every other menu action.
public func send() {
for udid in devices() {
session(for: udid)?.send()
}
guard let udid = frontmost() else { return }
session(for: udid)?.send()
}

/// The frontmost device's clipboard to the Mac. Every device at once would leave whichever
Expand Down
Loading
Loading