Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e463481
feat(viewer): count the frames a window receives and the pictures it …
Mastersam07 Sep 28, 2026
163e3f2
test(viewer): measure each window's idle cost, memory, latency and fo…
Mastersam07 Sep 28, 2026
0f47ec7
build(scripts): add a performance check against a per machine baseline
Mastersam07 Sep 28, 2026
2ac042e
fix(viewer): draw the foldable only when something on it changes
Mastersam07 Sep 28, 2026
8e24c0c
build(scripts): record the baseline with the foldable drawn on demand
Mastersam07 Sep 28, 2026
2d8bdbd
build(scripts): keep a baseline for each power mode
Mastersam07 Sep 28, 2026
72480e3
test(viewer): measure open time, energy, gpu and memory growth
Mastersam07 Sep 28, 2026
66e73ce
build(scripts): let the performance check keep windows busy for longer
Mastersam07 Sep 28, 2026
b6236f9
test(viewer): read window memory as the median while idle
Mastersam07 Sep 28, 2026
65ae275
build(scripts): record the normal power baseline with the new measure…
Mastersam07 Sep 28, 2026
5ad58e1
fix(simctl): ask an ordinary device for its orientation once
Mastersam07 Sep 28, 2026
782c2ff
fix(viewer): free a closed window's screen view
Mastersam07 Sep 28, 2026
ea1fc0c
fix(viewer): draw the foldable once for each change
Mastersam07 Sep 28, 2026
ef127fa
fix(viewer): keep fold steps on a fixed tick
Mastersam07 Sep 28, 2026
dcb47b3
test(viewer): allow for the measured noise in memory growth
Mastersam07 Sep 28, 2026
da9d077
test(viewer): give the foldable's reopen memory its own limit
Mastersam07 Sep 28, 2026
e86ab10
build(scripts): record the normal power baseline with the fixes in
Mastersam07 Sep 28, 2026
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
17 changes: 12 additions & 5 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,28 @@ unsigned build that reaches a release page is worse than a run that stops.
1. Add the version's section to `CHANGELOG.md`. The release notes on GitHub and the notes inside the
update feed both come from it, so they cannot drift apart.
2. Update `VERSION`.
3. Run the **Release** workflow from the Actions tab with `publish` left **false**. That is a dry
3. Run `scripts/performance-check.sh` on the Mac the baselines in `performance/` were recorded on,
with only an iPhone and an iPhone Duo booted and Low Power Mode off. Every booted device joins
the windows it opens at once, and the baseline names them. It takes about eight minutes, compares
with `baseline-normal-power.json`, or `baseline-low-power.json` when Low Power Mode is on, and
exits 1 if a window got slower to open, heavier, less responsive or used more energy or GPU than
the baseline allows, or if memory kept growing. Record a new baseline with `--record-baseline`
only when a change is meant to move the numbers, and commit it with that change.
4. Run the **Release** workflow from the Actions tab with `publish` left **false**. That is a dry
run: it builds, signs, notarizes, staples, packages, checksums and generates the feed, and
creates no tag, no release and no commit anywhere. The artifacts are attached to the run under a
short retention name so you can download and check them.
4. Install the dry run's DMG and use it. `stapler validate` on the app and on the DMG, and
5. Install the dry run's DMG and use it. `stapler validate` on the app and on the DMG, and
`shasum -c SHA256SUMS` from a different directory.
5. Run the workflow again with `publish` **true**. It creates the tag and a release marked as the
6. Run the workflow again with `publish` **true**. It creates the tag and a release marked as the
latest, carrying the DMG under its versioned name, the same DMG as `OpenDeviceHub.dmg`,
`SHA256SUMS` and the signed `appcast.xml`. The README's download button and its Install link fetch
`OpenDeviceHub.dmg` from the latest release, so they need no edit.
6. **Only once that release page exists**, commit the release's `appcast.xml` to the feed
7. **Only once that release page exists**, commit the release's `appcast.xml` to the feed
repository. Installed copies read the feed from there, never from the release, which is why the
feed URL can never move. A feed pointing at a download that is not there yet breaks updates for
everyone who reads it in the meantime.
7. Confirm an already installed older copy finds the update, verifies it and installs it, ending on
8. Confirm an already installed older copy finds the update, verifies it and installs it, ending on
the new version. Do this before announcing anything: a broken updater cannot be fixed by a later
release.

Expand Down
2 changes: 1 addition & 1 deletion engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ struct ODHubViewer: ParsableCommand {
}
let panel = opened.unfolded
let session = opened.session
let orientation = DevicectlService().orientation(udid: device.udid) ?? .portrait
let orientation = DevicectlService().orientation(udid: device.udid, foldable: opened.foldsAtHinge) ?? .portrait

let controller = try manager.open(
device: device,
Expand Down
7 changes: 7 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Input/HingeMove.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ public struct HingeMove: Sendable, Hashable {
}

public static let stepInterval: Duration = .milliseconds(16)

/// When the next step is due, counted from the start of the move: the first tick after
/// `elapsed`, so time taken between steps shortens the wait instead of adding to it, and a late
/// step is followed by the next tick rather than a burst.
public static func nextStep(after elapsed: Duration) -> Duration {
stepInterval * (Int(max(elapsed, .zero) / stepInterval) + 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ public struct DevicectlService: Sendable {
}

/// On a foldable the first answer after a turn repeats the answer given before it, however long
/// ago the turn was (27A266a), so the question is asked twice and the second answer kept.
public func orientation(udid: String) -> DeviceOrientation? {
/// ago the turn was (27A266a, 27A9269), so a foldable is asked twice and the second answer kept.
/// An ordinary iPhone answers right the first time (27A9269), and each ask costs 150 to 400 ms.
public func orientation(udid: String, foldable: Bool) -> DeviceOrientation? {
let arguments = Self.orientationArguments(udid: udid)
guard run(arguments) != nil, let output = run(arguments) else { return nil }
if foldable { guard run(arguments) != nil else { return nil } }
guard let output = run(arguments) else { return nil }
return Self.parseOrientation(Data(output.utf8))
}

Expand Down
12 changes: 9 additions & 3 deletions engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,11 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate

public var latencyReading: LatencyMeter.Reading? { latency.reading }

/// Frames the device has sent this window, and pictures the window has drawn, for measuring
/// what a window costs while nothing on screen changes.
public private(set) var framesReceived = 0
public var drawCount: Int { modelView?.renderCount ?? renderer.drawCount }

private func updateTitle() {
guard let window else { return }
let base = baseTitle ?? window.title
Expand Down Expand Up @@ -793,7 +798,6 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate

private func installClickToTap() {
let pixelSize = session.pixelSize
let screenView = screenView

// The model's hit test already yields the guest's own coordinates; nothing is turned here.
modelView?.onTouch = { [weak self] point, phase in
Expand All @@ -812,7 +816,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
let orientation = self.orientation
guard let primary = CoordinateMapper.normalize(
viewPoint: point,
viewSize: screenView.bounds.size,
viewSize: self.screenView.bounds.size,
pixelSize: orientation.displayedSize(portraitNative: pixelSize)
) else { return }

Expand Down Expand Up @@ -860,7 +864,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate

screenView.onGesture = { [weak self] phase, spread, angle in
guard let self else { return }
let size = screenView.bounds.size
let size = self.screenView.bounds.size
guard size.width > 0, size.height > 0 else { return }

let normalizedSpread = spread / min(size.width, size.height)
Expand Down Expand Up @@ -891,6 +895,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
renderer.accept(frame)
recording.current?.append(frame.surface, from: screenID, turnedBy: turns)
await MainActor.run { [weak self] in
self?.framesReceived += 1
// The flat view sits under the model and is not seen; drawing it costs the
// main thread a draw per frame.
if self?.modelView == nil { screenView.needsDisplay = true }
Expand Down Expand Up @@ -919,6 +924,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
if Task.isCancelled { return }
recording.current?.append(frame.surface, from: screenID, turnedBy: turns)
await MainActor.run { [weak self] in
self?.framesReceived += 1
self?.modelView?.setScreen(frame.surface, onCover: true, nativeRotation: rotation)
}
}
Expand Down
106 changes: 84 additions & 22 deletions engine/Sources/OpenDeviceHubViewer/DuoModelView.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import AppKit
import IOSurface
import Metal
import MetalKit
import OpenDeviceHubEngine
import QuartzCore
import SceneKit
import simd

/// A foldable drawn as the physical device, bent by the hinge, with the guest's screen on it.
///
/// Drawn by its own renderer once for each change. An `SCNView` drawing on demand draws about 16
/// more times after every change, a new frame from the device included, which at 2 frames a second
/// from the device was 35 draws and 9% of the CPU at rest (Xcode 27.1, 27A9269).
@MainActor
public final class DuoModelView: SCNView {
public final class DuoModelView: MTKView {
/// Times in the asset's closing clip. Its other flat-to-shut stretches also turn the hardware.
enum Pose {
static let open: TimeInterval = 260.0 / 24
Expand Down Expand Up @@ -83,6 +89,13 @@ public final class DuoModelView: SCNView {

/// How many probe renders have been made, for a test that expects a move to make none.
var probeRenders = 0
public private(set) var renderCount = 0
private let sceneRenderer: SCNRenderer
private let commandQueue: MTLCommandQueue
/// Drawing runs on a timer until then, for a lift that SceneKit animates.
private var animatingUntil: CFTimeInterval = 0
public var scene: SCNScene? { sceneRenderer.scene }
public var pointOfView: SCNNode? { sceneRenderer.pointOfView }
private let cameraNode = SCNNode()
private let innerScreen: SCNNode
private let coverScreen: SCNNode
Expand Down Expand Up @@ -129,6 +142,9 @@ public final class DuoModelView: SCNView {
let cover = Self.closest(to: CGSize(width: 11.230, height: 7.739), among: faces),
inner !== cover else { return nil }

guard let queue = metalDevice.makeCommandQueue() else { return nil }
commandQueue = queue
sceneRenderer = SCNRenderer(device: metalDevice, options: nil)
self.metalDevice = metalDevice
self.nativeQuarterTurns = ((-nativeRotation / 90) % 4 + 4) % 4
innerScreen = inner
Expand All @@ -137,8 +153,8 @@ public final class DuoModelView: SCNView {
lifts = Self.lifts(for: hardwareButtons, in: scene.rootNode)
activeScreen = showingCover ? cover : inner
content = scene.rootNode
// No animation players: a player churn per pose blocks behind the render thread for most
// of a frame, while setting thirty transforms by hand is free.
// No animation players: under an SCNView a player churn per pose blocked behind its render
// thread for most of a frame, while setting thirty transforms by hand is free.
tracks = Self.freeze(scene.rootNode).flatMap { PoseTrack.tracks(of: $0.animation, on: $0.node) }
// A source hands out its clips once, so the twin comes from a source of its own.
guard let twinSource = SCNSceneSource(url: asset, options: nil),
Expand All @@ -164,9 +180,7 @@ public final class DuoModelView: SCNView {
tracks: twinTracks
)

super.init(frame: .zero, options: [
SCNView.Option.preferredRenderingAPI.rawValue: SCNRenderingAPI.metal.rawValue,
])
super.init(frame: .zero, device: metalDevice)

let camera = SCNCamera()
camera.fieldOfView = 31
Expand All @@ -178,25 +192,24 @@ public final class DuoModelView: SCNView {
scene.rootNode.addChildNode(cameraNode)
installLighting(in: scene)

self.scene = scene
pointOfView = cameraNode
backgroundColor = .clear
wantsLayer = true
sceneRenderer.scene = scene
sceneRenderer.pointOfView = cameraNode
// What the SCNView this replaced drew into (27A9269), so the device looks the same.
colorPixelFormat = .bgra8Unorm_srgb
depthStencilPixelFormat = .depth32Float
sampleCount = 4
clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0)
layer?.isOpaque = false
antialiasingMode = .multisampling4X
allowsCameraControl = false
rendersContinuously = true
// Asked for 30, SceneKit draws at 24 on a 120 Hz display, and the rate cannot be changed
// once the view exists; a fold needs 60 to look like one.
(layer as? CAMetalLayer)?.colorspace = CGColorSpace(name: CGColorSpace.sRGB)
isPaused = true
enableSetNeedsDisplay = true
preferredFramesPerSecond = 60
isPlaying = false
loops = false

setHingeAngle(180)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
required init(coder: NSCoder) {
fatalError("not supported")
}

Expand All @@ -207,14 +220,15 @@ public final class DuoModelView: SCNView {
settleLifts()
if let move, let fraction = move.fraction(of: hingeAngle) {
let correction = move.start + (move.end - move.start) * Float(fraction)
// The twin's bones, not this scene's: a read here waits behind the render thread.
// The twin's bones, not this scene's, which under an SCNView waited behind its render thread.
place(
cameraNode, angle: hingeAngle, centre: twinCentre(at: hingeAngle),
correction: correction, distanceScale: move.scale(at: hingeAngle)
)
} else {
frameCamera()
}
needsDisplay = true
}

/// Framed from both ends, measured on the twin now; a probe render costs more than a frame, so
Expand Down Expand Up @@ -283,6 +297,50 @@ public final class DuoModelView: SCNView {
needsLayout = true
}

public override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
needsDisplay = true
}

public override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
needsDisplay = true
}

public override func draw(_ dirtyRect: NSRect) {
let now = CACurrentMediaTime()
defer {
if !isPaused, now >= animatingUntil {
isPaused = true
enableSetNeedsDisplay = true
}
}
guard let pass = currentRenderPassDescriptor, let drawable = currentDrawable,
let buffer = commandQueue.makeCommandBuffer() else { return }
sceneRenderer.render(
atTime: now,
viewport: CGRect(origin: .zero, size: drawableSize),
commandBuffer: buffer,
passDescriptor: pass
)
buffer.present(drawable)
buffer.commit()
renderCount += 1
}

/// Drawn on a timer for as long as SceneKit animates, then on demand again.
private func animate(for duration: TimeInterval) {
animatingUntil = max(animatingUntil, CACurrentMediaTime() + duration)
enableSetNeedsDisplay = false
isPaused = false
}

/// The picture as it is drawn now, at the drawable's size, and empty before the view has one.
public func snapshot() -> NSImage {
guard drawableSize.width > 0, drawableSize.height > 0 else { return NSImage(size: .zero) }
return sceneRenderer.snapshot(atTime: CACurrentMediaTime(), with: drawableSize, antialiasingMode: .multisampling4X)
}

/// The hardware turns too: the guest has already turned its picture inside the panel.
public func setOrientation(_ orientation: DeviceOrientation) {
let turns = ((orientation.degrees / 90) % 4 + 4) % 4
Expand Down Expand Up @@ -347,6 +405,7 @@ public final class DuoModelView: SCNView {
material.diffuse.wrapT = .clamp
material.lightingModel = .constant
}
needsDisplay = true
}

/// Frozen players rather than the view's scene time, which the view advances itself.
Expand Down Expand Up @@ -448,6 +507,7 @@ public final class DuoModelView: SCNView {
)
silhouette = framing.silhouette
projectButtons()
needsDisplay = true
}

/// Measurements belong to a window size and a way up; either changing throws them away.
Expand Down Expand Up @@ -873,17 +933,19 @@ public final class DuoModelView: SCNView {
let amount = restLift(of: button) + rise
let position = lift.outward * amount
let scale = SIMD3<Float>(repeating: 1) + abs(lift.outward) * (amount / lift.depth)
// An explicit transaction commits behind the render thread, which costs most of a frame
// in a live window; a plain set costs nothing and the run loop commits it.
// Under an SCNView an explicit transaction committed behind its render thread, which cost
// most of a frame in a live window; a plain set costs nothing.
if liftDuration > 0 {
SCNTransaction.begin()
SCNTransaction.animationDuration = liftDuration
lift.node.simdPosition = position
lift.node.simdScale = scale
SCNTransaction.commit()
animate(for: liftDuration)
} else {
lift.node.simdPosition = position
lift.node.simdScale = scale
needsDisplay = true
}
liftedReach = rise > 0 ? reach(of: button, lifted: amount) : nil
}
Expand Down Expand Up @@ -916,6 +978,7 @@ public final class DuoModelView: SCNView {
for material in node.geometry?.materials ?? [] {
material.emission.contents = lit ? NSColor(white: 0.35, alpha: 1) : NSColor.black
}
needsDisplay = true
}

func liftOffset(of button: HardwareButton) -> Float {
Expand Down Expand Up @@ -1269,4 +1332,3 @@ extension DuoModelView {
return (SCNVector3(origin), SCNVector3(far))
}
}

5 changes: 4 additions & 1 deletion engine/Sources/OpenDeviceHubViewer/FoldableController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ public final class FoldableController {
send(angle, to: control, for: udid, poke: false)
onMove?(udid, .angle(angle))
if progress >= 1 { break }
try? await Task.sleep(for: HingeMove.stepInterval)
try? await Task.sleep(
until: started + HingeMove.nextStep(after: ContinuousClock.now - started),
clock: .continuous
)
}
guard let self, !Task.isCancelled else { return }
moves[udid] = nil
Expand Down
2 changes: 2 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/FrameRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fragment float4 odhFragment(VertexOut in [[stage_in]], texture2d<float> screen [
/// `IOSurface`, so a frame is never copied through the CPU.
public final class FrameRenderer: NSObject, MTKViewDelegate {
public var onFrameDrawn: (() -> Void)?
public private(set) var drawCount = 0

private let device: MTLDevice
private let commandQueue: MTLCommandQueue
Expand Down Expand Up @@ -141,6 +142,7 @@ public final class FrameRenderer: NSObject, MTKViewDelegate {
buffer.present(drawable)
buffer.commit()

drawCount += 1
onFrameDrawn?()
}

Expand Down
12 changes: 10 additions & 2 deletions engine/Tests/OpenDeviceHubEngineTests/DevicectlServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,21 @@ final class DevicectlServiceTests: XCTestCase {
func testKeepsTheSecondAnswerSinceAFoldableRepeatsItsLastOne() {
let answers = Answers([report("portrait"), report("landscapeRight")])
let service = DevicectlService { _ in answers.next() }
XCTAssertEqual(service.orientation(udid: "ABC"), .landscapeLeft)
XCTAssertEqual(service.orientation(udid: "ABC", foldable: true), .landscapeLeft)
XCTAssertEqual(answers.asked, 2)
}

func testAsksAnOrdinaryDeviceOnce() {
let answers = Answers([report("landscapeRight"), report("portrait")])
let service = DevicectlService { _ in answers.next() }
XCTAssertEqual(service.orientation(udid: "ABC", foldable: false), .landscapeLeft)
XCTAssertEqual(answers.asked, 1)
}

func testNoAnswerWhenDevicectlCannotSeeTheDevice() {
let service = DevicectlService { _ in nil }
XCTAssertNil(service.orientation(udid: "ABC"))
XCTAssertNil(service.orientation(udid: "ABC", foldable: true))
XCTAssertNil(service.orientation(udid: "ABC", foldable: false))
}

func testAnythingElseIsNoAnswer() {
Expand Down
Loading
Loading