468 lines
24 KiB
Swift
468 lines
24 KiB
Swift
import AppKit
|
|
import Darwin
|
|
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
|
|
}
|
|
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
|
|
static var animationStart: NSRect?
|
|
static var animationTarget = NSRect.zero
|
|
static var sawIntermediateFrame = false
|
|
static var animatedTransitions = 0
|
|
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
|
|
// 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,
|
|
"animatedTransitions": animatedTransitions, "reduceMotion": !expectsAnimation]
|
|
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
|
|
PlayerModel.shared.shutdown()
|
|
NSApp.terminate(nil)
|
|
}
|
|
|
|
static func sameFrame(_ lhs: NSRect, _ rhs: NSRect) -> Bool {
|
|
abs(lhs.minX - rhs.minX) < 1 && abs(lhs.minY - rhs.minY) < 1 &&
|
|
abs(lhs.width - rhs.width) < 1 && abs(lhs.height - rhs.height) < 1
|
|
}
|
|
|
|
static func observeAnimation(from window: NSWindow, to target: NSRect) {
|
|
animationStart = window.frame
|
|
animationTarget = target
|
|
sawIntermediateFrame = false
|
|
}
|
|
|
|
static func sidebarHeaderIsVisible(in window: NSWindow) -> Bool {
|
|
guard let content = window.contentView else { return false }
|
|
let header = NSRect(x: content.bounds.maxX - 84,
|
|
y: content.isFlipped ? content.bounds.minY : content.bounds.maxY - 32,
|
|
width: 84, height: 32)
|
|
guard let bitmap = content.bitmapImageRepForCachingDisplay(in: header) else { return false }
|
|
content.cacheDisplay(in: header, to: bitmap)
|
|
let scale = CGFloat(bitmap.pixelsWide) / header.width
|
|
// Each control must paint a visible glyph, not merely exist in the view tree.
|
|
return (0..<3).allSatisfy { index in
|
|
var visiblePixels = 0
|
|
for x in Int(CGFloat(index * 28) * scale)..<Int(CGFloat(index * 28 + 24) * scale) {
|
|
for y in 0..<bitmap.pixelsHigh {
|
|
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
|
|
color.alphaComponent > 0.5,
|
|
min(color.redComponent, color.greenComponent, color.blueComponent) > 0.5 {
|
|
visiblePixels += 1
|
|
}
|
|
}
|
|
}
|
|
return visiblePixels >= 8
|
|
}
|
|
}
|
|
|
|
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,
|
|
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
|
if model.compactWindow.isTransitioning {
|
|
if expectsAnimation, let start = animationStart, !sawIntermediateFrame,
|
|
!sameFrame(window.frame, start), !sameFrame(window.frame, animationTarget) {
|
|
sawIntermediateFrame = true
|
|
// These run-loop-delivered requests must remain responsive mid-animation.
|
|
model.toggleCompact()
|
|
model.toggleCompact()
|
|
}
|
|
return
|
|
}
|
|
if animationStart != nil {
|
|
if expectsAnimation {
|
|
guard sawIntermediateFrame else { finish("No nonblocking animation frames at step \(step)"); return }
|
|
animatedTransitions += 1
|
|
}
|
|
animationStart = nil
|
|
}
|
|
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
|
|
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
|
window.layoutIfNeeded()
|
|
if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
|
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return
|
|
}
|
|
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
|
|
guard window.minSize.width >= (model.showsQueue ? 660 : 400), window.minSize.height >= 240 else {
|
|
finish("Normal minimum size was lost: \(window.minSize)"); return
|
|
}
|
|
case 2, 6:
|
|
// Repeated intent changes must keep the original restoration frame.
|
|
observeAnimation(from: window, to: CompactWindowController.initialFrame(
|
|
aspect: model.state.video_aspect_ratio,
|
|
visibleFrame: (window.screen ?? NSScreen.main!).visibleFrame, width: 320))
|
|
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
|
|
}
|
|
guard window.minSize.width >= 160, window.minSize.height >= 90 else {
|
|
finish("Compact minimum size was lost: \(window.minSize)"); return
|
|
}
|
|
observeAnimation(from: window, to: normalFrame)
|
|
model.toggleCompact()
|
|
}
|
|
step += 1
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private enum AppStartupCheck {
|
|
static var model: PlayerModel?
|
|
static var timer: Timer?
|
|
static let deadline = ContinuousClock.now.advanced(by: .seconds(15))
|
|
static let historyDomain = Bundle.main.bundleIdentifier! + ".history"
|
|
|
|
static func run() {
|
|
_ = NSApplication.shared
|
|
NSApp.setActivationPolicy(.regular)
|
|
let defaults = UserDefaults(suiteName: historyDomain)!
|
|
model = PlayerModel(defaults: defaults)
|
|
model!.setVolume(0)
|
|
model!.open(URL(fileURLWithPath: CommandLine.arguments[2]).appendingPathComponent("av.mp4"))
|
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
|
|
MainActor.assumeIsolated { finishWhenReady() }
|
|
}
|
|
NSApp.run()
|
|
}
|
|
|
|
static func finishWhenReady() {
|
|
guard let model else { return }
|
|
let ready = model.canTogglePlayback && model.state.status == PLAYER_PLAYING
|
|
guard ready || model.state.status == PLAYER_ERROR || ContinuousClock.now >= deadline else { return }
|
|
timer?.invalidate()
|
|
let pid = ProcessInfo.processInfo.processIdentifier
|
|
let application = NSRunningApplication.current
|
|
let recognized = application.processIdentifier == pid &&
|
|
NSWorkspace.shared.runningApplications.contains { $0.processIdentifier == pid && $0.bundleURL == Bundle.main.bundleURL }
|
|
let result: [String: Any] = [
|
|
"passed": ready && recognized && MPNowPlayingInfoCenter.default().playbackState == .playing,
|
|
"pid": pid,
|
|
"registeredPID": application.processIdentifier,
|
|
"bundleIdentifier": application.bundleIdentifier ?? "nil",
|
|
"playing": ready,
|
|
]
|
|
do {
|
|
let pipe = try FileHandle(forWritingTo: URL(fileURLWithPath: CommandLine.arguments[1]))
|
|
try pipe.write(contentsOf: JSONSerialization.data(withJSONObject: result))
|
|
try pipe.write(contentsOf: Data([10]))
|
|
try pipe.close()
|
|
} catch {
|
|
FileHandle.standardError.write(Data("Startup check could not report: \(error)\n".utf8))
|
|
}
|
|
model.shutdown()
|
|
UserDefaults(suiteName: historyDomain)!.removePersistentDomain(forName: historyDomain)
|
|
self.model = nil
|
|
NSApp.terminate(nil)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private enum CLIInstanceCheck {
|
|
static var timer: Timer?
|
|
static var requestID = ""
|
|
static var editorText = ""
|
|
static func selectMenuItem(_ title: String, in menu: NSMenu) -> Bool {
|
|
for (index, item) in menu.items.enumerated() {
|
|
if item.title == title, item.action != nil {
|
|
precondition(item.isEnabled, "Native menu action \(title) must be available")
|
|
menu.performActionForItem(at: index)
|
|
return true
|
|
}
|
|
if let submenu = item.submenu, selectMenuItem(title, in: submenu) { return true }
|
|
}
|
|
return false
|
|
}
|
|
|
|
static func prepareMenu(_ menu: NSMenu) {
|
|
// Direct key-equivalent dispatch does not open/track the native menu first.
|
|
// Ask AppKit to prepare and validate its real items just as normal menu use does.
|
|
menu.delegate?.menuNeedsUpdate?(menu)
|
|
menu.update()
|
|
for item in menu.items {
|
|
if let submenu = item.submenu { prepareMenu(submenu) }
|
|
}
|
|
}
|
|
|
|
static func captionSelector(in view: NSView) -> NSPopUpButton? {
|
|
if let selector = view as? NSPopUpButton, selector.toolTip == "Subtitles" { return selector }
|
|
for child in view.subviews {
|
|
if let selector = captionSelector(in: child) { return selector }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func windowSnapshot(_ window: NSWindow?) -> [String: Any] {
|
|
guard let window, let content = window.contentView else { return [:] }
|
|
func surface(in view: NSView) -> NSView? {
|
|
if view.identifier?.rawValue == "PlaybackSurface" { return view }
|
|
return view.subviews.lazy.compactMap { surface(in: $0) }.first
|
|
}
|
|
var result: [String: Any] = [
|
|
"width": window.frame.width, "height": window.frame.height,
|
|
"minWidth": window.minSize.width, "minHeight": window.minSize.height,
|
|
"maxWidth": window.maxSize.width, "maxHeight": window.maxSize.height,
|
|
"contentMinWidth": window.contentMinSize.width, "contentMinHeight": window.contentMinSize.height,
|
|
"contentMaxWidth": window.contentMaxSize.width, "contentMaxHeight": window.contentMaxSize.height,
|
|
"aspectWidth": window.aspectRatio.width, "aspectHeight": window.aspectRatio.height,
|
|
"resizeIncrementWidth": window.resizeIncrements.width,
|
|
"resizeIncrementHeight": window.resizeIncrements.height,
|
|
"resizable": window.styleMask.contains(.resizable),
|
|
]
|
|
if let surface = surface(in: content) {
|
|
func hit(_ point: NSPoint) -> NSView? {
|
|
content.hitTest(surface.convert(point, to: content.superview))
|
|
}
|
|
result["videoOwnsMouse"] = hit(NSPoint(x: surface.bounds.midX, y: surface.bounds.midY)) === surface
|
|
result["controlsOwnMouse"] = hit(NSPoint(x: surface.bounds.midX, y: 46)) !== surface
|
|
result["sidebarOwnsMouse"] = hit(NSPoint(x: surface.bounds.maxX + 130, y: surface.bounds.midY)) !== surface
|
|
result["videoWidth"] = surface.bounds.width
|
|
result["videoHeight"] = surface.bounds.height
|
|
if let video = PlayerModel.shared.videoView {
|
|
result["rendererWidth"] = video.bounds.width
|
|
result["rendererHeight"] = video.bounds.height
|
|
result["rendererAttached"] = video.superview === surface
|
|
result["rendererHidden"] = video.isHidden
|
|
}
|
|
result["surfaceMovesOnMouseDown"] = surface.mouseDownCanMoveWindow
|
|
let edges = [NSPoint(x: 1, y: content.bounds.midY),
|
|
NSPoint(x: content.bounds.maxX - 1, y: content.bounds.midY),
|
|
NSPoint(x: content.bounds.midX, y: 1),
|
|
NSPoint(x: content.bounds.midX, y: content.bounds.maxY - 1)]
|
|
let targets = edges.map { point in
|
|
content.hitTest(content.convert(point, to: content.superview))
|
|
}
|
|
result["edgeTargets"] = targets.map { $0.map { String(describing: type(of: $0)) } ?? "native window" }
|
|
result["edgesAvoidDragSurface"] = targets.allSatisfy { target in
|
|
target !== surface && !(target.map { String(describing: type(of: $0)).contains("ChromeView") } ?? false)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func run(_ configuration: URL) {
|
|
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
|
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
|
|
let stderrURL = directory.appendingPathComponent("stderr-\(ProcessInfo.processInfo.processIdentifier).log")
|
|
precondition(FileManager.default.createFile(atPath: stderrURL.path, contents: nil))
|
|
let errorLog = try! FileHandle(forWritingTo: stderrURL)
|
|
precondition(dup2(errorLog.fileDescriptor, STDERR_FILENO) >= 0)
|
|
try! errorLog.close()
|
|
setenv("PATH", settings["path"]!, 1)
|
|
_ = NSApplication.shared
|
|
_ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification,
|
|
object: nil, queue: .main) { _ in
|
|
MainActor.assumeIsolated { PlayerModel.shared.setVolume(0) }
|
|
}
|
|
// Exercise the real SwiftUI scene and delegate without displaying test windows.
|
|
_ = 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 {
|
|
let model = PlayerModel.shared
|
|
let pid = ProcessInfo.processInfo.processIdentifier
|
|
// Text input can create non-main auxiliary windows; count actual player windows.
|
|
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil }
|
|
for window in windows { window.alphaValue = 0 }
|
|
let request = directory.appendingPathComponent("keys-\(pid).json")
|
|
var pendingRequestID = ""
|
|
if let data = try? Data(contentsOf: request),
|
|
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let id = command["id"] as? String, id != requestID, let window = windows.first {
|
|
pendingRequestID = id
|
|
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
|
|
// Synthetic native key requests need foreground focus; cooperative activation
|
|
// can be refused by the unrelated app currently active on the workstation.
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.makeMain()
|
|
} else {
|
|
requestID = id
|
|
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
|
|
if let action = command["action"] as? String {
|
|
switch action {
|
|
case "youtube":
|
|
model.openYouTube(command["url"] as! String)
|
|
case "queue":
|
|
model.showsQueue = command["visible"] as! Bool
|
|
model.revealControls()
|
|
case "caption":
|
|
guard let view = window.contentView, let selector = captionSelector(in: view),
|
|
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
|
|
fatalError("Native captions selector is missing")
|
|
}
|
|
cell.attachPopUp(withFrame: selector.bounds, in: selector)
|
|
menu.update()
|
|
let label = command["label"] as! String
|
|
guard let item = menu.items.first(where: { $0.title == label }) else {
|
|
fatalError("Requested native subtitle selection is missing")
|
|
}
|
|
precondition(!item.isHidden && item.isEnabled, "Subtitle selection must be visible and enabled")
|
|
cell.dismissPopUp()
|
|
precondition(selectMenuItem(label, in: menu), "Native subtitle selection must be actionable")
|
|
case "seek":
|
|
let position = command["position"] as! Double
|
|
precondition(model.seekRelative(position - model.state.position), "Native seek must be accepted")
|
|
case "pause":
|
|
precondition(model.perform(.pause), "Native pause must be accepted")
|
|
case "play":
|
|
precondition(model.perform(.play), "Native play must be accepted")
|
|
case "quality":
|
|
precondition(model.canChangeYouTubeQuality, "Native quality change must be available")
|
|
model.setYouTubeQuality((command["height"] as! Int))
|
|
case "close":
|
|
model.closeFile()
|
|
case "quit":
|
|
timer?.invalidate()
|
|
precondition(model.perform(.quit), "Native quit must be accepted")
|
|
return
|
|
default:
|
|
fatalError("Unknown native check action \(action)")
|
|
}
|
|
}
|
|
if let events = command["events"] as? [[String: Any]] {
|
|
window.makeKey()
|
|
for input in events {
|
|
if let title = input["select"] as? String {
|
|
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
|
|
prepareMenu(menu)
|
|
precondition(selectMenuItem(title, in: menu), "Native menu action \(title) must exist")
|
|
continue
|
|
}
|
|
let editor = NSTextView(frame: .zero)
|
|
if input["editing"] as? Bool == true {
|
|
window.contentView?.addSubview(editor)
|
|
window.makeFirstResponder(editor)
|
|
} else { window.makeFirstResponder(nil) }
|
|
let text = input["key"] as! String
|
|
let event = NSEvent.keyEvent(with: .keyDown, location: .zero,
|
|
modifierFlags: NSEvent.ModifierFlags(rawValue: UInt(input["flags"] as? Int ?? 0)),
|
|
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
|
characters: text, charactersIgnoringModifiers: text,
|
|
isARepeat: input["repeat"] as? Bool ?? false,
|
|
keyCode: UInt16(input["code"] as? Int ?? 7))!
|
|
if input["menu"] as? Bool == true {
|
|
if let menu = NSApp.mainMenu { prepareMenu(menu) }
|
|
precondition(NSApp.mainMenu?.performKeyEquivalent(with: event) == true,
|
|
"Configured shortcut must be accepted by the native menu")
|
|
} else if input["queued"] as? Bool == true {
|
|
NSApp.postEvent(event, atStart: false)
|
|
} else { NSApp.sendEvent(event) }
|
|
if input["editing"] as? Bool == true && input["queued"] as? Bool != true {
|
|
editorText = editor.string
|
|
window.makeFirstResponder(nil)
|
|
editor.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let applications = NSWorkspace.shared.runningApplications.filter {
|
|
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
|
|
}
|
|
let snapshot: [String: Any] = [
|
|
"pid": pid,
|
|
"pids": applications.map(\.processIdentifier).sorted(),
|
|
"media": model.fileName ?? "",
|
|
"status": model.state.status.rawValue,
|
|
"position": model.state.position,
|
|
"duration": model.state.duration,
|
|
"seekPending": model.state.seek_pending,
|
|
"canSeek": model.canSeek,
|
|
"busy": model.isBusy,
|
|
"youtubeHeight": model.youtubeHeight ?? 0,
|
|
"canChangeQuality": model.canChangeYouTubeQuality,
|
|
"qualityHeights": model.youtubeHeights,
|
|
"windows": windows.count,
|
|
"hasAudio": model.state.has_audio,
|
|
"hasVideo": model.state.has_video,
|
|
"error": model.errorText ?? "",
|
|
"subtitleLabels": model.subtitleTracks.map(\.label),
|
|
"selectedSubtitle": model.selectedSubtitle ?? -1,
|
|
"subtitleText": model.subtitleText,
|
|
"subtitleError": model.subtitleError ?? "",
|
|
"showBufferInfo": model.showsBufferInfo,
|
|
"automaticQuality": model.automaticYouTubeQuality,
|
|
"snapToEdges": model.compactWindow.snappingEnabled,
|
|
"requestID": requestID,
|
|
"pendingRequestID": pendingRequestID,
|
|
"active": NSApp.isActive,
|
|
"keyWindow": windows.first?.isKeyWindow ?? false,
|
|
"mainWindow": windows.first?.isMainWindow ?? false,
|
|
"editorText": editorText,
|
|
"compact": model.isCompact,
|
|
"showsQueue": model.showsQueue,
|
|
"window": windowSnapshot(windows.first),
|
|
"onTop": model.isOnTop,
|
|
]
|
|
do {
|
|
let data = try JSONSerialization.data(withJSONObject: snapshot)
|
|
try data.write(to: directory.appendingPathComponent("state-\(pid).json"), options: .atomic)
|
|
} catch { fatalError("Could not publish CLI instance check: \(error)") }
|
|
}
|
|
}
|
|
terebiAppMain()
|
|
}
|
|
}
|