import AppKit import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "SingleInstance") /// Führt die Regel aus `SingleInstanceGuard` mit echten Prozessen aus. @MainActor public enum SingleInstance { public enum Result: Equatable, Sendable { /// Diese Instanz darf starten. case running /// Eine andere Instanz ließ sich nicht beenden; diese hier gibt auf. case blocked(by: [pid_t]) } /// Wie lange auf das Verschwinden der anderen Instanz gewartet wird, bevor /// nachgesetzt wird. Eine Instanz ohne Fenster ist praktisch sofort weg; /// die Frist fängt nur den Fall ab, dass sie gerade beim Aufräumen ist. private static let gracePeriod: TimeInterval = 1.5 private static let forceGracePeriod: TimeInterval = 1.0 /// Räumt fremde Instanzen weg. Gibt `false` zurück, wenn diese Instanz /// zurücktreten soll — der Aufrufer beendet sich dann. @discardableResult public static func claim() -> Result { let selfPID = ProcessInfo.processInfo.processIdentifier switch SingleInstanceGuard.decide(otherPIDs: runningPIDs(excluding: selfPID)) { case .proceed: return .running case .terminateOthers(let pids): log.notice("Andere Onyx-Instanzen gefunden: \(pids). Fordere sie zum Beenden auf.") apps(for: pids).forEach { $0.terminate() } wait(gracePeriod) // Wer freundlich nicht geht, wird nachdrücklicher gebeten. Eine // Instanz ohne Fenster hat nichts zu sichern, was verloren gehen // könnte — der Layout-Speicher wird bei jeder Änderung geschrieben. let stubborn = runningPIDs(excluding: selfPID) if !stubborn.isEmpty { log.notice("Noch aktiv: \(stubborn). Erzwinge das Beenden.") apps(for: stubborn).forEach { $0.forceTerminate() } wait(forceGracePeriod) } let remaining = runningPIDs(excluding: selfPID) switch SingleInstanceGuard.decideAfterTermination(remainingPIDs: remaining) { case .proceed: return .running case .standDown(let pids), .terminateOthers(let pids): log.error("Instanz \(pids) ließ sich nicht beenden — diese Instanz tritt zurück.") return .blocked(by: pids) } case .standDown(let pids): return .blocked(by: pids) } } /// Erklärt, warum nichts passiert ist. /// /// Ohne diesen Hinweis wirkt ein stiller Rücktritt wie ein Startfehler — /// man doppelklickt Onyx und es geschieht scheinbar nichts. Der praktische /// Fall ist eine aus Xcode gestartete Instanz, die im Debugger hängt und /// selbst SIGKILL überlebt; die lässt sich nur in Xcode beenden. public static func explainBlocked(by pids: [pid_t]) { let paths = apps(for: pids) .compactMap { $0.bundleURL?.path } .joined(separator: "\n") let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Onyx läuft bereits" alert.informativeText = """ Eine andere Onyx-Instanz läuft und ließ sich nicht beenden. \ Diese Instanz wird deshalb nicht gestartet, damit nicht zwei Panels \ gleichzeitig auf der Notch arbeiten. Läuft sie aus Xcode, beende sie dort mit ⌘. und starte erneut. \(paths) """ alert.addButton(withTitle: "OK") NSApp.activate(ignoringOtherApps: true) alert.runModal() } // MARK: - Prozesse private static func runningPIDs(excluding selfPID: pid_t) -> [pid_t] { guard let bundleID = Bundle.main.bundleIdentifier else { return [] } // Über die Bundle-Kennung, nicht über den Pfad: die beiden Instanzen, // die sich in der Praxis in die Quere kommen, stammen aus verschiedenen // Build-Ordnern und teilen sich nur die Kennung. let pids = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) .map(\.processIdentifier) return SingleInstanceGuard.others(from: pids, selfPID: selfPID) } private static func apps(for pids: [pid_t]) -> [NSRunningApplication] { pids.compactMap(NSRunningApplication.init(processIdentifier:)) } /// Blockierendes Warten ist hier richtig: es passiert einmal beim Start, /// bevor irgendein Fenster oder Timer existiert, und die Alternative wäre /// ein halb aufgebauter Zustand, während die andere Instanz noch läuft. private static func wait(_ seconds: TimeInterval) { let deadline = Date().addingTimeInterval(seconds) while Date() < deadline { RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) } } }