import AppKit import Darwin import MediaPlayer import ObjectiveC @_cdecl("terebi_app_main") @MainActor public func checkAppStartup() { if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") { WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) return } if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") { QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) return } 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 WindowDragCheck { static var timer: Timer? static var resultURL: URL! static var phase = 0 static var probe = 0 static var drags = 0 static var previousDrags = 0 static var start = NSPoint.zero static var implementations: [(Method, IMP)] = [] static var target: NSWindow? static var dragDepth = 0 static let names = ["video", "title", "playback padding", "play button", "volume slider", "start whitespace", "start button", "start artwork", "URL text field", "queue whitespace", "queue footer", "queue row", "queue button", "error backdrop", "error button", "audio artwork", "compact video", "compact padding", "compact button", "hidden controls"] static let controls: Set = [3, 4, 6, 8, 11, 12, 14, 18] // Infrastructure timeout only; verdicts depend on input routing, not elapsed time. static let deadline = ContinuousClock.now.advanced(by: .seconds(25)) static func run(result: URL) { resultURL = result _ = NSApplication.shared timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in MainActor.assumeIsolated { advance() } } RunLoop.main.add(timer!, forMode: .common) terebiAppMain() } static func finish(_ error: String? = nil) { timer?.invalidate() for (method, original) in implementations { imp_removeBlock(method_setImplementation(method, original)) } try! JSONSerialization.data(withJSONObject: [ "passed": error == nil, "error": error ?? "", "probes": probe, "drags": drags, ]).write(to: resultURL, options: .atomic) PlayerModel.shared.shutdown() NSApp.terminate(nil) } static func nativeView(in view: NSView, matching predicate: (NSView) -> Bool) -> NSView? { if predicate(view) { return view } for child in view.subviews { if let found = nativeView(in: child, matching: predicate) { return found } } return nil } static func point(in window: NSWindow) -> NSPoint? { let content = window.contentView! let w = content.bounds.width let h = content.bounds.height let view: NSView? switch probe { case 0, 7: return NSPoint(x: 100, y: 160) case 1: return NSPoint(x: 150, y: h - 16) case 2: return NSPoint(x: w / 2 - 210, y: 48) case 3: return NSPoint(x: w / 2, y: 64) case 4: view = nativeView(in: content) { ($0 as? NSSlider)?.accessibilityLabel() == "Volume" } case 5: return NSPoint(x: 600, y: 160) case 6: return NSPoint(x: 400, y: 272) case 8: view = nativeView(in: content) { ($0 as? NSTextField)?.isEditable == true } case 9: return NSPoint(x: w - 130, y: h / 2) case 10: return NSPoint(x: w - 160, y: 18) case 11: return NSPoint(x: w - 130, y: h - 80) case 12: return NSPoint(x: w - 183, y: 18) case 13: return NSPoint(x: 100, y: h / 2) case 14: return NSPoint(x: w / 2, y: h / 2 - 51.5) case 15, 19: return NSPoint(x: w / 2, y: h / 2) case 16: return NSPoint(x: 100, y: 100) case 17: return NSPoint(x: w / 2 - 77, y: 28) case 18: return NSPoint(x: w / 2 - 60, y: 28) default: return nil } guard let view, !view.bounds.isEmpty else { return nil } return view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil) } static func interceptDrags(in window: NSWindow) { target = window let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))! let original = unsafeBitCast(method_getImplementation(method), to: (@convention(c) (NSWindow, Selector, NSEvent) -> Void).self) let handler: @convention(block) (NSWindow, NSEvent) -> Void = { window, event in MainActor.assumeIsolated { if window === target && dragDepth == 0 { drags += 1 } dragDepth += 1 original(window, #selector(NSWindow.performDrag(with:)), event) dragDepth -= 1 } } implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler)))) // SwiftUI uses AppKit's completion-taking entry point on newer macOS releases. let selector = NSSelectorFromString("performWindowDragWithEvent:completionHandler:") if let method = class_getInstanceMethod(type(of: window), selector) { let original = unsafeBitCast(method_getImplementation(method), to: (@convention(c) (NSWindow, Selector, NSEvent, AnyObject?) -> Void).self) let handler: @convention(block) (NSWindow, NSEvent, AnyObject?) -> Void = { window, event, done in MainActor.assumeIsolated { if window === target && dragDepth == 0 { drags += 1 } dragDepth += 1 original(window, selector, event, done) dragDepth -= 1 } } implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler)))) } } static func advance() { guard ContinuousClock.now < deadline else { finish("Window drag timed out at \(names[probe]), phase \(phase)"); return } guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return } let model = PlayerModel.shared let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true) if target == nil { guard model.canTogglePlayback else { return } interceptDrags(in: window) model.setVolume(0) model.setPlayback(playing: false) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } if phase == 0 { guard NSApp.isActive, window.isKeyWindow else { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return } model.setVolume(0) switch probe { case 5: model.closeFile() case 8: for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 400, y: 272), modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: type == .leftMouseDown ? 1 : 2, clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)! NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false) } case 9: model.open(fixtures.appendingPathComponent("cache.mp4")) model.showsQueue = true case 13: model.showsQueue = false model.openYouTube("not a video URL") case 15: model.open(fixtures.appendingPathComponent("audio.wav")) case 16: model.open(fixtures.appendingPathComponent("cache.mp4")) case 19: model.hideControls(hideCursor: false) default: break } previousDrags = drags } else if phase == 1 { guard !model.isQueueTransitioning, !model.compactWindow.isTransitioning, !model.isBusy else { return } if probe == 16 && !model.isCompact { model.toggleCompact(); return } // Posted events do not move the physical pointer into the compact window. if probe != 19 { model.setPointerInsideWindow(true) } guard let point = point(in: window) else { return } start = point } else if (2...4).contains(phase) { let type: NSEvent.EventType = phase == 2 ? .leftMouseDown : (phase == 3 ? .leftMouseDragged : .leftMouseUp) let point = phase == 2 ? start : NSPoint(x: start.x + 20, y: start.y + 48) let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: probe * 8 + phase, clickCount: 1, pressure: phase == 4 ? 0 : 1)! let native = event.cgEvent! if type == .leftMouseDragged { native.setDoubleValueField(.mouseEventDeltaX, value: 20) native.setDoubleValueField(.mouseEventDeltaY, value: -48) } NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false) } else if phase == 6 { let expected = controls.contains(probe) ? 0 : 1 guard drags - previousDrags == expected else { finish("\(names[probe]): expected \(expected) native drag requests, got \(drags - previousDrags)"); return } probe += 1 if probe == names.count { finish(); return } phase = 0 return } phase += 1 } } @MainActor private enum QueueDragCheck { static var timer: Timer? static var step = 0 static var resultURL: URL! static var original: [UUID] = [] static var current: UUID? static var start = NSPoint.zero static var end = NSPoint.zero static var previousPoint = NSPoint.zero // Deadline only; ordering assertions do not depend on the wall clock. static let deadline = ContinuousClock.now.advanced(by: .seconds(20)) static func run(result: URL) { resultURL = result _ = NSApplication.shared _ = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) { NSLog("[DEBUG-queue-drag] received %@", String(describing: $0)) return $0 } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in MainActor.assumeIsolated { advance() } } // Keep injecting mouse events while SwiftUI tracks a pressed row. RunLoop.main.add(timer!, forMode: .common) terebiAppMain() } static func finish(_ error: String? = nil) { timer?.invalidate() try! JSONSerialization.data(withJSONObject: ["passed": error == nil, "error": error ?? ""]) .write(to: resultURL, options: .atomic) PlayerModel.shared.shutdown() NSApp.terminate(nil) } static func mouse(_ type: NSEvent.EventType, at point: NSPoint, in window: NSWindow) { let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: step, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)! let native = event.cgEvent! if type == .leftMouseDragged { native.setDoubleValueField(.mouseEventDeltaX, value: point.x - previousPoint.x) native.setDoubleValueField(.mouseEventDeltaY, value: previousPoint.y - point.y) } previousPoint = point NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false) } static func advance() { let model = PlayerModel.shared guard ContinuousClock.now < deadline else { finish("Queue interaction did not complete at step \(step): status \(model.state.status), media \(model.fileName ?? "none"), windows \(NSApp.windows.count)") return } guard model.canTogglePlayback, let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }), let content = window.contentView else { return } if step == 0 { model.setVolume(0) let fixtures = model.queue.currentURL!.deletingLastPathComponent() model.addToQueue(["av.mp4", "cache.mp4"].map { fixtures.appendingPathComponent($0) }) current = model.queue.currentID original = model.queue.items.map(\.id) NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) } else if step == 1 { guard !model.isQueueTransitioning else { return } guard NSApp.isActive && window.isKeyWindow else { NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) return } window.layoutIfNeeded() if let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) { content.cacheDisplay(in: content.bounds, to: bitmap) try! bitmap.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: "/tmp/terebi-queue-debug.png")) } let top = content.isFlipped ? content.bounds.minY : content.bounds.maxY let sign: CGFloat = content.isFlipped ? 1 : -1 start = content.convert(NSPoint(x: content.bounds.maxX - 100, y: top + sign * 82), to: nil) end = NSPoint(x: start.x, y: start.y - 120) NSLog("[DEBUG-queue-drag] start %@ end %@ hit %@", String(describing: start), String(describing: end), String(describing: content.hitTest(content.convert(start, from: nil)))) mouse(.leftMouseDown, at: start, in: window) } else if step == 2 { mouse(.leftMouseDragged, at: NSPoint(x: start.x, y: start.y - 10), in: window) } else if step == 3 { mouse(.leftMouseDragged, at: end, in: window) } else if step == 12 { guard model.queue.items.map(\.id) == original else { finish("Queue committed before release"); return } // Several event-loop turns without a mouseDragged event, then release in place. mouse(.leftMouseUp, at: end, in: window) } else if step == 14 { guard model.queue.items.map(\.id) == [original[1], original[2], original[0]], model.queue.currentID == current else { finish("Stationary release order: \(model.queue.items.map(\.url.lastPathComponent))"); return } mouse(.leftMouseDown, at: end, in: window) } else if step == 15 { mouse(.leftMouseDragged, at: NSPoint(x: end.x, y: end.y + 10), in: window) } else if step == 16 { mouse(.leftMouseDragged, at: start, in: window) } else if step == 25 { mouse(.leftMouseUp, at: start, in: window) } else if step == 27 { guard model.queue.items.map(\.id) == original, model.queue.currentID == current else { finish("Stationary upward release did not restore the original order"); return } let secondRow = NSPoint(x: start.x, y: start.y - 60) mouse(.leftMouseDown, at: secondRow, in: window) } else if step == 28 { mouse(.leftMouseUp, at: NSPoint(x: start.x, y: start.y - 60), in: window) } else if step == 35 { guard model.queue.currentID == original[1], model.queue.items.map(\.id) == original, model.state.status == PLAYER_PLAYING else { finish("Single click did not play the second row without reordering"); return } finish() return } step += 1 } } @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 animatedQueueTransitions = 0 static var queueVideoSize: NSSize? static var observingQueue = false static var animationQueueWidth: CGFloat = 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, "animatedQueueTransitions": animatedQueueTransitions, "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 animationQueueWidth = PlayerModel.shared.queueWidth } 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 // Fullscreen disables pinning; its glyph uses the native dimmed tint. let minimumBrightness = index == 0 && window.styleMask.contains(.fullScreen) ? 0.25 : 0.5 for x in Int(CGFloat(index * 28) * scale).. 0.5, min(color.redComponent, color.greenComponent, color.blueComponent) > minimumBrightness { 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 || model.isQueueTransitioning { window.layoutIfNeeded() if model.isQueueTransitioning && !sidebarHeaderIsVisible(in: window) { finish("Top-right controls left the window corner during queue animation at step \(step)"); return } if let size = queueVideoSize, let video = model.videoView { guard abs(video.bounds.width - size.width) < 2, abs(video.bounds.height - size.height) < 2 else { finish("Queue animation changed video size at step \(step): \(video.bounds.size), expected \(size)"); return } } if expectsAnimation, let start = animationStart, !sawIntermediateFrame, (!sameFrame(window.frame, start) && !sameFrame(window.frame, animationTarget) || observingQueue && abs(model.queueWidth - animationQueueWidth) > 1 && model.queueWidth > 1 && model.queueWidth < 259) { sawIntermediateFrame = true // These run-loop-delivered requests must remain responsive mid-animation. if model.isQueueTransitioning { let frame = window.frame model.showsQueue.toggle() model.showsQueue.toggle() guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return } } else { model.toggleCompact() model.toggleCompact() } } return } if animationStart != nil { if expectsAnimation { guard sawIntermediateFrame else { finish("No nonblocking animation frames at step \(step)"); return } if observingQueue { animatedQueueTransitions += 1 } else { animatedTransitions += 1 } } animationStart = nil guard sameFrame(window.frame, animationTarget) else { finish("Animation missed target at step \(step): \(window.frame), expected \(animationTarget)"); return } queueVideoSize = nil observingQueue = false } if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) } guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return } if model.fullScreenIntent != nil { 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 } if step >= 33 { switch step { case 33, 34: guard model.isFullScreen else { finish("Fullscreen entry failed"); return } normalFrame = window.frame observeAnimation(from: window, to: normalFrame) observingQueue = true model.showsQueue.toggle() case 35: guard sameFrame(window.frame, normalFrame), model.showsQueue, let video = model.videoView, let content = window.contentView, abs(video.bounds.width - (content.bounds.width - 260)) < 2 else { finish("Fullscreen queue did not retain fixed window bounds and fit the video"); return } model.toggleFullScreen() default: guard !model.isFullScreen else { finish("Fullscreen exit failed"); return } finish(); return } step += 1 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 { model.toggleFullScreen() step += 1 return } let showsQueue = step % 8 == 4 if showsQueue != model.showsQueue { if showsQueue, let screen = window.screen?.visibleFrame { window.setFrameOrigin(NSPoint(x: screen.maxX - window.frame.width, y: window.frame.minY)) } var target = window.frame target.size.width += showsQueue ? 260 : -260 if let screen = window.screen?.visibleFrame { target.origin.x = max(screen.minX, min(target.minX, screen.maxX - target.width)) } observeAnimation(from: window, to: target) observingQueue = true queueVideoSize = model.videoView?.bounds.size model.showsQueue = showsQueue } 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() } }