diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index c00b10c..a1aaf54 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -7,12 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 378A5A417112CCB31EF3F774 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; - 4E729C7A6498B4679C8D2C42 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; + 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; + 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; }; - 68EE5495435A82E20248EBC2 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; + 68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; - EA4D6312E8A96E177F10797F /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; + B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; + EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,10 +30,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4E729C7A6498B4679C8D2C42 /* OnyxDesign in Frameworks */, - 68EE5495435A82E20248EBC2 /* OnyxNotch in Frameworks */, - EA4D6312E8A96E177F10797F /* OnyxWidgetKit in Frameworks */, - 378A5A417112CCB31EF3F774 /* OnyxMenuBar in Frameworks */, + 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */, + 68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */, + EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */, + 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */, + B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -99,6 +101,7 @@ ); name = Onyx; packageProductDependencies = ( + C64A111F449D0F9D36ED46FB /* OnyxCore */, 3D680DD645941493A7D3559E /* OnyxDesign */, 3A32CAE6CC4D01D660F0016F /* OnyxNotch */, E66577A40689DF225BCED184 /* OnyxWidgetKit */, @@ -370,6 +373,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxMenuBar; }; + C64A111F449D0F9D36ED46FB /* OnyxCore */ = { + isa = XCSwiftPackageProductDependency; + productName = OnyxCore; + }; E66577A40689DF225BCED184 /* OnyxWidgetKit */ = { isa = XCSwiftPackageProductDependency; productName = OnyxWidgetKit; diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 1b1cb25..5e54b93 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import OnyxCore import OnyxDesign import OnyxNotch import OnyxMenuBar @@ -28,6 +29,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + // Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen + // bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe + // Notch — das sieht aus wie ein Ruckler und ist keiner. + if case .blocked(let pids) = SingleInstance.claim() { + SingleInstance.explainBlocked(by: pids) + NSApp.terminate(nil) + return + } + PlaceholderWidgets.registerAll() let known = Set(WidgetRegistry.shared.all.map(\.id)) layout = LayoutStore.standard().load(knownWidgetIDs: known) diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index df2792b..e91d4e6 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -5,12 +5,16 @@ let package = Package( name: "OnyxKit", platforms: [.macOS(.v26)], products: [ + .library(name: "OnyxCore", targets: ["OnyxCore"]), .library(name: "OnyxDesign", targets: ["OnyxDesign"]), .library(name: "OnyxNotch", targets: ["OnyxNotch"]), .library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]), .library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]), ], targets: [ + .target(name: "OnyxCore"), + .testTarget(name: "OnyxCoreTests", dependencies: ["OnyxCore"]), + .target(name: "OnyxDesign"), .target(name: "OnyxNotch", dependencies: ["OnyxDesign"]), diff --git a/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift b/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift new file mode 100644 index 0000000..957cf84 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift @@ -0,0 +1,115 @@ +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)) + } + } +} diff --git a/Packages/OnyxKit/Sources/OnyxCore/SingleInstanceGuard.swift b/Packages/OnyxKit/Sources/OnyxCore/SingleInstanceGuard.swift new file mode 100644 index 0000000..6d95892 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxCore/SingleInstanceGuard.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Sorgt dafür, dass immer nur eine Onyx-Instanz läuft. +/// +/// Zwei Instanzen sind kein Schönheitsfehler: jede bringt eine eigene +/// Zustandsmaschine, eigene Timer und eine eigene Animation auf dieselbe Notch +/// mit. Das Ergebnis sieht aus wie ein Ruckler und ist keiner — es sind zwei +/// Panels, die gegeneinander arbeiten. +/// +/// **Die zuletzt gestartete Instanz gewinnt.** Beim Entwickeln ist das die +/// frisch gebaute. Die andere Reihenfolge — erste gewinnt — würde bedeuten, +/// dass man nach jedem Build weiter die alte Fassung testet, ohne es zu merken. +/// Für den normalen Gebrauch ist der Unterschied unsichtbar: Onyx hat kein +/// Fenster, das man in den Vordergrund holen könnte. +public enum SingleInstanceGuard { + + public enum Decision: Equatable, Sendable { + /// Kein Konkurrent — weiterlaufen. + case proceed + /// Diese Instanzen zum Beenden auffordern, danach erneut prüfen. + case terminateOthers([pid_t]) + /// Ließen sich nicht beenden — diese Instanz tritt zurück. + case standDown([pid_t]) + } + + /// Alle fremden Instanzen. Die eigene fällt zuverlässig heraus, auch wenn + /// sie mehrfach in der Liste steht — sich selbst zu beenden wäre der + /// denkbar schlechteste Ausgang. + public static func others(from allPIDs: [pid_t], selfPID: pid_t) -> [pid_t] { + allPIDs.filter { $0 != selfPID } + } + + public static func decide(otherPIDs: [pid_t]) -> Decision { + otherPIDs.isEmpty ? .proceed : .terminateOthers(otherPIDs) + } + + public static func decideAfterTermination(remainingPIDs: [pid_t]) -> Decision { + remainingPIDs.isEmpty ? .proceed : .standDown(remainingPIDs) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxCoreTests/SingleInstanceTests.swift b/Packages/OnyxKit/Tests/OnyxCoreTests/SingleInstanceTests.swift new file mode 100644 index 0000000..e14e348 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxCoreTests/SingleInstanceTests.swift @@ -0,0 +1,77 @@ +import Testing +@testable import OnyxCore + +// Zwei laufende Onyx-Instanzen sind kein Schönheitsfehler: jede bringt eine +// eigene Zustandsmaschine, eigene Timer und eine eigene Animation auf dieselbe +// Notch mit. Das Ergebnis sieht aus wie ein Ruckler und ist keiner. +// +// Die Regel lautet: die zuletzt gestartete Instanz gewinnt. Beim Entwickeln ist +// das die frisch gebaute — die andere Reihenfolge würde bedeuten, dass man nach +// jedem Build weiter die alte Fassung testet, ohne es zu merken. + +@Suite("Einzelinstanz") +struct SingleInstanceTests { + + @Test("Keine andere Instanz — einfach weiterlaufen") + func aloneProceeds() { + #expect(SingleInstanceGuard.decide(otherPIDs: []) == .proceed) + } + + @Test("Eine andere Instanz wird zum Beenden aufgefordert") + func oneOtherIsAskedToQuit() { + #expect(SingleInstanceGuard.decide(otherPIDs: [4711]) == .terminateOthers([4711])) + } + + @Test("Mehrere andere Instanzen werden alle aufgefordert") + func allOthersAreAsked() { + #expect(SingleInstanceGuard.decide(otherPIDs: [1, 2, 3]) == .terminateOthers([1, 2, 3])) + } + + @Test("Sind danach alle weg, läuft diese Instanz weiter") + func proceedsWhenOthersAreGone() { + #expect(SingleInstanceGuard.decideAfterTermination(remainingPIDs: []) == .proceed) + } + + @Test("Bleibt eine übrig, tritt diese Instanz zurück statt danebenzulaufen") + func standsDownWhenOthersSurvive() { + // Kommt real vor: eine aus Xcode gestartete Instanz hängt im Debugger + // und überlebt sogar SIGKILL. Dann ist Zurücktreten die einzig richtige + // Antwort — zwei Panels auf einer Notch ist die schlechtere Lage. + #expect(SingleInstanceGuard.decideAfterTermination(remainingPIDs: [4711]) + == .standDown([4711])) + } + + // MARK: - Eigene Instanz aussortieren + + @Test("Die eigene Instanz zählt nie als Konkurrenz") + func selfIsNeverACompetitor() { + let others = SingleInstanceGuard.others(from: [100, 200, 300], selfPID: 200) + #expect(others == [100, 300]) + } + + @Test("Bin ich der einzige Prozess, bleibt nichts übrig") + func onlySelfLeavesNothing() { + #expect(SingleInstanceGuard.others(from: [200], selfPID: 200).isEmpty) + } + + @Test("Eine leere Prozessliste ergibt keine Konkurrenz") + func emptyListIsSafe() { + #expect(SingleInstanceGuard.others(from: [], selfPID: 200).isEmpty) + } + + @Test("Die eigene PID doppelt in der Liste wird trotzdem vollständig entfernt") + func duplicateSelfIsRemoved() { + // Kann bei Übergabefehlern vorkommen. Sich selbst zu beenden wäre der + // denkbar schlechteste Ausgang. + #expect(SingleInstanceGuard.others(from: [200, 200, 300], selfPID: 200) == [300]) + } + + // MARK: - Ganzer Ablauf + + @Test("Der übliche Fall: eine alte Instanz weicht, die neue übernimmt") + func typicalTakeover() { + let others = SingleInstanceGuard.others(from: [100, 200], selfPID: 200) + #expect(SingleInstanceGuard.decide(otherPIDs: others) == .terminateOthers([100])) + #expect(SingleInstanceGuard.decideAfterTermination(remainingPIDs: []) == .proceed) + } +} diff --git a/project.yml b/project.yml index 09046b7..650a141 100644 --- a/project.yml +++ b/project.yml @@ -29,6 +29,8 @@ targets: sources: - path: Onyx dependencies: + - package: OnyxKit + product: OnyxCore - package: OnyxKit product: OnyxDesign - package: OnyxKit