Einstellungen wirken sofort statt erst nach einem Neustart. In einer App ohne Fenster ist eine verzögerte Änderung praktisch unauffindbar — man sieht ja nicht, dass etwas passiert ist. AppModel meldet Layout- und Anzeigeänderungen an den Koordinator, das Panel zeichnet sich neu und die Panelgröße zieht mit. Widgets lassen sich hinzufügen, entfernen, in der Größe ändern und per Drag-and-Drop umsortieren. Angeboten werden nur die Größen, die ein Widget sinnvoll ausfüllt — ein Mini-Monat in 1x1 wäre unleserlich. Lokalisierung als String Catalog in DE und EN, Quellsprache Englisch damit die Schlüssel im Code lesbar bleiben. Der Katalog von OnyxCore musste ausdrücklich als Ressource deklariert werden: ohne das gibt es kein Bundle.module und die Übersetzungen wären zur Laufzeit unauffindbar. Im gebauten Bundle liegen jetzt de.lproj und en.lproj, auch für das Paket. Kein Menüleisten-Tab: solange es keine Module gibt, wäre das eine Oberfläche für ein Feature, das noch nicht existiert. Er kommt mit Phase 5. 80 Tests grün.
111 lines
4.7 KiB
Swift
111 lines
4.7 KiB
Swift
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 = String(localized: "singleInstance.blocked.message",
|
|
bundle: .module)
|
|
alert.informativeText = String(
|
|
format: String(localized: "singleInstance.blocked.informative", bundle: .module),
|
|
paths)
|
|
alert.addButton(withTitle: String(localized: "common.ok", bundle: .module))
|
|
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))
|
|
}
|
|
}
|
|
}
|