Files
onyx/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift
Guido Schmit dd25d788c0 Phase 2 abgeschlossen: Einstellungsfenster und Lokalisierung
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.
2026-08-10 18:03:34 +02:00

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))
}
}
}