fix: stabilize native window layout transitions

This commit is contained in:
2026-10-18 18:16:16 +02:00
parent 9670645b9a
commit 29a70678a4
4 changed files with 104 additions and 12 deletions
+19 -1
View File
@@ -36,6 +36,22 @@ class StartupStreamingServer(StreamingServer):
super()._get(request)
def check_window_layout(root, app, fixtures):
result_path = root / "window-layout.json"
diagnostics = root / "window-layout.log"
result = subprocess.run(
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path)],
capture_output=True, text=True, timeout=40,
)
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed") or report.get("steps") != 32:
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native SwiftUI window layout: repeated compact/normal transitions with queue open/closed, "
"rapid intent changes, original window and exact normal geometry restored")
def check_cli_instances(root, app, fixtures):
cli = root / "terebi"
cli.symlink_to(app / "Contents/MacOS/terebi-cli")
@@ -635,7 +651,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_cli_instances(root, app, fixtures)
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
finally:
os.close(descriptor)
subprocess.run(
+78
View File
@@ -5,6 +5,10 @@ import MediaPlayer
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let index = CommandLine.arguments.firstIndex(of: "--check-window-layout") {
WindowLayoutCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") {
CLIInstanceCheck.run(configuration)
return
@@ -12,6 +16,80 @@ public func checkAppStartup() {
AppStartupCheck.run()
}
@MainActor
private enum WindowLayoutCheck {
static var timer: Timer?
static var resultURL: URL!
static var step = 0
static var windowIdentity: ObjectIdentifier?
static var normalFrame = NSRect.zero
// Infrastructure timeout only; assertions depend on native transition completion.
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
_ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated { PlayerModel.shared.setVolume(0) }
}
_ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated {
for window in NSApp.windows { window.alphaValue = 0; window.ignoresMouseEvents = true }
}
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
terebiAppMain()
}
static func finish(_ error: String? = nil) {
timer?.invalidate()
let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step]
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func advance() {
let model = PlayerModel.shared
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
guard model.canTogglePlayback, model.state.has_video, !model.compactWindow.isTransitioning,
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
window.layoutIfNeeded()
switch step % 8 {
case 0, 4:
if step > 0 {
let actual = window.frame
guard abs(actual.minX - normalFrame.minX) < 1, abs(actual.minY - normalFrame.minY) < 1,
abs(actual.width - normalFrame.width) < 1, abs(actual.height - normalFrame.height) < 1,
!model.isCompact else { finish("Normal geometry was not restored at step \(step): \(actual)"); return }
}
if step == 32 { finish(); return }
model.showsQueue = step % 8 == 4
case 1, 5:
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
normalFrame = window.frame
case 2, 6:
// Repeated intent changes must keep the original restoration frame.
model.toggleCompact()
model.toggleCompact()
model.toggleCompact()
default:
guard model.isCompact, window.frame.width >= 160, window.frame.height >= 90,
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01 else {
finish("Compact geometry did not settle at step \(step): \(window.frame)"); return
}
model.toggleCompact()
}
step += 1
}
}
@MainActor
private enum AppStartupCheck {
static var model: PlayerModel?