Files
terebi/Checks/app-startup.swift
T

238 lines
12 KiB
Swift

import AppKit
import Darwin
import MediaPlayer
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") {
CLIInstanceCheck.run(configuration)
return
}
AppStartupCheck.run()
}
@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 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 "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 { NSApp.sendEvent(event) }
if input["editing"] 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 ?? "",
"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,
"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()
}
}