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