Skip to content
Open
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
49 changes: 29 additions & 20 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,25 @@ struct ODHubViewer: ParsableCommand {
displayReport: { try await adapter.displayReport($0) }
)
foldables.report = { print($0) }
let orientations = ScreenOrientationFollower(
read: { try await adapter.displayReport($0) },
panels: { (try? adapter.panels($0)) ?? [] }
)
orientations.onScreenChange = { foldables.nudge($0) }
foldables.onMove = { udid, event in
guard let controller = manager.controller(for: udid) else { return }
switch event {
case .began(let target): controller.beginFold(to: target)
case .angle(let angle): controller.showHingeAngle(angle)
case .angle(let angle):
controller.showHingeAngle(angle)
orientations.hingeMoved(udid, to: angle)
case .ended: controller.endFold()
}
}
manager.onDeviceClosed = {
pasteboard.forget($0)
foldables.forget($0)
orientations.forget($0)
}

let previews = CapturePreviewPresenter(report: { print($0) })
Expand All @@ -132,10 +140,16 @@ struct ODHubViewer: ParsableCommand {
try adapter.setOrientation(orientation, udid: udid)
}
controller.setOrientation(orientation)
orientations.turned(udid, to: orientation)
} catch {
print("rotate failed: \(error.localizedDescription)")
}
}
let rotate: @MainActor (Bool, String) -> Void = { left, udid in
guard let controller = manager.controller(for: udid) else { return }
let from = orientations.lastTurn(udid) ?? controller.currentOrientation
turn(left ? from.rotatedLeft : from.rotatedRight, udid)
}

// Opens unfolded unless told otherwise, which is where the device's own tooling starts.
let followFold: @MainActor (String, DeviceWindowController, Double?) -> Void = { udid, controller, angle in
Expand All @@ -146,12 +160,12 @@ struct ODHubViewer: ParsableCommand {
foldables.setAngle(angle, for: udid)
foldables.follow(
udid,
nudges: controller.screenChanges,
onPanel: { [weak controller] panel in
controller?.setActivePanel(screenID: panel.displayID)
},
onHinge: { [weak controller] degrees in
controller?.showHingeAngle(degrees)
orientations.hingeMoved(udid, to: degrees)
}
)
}
Expand All @@ -164,12 +178,14 @@ struct ODHubViewer: ParsableCommand {
adapter: adapter,
manager: manager,
allowBoot: allowBoot,
rotate: turn,
rotate: rotate,
present: present
)
recent.remember(udid)
pasteboard.adopt(udid)
if let controller = manager.controller(for: udid), controller.foldsAtHinge {
guard let controller = manager.controller(for: udid) else { return }
orientations.follow(udid, controller: controller)
if controller.foldsAtHinge {
followFold(udid, controller, foldables.angle(for: udid))
}
}
Expand All @@ -179,7 +195,9 @@ struct ODHubViewer: ParsableCommand {
manager.onReattached = { udid in
pasteboard.forget(udid)
pasteboard.adopt(udid)
guard let controller = manager.controller(for: udid), controller.foldsAtHinge else { return }
guard let controller = manager.controller(for: udid) else { return }
orientations.follow(udid, controller: controller)
guard controller.foldsAtHinge else { return }
let angle = foldables.angle(for: udid)
foldables.forget(udid)
followFold(udid, controller, angle)
Expand Down Expand Up @@ -343,12 +361,8 @@ struct ODHubViewer: ParsableCommand {
manager.frontmostUDID.map { manager.hasCameraControl($0) } ?? false
},
rotate: { left in
guard let udid = manager.frontmostUDID,
let controller = manager.controller(for: udid) else { return }
let next = left
? controller.currentOrientation.rotatedLeft
: controller.currentOrientation.rotatedRight
turn(next, udid)
guard let udid = manager.frontmostUDID else { return }
rotate(left, udid)
},
restart: {
guard let udid = manager.frontmostUDID else { return }
Expand Down Expand Up @@ -613,7 +627,7 @@ struct ODHubViewer: ParsableCommand {
adapter: any SimulatorAdapter,
manager: DeviceWindowManager,
allowBoot: Bool,
rotate: @escaping @MainActor (DeviceOrientation, String) -> Void,
rotate: @escaping @MainActor (Bool, String) -> Void,
present: @escaping @MainActor ([URL]) -> Void
) throws {
guard var device = devices.first(where: {
Expand Down Expand Up @@ -787,7 +801,7 @@ private func installToolbar(
manager: DeviceWindowManager,
adapter: any SimulatorAdapter,
saveScreenshotsToClipboard: @escaping @MainActor () -> Bool,
rotate: @escaping @MainActor (DeviceOrientation, String) -> Void,
rotate: @escaping @MainActor (Bool, String) -> Void,
present: @escaping @MainActor ([URL]) -> Void
) {
guard let controller = manager.controller(for: udid) else { return }
Expand Down Expand Up @@ -821,13 +835,8 @@ private func installToolbar(
stopRecording: {
present(manager.toggleRecording(into: CaptureStaging.directory()))
},
rotate: { [weak controller] toLeft in
guard let controller else { return }
rotate(
toLeft ? controller.currentOrientation.rotatedLeft
: controller.currentOrientation.rotatedRight,
udid
)
rotate: { toLeft in
rotate(toLeft, udid)
}
))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import Foundation

/// How the guest's screen is turned, as it changes, whoever turned the device: the viewer, Device
/// Hub or the guest itself. It is the screen's own layout that counts, not how the device is held,
/// since a screen that does not turn (an iPhone's home screen, or upside down on a Face ID phone)
/// keeps showing its picture the way it was.
public final class ScreenOrientationWatcher: @unchecked Sendable {
public enum Event: Sendable, Equatable {
/// The screen turned, or was read for the first time.
case turned(DeviceOrientation)
/// Looked at again after a turn was asked for, the screen had not moved.
case stillAt(DeviceOrientation)
}

public let changes: AsyncStream<Event>

static let burstInterval: Duration = .milliseconds(100)
static let burstReads = 25
/// While a foldable hands its layout from one panel to the other, the report still names the old
/// panel for about a fifth of a second but already turns it by the new one's angle (27A9269), so
/// a reading has to hold this long to count.
static let settleTime: Duration = .milliseconds(400)
/// A fold never turns a foldable, but its report says otherwise for a while on the way, longer
/// than `settleTime` at times (27A9269), so nothing read while the hinge moves counts.
static let foldQuiet: Duration = .seconds(1)
/// How long after asking for a turn the screen is looked at again. A turn the screen took was
/// believed at most 1.2 seconds after it was asked for (27A9269).
static let confirmDelay: Duration = .seconds(2)

private let continuation: AsyncStream<Event>.Continuation
private let read: @Sendable () async throws -> DisplayReport
private let nativeRotation: @Sendable (Int) -> Int
private var pokes: AsyncStream<Void>.Continuation?
private var task: Task<Void, Never>?
/// Kept from one look to the next: the report catches up with a turn half a second or more after
/// the screens announce it (27A9269), so a look begins by reading the orientation before the
/// turn, which must not count as a change back to it. Only the one task that looks touches it.
private var settler = OrientationSettler()
private let lock = NSLock()
private var quietUntil: ContinuousClock.Instant?
private var confirmAt: ContinuousClock.Instant?

/// `nativeRotation` is the angle the panel with that screen ID is built into the body at, from
/// the device type. The report's own figure reads 0 for both of a foldable's panels (27A9269).
public init(
read: @escaping @Sendable () async throws -> DisplayReport,
nativeRotation: @escaping @Sendable (Int) -> Int
) {
var escaping: AsyncStream<Event>.Continuation!
changes = AsyncStream(bufferingPolicy: .bufferingNewest(4)) { escaping = $0 }
continuation = escaping
self.read = read
self.nativeRotation = nativeRotation

var pokeContinuation: AsyncStream<Void>.Continuation!
let pokeStream = AsyncStream<Void>(bufferingPolicy: .bufferingNewest(1)) { pokeContinuation = $0 }
pokes = pokeContinuation
task = Task { [weak self] in
for await _ in pokeStream {
await self?.burst()
}
}
}

/// Looks at the screen now and for a couple of seconds after, since the report catches up with
/// the screens' own announcement a little later.
public func poke() {
pokes?.yield(())
}

/// The hinge moved: what the report says until a second after is not believed, and it is looked
/// at again after that.
public func holdStill() {
lock.lock()
quietUntil = .now + Self.foldQuiet
lock.unlock()
poke()
}

/// A turn was asked for, which the screen may refuse (upside down on a Face ID phone) or never
/// hear of: a foldable's turn is sent without an answer. Once the screen has had time to follow,
/// it is looked at again and said to be still where it was if it has not moved.
public func confirm() {
lock.lock()
confirmAt = .now + Self.confirmDelay
lock.unlock()
poke()
}

// Swift 6 will not let a lock be taken directly in an async function.
private func isQuiet(at now: ContinuousClock.Instant) -> Bool {
lock.lock()
defer { lock.unlock() }
return quietUntil.map { now < $0 } ?? false
}

private func takeConfirmation(at now: ContinuousClock.Instant) -> Bool {
lock.lock()
defer { lock.unlock() }
guard let due = confirmAt, now >= due else { return false }
confirmAt = nil
return true
}

public func close() {
task?.cancel()
task = nil
pokes?.finish()
continuation.finish()
}

private func burst() async {
for _ in 0..<Self.burstReads {
if Task.isCancelled { return }
let report = try? await read()
let now = ContinuousClock.now
let quiet = isQuiet(at: now)
let reading = quiet ? nil : report.flatMap { Self.reading(from: $0, nativeRotation: nativeRotation) }
if let settled = settler.observe(reading, at: now, holdingFor: Self.settleTime) {
continuation.yield(.turned(settled))
}
// A screen still settling on something new is turning, and will say so itself.
if !quiet, takeConfirmation(at: now), let believed = settler.believed, reading?.orientation == believed {
continuation.yield(.stillAt(believed))
}
try? await Task.sleep(for: Self.burstInterval)
}
}

/// The active panel's layout turned by the angle the panel sits at in the body.
public static func reading(
from report: DisplayReport,
nativeRotation: (Int) -> Int
) -> OrientationSettler.Reading? {
guard report.isSettled, let active = report.activeIntegrated,
let orientation = DeviceOrientation(degrees: active.currentRotation + nativeRotation(active.displayID))
else { return nil }
return OrientationSettler.Reading(orientation: orientation, panel: active.displayID)
}
}

/// Believes a reading once it has held, on the same panel, for a while.
public struct OrientationSettler: Sendable {
public struct Reading: Sendable, Equatable {
public let orientation: DeviceOrientation
public let panel: Int

public init(orientation: DeviceOrientation, panel: Int) {
self.orientation = orientation
self.panel = panel
}
}

private var candidate: (reading: Reading, since: ContinuousClock.Instant)?
public private(set) var believed: DeviceOrientation?

public init() {}

/// The orientation once it is believed, and again each time a different one is.
public mutating func observe(
_ reading: Reading?,
at now: ContinuousClock.Instant,
holdingFor settle: Duration
) -> DeviceOrientation? {
guard let reading else {
candidate = nil
return nil
}
if candidate?.reading != reading {
candidate = (reading, now)
}
guard let candidate, now - candidate.since >= settle,
candidate.reading.orientation != believed else { return nil }
believed = candidate.reading.orientation
return believed
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ public enum DeviceOrientation: String, Sendable, Hashable, CaseIterable, Codable
}
}

/// The orientation turned this many degrees clockwise, in any multiple of a quarter turn.
public init?(degrees: Int) {
let turned = (degrees % 360 + 360) % 360
guard let found = Self.allCases.first(where: { $0.degrees == turned }) else { return nil }
self = found
}

public var isLandscape: Bool {
self == .landscapeLeft || self == .landscapeRight
}
Expand Down
14 changes: 5 additions & 9 deletions engine/Sources/OpenDeviceHubViewer/FoldableController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,8 @@ public final class FoldableController {
followers[udid]?.watcher.poke()
}

/// `nudges` are the panels' own change announcements; each makes the watcher look at once.
public func follow(
_ udid: String,
nudges: [AsyncStream<ScreenProperties>] = [],
onPanel: @escaping @MainActor (DisplayReport.Display) -> Void,
onHinge: @escaping @MainActor (Double) -> Void
) {
Expand Down Expand Up @@ -162,16 +160,14 @@ public final class FoldableController {
onPanel(panel)
}
}]
for nudge in nudges {
tasks.append(Task {
for await _ in nudge {
watcher.poke()
}
})
}
followers[udid] = Follower(watcher: watcher, hinge: hinge, tasks: tasks)
}

/// The device's screens announced a change, which on a fold comes before the report has one.
public func nudge(_ udid: String) {
followers[udid]?.watcher.poke()
}

public func activePanel(for udid: String) -> DisplayReport.Display? {
followers[udid]?.watcher.activePanel
}
Expand Down
Loading
Loading