From fbe13fd1cb3a11cf3f73f4cac551e29ce7108633 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 10 Aug 2026 17:17:16 +0200 Subject: [PATCH] =?UTF-8?q?Phase=201:=20Notch-Shell,=20Design-System,=20Me?= =?UTF-8?q?n=C3=BCleisten-Infrastruktur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zustandsmaschine und Geometrie sind testgetrieben entstanden und tragen 40 Tests — sie sind frei von AppKit, damit jeder Übergang ohne Fenster und ohne Warten prüfbar ist. Zeit kommt nur als Ereignis herein. Zwei Entscheidungen, die im Code begründet sind: Der Zeiger wird über einen globalen Ereignismonitor verfolgt, nicht über ein unsichtbares Fenster auf der Notch. Ein solches Fenster müsste Mausereignisse annehmen, um sie zu bemerken, und würde damit Menüleiste und Fensterknöpfe darunter unbenutzbar machen. "Nur internes Display" fällt auf ein externes zurück, wenn kein eingebautes da ist. Am Dock mit geschlossenem Deckel hieße die Einstellung wörtlich genommen, dass Onyx unerreichbar wird. Design: NSVisualEffectView mit eigenem Tint statt Liquid Glass — Onyx ist Stein, kein Glas. Alle Farben und Maße liegen als Tokens in OnyxDesign. Menüleiste: jedes Modul bekommt ein eigenes NSStatusItem. Elemente, denen macOS mangels Platz keine Breite gibt, werden erkannt und gemeldet, statt still zu verschwinden. App-Target über XcodeGen, damit die Projektdefinition im Diff lesbar bleibt. Nicht sandboxed, mit App-Group-Entitlement — baut, startet, signiert mit PP34X97WS3. --- Onyx.xcodeproj/project.pbxproj | 376 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + Onyx/Info.plist | 44 ++ Onyx/Onyx.entitlements | 27 ++ Onyx/OnyxApp.swift | 110 +++++ Packages/OnyxKit/Package.swift | 25 ++ .../Sources/OnyxDesign/OnyxSurface.swift | 72 ++++ .../Sources/OnyxDesign/OnyxTokens.swift | 67 ++++ .../OnyxMenuBar/MenuBarController.swift | 138 +++++++ .../Sources/OnyxMenuBar/MenuBarModule.swift | 64 +++ .../Sources/OnyxNotch/NSScreen+Metrics.swift | 35 ++ .../Sources/OnyxNotch/NotchCoordinator.swift | 159 ++++++++ .../Sources/OnyxNotch/NotchGeometry.swift | 146 +++++++ .../Sources/OnyxNotch/NotchPanel.swift | 68 ++++ .../OnyxNotch/NotchScreenController.swift | 106 +++++ .../Sources/OnyxNotch/NotchStateMachine.swift | 154 +++++++ .../Sources/OnyxWidgetKit/Placeholder.swift | 1 + .../Tests/OnyxMenuBarTests/Placeholder.swift | 1 + .../OnyxNotchTests/DisplayPolicyTests.swift | 67 ++++ .../OnyxNotchTests/NotchGeometryTests.swift | 142 +++++++ .../NotchStateMachineTests.swift | 250 ++++++++++++ .../OnyxWidgetKitTests/Placeholder.swift | 1 + Spikes/smc-dump.swift | 93 +++++ project.yml | 51 +++ 24 files changed, 2204 insertions(+) create mode 100644 Onyx.xcodeproj/project.pbxproj create mode 100644 Onyx.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Onyx/Info.plist create mode 100644 Onyx/Onyx.entitlements create mode 100644 Onyx/OnyxApp.swift create mode 100644 Packages/OnyxKit/Package.swift create mode 100644 Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift create mode 100644 Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift create mode 100644 Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift create mode 100644 Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift create mode 100644 Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift create mode 100644 Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift create mode 100644 Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift create mode 100644 Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift create mode 100644 Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift create mode 100644 Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift create mode 100644 Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift create mode 100644 Packages/OnyxKit/Tests/OnyxMenuBarTests/Placeholder.swift create mode 100644 Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift create mode 100644 Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift create mode 100644 Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift create mode 100644 Packages/OnyxKit/Tests/OnyxWidgetKitTests/Placeholder.swift create mode 100644 project.yml diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj new file mode 100644 index 0000000..19e1e5c --- /dev/null +++ b/Onyx.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 378A5A417112CCB31EF3F774 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; + 4E729C7A6498B4679C8D2C42 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; + 68EE5495435A82E20248EBC2 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; + 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; + EA4D6312E8A96E177F10797F /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; }; + 87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = ""; }; + F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7FF994FE5530D53BEFBE13FE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4E729C7A6498B4679C8D2C42 /* OnyxDesign in Frameworks */, + 68EE5495435A82E20248EBC2 /* OnyxNotch in Frameworks */, + EA4D6312E8A96E177F10797F /* OnyxWidgetKit in Frameworks */, + 378A5A417112CCB31EF3F774 /* OnyxMenuBar in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0FBA13B97C53F1D0D740A633 /* Resources */ = { + isa = PBXGroup; + children = ( + ); + path = Resources; + sourceTree = ""; + }; + 339A2C4AF25B5612E90B795D /* Products */ = { + isa = PBXGroup; + children = ( + 0804288DC4A8146DD9F3FC3E /* Onyx.app */, + ); + name = Products; + sourceTree = ""; + }; + 40AB0784F956D00B4E1362FB = { + isa = PBXGroup; + children = ( + D6653B90FCB65F334F91F2F1 /* Onyx */, + 573275C59A65A7C11CCEC90A /* Packages */, + 339A2C4AF25B5612E90B795D /* Products */, + ); + sourceTree = ""; + }; + 573275C59A65A7C11CCEC90A /* Packages */ = { + isa = PBXGroup; + children = ( + 821B742C211265914B6CC44B /* OnyxKit */, + ); + path = Packages; + sourceTree = ""; + }; + D6653B90FCB65F334F91F2F1 /* Onyx */ = { + isa = PBXGroup; + children = ( + 0FBA13B97C53F1D0D740A633 /* Resources */, + 50A018673EE6BD9C50F1EFA0 /* Info.plist */, + 87597F09E971C14CF1E475DB /* Onyx.entitlements */, + F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */, + ); + path = Onyx; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2EB428ADC48D89BCA28D3EE1 /* Onyx */ = { + isa = PBXNativeTarget; + buildConfigurationList = C126F7CB2B14EC196CE8743D /* Build configuration list for PBXNativeTarget "Onyx" */; + buildPhases = ( + 6B11A5AC704B6429B9484789 /* Sources */, + 7FF994FE5530D53BEFBE13FE /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Onyx; + packageProductDependencies = ( + 3D680DD645941493A7D3559E /* OnyxDesign */, + 3A32CAE6CC4D01D660F0016F /* OnyxNotch */, + E66577A40689DF225BCED184 /* OnyxWidgetKit */, + A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */, + ); + productName = Onyx; + productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 32F5E88F3CF35AE8C0E00613 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 2EB428ADC48D89BCA28D3EE1 = { + DevelopmentTeam = PP34X97WS3; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 0A2ACEAA7E2F36634202B241 /* Build configuration list for PBXProject "Onyx" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 40AB0784F956D00B4E1362FB; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 862B24C4FC93B2D8783C51A3 /* XCLocalSwiftPackageReference "Packages/OnyxKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 339A2C4AF25B5612E90B795D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2EB428ADC48D89BCA28D3EE1 /* Onyx */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 6B11A5AC704B6429B9484789 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 42021FACB216529C4EA156E9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Onyx/Onyx.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = Onyx/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx; + SDKROOT = macosx; + }; + name = Debug; + }; + A8521951AFD4AF6BDE351F27 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_CODE_SIGN_FLAGS = "--deep"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + DEB51EF5615CABC756F24ADD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CODE_SIGN_FLAGS = "--deep"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + E2D28160550E1CF316474C7E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Onyx/Onyx.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = Onyx/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx; + SDKROOT = macosx; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0A2ACEAA7E2F36634202B241 /* Build configuration list for PBXProject "Onyx" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DEB51EF5615CABC756F24ADD /* Debug */, + A8521951AFD4AF6BDE351F27 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C126F7CB2B14EC196CE8743D /* Build configuration list for PBXNativeTarget "Onyx" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 42021FACB216529C4EA156E9 /* Debug */, + E2D28160550E1CF316474C7E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 862B24C4FC93B2D8783C51A3 /* XCLocalSwiftPackageReference "Packages/OnyxKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/OnyxKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = { + isa = XCSwiftPackageProductDependency; + productName = OnyxNotch; + }; + 3D680DD645941493A7D3559E /* OnyxDesign */ = { + isa = XCSwiftPackageProductDependency; + productName = OnyxDesign; + }; + A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */ = { + isa = XCSwiftPackageProductDependency; + productName = OnyxMenuBar; + }; + E66577A40689DF225BCED184 /* OnyxWidgetKit */ = { + isa = XCSwiftPackageProductDependency; + productName = OnyxWidgetKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */; +} diff --git a/Onyx.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Onyx.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Onyx.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Onyx/Info.plist b/Onyx/Info.plist new file mode 100644 index 0000000..cf85f58 --- /dev/null +++ b/Onyx/Info.plist @@ -0,0 +1,44 @@ + + + + + CFBundleDevelopmentRegion + de + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Onyx + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + + LSUIElement + + + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + + + NSCalendarsFullAccessUsageDescription + Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben. + NSLocationWhenInUseUsageDescription + Onyx zeigt das Wetter an deinem aktuellen Standort. Du kannst stattdessen auch einen festen Ort wählen. + NSAppleEventsUsageDescription + Onyx steuert Spotify und Musik, wenn die universelle Medienschnittstelle nicht verfügbar ist. + NSAudioCaptureUsageDescription + Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet. + + NSHumanReadableCopyright + © 2026 Scarriffleservices + + diff --git a/Onyx/Onyx.entitlements b/Onyx/Onyx.entitlements new file mode 100644 index 0000000..b1cd6bd --- /dev/null +++ b/Onyx/Onyx.entitlements @@ -0,0 +1,27 @@ + + + + + + + com.apple.security.application-groups + + PP34X97WS3.group.com.scarriffleservices.calendarr + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift new file mode 100644 index 0000000..8b505e8 --- /dev/null +++ b/Onyx/OnyxApp.swift @@ -0,0 +1,110 @@ +import SwiftUI +import AppKit +import OnyxDesign +import OnyxNotch +import OnyxMenuBar + +@main +struct OnyxApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + + var body: some Scene { + // Onyx hat kein Hauptfenster. Alles lebt im Notch-Panel und in der + // Menüleiste; die Einstellungen kommen in Phase 2 als eigenes Fenster. + Settings { EmptyView() } + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + + private var coordinator: NotchCoordinator? + private var menuBar: MenuBarController? + private var onyxItem: NSStatusItem? + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + + let coordinator = NotchCoordinator(policy: .builtInOnly) { displayID in + NSHostingView(rootView: NotchPanelContent(displayID: displayID)) + } + coordinator.start() + self.coordinator = coordinator + + let menuBar = MenuBarController() + menuBar.start() + self.menuBar = menuBar + + installOnyxStatusItem() + } + + func applicationWillTerminate(_ notification: Notification) { + coordinator?.stop() + menuBar?.stop() + } + + /// Das feste Onyx-Element: Panel öffnen, Einstellungen, Beenden. Es ist auch + /// das Sicherheitsnetz, falls die Notch einmal nicht reagiert. + private func installOnyxStatusItem() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + item.autosaveName = "onyx.menubar.main" + item.button?.image = NSImage(systemSymbolName: "circle.hexagongrid.fill", + accessibilityDescription: "Onyx") + item.button?.image?.isTemplate = true + + let menu = NSMenu() + menu.addItem(withTitle: "Panel öffnen", action: #selector(togglePanel), keyEquivalent: "") + .target = self + menu.addItem(.separator()) + menu.addItem(withTitle: "Einstellungen …", action: #selector(openSettings), keyEquivalent: ",") + .target = self + menu.addItem(.separator()) + menu.addItem(withTitle: "Onyx beenden", action: #selector(quit), keyEquivalent: "q") + .target = self + item.menu = menu + + onyxItem = item + } + + @objc private func togglePanel() { coordinator?.togglePanelUnderPointer() } + + @objc private func openSettings() { + NSApp.activate(ignoringOtherApps: true) + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + + @objc private func quit() { NSApp.terminate(nil) } +} + +/// Platzhalterinhalt für Phase 1. Er beweist, dass Fenster, Geometrie und +/// Zustandsmaschine zusammenspielen; die Widget-Ebene kommt in Phase 2. +private struct NotchPanelContent: View { + let displayID: CGDirectDisplayID + + var body: some View { + ZStack { + OnyxSurface() + + VStack(alignment: .leading, spacing: Onyx.Metric.tileSpacing) { + Text("Onyx") + .font(Onyx.Font.title) + .foregroundStyle(Onyx.Color.textPrimary) + + Text("Phase 1 — Notch-Shell steht.") + .font(Onyx.Font.body) + .foregroundStyle(Onyx.Color.textSecondary) + + HStack(spacing: Onyx.Metric.tileSpacing) { + ForEach(["Kalender", "Wetter", "Medien"], id: \.self) { name in + OnyxTile { + Text(name) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + } + } + } + } + .padding(Onyx.Metric.panelPadding) + } + } +} diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift new file mode 100644 index 0000000..df2792b --- /dev/null +++ b/Packages/OnyxKit/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "OnyxKit", + platforms: [.macOS(.v26)], + products: [ + .library(name: "OnyxDesign", targets: ["OnyxDesign"]), + .library(name: "OnyxNotch", targets: ["OnyxNotch"]), + .library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]), + .library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]), + ], + targets: [ + .target(name: "OnyxDesign"), + + .target(name: "OnyxNotch", dependencies: ["OnyxDesign"]), + .testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]), + + .target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]), + .testTarget(name: "OnyxWidgetKitTests", dependencies: ["OnyxWidgetKit"]), + + .target(name: "OnyxMenuBar", dependencies: ["OnyxDesign", "OnyxWidgetKit"]), + .testTarget(name: "OnyxMenuBarTests", dependencies: ["OnyxMenuBar"]), + ] +) diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift new file mode 100644 index 0000000..d8dc620 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift @@ -0,0 +1,72 @@ +import SwiftUI +import AppKit + +/// Die Vibrancy-Ebene hinter dem Panel. +/// +/// `.hudWindow` mit `behindWindow` lässt den Hintergrund durchscheinen, ohne ihn +/// lesbar zu lassen. Der Tint darüber macht daraus schwarzen Stein statt grauem Glas. +public struct OnyxVisualEffect: NSViewRepresentable { + public init() {} + + public func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .hudWindow + view.blendingMode = .behindWindow + view.state = .active + view.appearance = NSAppearance(named: .darkAqua) + return view + } + + public func updateNSView(_ view: NSVisualEffectView, context: Context) {} +} + +/// Der Hintergrund des Panels: Vibrancy, Tint, ein sehr flacher Glanz von oben +/// und eine Haarlinie als Innenkante. +public struct OnyxSurface: View { + private let cornerRadius: CGFloat + + public init(cornerRadius: CGFloat = Onyx.Metric.panelCornerRadius) { + self.cornerRadius = cornerRadius + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + shape + .fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity)) + .background(OnyxVisualEffect().clipShape(shape)) + .overlay { + // Die Bänderung des Steins: oben ein Hauch heller, nach 40 % + // vollständig verlaufen. Mehr wirkt sofort billig. + shape.fill( + LinearGradient( + stops: [ + .init(color: .white.opacity(0.02), location: 0), + .init(color: .clear, location: 0.4), + ], + startPoint: .top, endPoint: .bottom)) + } + .overlay { + shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth) + } + } +} + +/// Eine Kachel auf der Panelfläche. +public struct OnyxTile: View { + private let content: Content + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: Onyx.Metric.tileCornerRadius, style: .continuous) + + content + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(shape.fill(Onyx.Color.elevated)) + .overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift new file mode 100644 index 0000000..1439936 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Die einzige Quelle für Farben, Maße und Bewegung in Onyx. +/// +/// Onyx ist ein tiefschwarzer Stein mit feiner Bänderung und mattem Glanz — kein +/// Glas. Das Panel benutzt deshalb bewusst **nicht** die Liquid-Glass-APIs von +/// macOS 26, sondern eine dunkle Vibrancy-Ebene mit eigenem Tint darüber. +public enum Onyx { + + // MARK: - Farben + + public enum Color { + /// Grundfläche des Panels. + public static let surface = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051) + /// Kacheln auf der Grundfläche. + public static let elevated = SwiftUI.Color(red: 0.078, green: 0.078, blue: 0.090) + /// Innenkante — der halbe „edle" Effekt steckt in dieser einen Linie. + public static let hairline = SwiftUI.Color.white.opacity(0.08) + + public static let textPrimary = SwiftUI.Color(red: 0.949, green: 0.949, blue: 0.957) + public static let textSecondary = SwiftUI.Color.white.opacity(0.58) + public static let textTertiary = SwiftUI.Color.white.opacity(0.34) + + /// Kühl und entsättigt. Farbe ist in Onyx ein Signal, keine Dekoration. + public static let accent = SwiftUI.Color(red: 0.498, green: 0.659, blue: 1.0) + public static let positive = SwiftUI.Color(red: 0.435, green: 0.812, blue: 0.592) + public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290) + public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361) + + /// Deckkraft des Tints über der Vibrancy-Ebene. Darunter liest man den + /// Hintergrund durch, darüber wirkt das Panel wie eine schwarze Platte. + public static let surfaceOpacity: Double = 0.74 + } + + // MARK: - Maße + + public enum Metric { + public static let panelCornerRadius: CGFloat = 26 + public static let tileCornerRadius: CGFloat = 16 + public static let panelPadding: CGFloat = 16 + public static let tileSpacing: CGFloat = 10 + public static let hairlineWidth: CGFloat = 1 + } + + // MARK: - Bewegung + + public enum Motion { + /// Ein einziger Spring für alles Ausfahren. Mehrere Kurven im selben + /// Panel wirken unruhig, egal wie gut jede für sich ist. + public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86) + /// Für Messwerte: sichtbar, aber ohne Pulsieren. + public static let value = Animation.easeOut(duration: 0.25) + } + + // MARK: - Typografie + + public enum Font { + public static let title = SwiftUI.Font.system(size: 13, weight: .semibold) + public static let body = SwiftUI.Font.system(size: 12, weight: .regular) + public static let caption = SwiftUI.Font.system(size: 10, weight: .medium) + /// Für alle Messwerte — ohne feste Ziffernbreite zappeln die Zahlen. + public static let metric = SwiftUI.Font.system(size: 20, weight: .medium) + .monospacedDigit() + public static let metricSmall = SwiftUI.Font.system(size: 11, weight: .medium) + .monospacedDigit() + } +} diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift new file mode 100644 index 0000000..de73675 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift @@ -0,0 +1,138 @@ +import AppKit +import SwiftUI + +/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul. +/// +/// Die Reihenfolge merkt sich macOS selbst über `autosaveName`; Onyx speichert sie +/// nicht doppelt. Was Onyx speichert, ist welche Module an sind und wie sie +/// aussehen sollen. +@MainActor +public final class MenuBarController { + + /// Ein Modul, dessen Element keine Breite bekommen hat. + /// + /// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte + /// zusätzlich blockiert. Ein Element, das dort nicht mehr hinpasst, wird von + /// macOS kommentarlos weggelassen — für den Nutzer sieht das aus, als wäre + /// die Einstellung wirkungslos. Deshalb wird der Fall erkannt und gemeldet. + public private(set) var hiddenModuleIDs: Set = [] + + /// Wird gerufen, wenn sich `hiddenModuleIDs` ändert — die Einstellungen + /// zeigen daraufhin einen Hinweis am betroffenen Modul. + public var onVisibilityChanged: ((Set) -> Void)? + + private struct Entry { + let module: any MenuBarModule + let item: NSStatusItem + let popover: NSPopover + var settings: MenuBarModuleSettings + } + + private var entries: [String: Entry] = [:] + private var visibilityTimer: Timer? + + public init() {} + + public func start() { + // Ob ein Element sichtbar ist, meldet AppKit nicht. Es bleibt nur, + // gelegentlich nachzusehen. + visibilityTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in + MainActor.assumeIsolated { [weak self] in self?.checkVisibility() } + } + } + + public func stop() { + visibilityTimer?.invalidate() + visibilityTimer = nil + for id in entries.keys { setEnabled(false, forModuleID: id) } + } + + // MARK: - Module + + public func register(_ module: any MenuBarModule, settings: MenuBarModuleSettings) { + guard entries[module.id] == nil else { return } + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.autosaveName = "onyx.menubar.\(module.id)" + item.isVisible = false + + let popover = NSPopover() + popover.behavior = .transient + popover.animates = true + + entries[module.id] = Entry(module: module, item: item, popover: popover, settings: settings) + apply(settings, toModuleID: module.id) + } + + public func setEnabled(_ enabled: Bool, forModuleID id: String) { + guard var entry = entries[id] else { return } + entry.settings.isEnabled = enabled + entries[id] = entry + apply(entry.settings, toModuleID: id) + } + + public func setPresentation(_ presentation: MenuBarPresentation, forModuleID id: String) { + guard var entry = entries[id] else { return } + entry.settings.presentation = presentation + entries[id] = entry + apply(entry.settings, toModuleID: id) + } + + public func settings(forModuleID id: String) -> MenuBarModuleSettings? { + entries[id]?.settings + } + + private func apply(_ settings: MenuBarModuleSettings, toModuleID id: String) { + guard let entry = entries[id] else { return } + + entry.item.isVisible = settings.isEnabled + + if settings.isEnabled { + let view = entry.module.makeStatusView(presentation: settings.presentation) + if let button = entry.item.button { + button.subviews.forEach { $0.removeFromSuperview() } + view.frame = button.bounds + view.autoresizingMask = [.width, .height] + button.addSubview(view) + button.target = self + button.action = #selector(statusItemClicked(_:)) + // Beide Tasten annehmen: rechts öffnet später das Kontextmenü. + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + button.identifier = NSUserInterfaceItemIdentifier(id) + } + entry.item.length = view.fittingSize.width > 0 ? view.fittingSize.width : NSStatusItem.variableLength + entry.module.activate() + } else { + entry.module.deactivate() + } + checkVisibility() + } + + // MARK: - Interaktion + + @objc private func statusItemClicked(_ sender: NSStatusBarButton) { + guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return } + + if entry.popover.isShown { + entry.popover.performClose(nil) + return + } + entry.popover.contentViewController = NSHostingController( + rootView: entry.module.makePopoverView()) + entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + // Ohne das bleibt das Popover hinter dem aktiven Fenster. + entry.popover.contentViewController?.view.window?.makeKey() + } + + private func checkVisibility() { + var hidden = Set() + for (id, entry) in entries where entry.settings.isEnabled { + // `isVisible` bleibt true, auch wenn das Element mangels Platz nicht + // gezeichnet wird. Der verlässliche Hinweis ist ein Knopf ohne Fenster. + if entry.item.button?.window == nil { hidden.insert(id) } + } + guard hidden != hiddenModuleIDs else { return } + hiddenModuleIDs = hidden + onVisibilityChanged?(hidden) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift new file mode 100644 index 0000000..9c016cd --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift @@ -0,0 +1,64 @@ +import AppKit +import SwiftUI + +/// Wie ein Modul seinen Messwert in der Menüleiste zeichnet. +public enum MenuBarPresentation: String, Codable, Sendable, CaseIterable, Identifiable { + /// Nur die Zahl, z. B. `34 %` oder `↓ 2,4 MB/s`. + case value + /// Schmales Verlaufsdiagramm über die letzten Messungen. + case graph + /// Ein Balken je Cluster bzw. je Richtung. + case bars + case valueAndGraph + /// Nur ein Glyph, nach Grenzwert eingefärbt. + case symbol + + public var id: String { rawValue } +} + +/// Die Menüleisten-Entsprechung zu einem Widget. +/// +/// Ein Modul liefert nur Darstellung. Die Daten kommen aus demselben Provider, +/// den auch das Panel-Widget benutzt — es gibt keine zweite Messschleife. +@MainActor +public protocol MenuBarModule: AnyObject { + var id: String { get } + var displayName: String { get } + + /// Zeichnet den kompakten Zustand. Muss eine feste Breite je Darstellungsart + /// liefern, sonst springt die ganze Menüleiste bei jedem Messwert. + func makeStatusView(presentation: MenuBarPresentation) -> NSView + + /// Der Inhalt des Popovers beim Klick. + func makePopoverView() -> AnyView + + /// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier + /// meldet sich das Modul beim Provider an und wieder ab. + func activate() + func deactivate() +} + +/// Was der Nutzer je Modul eingestellt hat. +public struct MenuBarModuleSettings: Codable, Sendable, Equatable { + public var isEnabled: Bool + public var presentation: MenuBarPresentation + /// Abtastintervall in Sekunden, 1–10. + public var refreshInterval: TimeInterval + public var usesThresholdColors: Bool + + public init(isEnabled: Bool = false, + presentation: MenuBarPresentation = .value, + refreshInterval: TimeInterval = 2, + usesThresholdColors: Bool = true) { + self.isEnabled = isEnabled + self.presentation = presentation + self.refreshInterval = refreshInterval.clamped(to: 1...10) + self.usesThresholdColors = usesThresholdColors + } +} + +extension TimeInterval { + func clamped(to range: ClosedRange) -> TimeInterval { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift b/Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift new file mode 100644 index 0000000..980ebbf --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift @@ -0,0 +1,35 @@ +import AppKit + +public extension NSScreen { + + /// Die `CGDirectDisplayID` dieses Bildschirms. Anders als der Index in + /// `NSScreen.screens` überlebt sie ein Umstecken oder Umsortieren. + var displayID: CGDirectDisplayID { + deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? 0 + } + + var isBuiltInDisplay: Bool { + CGDisplayIsBuiltin(displayID) != 0 + } + + /// Reduziert den Bildschirm auf genau die Werte, die die Geometrie braucht. + /// Alles Weitere bleibt draußen, damit `NotchGeometry` ohne Fenstersystem + /// prüfbar bleibt. + var onyxMetrics: ScreenMetrics { + ScreenMetrics(frame: frame, + safeAreaTop: safeAreaInsets.top, + auxiliaryTopLeft: auxiliaryTopLeftArea, + auxiliaryTopRight: auxiliaryTopRightArea, + isBuiltIn: isBuiltInDisplay, + displayID: displayID) + } + + /// Verdeckt gerade ein Vollbildfenster die Menüleiste? + /// + /// Es gibt keine öffentliche API, die „hier läuft ein Vollbildvideo" meldet. + /// Der verlässliche Hinweis ist die verschwundene Menüleiste: bei echtem + /// Vollbild reicht `visibleFrame` bis an die Oberkante von `frame`. + var isShowingFullScreenWindow: Bool { + frame.maxY - visibleFrame.maxY < 1 + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift new file mode 100644 index 0000000..208558a --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift @@ -0,0 +1,159 @@ +import AppKit + +/// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt +/// alle mit der Zeigerposition. +/// +/// Die Zeigerposition kommt aus einem globalen Ereignismonitor statt aus einem +/// unsichtbaren Fenster über der Notch. Ein solches Fenster müsste Mausereignisse +/// annehmen, um sie zu bemerken — und würde damit die Menüleiste und die +/// Fensterknöpfe darunter unbenutzbar machen. Mausbewegungen global zu beobachten +/// verlangt anders als bei Tastatureingaben keine Bedienungshilfen-Berechtigung. +@MainActor +public final class NotchCoordinator { + + public var policy: DisplayPolicy { + didSet { guard policy != oldValue else { return }; rebuild() } + } + + public var virtualNotchSize: CGSize { + didSet { guard virtualNotchSize != oldValue else { return }; rebuild() } + } + + private var controllers: [CGDirectDisplayID: NotchScreenController] = [:] + private var monitors: [Any] = [] + private var suppressionTimer: Timer? + + /// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene. + private let makeContent: (CGDirectDisplayID) -> NSView + + public init(policy: DisplayPolicy = .builtInOnly, + virtualNotchSize: CGSize = CGSize(width: 220, height: 32), + makeContent: @escaping (CGDirectDisplayID) -> NSView) { + self.policy = policy + self.virtualNotchSize = virtualNotchSize + self.makeContent = makeContent + } + + // MARK: - Lebenszyklus + + public func start() { + rebuild() + installMonitors() + NotificationCenter.default.addObserver( + self, selector: #selector(screensChanged), + name: NSApplication.didChangeScreenParametersNotification, object: nil) + + // Vollbild lässt sich nicht über eine Benachrichtigung erfragen; es + // bleibt nur, gelegentlich nachzusehen. Zwei Sekunden reichen — das + // Panel ist ohnehin zu, solange niemand die Notch berührt. + suppressionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + MainActor.assumeIsolated { [weak self] in self?.refreshSuppression() } + } + } + + public func stop() { + monitors.forEach(NSEvent.removeMonitor) + monitors.removeAll() + suppressionTimer?.invalidate() + suppressionTimer = nil + NotificationCenter.default.removeObserver(self) + controllers.values.forEach { $0.tearDown() } + controllers.removeAll() + } + + // MARK: - Bildschirme + + @objc private func screensChanged() { rebuild() } + + private func rebuild() { + let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics)) + let wantedIDs = Set(wanted.map(\.displayID)) + + for (id, controller) in controllers where !wantedIDs.contains(id) { + controller.tearDown() + controllers[id] = nil + } + + for metrics in wanted { + if let existing = controllers[metrics.displayID] { + // Bestehenden Controller aktualisieren statt neu zu bauen: + // ein Auflösungswechsel darf ein offenes Panel nicht wegreißen. + existing.update(metrics: metrics, virtualSize: virtualNotchSize) + } else { + controllers[metrics.displayID] = NotchScreenController( + metrics: metrics, + virtualSize: virtualNotchSize, + content: makeContent(metrics.displayID)) + } + } + refreshSuppression() + } + + // MARK: - Ereignisse + + private func installMonitors() { + let moved: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .rightMouseDragged] + + if let global = NSEvent.addGlobalMonitorForEvents(matching: moved, handler: { _ in + MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } + }) { monitors.append(global) } + + // Der globale Monitor schweigt, solange Onyx selbst aktiv ist — dann + // greift dieser hier, sonst schließt das Panel unter dem eigenen Zeiger. + if let local = NSEvent.addLocalMonitorForEvents(matching: moved, handler: { event in + MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } + return event + }) { monitors.append(local) } + + if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in + MainActor.assumeIsolated { [weak self] in + guard let self else { return } + let point = NSEvent.mouseLocation + for controller in controllers.values + where controller.isVisible && controller.geometry.triggerRect.contains(point) { + controller.click() + } + } + return event + }) { monitors.append(clicks) } + + if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in + guard event.keyCode == 53 else { return event } // esc + MainActor.assumeIsolated { [weak self] in + self?.controllers.values.forEach { $0.escape() } + } + return nil + }) { monitors.append(keys) } + } + + private func pointerMoved() { + let point = NSEvent.mouseLocation + for controller in controllers.values { + controller.pointerMoved(to: point) + } + } + + private func refreshSuppression() { + for screen in NSScreen.screens { + controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow) + } + } + + // MARK: - Zugriff für die App + + public var activeControllers: [NotchScreenController] { Array(controllers.values) } + + public func controller(for displayID: CGDirectDisplayID) -> NotchScreenController? { + controllers[displayID] + } + + /// Öffnet das Panel auf dem Bildschirm, auf dem gerade der Zeiger steht — + /// der Weg, über den das Menüleisten-Icon das Panel aufruft. + public func togglePanelUnderPointer() { + let point = NSEvent.mouseLocation + guard let screen = NSScreen.screens.first(where: { $0.frame.contains(point) }) + ?? NSScreen.main, + let controller = controllers[screen.displayID] else { return } + controller.click() + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift new file mode 100644 index 0000000..67fd955 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift @@ -0,0 +1,146 @@ +import CoreGraphics + +/// Die Bildschirmwerte, aus denen sich die Notch ableiten lässt — als eigener +/// Typ, damit die Geometrie ohne echten `NSScreen` prüfbar bleibt. +public struct ScreenMetrics: Equatable, Sendable, Identifiable { + public let frame: CGRect + public let safeAreaTop: CGFloat + public let auxiliaryTopLeft: CGRect? + public let auxiliaryTopRight: CGRect? + public let isBuiltIn: Bool + /// `CGDirectDisplayID` — stabil über Anordnungswechsel hinweg, anders als + /// der Index in `NSScreen.screens`. + public let displayID: UInt32 + + public var id: UInt32 { displayID } + + public init(frame: CGRect, + safeAreaTop: CGFloat, + auxiliaryTopLeft: CGRect?, + auxiliaryTopRight: CGRect?, + isBuiltIn: Bool = false, + displayID: UInt32 = 0) { + self.frame = frame + self.safeAreaTop = safeAreaTop + self.auxiliaryTopLeft = auxiliaryTopLeft + self.auxiliaryTopRight = auxiliaryTopRight + self.isBuiltIn = isBuiltIn + self.displayID = displayID + } +} + +/// Auf welchen Displays Onyx überhaupt erscheint. +public enum DisplayPolicy: String, Codable, Sendable, CaseIterable { + /// Nur das eingebaute Display. + case builtInOnly + /// Jedes angeschlossene Display, externe mit virtueller Notch. + case allDisplays + + public func screens(from available: [ScreenMetrics]) -> [ScreenMetrics] { + switch self { + case .allDisplays: + return available + case .builtInOnly: + let builtIn = available.filter(\.isBuiltIn) + // Am Dock mit geschlossenem Deckel gibt es kein eingebautes Display. + // Diese Einstellung beim Wort zu nehmen hieße, Onyx unerreichbar zu + // machen — deshalb ein einzelnes Ersatzdisplay statt gar keinem. + guard builtIn.isEmpty else { return builtIn } + return Array(available.prefix(1)) + } + } +} + +/// Wo die Notch sitzt und wo das Panel dazu hingehört. +/// +/// Auf dem eingebauten Display wird die echte Notch aus den beiden Hilfsflächen +/// links und rechts davon berechnet. Auf allen anderen Displays tritt ein +/// virtueller Ersatz an dieselbe Stelle, damit Onyx dort überhaupt erreichbar ist. +public struct NotchGeometry: Equatable, Sendable { + + public enum Kind: Equatable, Sendable { + /// Aus `auxiliaryTopLeftArea` / `auxiliaryTopRightArea` abgeleitet. + case physical + /// Frei gesetzter Balken oben mittig. + case virtual + } + + public let kind: Kind + /// Die Notch selbst, in Bildschirmkoordinaten (Ursprung unten links). + public let rect: CGRect + public let screenFrame: CGRect + + /// Wie weit die Auslösefläche über die Notch hinausreicht. Ohne diesen Rand + /// muss man die Notch pixelgenau treffen, und die unteren Bildpunkte der + /// Notch selbst nimmt die Menüleiste bereits für sich in Anspruch. + public static let triggerInsetX: CGFloat = 16 + public static let triggerInsetY: CGFloat = 4 + + public init(kind: Kind, rect: CGRect, screenFrame: CGRect) { + self.kind = kind + self.rect = rect + self.screenFrame = screenFrame + } + + public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize) -> NotchGeometry { + // Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am + // Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt, + // obwohl es nichts gibt, um das man herumlegen könnte. + if let left = metrics.auxiliaryTopLeft, + let right = metrics.auxiliaryTopRight, + metrics.safeAreaTop > 0 { + let width = metrics.frame.width - left.width - right.width + if width > 0 { + return NotchGeometry( + kind: .physical, + rect: CGRect(x: left.maxX, + y: metrics.frame.maxY - metrics.safeAreaTop, + width: width, + height: metrics.safeAreaTop), + screenFrame: metrics.frame) + } + } + + let width = min(virtualSize.width, metrics.frame.width) + return NotchGeometry( + kind: .virtual, + rect: CGRect(x: metrics.frame.midX - width / 2, + y: metrics.frame.maxY - virtualSize.height, + width: width, + height: virtualSize.height), + screenFrame: metrics.frame) + } + + /// Die Fläche, deren Betreten das Panel scharf schaltet. + public var triggerRect: CGRect { + rect + .insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY) + .clamped(to: screenFrame) + } + + /// Wohin das Panel gehört: auf die Notch zentriert, oben bündig mit dem + /// Bildschirmrand — es wächst aus der Notch heraus, statt darunter zu schweben. + public func panelRect(for size: CGSize) -> CGRect { + let width = min(size.width, screenFrame.width) + let height = min(size.height, screenFrame.height) + + return CGRect(x: rect.midX - width / 2, + y: screenFrame.maxY - height, + width: width, + height: height) + .clamped(to: screenFrame) + } +} + +extension CGRect { + /// Schiebt das Rechteck in die Grenzen hinein, statt es abzuschneiden. + /// Ein zurechtgestutztes Panel wäre halb unsichtbar; ein verschobenes ist + /// vollständig da. + func clamped(to bounds: CGRect) -> CGRect { + let width = Swift.min(width, bounds.width) + let height = Swift.min(height, bounds.height) + let x = Swift.min(Swift.max(minX, bounds.minX), bounds.maxX - width) + let y = Swift.min(Swift.max(minY, bounds.minY), bounds.maxY - height) + return CGRect(x: x, y: y, width: width, height: height) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift new file mode 100644 index 0000000..5062d7b --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift @@ -0,0 +1,68 @@ +import AppKit + +/// Das Fenster, in dem das Notch-Panel lebt. +/// +/// Zwei Anforderungen kollidieren hier: es muss über allem liegen, auch über der +/// Menüleiste — und es darf der aktiven App niemals den Fokus wegnehmen. Wer beim +/// Blick auf den Kalender den Cursor aus seinem Editor verliert, benutzt das +/// Panel kein zweites Mal. +public final class NotchPanel: NSPanel { + + public init(contentRect: NSRect) { + super.init(contentRect: contentRect, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + + isFloatingPanel = true + // Ohne das wird das Panel beim ersten Klick zum Schlüsselfenster und + // entzieht der aktiven App den Fokus. + becomesKeyOnlyIfNeeded = true + hidesOnDeactivate = false + + // Über der Menüleiste, aber unter Bildschirmschoner und Anmeldefenster. + level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.statusWindow)) + 1) + + // Auf allen Schreibtischen sichtbar und auch neben Vollbildfenstern, + // ohne beim Umschalten mitzuwandern. + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, + .stationary, .ignoresCycle] + + backgroundColor = .clear + isOpaque = false + hasShadow = true + isMovable = false + isMovableByWindowBackground = false + animationBehavior = .none + + // Nicht in Bildschirmaufnahmen und Fensterlisten auftauchen. + isExcludedFromWindowsMenu = true + } + + /// Randlose Fenster gelten sonst als nicht fokussierbar — das bräuchten wir + /// aber, damit `esc` im fixierten Zustand ankommt. + public override var canBecomeKey: Bool { true } + public override var canBecomeMain: Bool { false } +} + +/// Der unsichtbare Fühler über der Notch. +/// +/// Er zeichnet nichts und nimmt keine Klicks an — `ignoresMouseEvents` bleibt an, +/// sonst würden die Menüleiste und die Fensterknöpfe darunter unerreichbar. +/// Die Zeigerposition kommt aus einem globalen Monitor, nicht aus diesem Fenster; +/// das Fenster dient nur der Sichtprüfung beim Entwickeln. +public final class NotchDebugOverlay: NSPanel { + + public init(frame: NSRect) { + super.init(contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.statusWindow)) + 2) + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + backgroundColor = NSColor.systemPink.withAlphaComponent(0.25) + isOpaque = false + hasShadow = false + ignoresMouseEvents = true + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift new file mode 100644 index 0000000..909fd29 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -0,0 +1,106 @@ +import AppKit + +/// Führt für genau einen Bildschirm Geometrie, Zustandsmaschine und Fenster zusammen. +/// +/// Die Entscheidungen trifft `NotchStateMachine`; diese Klasse übersetzt sie nur +/// in Timer und Fensteraufrufe. Deshalb steht hier auch kein `if phase == …`. +@MainActor +public final class NotchScreenController { + + public private(set) var geometry: NotchGeometry + public let displayID: CGDirectDisplayID + + private var machine = NotchStateMachine() + private let panel: NotchPanel + private var armTimer: DispatchWorkItem? + private var closeTimer: DispatchWorkItem? + + /// Größe des ausgefahrenen Panels. Ab Phase 2 liefert das die Layout-Engine. + public var expandedSize = CGSize(width: 640, height: 320) { + didSet { if machine.isVisible { layoutPanel() } } + } + + public init(metrics: ScreenMetrics, virtualSize: CGSize, content: NSView) { + self.displayID = metrics.displayID + self.geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) + self.panel = NotchPanel(contentRect: .zero) + panel.contentView = content + layoutPanel() + } + + public var phase: NotchPhase { machine.phase } + public var isVisible: Bool { machine.isVisible } + + // MARK: - Eingaben + + /// Zeigerposition in globalen Bildschirmkoordinaten. + public func pointerMoved(to point: CGPoint) { + let inside = geometry.triggerRect.contains(point) + || (machine.isVisible && panel.frame.contains(point)) + apply(machine.handle(inside ? .pointerEntered : .pointerExited)) + } + + public func click() { apply(machine.handle(.click)) } + public func escape() { apply(machine.handle(.escape)) } + public func setSuppressed(_ suppressed: Bool) { apply(machine.handle(.suppressed(suppressed))) } + + /// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung. + public func update(metrics: ScreenMetrics, virtualSize: CGSize) { + geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) + layoutPanel() + } + + public func tearDown() { + armTimer?.cancel() + closeTimer?.cancel() + panel.orderOut(nil) + } + + // MARK: - Wirkungen + + private func apply(_ effects: [NotchEffect]) { + for effect in effects { + switch effect { + case .startArmTimer(let delay): + armTimer?.cancel() + armTimer = schedule(after: delay) { [weak self] in + guard let self else { return } + apply(machine.handle(.armTimerFired)) + } + + case .cancelArmTimer: + armTimer?.cancel() + armTimer = nil + + case .startCloseTimer(let delay): + closeTimer?.cancel() + closeTimer = schedule(after: delay) { [weak self] in + guard let self else { return } + apply(machine.handle(.closeTimerFired)) + } + + case .cancelCloseTimer: + closeTimer?.cancel() + closeTimer = nil + + case .show: + layoutPanel() + panel.orderFrontRegardless() + + case .hide: + panel.orderOut(nil) + } + } + } + + private func schedule(after delay: TimeInterval, _ body: @escaping @MainActor () -> Void) + -> DispatchWorkItem { + let item = DispatchWorkItem { MainActor.assumeIsolated { body() } } + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + return item + } + + private func layoutPanel() { + panel.setFrame(geometry.panelRect(for: expandedSize), display: machine.isVisible) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift new file mode 100644 index 0000000..f70ad51 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift @@ -0,0 +1,154 @@ +import Foundation + +/// Sichtbarer Zustand des Notch-Panels. +public enum NotchPhase: Equatable, Sendable { + /// Panel unsichtbar, nichts läuft. + case idle + /// Zeiger ist in der Notch, die Entprellzeit läuft. Panel noch unsichtbar. + case arming + /// Panel sichtbar, folgt dem Zeiger. + case open + /// Panel sichtbar und festgehalten — der Zeiger darf weg. + case pinned + /// Zeiger ist draußen, die Nachlauffrist läuft. Panel noch sichtbar. + case closing +} + +/// Alles, was die Maschine bewegen kann. Zeit kommt nur als Ereignis herein, +/// damit sich jeder Übergang ohne Warten prüfen lässt. +public enum NotchInput: Equatable, Sendable { + case pointerEntered + case pointerExited + case click + case escape + case armTimerFired + case closeTimerFired + /// Vollbildvideo, „Nicht stören" oder ein anderer Grund, das Panel wegzuhalten. + case suppressed(Bool) +} + +/// Was der Aufrufer nach einem Übergang zu tun hat. Die Maschine selbst +/// berührt weder Fenster noch Timer. +public enum NotchEffect: Equatable, Sendable { + case startArmTimer(TimeInterval) + case cancelArmTimer + case startCloseTimer(TimeInterval) + case cancelCloseTimer + case show + case hide +} + +/// Das Öffnungs- und Schließverhalten des Notch-Panels, frei von AppKit. +/// +/// Zwei Eigenschaften sind wichtiger als sie aussehen: +/// +/// **Entprellung.** Der Zeiger streift die Notch ständig beiläufig — auf dem Weg +/// zur Menüleiste, zu den Fensterknöpfen. Ohne Wartezeit klappt das Panel dabei +/// dauernd auf. Deshalb `arming` als eigener Zustand. +/// +/// **Nachlauffrist.** Wer vom oberen Rand diagonal zu einem Widget zieht, verlässt +/// die Notch-Zone kurz. Ohne Frist schließt das Panel genau in dem Moment, in dem +/// man es benutzen will. Deshalb `closing` als eigener Zustand. +public struct NotchStateMachine: Equatable, Sendable { + + /// Wartezeit, bevor ein Hover als Absicht gilt. + public static let armDelay: TimeInterval = 0.22 + /// Nachlauffrist, bevor ein verlassenes Panel schließt. + public static let closeDelay: TimeInterval = 0.30 + + public private(set) var phase: NotchPhase = .idle + public private(set) var isSuppressed: Bool = false + + public init() {} + + /// Sichtbar, egal aus welchem Grund. + public var isVisible: Bool { + switch phase { + case .open, .pinned, .closing: true + case .idle, .arming: false + } + } + + @discardableResult + public mutating func handle(_ input: NotchInput) -> [NotchEffect] { + // Unterdrückung schlägt jeden anderen Zustand, auch die Fixierung. + // Ein angeheftetes Panel über einem Vollbildvideo wäre sonst nicht + // mehr wegzubekommen, ohne das Video zu verlassen. + if case .suppressed(let active) = input { + guard active != isSuppressed else { return [] } + isSuppressed = active + + guard active else { return [] } + let wasVisible = isVisible + phase = .idle + return wasVisible + ? [.cancelArmTimer, .cancelCloseTimer, .hide] + : [.cancelArmTimer, .cancelCloseTimer] + } + + guard !isSuppressed else { return [] } + + switch (phase, input) { + + // Öffnen + case (.idle, .pointerEntered): + phase = .arming + return [.startArmTimer(Self.armDelay)] + + case (.arming, .armTimerFired): + phase = .open + return [.show] + + case (.arming, .pointerExited): + phase = .idle + return [.cancelArmTimer] + + // Schließen + case (.open, .pointerExited): + phase = .closing + return [.startCloseTimer(Self.closeDelay)] + + case (.closing, .pointerEntered): + phase = .open + return [.cancelCloseTimer] + + case (.closing, .closeTimerFired): + phase = .idle + return [.hide] + + // Fixieren + case (.open, .click), (.closing, .click): + phase = .pinned + return [.cancelCloseTimer] + + case (.pinned, .click): + // Lösen, aber offen lassen: der Zeiger ist im Panel, sonst wäre der + // Klick nicht angekommen. Von hier gelten wieder die normalen Regeln. + phase = .open + return [] + + // Beenden + case (.open, .escape), (.pinned, .escape), (.closing, .escape): + phase = .idle + return [.hide] + + case (.arming, .escape): + phase = .idle + return [.cancelArmTimer] + + // Alles andere ist bewusst folgenlos: doppelte Ereignisse aus + // überlappenden Tracking-Areas und verspätete Timer, die nach dem + // Abbestellen noch feuern. + default: + return [] + } + } +} + +extension NotchStateMachine { + /// Nur für Tests: setzt die Phase direkt, statt sie über eine Ereigniskette + /// aufzubauen. Hält die Tests bei dem Übergang, den sie prüfen. + mutating func forcePhase(_ phase: NotchPhase) { + self.phase = phase + } +} diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift new file mode 100644 index 0000000..ac8accc --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift @@ -0,0 +1 @@ +// OnyxWidgetKit diff --git a/Packages/OnyxKit/Tests/OnyxMenuBarTests/Placeholder.swift b/Packages/OnyxKit/Tests/OnyxMenuBarTests/Placeholder.swift new file mode 100644 index 0000000..e69594a --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxMenuBarTests/Placeholder.swift @@ -0,0 +1 @@ +// OnyxMenuBarTests diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift new file mode 100644 index 0000000..326d183 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift @@ -0,0 +1,67 @@ +import Testing +import CoreGraphics +@testable import OnyxNotch + +@Suite("Anzeigen-Auswahl") +struct DisplayPolicyTests { + + static let builtIn = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 1800, height: 1169), + safeAreaTop: 38, + auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38), + auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38), + isBuiltIn: true, + displayID: 1 + ) + + static let external = ScreenMetrics( + frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440), + safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil, + isBuiltIn: false, displayID: 2 + ) + + static let secondExternal = ScreenMetrics( + frame: CGRect(x: -2560, y: 0, width: 2560, height: 1440), + safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil, + isBuiltIn: false, displayID: 3 + ) + + @Test("Nur intern: externe Displays bekommen kein Panel") + func builtInOnlySkipsExternal() { + let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.builtIn, Self.external]) + #expect(chosen.map(\.displayID) == [1]) + } + + @Test("Alle Displays: jedes bekommt eins") + func allDisplaysCoversEverything() { + let chosen = DisplayPolicy.allDisplays + .screens(from: [Self.builtIn, Self.external, Self.secondExternal]) + #expect(Set(chosen.map(\.displayID)) == [1, 2, 3]) + } + + @Test("Nur intern und der Deckel ist zu: Onyx wäre unerreichbar, also fällt es auf extern zurück") + func builtInOnlyFallsBackWhenLidClosed() { + // Deckel zu am Dock: das eingebaute Display verschwindet aus der Liste. + // Ohne Rückfall hätte man gar keinen Zugang mehr zur App. + let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.external]) + #expect(chosen.map(\.displayID) == [2]) + } + + @Test("Rückfall wählt genau ein Display, nicht alle") + func fallbackPicksOnlyOne() { + let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.external, Self.secondExternal]) + #expect(chosen.count == 1) + } + + @Test("Ohne Displays kein Panel — und kein Absturz") + func noScreensIsSafe() { + #expect(DisplayPolicy.builtInOnly.screens(from: []).isEmpty) + #expect(DisplayPolicy.allDisplays.screens(from: []).isEmpty) + } + + @Test("Mehrere eingebaute Displays gibt es nicht, aber der Code darf daran nicht scheitern") + func handlesImplausibleInput() { + let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.builtIn, Self.builtIn]) + #expect(chosen.count == 2) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift new file mode 100644 index 0000000..a8ac95a --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift @@ -0,0 +1,142 @@ +import Testing +import CoreGraphics +@testable import OnyxNotch + +// Bildschirmkoordinaten auf macOS haben ihren Ursprung unten links, y wächst nach +// oben. Die Notch sitzt also bei y = frame.maxY − höhe, nicht bei y = 0. + +@Suite("Notch-Geometrie") +struct NotchGeometryTests { + + /// Gemessen auf dem Zielgerät (MacBook Pro Mac17,9, M5 Pro) am 10.08.2026. + /// Dient als Referenzfall — wenn dieser Test bricht, hat Apple die + /// Notch-Metrik geändert und nicht wir. + static let builtIn = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 1800, height: 1169), + safeAreaTop: 38, + auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38), + auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38) + ) + + /// Typischer externer Monitor, rechts neben dem eingebauten angeordnet. + static let external = ScreenMetrics( + frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440), + safeAreaTop: 0, + auxiliaryTopLeft: nil, + auxiliaryTopRight: nil + ) + + static let virtualSize = CGSize(width: 220, height: 32) + + // MARK: - Erkennung + + @Test("Eingebautes Display: echte Notch aus den Hilfsflächen berechnet") + func physicalNotchFromRealDevice() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + + #expect(g.kind == .physical) + #expect(g.rect == CGRect(x: 790, y: 1131, width: 220, height: 38)) + } + + @Test("Externes Display ohne Notch: virtueller Ersatz, oben mittig") + func virtualNotchOnExternal() { + let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize) + + #expect(g.kind == .virtual) + // Mittig: 1800 + (2560 − 220)/2 = 2970 + #expect(g.rect == CGRect(x: 2970, y: 1408, width: 220, height: 32)) + } + + @Test("safeAreaTop ohne Hilfsflächen gilt nicht als Notch") + func safeAreaWithoutAuxIsVirtual() { + // Kommt bei manchen Spiegelungs- und Fernanzeige-Konfigurationen vor: + // ein Inset ist gesetzt, aber es gibt keine Notch, um die man herumlegen + // könnte. Ein Panel an dieser Stelle säße im Nichts. + let odd = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 1800, height: 1169), + safeAreaTop: 38, + auxiliaryTopLeft: nil, + auxiliaryTopRight: nil + ) + #expect(NotchGeometry.resolve(odd, virtualSize: Self.virtualSize).kind == .virtual) + } + + @Test("Notch-Mitte liegt auf der Bildschirmmitte") + func notchIsCentred() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + #expect(g.rect.midX == Self.builtIn.frame.midX) + } + + // MARK: - Auslösefläche + + @Test("Auslösefläche ist breiter und tiefer als die Notch") + func triggerIsLargerThanNotch() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + let t = g.triggerRect + + #expect(t.width > g.rect.width) + #expect(t.height > g.rect.height) + #expect(t.midX == g.rect.midX) + } + + @Test("Auslösefläche ragt nie über den oberen Bildschirmrand hinaus") + func triggerStaysOnScreen() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + #expect(g.triggerRect.maxY <= Self.builtIn.frame.maxY) + #expect(g.triggerRect.minX >= Self.builtIn.frame.minX) + #expect(g.triggerRect.maxX <= Self.builtIn.frame.maxX) + } + + @Test("Auslösefläche eines schmalen Bildschirms bleibt innerhalb") + func triggerClampsOnNarrowScreen() { + let narrow = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 240, height: 400), + safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil + ) + let g = NotchGeometry.resolve(narrow, virtualSize: Self.virtualSize) + + #expect(g.triggerRect.minX >= 0) + #expect(g.triggerRect.maxX <= 240) + } + + // MARK: - Panelfläche + + @Test("Panel hängt unter der Notch und ist auf sie zentriert") + func panelHangsBelowNotch() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + let panel = g.panelRect(for: CGSize(width: 640, height: 320)) + + #expect(panel.midX == g.rect.midX) + // Oberkante bündig mit dem Bildschirmrand — das Panel wächst aus der + // Notch heraus, es schwebt nicht darunter. + #expect(panel.maxY == Self.builtIn.frame.maxY) + #expect(panel.height == 320) + } + + @Test("Panel breiter als der Bildschirm wird eingepasst statt abgeschnitten") + func panelClampsToScreen() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + let panel = g.panelRect(for: CGSize(width: 5000, height: 320)) + + #expect(panel.minX >= Self.builtIn.frame.minX) + #expect(panel.maxX <= Self.builtIn.frame.maxX) + } + + @Test("Panel auf dem externen Display liegt in dessen Koordinaten, nicht bei null") + func panelUsesScreenOrigin() { + let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize) + let panel = g.panelRect(for: CGSize(width: 640, height: 320)) + + #expect(panel.minX >= 1800) + #expect(panel.maxY == 1440) + } + + @Test("Zu hohes Panel wird auf die Bildschirmhöhe begrenzt") + func panelClampsHeight() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + let panel = g.panelRect(for: CGSize(width: 640, height: 99_999)) + + #expect(panel.height <= Self.builtIn.frame.height) + #expect(panel.minY >= Self.builtIn.frame.minY) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift new file mode 100644 index 0000000..dbd44a3 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift @@ -0,0 +1,250 @@ +import Testing +@testable import OnyxNotch + +// Die Zustandsmaschine ist die Spezifikation des Panelverhaltens. Sie ist bewusst +// frei von AppKit: keine Fenster, keine Timer, keine Uhr. Zeit kommt ausschließlich +// als Eingabe herein (`.armTimerFired` / `.closeTimerFired`), damit jeder Übergang +// ohne Warten prüfbar ist. + +@Suite("Notch-Zustandsmaschine") +struct NotchStateMachineTests { + + // MARK: - Öffnen + + @Test("Zeiger betritt die Notch → Entprellung startet, Panel bleibt zu") + func pointerEntersStartsArming() { + var machine = NotchStateMachine() + let effects = machine.handle(.pointerEntered) + + #expect(machine.phase == .arming) + #expect(effects == [.startArmTimer(NotchStateMachine.armDelay)]) + } + + @Test("Entprellzeit abgelaufen → Panel öffnet") + func armTimerOpens() { + var machine = NotchStateMachine() + _ = machine.handle(.pointerEntered) + let effects = machine.handle(.armTimerFired) + + #expect(machine.phase == .open) + #expect(effects == [.show]) + } + + @Test("Zeiger verlässt die Notch vor Ablauf → kein Öffnen") + func leavingDuringArmingCancels() { + var machine = NotchStateMachine() + _ = machine.handle(.pointerEntered) + let effects = machine.handle(.pointerExited) + + #expect(machine.phase == .idle) + #expect(effects == [.cancelArmTimer]) + } + + // MARK: - Schließen + + @Test("Zeiger verlässt geöffnetes Panel → Nachlauffrist, noch kein Schließen") + func leavingOpenStartsGrace() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + let effects = machine.handle(.pointerExited) + + #expect(machine.phase == .closing) + #expect(effects == [.startCloseTimer(NotchStateMachine.closeDelay)]) + } + + @Test("Rückkehr innerhalb der Nachlauffrist → Panel bleibt offen") + func returningDuringGraceKeepsOpen() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + _ = machine.handle(.pointerExited) + let effects = machine.handle(.pointerEntered) + + #expect(machine.phase == .open) + #expect(effects == [.cancelCloseTimer]) + } + + @Test("Nachlauffrist abgelaufen → Panel schließt") + func graceExpiryCloses() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + _ = machine.handle(.pointerExited) + let effects = machine.handle(.closeTimerFired) + + #expect(machine.phase == .idle) + #expect(effects == [.hide]) + } + + // MARK: - Fixieren + + @Test("Klick fixiert das offene Panel") + func clickPins() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + let effects = machine.handle(.click) + + #expect(machine.phase == .pinned) + #expect(effects == [.cancelCloseTimer]) + } + + @Test("Fixiertes Panel bleibt offen, wenn der Zeiger weggeht — das ist der Zweck") + func pinnedIgnoresPointerExit() { + var machine = NotchStateMachine() + machine.forcePhase(.pinned) + let effects = machine.handle(.pointerExited) + + #expect(machine.phase == .pinned) + #expect(effects.isEmpty) + } + + @Test("Zweiter Klick löst die Fixierung, Panel bleibt offen solange der Zeiger drin ist") + func secondClickUnpins() { + var machine = NotchStateMachine() + machine.forcePhase(.pinned) + let effects = machine.handle(.click) + + #expect(machine.phase == .open) + #expect(effects.isEmpty) + } + + @Test("esc löst die Fixierung und schließt") + func escapeClosesPinned() { + var machine = NotchStateMachine() + machine.forcePhase(.pinned) + let effects = machine.handle(.escape) + + #expect(machine.phase == .idle) + #expect(effects == [.hide]) + } + + @Test("esc schließt auch ein nur geöffnetes Panel") + func escapeClosesOpen() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + let effects = machine.handle(.escape) + + #expect(machine.phase == .idle) + #expect(effects == [.hide]) + } + + @Test("esc im Ruhezustand tut nichts") + func escapeIdleIsNoop() { + var machine = NotchStateMachine() + let effects = machine.handle(.escape) + + #expect(machine.phase == .idle) + #expect(effects.isEmpty) + } + + // MARK: - Unterdrückung (Vollbildvideo, „Nicht stören") + + @Test("Unterdrückung schließt ein offenes Panel und räumt die Timer ab") + func suppressionClosesOpen() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + let effects = machine.handle(.suppressed(true)) + + #expect(machine.phase == .idle) + #expect(effects == [.cancelArmTimer, .cancelCloseTimer, .hide]) + } + + @Test("Unterdrückung löst auch die Fixierung — sonst klebt das Panel im Vollbild") + func suppressionBeatsPinning() { + var machine = NotchStateMachine() + machine.forcePhase(.pinned) + _ = machine.handle(.suppressed(true)) + + #expect(machine.phase == .idle) + } + + @Test("Während der Unterdrückung öffnet Hovern nicht") + func suppressedIgnoresHover() { + var machine = NotchStateMachine() + _ = machine.handle(.suppressed(true)) + let effects = machine.handle(.pointerEntered) + + #expect(machine.phase == .idle) + #expect(effects.isEmpty) + } + + @Test("Nach dem Ende der Unterdrückung öffnet Hovern wieder") + func unsuppressingRestoresHover() { + var machine = NotchStateMachine() + _ = machine.handle(.suppressed(true)) + _ = machine.handle(.pointerEntered) + _ = machine.handle(.suppressed(false)) + _ = machine.handle(.pointerEntered) + + #expect(machine.phase == .arming) + } + + @Test("Ende der Unterdrückung öffnet nicht von selbst, auch wenn der Zeiger dort steht") + func unsuppressingDoesNotAutoOpen() { + var machine = NotchStateMachine() + _ = machine.handle(.suppressed(true)) + let effects = machine.handle(.suppressed(false)) + + #expect(machine.phase == .idle) + #expect(effects.isEmpty) + } + + // MARK: - Robustheit gegen doppelte Ereignisse + + @Test("Zweimal betreten ohne zwischenzeitliches Verlassen startet keinen zweiten Timer") + func repeatedEnterIsIdempotent() { + var machine = NotchStateMachine() + _ = machine.handle(.pointerEntered) + let effects = machine.handle(.pointerEntered) + + #expect(machine.phase == .arming) + #expect(effects.isEmpty) + } + + @Test("Verlassen im Ruhezustand tut nichts") + func exitWhileIdleIsNoop() { + var machine = NotchStateMachine() + let effects = machine.handle(.pointerExited) + + #expect(machine.phase == .idle) + #expect(effects.isEmpty) + } + + @Test("Verspäteter Timer nach dem Schließen öffnet nicht mehr") + func staleArmTimerDoesNotOpen() { + var machine = NotchStateMachine() + _ = machine.handle(.pointerEntered) + _ = machine.handle(.pointerExited) // Timer wurde abbestellt … + let effects = machine.handle(.armTimerFired) // … feuert aber trotzdem noch + + #expect(machine.phase == .idle) + #expect(effects.isEmpty) + } + + @Test("Verspäteter Schließtimer schließt kein wieder geöffnetes Panel") + func staleCloseTimerDoesNotHide() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + _ = machine.handle(.pointerExited) + _ = machine.handle(.pointerEntered) // zurück, Timer abbestellt … + let effects = machine.handle(.closeTimerFired) // … feuert trotzdem + + #expect(machine.phase == .open) + #expect(effects.isEmpty) + } + + @Test("Klick im Ruhezustand öffnet nicht — sonst öffnet jeder Klick oben am Rand") + func clickWhileIdleIsNoop() { + var machine = NotchStateMachine() + let effects = machine.handle(.click) + + #expect(machine.phase == .idle) + #expect(effects.isEmpty) + } + + // MARK: - Zeitkonstanten + + @Test("Entprellung und Nachlauf entsprechen dem Plan") + func timingConstants() { + #expect(NotchStateMachine.armDelay == 0.22) + #expect(NotchStateMachine.closeDelay == 0.30) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/Placeholder.swift b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/Placeholder.swift new file mode 100644 index 0000000..d292376 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/Placeholder.swift @@ -0,0 +1 @@ +// OnyxWidgetKitTests diff --git a/Spikes/smc-dump.swift b/Spikes/smc-dump.swift index 839810a..6c9d38f 100644 --- a/Spikes/smc-dump.swift +++ b/Spikes/smc-dump.swift @@ -315,6 +315,99 @@ for (key, note) in [("PSTR", "Systemleistung W"), ("PDTR", "DC-Eingang W"), // --- Vollständiger Dump --------------------------------------------------- +// --- Wächter: Änderungen an den Ladekandidaten protokollieren ------------- +// +// Findet den Ladelimit-Key durch Beobachtung, nicht durch Raten: es wird nur +// gelesen, während macOS selbst die Ladegrenze umstellt. + +if CommandLine.arguments.contains("--watch") { + let interval = 2.0 + let deadline = Date().addingTimeInterval(45 * 60) + + // Alle Keys, die für die Ladesteuerung in Frage kommen, plus Kontext. + var watched: [String] = [] + if let total = try? smc.keyCount() { + for i in 0.. [String: String] { + var out: [String: String] = [:] + for key in watched { + if let v = try? smc.read(key: key) { out[key] = v.hex } + } + return out + } + + let stamp = { () -> String in + let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f.string(from: Date()) + } + + // --- Selbstkalibrierung ------------------------------------------------ + // + // Eine feste Ausschlussliste ist zwecklos: Spannungen, Ströme, Temperaturen + // und Zähler ändern sich im Sekundentakt und begraben das eine Ereignis, + // auf das es ankommt. Stattdessen erst zusehen, was von allein zappelt. + + let calibrationSeconds = 60.0 + print("KALIBRIERUNG — \(watched.count) Kandidaten, \(Int(calibrationSeconds)) s") + print("Bitte in dieser Zeit NICHTS umstellen. Nur lesend.") + print(String(repeating: "─", count: 72)) + + var baseline = snapshotNow() + var noisy = Set() + let calibrationEnd = Date().addingTimeInterval(calibrationSeconds) + + while Date() < calibrationEnd { + Thread.sleep(forTimeInterval: interval) + let current = snapshotNow() + for (key, value) in current where baseline[key] != value { + noisy.insert(key) + baseline[key] = value + } + } + + let stable = watched.filter { !noisy.contains($0) } + print("[\(stamp())] \(noisy.count) Keys zappeln von allein und werden ignoriert.") + print("[\(stamp())] \(stable.count) Keys sind stabil und werden beobachtet:") + print(" \(stable.sorted().joined(separator: " "))") + print(String(repeating: "─", count: 72)) + print("JETZT: Netzteil anstecken, dann die Ladegrenze umschalten.") + fflush(stdout) + + watched = stable + var previous = snapshotNow() + var lastPower: String? = nil + + while Date() < deadline { + Thread.sleep(forTimeInterval: interval) + let current = snapshotNow() + + // Netzteilzustand getrennt mitschreiben, damit die Ereignisse + // zeitlich einzuordnen sind. + if let ac = try? smc.read(key: "AC-W"), ac.hex != lastPower { + print("[\(stamp())] ⚡︎ NETZTEIL: AC-W \(lastPower ?? "—") → \(ac.hex)") + lastPower = ac.hex + fflush(stdout) + } + + for (key, value) in current where previous[key] != value { + let old = previous[key] ?? "—" + let v = try? smc.read(key: key) + print("[\(stamp())] ★ \(key) (\(v?.type ?? "?")): \(old) → \(value) = \(v?.decoded ?? "—")") + fflush(stdout) + } + previous = current + } + print("[\(stamp())] Wächter beendet.") + exit(0) +} + if CommandLine.arguments.contains("--all") { section("ALLE KEYS") guard let total = try? smc.keyCount() else { diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..09046b7 --- /dev/null +++ b/project.yml @@ -0,0 +1,51 @@ +name: Onyx +options: + bundleIdPrefix: com.scarriffleservices + deploymentTarget: + macOS: "26.0" + createIntermediateGroups: true + groupSortPosition: top + +settings: + base: + DEVELOPMENT_TEAM: PP34X97WS3 + SWIFT_VERSION: "6.0" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + ENABLE_HARDENED_RUNTIME: YES + # Der MediaRemote-Adapter bringt ein ad-hoc signiertes Framework mit, das + # der mit Team-Zertifikat signierte Host sonst nicht laden darf. + OTHER_CODE_SIGN_FLAGS: "--deep" + SWIFT_STRICT_CONCURRENCY: complete + +packages: + OnyxKit: + path: Packages/OnyxKit + +targets: + Onyx: + type: application + platform: macOS + sources: + - path: Onyx + dependencies: + - package: OnyxKit + product: OnyxDesign + - package: OnyxKit + product: OnyxNotch + - package: OnyxKit + product: OnyxWidgetKit + - package: OnyxKit + product: OnyxMenuBar + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx + INFOPLIST_FILE: Onyx/Info.plist + CODE_SIGN_ENTITLEMENTS: Onyx/Onyx.entitlements + CODE_SIGN_STYLE: Automatic + # Kein App-Sandbox: der privilegierte Helper, IOKit und der + # MediaRemote-Adapter sind damit unvereinbar. Der App-Group-Container + # bleibt trotzdem erreichbar — siehe docs/spikes/D-appgroup.md. + ENABLE_APP_SANDBOX: NO + ENABLE_USER_SCRIPT_SANDBOXING: NO + COMBINE_HIDPI_IMAGES: YES