diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 8741db4..2a1f9ab 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; }; 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; }; + 1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; }; 1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; }; 2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; }; 378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; @@ -24,11 +25,13 @@ 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; }; A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; }; A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */; }; + B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */; }; B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; }; C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; }; C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; }; C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; }; + C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; }; CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; }; CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; @@ -49,10 +52,13 @@ 055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; }; 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = ""; }; + 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = ""; }; 45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCAccess.swift; sourceTree = ""; }; 508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindowController.swift; sourceTree = ""; }; + 63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; 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 = ""; }; 8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -149,8 +155,11 @@ FC3FDFD387188D36AF8287DE /* FanControl.swift */, F12041E330231599C6540C21 /* FanSettingsView.swift */, 50A018673EE6BD9C50F1EFA0 /* Info.plist */, + 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */, 508DECB5C05264E6130C373E /* Localizable.xcstrings */, 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */, + 63A5510538846C8427AEB831 /* OnboardingView.swift */, + 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */, 87597F09E971C14CF1E475DB /* Onyx.entitlements */, F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */, CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */, @@ -292,7 +301,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "OUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n"; + shellScript = "# Im Release mit der echten Identität signieren: ad-hoc signierte\n# Bibliotheken lehnt die Notarisierung ab.\nif [ \"$CONFIGURATION\" = \"Release\" ]; then\n export SIGN_IDENTITY=\"$EXPANDED_CODE_SIGN_IDENTITY\"\nfi\nOUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n"; }; E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */ = { isa = PBXShellScriptBuildPhase; @@ -310,7 +319,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "APP=\"$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH\"\nmkdir -p \"$APP/MacOS\" \"$APP/Library/LaunchDaemons\"\ncp \"$BUILT_PRODUCTS_DIR/OnyxHelper\" \"$APP/MacOS/OnyxHelper\"\ncp \"$SRCROOT/OnyxHelper/com.scarriffleservices.onyx.helper.plist\" \\\n \"$APP/Library/LaunchDaemons/\"\n# Der Helfer wird eigenständig signiert — die Signatur der App deckt\n# ihn nicht ab, und ohne gültige eigene Signatur lehnt launchd ihn ab.\n#\n# `-i` ist Pflicht: Xcode signiert Kommandozeilenprogramme sonst mit\n# dem Produktnamen (\"OnyxHelper\") als Bezeichner. Die App prüft aber\n# auf com.scarriffleservices.onyx.helper und würde ihren eigenen\n# Helfer ablehnen.\ncodesign --force --options runtime --timestamp=none \\\n -i com.scarriffleservices.onyx.helper \\\n --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" \"$APP/MacOS/OnyxHelper\"\n\n# Kein Neusignieren der App hier: Xcode signiert sie als letzten\n# Schritt, nach diesem Skript. Das repariert das Siegel von selbst\n# und legt im Debug-Build auch get-task-allow bei, ohne die im\n# Skript nachzubauen wäre — nachgemessen an der Reihenfolge im\n# Bauprotokoll (PhaseScriptExecution vor CodeSign Onyx.app).\n"; + shellScript = "APP=\"$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH\"\nmkdir -p \"$APP/MacOS\" \"$APP/Library/LaunchDaemons\"\ncp \"$BUILT_PRODUCTS_DIR/OnyxHelper\" \"$APP/MacOS/OnyxHelper\"\ncp \"$SRCROOT/OnyxHelper/com.scarriffleservices.onyx.helper.plist\" \\\n \"$APP/Library/LaunchDaemons/\"\n# Der Helfer wird eigenständig signiert — die Signatur der App deckt\n# ihn nicht ab, und ohne gültige eigene Signatur lehnt launchd ihn ab.\n#\n# `-i` ist Pflicht: Xcode signiert Kommandozeilenprogramme sonst mit\n# dem Produktnamen (\"OnyxHelper\") als Bezeichner. Die App prüft aber\n# auf com.scarriffleservices.onyx.helper und würde ihren eigenen\n# Helfer ablehnen.\n# Zeitstempel im Release, keiner im Debug.\n#\n# Die Notarisierung verlangt einen sicheren Zeitstempel; ohne ihn\n# lehnt Apple das Paket ab, und zwar erst nach dem Hochladen. Im\n# Debug wäre er nur eine Netzanfrage bei jedem Bauen.\nif [ \"$CONFIGURATION\" = \"Release\" ]; then\n TIMESTAMP=\"--timestamp\"\nelse\n TIMESTAMP=\"--timestamp=none\"\nfi\ncodesign --force --options runtime $TIMESTAMP \\\n -i com.scarriffleservices.onyx.helper \\\n --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" \"$APP/MacOS/OnyxHelper\"\n\n# Kein Neusignieren der App hier: Xcode signiert sie als letzten\n# Schritt, nach diesem Skript. Das repariert das Siegel von selbst\n# und legt im Debug-Build auch get-task-allow bei, ohne die im\n# Skript nachzubauen wäre — nachgemessen an der Reihenfolge im\n# Bauprotokoll (PhaseScriptExecution vor CodeSign Onyx.app).\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -322,6 +331,9 @@ C041F171FB69F4381309D978 /* AppModel.swift in Sources */, CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */, 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */, + B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */, + C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */, + 1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */, 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */, 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */, diff --git a/Onyx/LaunchAtLogin.swift b/Onyx/LaunchAtLogin.swift new file mode 100644 index 0000000..43310e4 --- /dev/null +++ b/Onyx/LaunchAtLogin.swift @@ -0,0 +1,55 @@ +import Foundation +import ServiceManagement +import Observation +import OnyxCore +import OSLog + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Login") + +/// Ob Onyx beim Anmelden mitstartet. +/// +/// Über `SMAppService.mainApp` und nicht über den alten Weg mit einem +/// versteckten Helferprogramm: macOS trägt die App dann selbst in die +/// Systemeinstellungen ein, und der Nutzer sieht dort einen Eintrag, den er +/// versteht. Der Preis ist, dass er ihn dort auch abschalten kann — und dann +/// hilft kein `register()` mehr, sondern nur der Weg zurück dorthin. +@MainActor +@Observable +final class LaunchAtLogin { + + private(set) var state: LoginItemState = .notRegistered + + init() { + refresh() + } + + func refresh() { + state = LoginItemState(statusRawValue: SMAppService.mainApp.status.rawValue) + } + + var isEnabled: Bool { state.isEnabled } + var needsSystemSettings: Bool { state.needsSystemSettings } + + /// - Returns: ob es geklappt hat. Bei `false` steht der Grund in `state`. + @discardableResult + func set(_ enabled: Bool) -> Bool { + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + refresh() + log.notice("Anmeldeobjekt \(enabled ? "eingetragen" : "entfernt", privacy: .public), Zustand \(String(describing: self.state), privacy: .public)") + return isEnabled == enabled + } catch { + log.error("Anmeldeobjekt: \(error.localizedDescription, privacy: .public)") + refresh() + return false + } + } + + func openSystemSettings() { + SMAppService.openSystemSettingsLoginItems() + } +} diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 622fc5b..4c64489 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -532,6 +532,230 @@ } } }, + "onboarding.allow": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erlauben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow" + } + } + } + }, + "onboarding.audio": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemton" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System audio" + } + } + } + }, + "onboarding.audio.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nötig für die Lautstärke einzelner Programme. Onyx leitet den Ton der geregelten Programme um — und nur deren." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Needed for per-app volume. Onyx routes the audio of the apps you control, and only those." + } + } + } + }, + "onboarding.calendar": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" + } + } + } + }, + "onboarding.calendar.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt deine Termine im Panel. Nur lesend — Onyx ändert nie einen Kalender." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows your appointments in the panel. Read-only — Onyx never changes a calendar." + } + } + } + }, + "onboarding.done": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fertig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + } + } + }, + "onboarding.helper": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüftersteuerung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control" + } + } + } + }, + "onboarding.helper.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Installiert einen kleinen privilegierten Dienst. Er setzt nur Lüfterdrehzahlen und fällt auf Automatik zurück, wenn Onyx schweigt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installs a small privileged service. It only sets fan speeds and falls back to automatic if Onyx goes silent." + } + } + } + }, + "onboarding.launchAtLogin": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx beim Anmelden starten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Onyx at login" + } + } + } + }, + "onboarding.location": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" + } + } + } + }, + "onboarding.location.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter für deinen Aufenthaltsort. Alternativ lässt sich je Widget ein fester Ort einstellen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Weather for where you are. You can also set a fixed place per widget instead." + } + } + } + }, + "onboarding.openSettings": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" + } + } + } + }, + "onboarding.subtitle": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx braucht vier Dinge vom System. Jedes für etwas anderes, und keines davon ist Pflicht — erlaube, was du nutzen möchtest." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx needs four things from the system. Each one is for something different, and none of them is required — grant what you want to use." + } + } + } + }, + "onboarding.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Willkommen bei Onyx" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome to Onyx" + } + } + } + }, "settings.display.all": { "localizations": { "de": { @@ -663,6 +887,54 @@ } } }, + "settings.general": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + } + } + }, + "settings.launchAtLogin": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Anmelden starten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Start at login" + } + } + } + }, + "settings.launchAtLogin.blocked": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldeobjekte wurden für Onyx in den Systemeinstellungen abgeschaltet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Login items were turned off for Onyx in System Settings." + } + } + } + }, "settings.menubar.bars": { "extractionState": "stale", "localizations": { @@ -764,6 +1036,22 @@ } } }, + "settings.onboarding.show": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einrichtung erneut zeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show setup again" + } + } + } + }, "settings.permissions.calendar": { "extractionState": "stale", "localizations": { @@ -899,7 +1187,12 @@ } } }, + "settings.shelf.count %@": { + "comment": "A label showing the number of items in the shelf. The argument is the number of items in the shelf.", + "isCommentAutoGenerated": true + }, "settings.shelf.count %lld": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -915,7 +1208,12 @@ } } }, + "settings.shelf.days %@": { + "comment": "A label that lets the user select how many days to wait before automatically removing items from the shelf. The argument is the number of days.", + "isCommentAutoGenerated": true + }, "settings.shelf.days %lld": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -948,6 +1246,7 @@ } }, "settings.shelf.reveal": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -964,6 +1263,7 @@ } }, "settings.shelf.title": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1116,6 +1416,7 @@ } }, "settings.tab.sources": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { diff --git a/Onyx/OnboardingView.swift b/Onyx/OnboardingView.swift new file mode 100644 index 0000000..c721ba5 --- /dev/null +++ b/Onyx/OnboardingView.swift @@ -0,0 +1,206 @@ +import SwiftUI +import AppKit +import CalendarProvider +import WeatherProvider +import AudioProvider + +/// Der erste Start. +/// +/// Onyx braucht vier Dinge vom System, und jedes für etwas anderes. Sie alle +/// beim Start hintereinander abzufragen wäre der übliche Weg und der falsche: +/// wer vier Dialoge sieht, ohne zu wissen wofür, klickt viermal „Nicht +/// erlauben" — und macOS fragt danach nie wieder. Deshalb steht hier zu jedem +/// Punkt, wofür er gut ist, und jeder wird einzeln und auf Klick angefragt. +/// +/// Nichts davon ist Pflicht. Ohne Kalenderzugriff fehlt ein Widget, nicht die +/// App. +struct OnboardingView: View { + + let calendarModel: CalendarModel + let weatherModel: WeatherModel + let mixer: AudioMixer + let fanControl: FanControl + let launchAtLogin: LaunchAtLogin + let onFinish: () -> Void + + @State private var calendarGranted = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + OnboardingRow( + symbol: "calendar", + title: "onboarding.calendar", + detail: "onboarding.calendar.why", + state: calendarState) { + calendarGranted = await calendarModel.requestAccessNow() + } openSettings: { + open("Privacy_Calendars") + } + + OnboardingRow( + symbol: "location", + title: "onboarding.location", + detail: "onboarding.location.why", + state: locationState) { + weatherModel.requestLocationAuthorization() + } openSettings: { + open("Privacy_LocationServices") + } + + OnboardingRow( + symbol: "slider.horizontal.3", + title: "onboarding.audio", + detail: "onboarding.audio.why", + state: mixer.isEnabled ? .granted : .undetermined) { + mixer.enable() + } openSettings: { + open("Privacy_Microphone") + } + + OnboardingRow( + symbol: "fan", + title: "onboarding.helper", + detail: "onboarding.helper.why", + state: helperState) { + fanControl.install() + } openSettings: { + fanControl.openApprovalSettings() + } + } + .padding(.horizontal, 22) + .padding(.vertical, 6) + } + + Divider() + footer + } + .frame(width: 520, height: 520) + .task { + calendarGranted = calendarModel.hasAccess + fanControl.refreshInstallState() + launchAtLogin.refresh() + } + } + + // MARK: - Kopf und Fuß + + private var header: some View { + HStack(spacing: 14) { + Image(nsImage: NSApp.applicationIconImage) + .resizable().frame(width: 52, height: 52) + VStack(alignment: .leading, spacing: 3) { + Text("onboarding.title").font(.title2).fontWeight(.semibold) + Text("onboarding.subtitle") + .font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(22) + } + + private var footer: some View { + HStack { + Toggle(isOn: Binding( + get: { launchAtLogin.isEnabled }, + set: { _ = launchAtLogin.set($0) })) { + Text("onboarding.launchAtLogin") + } + .toggleStyle(.checkbox) + .disabled(launchAtLogin.needsSystemSettings) + + if launchAtLogin.needsSystemSettings { + Button("onboarding.openSettings") { launchAtLogin.openSystemSettings() } + .buttonStyle(.link) + } + + Spacer() + Button("onboarding.done", action: onFinish) + .keyboardShortcut(.defaultAction) + } + .padding(22) + } + + // MARK: - Zustände + + private var calendarState: OnboardingRow.State { + if calendarGranted { return .granted } + return calendarModel.authorization == .denied ? .denied : .undetermined + } + + private var locationState: OnboardingRow.State { + switch weatherModel.locationAuthorization { + case .granted: .granted + case .denied: .denied + case .undetermined: .undetermined + } + } + + private var helperState: OnboardingRow.State { + switch fanControl.installState { + case .installed: .granted + // Nicht `.denied`: hier hilft tatsächlich der Gang in die + // Systemeinstellungen, und der Knopf soll das auch sagen. + case .requiresApproval: .denied + case .notInstalled, .failed: .undetermined + } + } + + private func open(_ pane: String) { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") + else { return } + NSWorkspace.shared.open(url) + } +} + +/// Ein Punkt im Onboarding: wofür, wie es steht, und was von hier weiterführt. +private struct OnboardingRow: View { + enum State { case granted, denied, undetermined } + + let symbol: String + let title: LocalizedStringKey + let detail: LocalizedStringKey + let state: State + let request: () async -> Void + let openSettings: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: symbol) + .font(.system(size: 15)) + .foregroundStyle(state == .granted ? .green : .secondary) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 2) { + Text(title).fontWeight(.medium) + Text(detail) + .font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + switch state { + case .granted: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16)) + case .undetermined: + Button("onboarding.allow") { Task { await request() } } + case .denied: + // Nach einer Ablehnung fragt macOS nie wieder — ein + // Erlauben-Knopf wäre hier eine Lüge. + Button("onboarding.openSettings", action: openSettings) + } + } + .padding(.vertical, 12) + .overlay(alignment: .bottom) { Divider() } + } +} diff --git a/Onyx/OnboardingWindowController.swift b/Onyx/OnboardingWindowController.swift new file mode 100644 index 0000000..022b7de --- /dev/null +++ b/Onyx/OnboardingWindowController.swift @@ -0,0 +1,77 @@ +import AppKit +import SwiftUI +import CalendarProvider +import WeatherProvider +import AudioProvider + +/// Zeigt das Onboarding — einmal, beim ersten Start. +/// +/// Als eigenes Fenster und nicht im Panel: TCC blendet Berechtigungsdialoge +/// stillschweigend aus, wenn keine App im Vordergrund steht, und eine +/// `.accessory`-App mit ausgefahrenem Panel steht nicht im Vordergrund. Genau +/// daran ist die Kalenderberechtigung schon einmal gescheitert. +@MainActor +final class OnboardingWindowController: NSObject, NSWindowDelegate { + + private static let doneKey = "onboarding.completed" + + private var window: NSWindow? + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var isDone: Bool { defaults.bool(forKey: Self.doneKey) } + + func showIfNeeded(calendarModel: CalendarModel, weatherModel: WeatherModel, + mixer: AudioMixer, fanControl: FanControl, + launchAtLogin: LaunchAtLogin) { + guard !isDone else { return } + show(calendarModel: calendarModel, weatherModel: weatherModel, mixer: mixer, + fanControl: fanControl, launchAtLogin: launchAtLogin) + } + + func show(calendarModel: CalendarModel, weatherModel: WeatherModel, + mixer: AudioMixer, fanControl: FanControl, launchAtLogin: LaunchAtLogin) { + // Solange dieses Fenster steht, gehört Onyx in den Dock und in den + // Vordergrund — sonst erscheinen die Systemdialoge gar nicht. + NSApp.setActivationPolicy(.regular) + + if window == nil { + let hosting = NSHostingController( + rootView: OnboardingView( + calendarModel: calendarModel, weatherModel: weatherModel, + mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin, + onFinish: { [weak self] in self?.finish() })) + + let window = NSWindow(contentViewController: hosting) + window.title = "Onyx" + window.styleMask = [.titled, .closable] + window.isReleasedWhenClosed = false + window.delegate = self + window.center() + self.window = window + } + + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } + + /// Auch das Schließen über den roten Knopf gilt als erledigt. Ein Fenster, + /// das beim nächsten Start wiederkommt, weil man es weggeklickt hat, ist + /// eine Zumutung. + private func finish() { + defaults.set(true, forKey: Self.doneKey) + window?.close() + } + + func windowWillClose(_ notification: Notification) { + defaults.set(true, forKey: Self.doneKey) + // Zurück in den Hintergrund — es sei denn, ein anderes Fenster steht + // noch offen. + if !NSApp.windows.contains(where: { $0 !== window && $0.isVisible }) { + NSApp.setActivationPolicy(.accessory) + } + } +} diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 30221a8..fcdab9f 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -12,6 +12,9 @@ import MetricsProvider import NetworkProvider import AudioProvider import ShelfProvider +import OSLog + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App") @main struct OnyxApp: App { @@ -44,6 +47,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let settingsWindow = SettingsWindowController() private let fanControl = FanControl() private let audioMixer = AudioMixer() + private let launchAtLogin = LaunchAtLogin() + private let onboarding = OnboardingWindowController() func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -113,12 +118,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// dort freischalten könnte. Genau dieser Zustand ist beim Testen /// aufgetreten. private func requestPermissions() { - guard let calendarModel else { return } + guard let calendarModel, let weatherModel else { return } + log.notice("Start: Einrichtung erledigt = \(self.onboarding.isDone, privacy: .public)") // Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt // das Widget den Berechtigungshinweis statt einer leeren Liste. calendarModel.refresh() - // Fenster öffnen, solange irgendeine Berechtigung noch aussteht. - let needsLocation = weatherModel?.locationIsUndetermined ?? false + + // Die Einrichtung kommt beim ersten Start immer — auch wenn zufällig + // schon alles erlaubt ist. Sie erklärt nicht nur Berechtigungen, + // sondern auch den Helfer und den Autostart, und sie ist der einzige + // Moment, in dem jemand erfährt, was Onyx eigentlich alles kann. + guard onboarding.isDone else { + onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel, + mixer: audioMixer, fanControl: fanControl, + launchAtLogin: launchAtLogin) + return + } + + // Danach nur noch, wenn wirklich etwas fehlt. + let needsLocation = weatherModel.locationIsUndetermined guard !calendarModel.hasAccess || needsLocation else { return } // Nicht im Hintergrund anfragen. @@ -131,12 +149,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // `NSApp.activate` davor hilft nicht: Aktivierung wirkt asynchron und // ist beim Aufruf noch nicht durch. // - // Deshalb der ehrliche Weg: beim ersten Start das Einstellungsfenster - // öffnen. Dort ist ein echtes Fenster im Vordergrund, der Dialog - // erscheint zuverlässig — und der Nutzer weiß, warum gefragt wird. + // Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die + // Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann + // genügt der Berechtigungsbereich der Einstellungen. showSettings(tab: .permissions) } + /// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus. + private func showOnboarding() { + guard let calendarModel, let weatherModel else { return } + onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel, + mixer: audioMixer, fanControl: fanControl, + launchAtLogin: launchAtLogin) + } + func applicationWillTerminate(_ notification: Notification) { coordinator?.stop() menuBar?.stop() @@ -279,7 +305,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let model, let calendarModel, let weatherModel else { return } settingsWindow.show(model: model, calendarModel: calendarModel, weatherModel: weatherModel, metricsModel: metricsModel, - shelfStore: shelfStore, fanControl: fanControl, tab: tab) + shelfStore: shelfStore, fanControl: fanControl, + launchAtLogin: launchAtLogin, + showOnboarding: { [weak self] in self?.showOnboarding() }, + tab: tab) } @objc private func quit() { NSApp.terminate(nil) } diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index fe8dda5..cdd71a0 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -20,6 +20,8 @@ struct SettingsView: View { let metricsModel: MetricsModel? let shelfStore: ShelfStore? let fanControl: FanControl + let launchAtLogin: LaunchAtLogin + let showOnboarding: () -> Void @Binding var selectedTab: SettingsTab var body: some View { @@ -39,7 +41,8 @@ struct SettingsView: View { FanSettingsView(control: fanControl) .tabItem { Label("settings.tab.fans", systemImage: "fan") } .tag(SettingsTab.fans) - PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel) + PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel, + launchAtLogin: launchAtLogin, showOnboarding: showOnboarding) .tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") } .tag(SettingsTab.permissions) } @@ -58,6 +61,8 @@ struct SettingsView: View { private struct PermissionSettings: View { let calendarModel: CalendarModel let weatherModel: WeatherModel + let launchAtLogin: LaunchAtLogin + let showOnboarding: () -> Void @State private var calendarGranted = false private var locationState: PermissionRow.State { @@ -70,6 +75,36 @@ private struct PermissionSettings: View { var body: some View { Form { + Section("settings.general") { + Toggle(isOn: Binding( + get: { launchAtLogin.isEnabled }, + set: { _ = launchAtLogin.set($0) })) { + Text("settings.launchAtLogin") + } + .disabled(launchAtLogin.needsSystemSettings) + + // Der Zustand, der sonst in eine Schleife führt: der Schalter + // steht auf aus, Drücken bewirkt nichts, weil das System den + // Eintrag blockiert. Also steht hier, wo es weitergeht. + if launchAtLogin.needsSystemSettings { + HStack { + Text("settings.launchAtLogin.blocked") + .font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button("settings.permissions.openSystem") { + launchAtLogin.openSystemSettings() + } + } + } + + HStack { + Spacer() + Button("settings.onboarding.show", action: showOnboarding) + .buttonStyle(.borderless) + } + } + Section { PermissionRow( title: "settings.permissions.calendar", @@ -96,6 +131,7 @@ private struct PermissionSettings: View { .formStyle(.grouped) .padding() .task { + launchAtLogin.refresh() calendarGranted = calendarModel.hasAccess guard !calendarGranted, calendarModel.authorization == .undetermined else { return } // Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 929c6e5..6b45487 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -28,6 +28,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl, + launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void, tab: SettingsTab = .widgets) { selectedTab = tab keepsDockIcon = model.showsDockIcon @@ -48,6 +49,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { metricsModel: metricsModel, shelfStore: shelfStore, fanControl: fanControl, + launchAtLogin: launchAtLogin, + showOnboarding: showOnboarding, selectedTab: Binding( get: { [weak self] in self?.selectedTab ?? .widgets }, set: { [weak self] in self?.selectedTab = $0 }))) diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift index 0cffb53..54c1709 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift @@ -27,6 +27,9 @@ public final class AudioMixer { private var taps: [String: ProcessTap] = [:] private var refreshTimer: Timer? private var watchdogTimer: Timer? + private var levelTimer: Timer? + /// Der letzte Ausschlag je Programm, 0…1. + public private(set) var levels: [String: Double] = [:] private let defaults: UserDefaults /// Wann ein Prozess zuletzt Ton ausgegeben hat. Ohne dieses Gedächtnis /// verschwindet ein pausiertes Programm sofort aus der Liste. @@ -75,8 +78,10 @@ public final class AudioMixer { isEnabled = false refreshTimer?.invalidate(); refreshTimer = nil watchdogTimer?.invalidate(); watchdogTimer = nil + levelTimer?.invalidate(); levelTimer = nil for tap in taps.values { tap.stop() } taps.removeAll() + levels = [:] lastError = nil log.notice("Mixer ausgeschaltet — Tonweg unverändert") } @@ -99,6 +104,7 @@ public final class AudioMixer { isManaged: managed.contains(process.bundleID)) } syncTaps() + startLevelTimerIfNeeded() } /// Legt Taps an und ab, sodass sie zu `managed` und den laufenden @@ -146,7 +152,7 @@ public final class AudioMixer { private func checkForStalledTaps() { for (bundleID, tap) in taps { guard tap.isRunning, - Date().timeIntervalSince(tap.lastAudioAt) > 30, + tap.secondsSinceAudio > 30, processes.first(where: { $0.bundleID == bundleID })?.isPlaying == true else { continue } @@ -181,6 +187,41 @@ public final class AudioMixer { public func isManaged(_ bundleID: String) -> Bool { managed.contains(bundleID) } + /// Der Ausschlag eines Programms, 0…1 — für die Pegelanzeige. + public func level(for bundleID: String) -> Double { levels[bundleID] ?? 0 } + + /// Holt die Ausschläge ab. + /// + /// Läuft nur, solange überhaupt Taps bestehen: ohne abgegriffenes Programm + /// gibt es nichts zu messen, und ein Zeitgeber, der 15-mal pro Sekunde + /// nichts tut, gehört nicht in eine App, die im Hintergrund ruhig sein soll. + private func sampleLevels() { + guard !taps.isEmpty else { + if !levels.isEmpty { levels = [:] } + levelTimer?.invalidate() + levelTimer = nil + return + } + var next: [String: Double] = [:] + for (bundleID, tap) in taps { + let peak = Double(tap.takePeak()) + let previous = levels[bundleID] ?? 0 + // Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt, + // flackert; einer, der nur fällt, verpasst die Spitzen. + next[bundleID] = peak > previous ? peak : previous * 0.72 + } + levels = next + } + + private func startLevelTimerIfNeeded() { + guard levelTimer == nil, !taps.isEmpty else { return } + let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in + Task { @MainActor in self?.sampleLevels() } + } + RunLoop.main.add(timer, forMode: .common) + levelTimer = timer + } + public func setManaged(_ isManaged: Bool, for bundleID: String) { if isManaged { managed.insert(bundleID) } else { managed.remove(bundleID) } persist() diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings index 3d4b5e2..c103a59 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -1,109 +1,123 @@ { - "sourceLanguage" : "en", - "strings" : { - "" : { - + "sourceLanguage": "en", + "strings": { + "": {}, + "%lld %%": { + "comment": "A row of controls for adjusting the volume and muting a process.", + "isCommentAutoGenerated": true }, - "%lld %%" : { - "comment" : "A row of controls for adjusting the volume and muting a process.", - "isCommentAutoGenerated" : true - }, - "mixer.name" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Audio-Mixer" + "mixer.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Audio-Mixer" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Audio mixer" + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio mixer" } } } }, - "mixer.noProcesses" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Programm gibt gerade Ton aus" + "mixer.noProcesses": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Programm gibt gerade Ton aus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No app is playing audio" + "en": { + "stringUnit": { + "state": "translated", + "value": "No app is playing audio" } } } }, - "mixer.off" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mixer ausschalten" + "mixer.off": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mixer ausschalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Turn mixer off" + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn mixer off" } } } }, - "mixer.off.explain" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Regelt die Lautstärke einzelner Programme, auch über 100 %." + "mixer.off.explain": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lautstärke einzelner Programme regeln, auch über 100 %." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sets the volume of individual apps, including above 100%." + "en": { + "stringUnit": { + "state": "translated", + "value": "Set the volume of individual apps, including above 100 %." } } } }, - "mixer.off.warning" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dafür greift Onyx in den Tonweg der ausgewählten Programme ein. Beim Ausschalten wird der ursprüngliche Zustand sofort wiederhergestellt." + "mixer.off.warning": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx intercepts the audio path of the selected apps to do this. Turning it off restores the original state immediately." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately." } } } }, - "mixer.on" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mixer einschalten" + "mixer.on": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mixer einschalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Turn mixer on" + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn mixer on" + } + } + } + }, + "mixer.release": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht mehr regeln" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop controlling" } } } } }, - "version" : "1.1" -} \ No newline at end of file + "version": "1.1" +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 2f3dc8d..c014d19 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -15,15 +15,17 @@ public struct MixerView: View { } public var body: some View { - VStack(alignment: .leading, spacing: compact ? 6 : 10) { + VStack(alignment: .leading, spacing: compact ? 10 : 14) { if mixer.isEnabled { if mixer.processes.isEmpty { Text("mixer.noProcesses", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) } else { ForEach(mixer.processes.prefix(compact ? 4 : 10)) { process in - ProcessRow(mixer: mixer, process: process, compact: compact) + ProcessRow(mixer: mixer, process: process) } } @@ -32,40 +34,42 @@ public struct MixerView: View { .fixedSize(horizontal: false, vertical: true) } - Divider().overlay(Onyx.Color.hairline) // Der Notausschalter gehört sichtbar dazu, nicht in ein Menü: // wenn der Ton weg ist, ist das der Knopf, den man sucht. Button { mixer.disable() } label: { - // Label(_:systemImage:) kennt kein Bundle und sucht immer - // im Hauptbundle - die Strings liegen aber im Modulbundle. - // Ohne den Umweg über Text steht im Knopf „mixer.off". - Label { - Text("mixer.off", bundle: .module).font(Onyx.Font.caption) - } icon: { - Image(systemName: "power") - } + Text("mixer.off", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) } .buttonStyle(.plain) - .foregroundStyle(Onyx.Color.textSecondary) + .padding(.top, 2) } else { OffState(mixer: mixer, compact: compact) } } .frame(maxWidth: .infinity, alignment: .leading) + .animation(Onyx.Motion.value, value: mixer.processes.count) } } -/// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt. +/// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt — in zwei +/// Sätzen, nicht in einem Absatz. Wer vier Zeilen lesen muss, bevor er einen +/// Regler sieht, liest sie nicht. private struct OffState: View { let mixer: AudioMixer let compact: Bool var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text("mixer.off.explain", bundle: .module) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.textSecondary) - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 15, weight: .light)) + .foregroundStyle(Onyx.Color.textTertiary) + Text("mixer.off.explain", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } if !compact { Text("mixer.off.warning", bundle: .module) @@ -75,14 +79,14 @@ private struct OffState: View { } Button { mixer.enable() } label: { - Label { - Text("mixer.on", bundle: .module).font(Onyx.Font.caption) - } icon: { - Image(systemName: "slider.horizontal.3") - } + Text("mixer.on", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.surface) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Onyx.Color.accent, in: .capsule) } .buttonStyle(.plain) - .foregroundStyle(Onyx.Color.accent) } } } @@ -90,83 +94,78 @@ private struct OffState: View { private struct ProcessRow: View { let mixer: AudioMixer let process: AudioProcess - let compact: Bool + @State private var isHovered = false + + private var managed: Bool { mixer.isManaged(process.bundleID) } + private var muted: Bool { mixer.isMuted(process.bundleID) } + private var volume: Double { mixer.volume(for: process.bundleID) } var body: some View { - let managed = mixer.isManaged(process.bundleID) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 7) { icon Text(process.name) .font(Onyx.Font.caption) - .foregroundStyle(process.isPlaying ? Onyx.Color.textPrimary - : Onyx.Color.textSecondary) + .foregroundStyle(managed ? Onyx.Color.textPrimary : Onyx.Color.textSecondary) .lineLimit(1) // In der Mitte kürzen statt am Ende: bei zwei ähnlichen // Namen ist das Ende oft das Unterscheidende. .truncationMode(.middle) .layoutPriority(1) - if process.isPlaying { - Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5) - } - Spacer(minLength: 0) + Spacer(minLength: 4) + // Die Prozentzahl erscheint erst, wenn sie etwas aussagt. + // Bei jedem Programm „100 %" zu zeigen ist Zahlenrauschen. if managed { - Button { - mixer.setMuted(!mixer.isMuted(process.bundleID), for: process.bundleID) - } label: { - Image(systemName: mixer.isMuted(process.bundleID) - ? "speaker.slash.fill" : "speaker.wave.2.fill") - .font(.system(size: 10)) - } - .buttonStyle(.plain) - .foregroundStyle(mixer.isMuted(process.bundleID) - ? Onyx.Color.critical : Onyx.Color.textTertiary) + Text("\(Int(volume * 100)) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(volume > 1 ? Onyx.Color.warning : Onyx.Color.textTertiary) + .monospacedDigit() + .transition(.opacity) } - - // Erst wenn ein Programm ausgewählt ist, wird sein Ton - // abgegriffen. Alles andere läuft unverändert am Mixer vorbei. - Toggle("", isOn: Binding( - get: { managed }, - set: { mixer.setManaged($0, for: process.bundleID) })) - .toggleStyle(.switch) - .controlSize(.mini) - .labelsHidden() } - if managed { - // Die Prozentzahl steht beim Regler, nicht beim Namen: oben ist - // sie die Breite, die den Namen abschneidet, hier gehört sie - // ohnehin hin. - HStack(spacing: 6) { - Slider(value: Binding( - get: { mixer.volume(for: process.bundleID) }, - set: { mixer.setVolume($0, for: process.bundleID) }), - in: 0...Gain.maximumVolume) - .controlSize(.mini) - .disabled(mixer.isMuted(process.bundleID)) - .overlay(alignment: .leading) { - // Markierung bei 100 %: darüber wird verstärkt, und - // man soll sehen, wann man diese Grenze überschreitet. - GeometryReader { geometry in - Rectangle() - .fill(Onyx.Color.hairline) - .frame(width: 1, height: 8) - .offset(x: geometry.size.width / Gain.maximumVolume, y: 4) - } - } + HStack(spacing: 7) { + VolumeBar( + volume: volume, + level: mixer.level(for: process.bundleID), + isActive: managed && !muted, + onChange: { newValue in + // Am Regler zu ziehen **ist** die Entscheidung, dieses + // Programm zu regeln. Ein zusätzlicher Schalter davor + // wäre eine Hürde, die nichts schützt — der große + // Ausschalter unten stellt ohnehin alles zurück. + if !managed { mixer.setManaged(true, for: process.bundleID) } + mixer.setVolume(newValue, for: process.bundleID) + }) - Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %") - .font(Onyx.Font.metricSmall) - .foregroundStyle(mixer.volume(for: process.bundleID) > 1 - ? Onyx.Color.warning : Onyx.Color.textTertiary) - // Feste Breite, sonst wandert der Regler beim Ziehen. - .frame(width: 34, alignment: .trailing) + Button { + if !managed { mixer.setManaged(true, for: process.bundleID) } + mixer.setMuted(!muted, for: process.bundleID) + } label: { + Image(systemName: muted ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.system(size: 9)) + .frame(width: 12) + } + .buttonStyle(.plain) + .foregroundStyle(muted ? Onyx.Color.critical + : (managed ? Onyx.Color.textTertiary + : Onyx.Color.textTertiary.opacity(0.5))) + } + } + .contentShape(.rect) + .onHover { isHovered = $0 } + .contextMenu { + if managed { + Button { + mixer.setManaged(false, for: process.bundleID) + } label: { + Text("mixer.release", bundle: .module) } } } + .animation(Onyx.Motion.value, value: managed) } @ViewBuilder @@ -174,15 +173,98 @@ private struct ProcessRow: View { // Auch Hilfsprozesse bekommen das Symbol ihres Programms — sonst steht // neben „Google Chrome" ein leerer Platzhalter. if let image = ProcessNaming.icon(bundleID: process.bundleID, pid: process.pid) { - Image(nsImage: image).resizable().frame(width: 14, height: 14) + Image(nsImage: image) + .resizable().frame(width: 15, height: 15) + .opacity(managed ? 1 : 0.65) } else { Image(systemName: "app.dashed") .font(.system(size: 11)) .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 15, height: 15) } } } +/// Der Lautstärkeregler. +/// +/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes +/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt. +/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und +/// die Grenze bei 100 %. +private struct VolumeBar: View { + let volume: Double + let level: Double + let isActive: Bool + let onChange: (Double) -> Void + + @State private var isDragging = false + + private let trackHeight: CGFloat = 5 + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let fraction = min(max(volume / Gain.maximumVolume, 0), 1) + let fillWidth = width * fraction + + ZStack(alignment: .leading) { + Capsule() + .fill(Onyx.Color.elevated) + .frame(height: trackHeight) + + // Markierung bei 100 %: darüber wird verstärkt, und man soll + // sehen, wann man diese Grenze überschreitet. + Rectangle() + .fill(Onyx.Color.hairline) + .frame(width: 1, height: trackHeight + 3) + .offset(x: width / Gain.maximumVolume) + + Capsule() + .fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent) + .frame(width: fillWidth, height: trackHeight) + .opacity(isActive ? 1 : 0.28) + + // Der Pegel läuft **im** Regler mit, nicht daneben: eine + // zweite Leiste pro Zeile wäre doppelt so viel Grafik für + // dieselbe Auskunft. + if isActive, level > 0.001 { + Capsule() + .fill(.white.opacity(0.35)) + .frame(width: fillWidth * meterFraction, height: trackHeight) + .blendMode(.plusLighter) + .allowsHitTesting(false) + } + + Circle() + .fill(Onyx.Color.textPrimary) + .frame(width: isDragging ? 10 : 8, height: isDragging ? 10 : 8) + .shadow(color: .black.opacity(0.4), radius: 1, y: 0.5) + .offset(x: fillWidth - (isDragging ? 5 : 4)) + .opacity(isActive ? 1 : 0.5) + } + .frame(height: 14) + .contentShape(.rect) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + isDragging = true + let position = min(max(value.location.x / width, 0), 1) + onChange(position * Gain.maximumVolume) + } + .onEnded { _ in isDragging = false }) + } + .frame(height: 14) + .animation(Onyx.Motion.value, value: isDragging) + } + + /// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: + /// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren + /// Bereich so weit, dass man überhaupt etwas sieht. + private var meterFraction: Double { + min(1, sqrt(min(max(level, 0), 1))) + } +} + // MARK: - Widget public struct AudioMixerWidget: OnyxWidget { diff --git a/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift index 4c8fe9a..60917e9 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization import AudioToolbox import CoreAudio import AVFoundation @@ -34,15 +35,43 @@ final class ProcessTap { /// aber ein einzelner `Float` wird auf arm64 atomar geschrieben. private(set) var gain: Float = 1 + /// Der höchste Ausschlag seit dem letzten Abholen — als Bitmuster, weil + /// `Atomic` keine Fließkommazahlen führt. + /// + /// Sperrenfrei und damit im Echtzeitpfad erlaubt. Eine gewöhnliche + /// Eigenschaft zu beschreiben wäre ein Wettlauf mit dem Leser, und eine + /// Sperre im Audiothread ist der klassische Weg zu Aussetzern. + private let peakBits = Atomic(0) + + /// Wann zuletzt Ton kam — in Sekunden seit dem Programmstart, damit der + /// Echtzeitpfad keine `Date` anfassen muss. + private let lastAudioUptime = Atomic(0) + private var tapID = AudioObjectID(kAudioObjectUnknown) private var aggregateID = AudioObjectID(kAudioObjectUnknown) private var procID: AudioDeviceIOProcID? private let tapUUID = UUID() /// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund. - private(set) var lastAudioAt = Date() private(set) var isRunning = false + /// Wie lange dieser Tap schon nur Stille liefert. + var secondsSinceAudio: TimeInterval { + let stored = lastAudioUptime.load(ordering: .relaxed) + guard stored > 0 else { return 0 } + return max(0, Self.uptime() - Double(stored) / 1000) + } + + /// Der Ausschlag seit dem letzten Abruf, 0…1. Holt ihn ab und setzt zurück, + /// damit die Anzeige abfällt, statt auf dem Höchstwert stehen zu bleiben. + func takePeak() -> Float { + Float(bitPattern: peakBits.exchange(0, ordering: .relaxed)) + } + + private static func uptime() -> Double { + Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + } + init(process: AudioProcess) { self.process = process } @@ -111,7 +140,7 @@ final class ProcessTap { } isRunning = true - lastAudioAt = Date() + lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed) log.notice("Tap aktiv für \(self.process.name, privacy: .public)") } @@ -126,7 +155,7 @@ final class ProcessTap { let outputList = UnsafeMutableAudioBufferListPointer(output) let factor = gain - var sawAudio = false + var peak: Float = 0 for (index, outputBuffer) in outputList.enumerated() { guard index < inputList.count, @@ -143,12 +172,23 @@ final class ProcessTap { // Nur begrenzen, wenn wirklich verstärkt wird — unterhalb der // Schwelle ist die Kennlinie ohnehin die Identität, und im // Echtzeitpfad zählt jede eingesparte Rechnung. - outputSamples[sample] = factor > 1 ? SoftClip.apply(value) : value - if !sawAudio, value != 0 { sawAudio = true } + let limited = factor > 1 ? SoftClip.apply(value) : value + outputSamples[sample] = limited + // Der Ausschlag wird im selben Durchlauf mitgenommen: ein + // zweiter Durchgang nur zum Messen wäre im Echtzeitpfad die + // teuerste Art, an dieselbe Zahl zu kommen. + let magnitude = abs(limited) + if magnitude > peak { peak = magnitude } } } - if sawAudio { lastAudioAt = Date() } + if peak > 0 { + // Höchstwert behalten, bis die Anzeige ihn abholt — sonst + // verschluckt ein Puffer mit Stille die Spitze davor. + let previous = Float(bitPattern: peakBits.load(ordering: .relaxed)) + if peak > previous { peakBits.store(peak.bitPattern, ordering: .relaxed) } + lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed) + } } // MARK: - Abbau diff --git a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings index f1dca70..4428709 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings @@ -1,333 +1,358 @@ { - "sourceLanguage": "en", - "strings": { - "": {}, - "%lld": { - "comment": "A day of the month. The argument is the day of the month.", - "isCommentAutoGenerated": true + "sourceLanguage" : "en", + "strings" : { + "" : { + }, - "calendar.source.apple": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Apple Kalender" + "%lld" : { + "comment" : "A day of the month. The argument is the day of the month.", + "isCommentAutoGenerated" : true + }, + "calendar.source.apple" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Kalender" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Apple Calendar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Calendar" } } } }, - "calendar.source.both": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Beide" + "calendar.source.both" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beide" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Both" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Both" } } } }, - "calendar.source.calendarr": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Calendarr" + "calendar.source.calendarr" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendarr" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Calendarr" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendarr" } } } }, - "calendar.source.readOnly": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr." + "calendar.source.readOnly" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Onyx only reads. It never signs in and never writes to Calendarr." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx only reads. It never signs in and never writes to Calendarr." } } } }, - "calendar.source.status.coverage": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bekannt bis %@" + "calendar.source.status.coverage" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bekannt bis %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Known through %@" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Known through %@" } } } }, - "calendar.source.status.incompatible": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren." + "calendar.source.status.coverage %@" : { + "comment" : "A footnote in the Calendarr settings, indicating the end of the coverage window.", + "isCommentAutoGenerated" : true + }, + "calendar.source.status.incompatible" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Calendarr writes a newer format. Please update Onyx." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendarr writes a newer format. Please update Onyx." } } } }, - "calendar.source.status.loggedOut": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt." + "calendar.source.status.loggedOut" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Not signed in to Calendarr. Old events are not shown." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not signed in to Calendarr. Old events are not shown." } } } }, - "calendar.source.status.neverWritten": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird." + "calendar.source.status.neverWritten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Calendarr once so it can write a snapshot." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Calendarr once so it can write a snapshot." } } } }, - "calendar.source.status.ready": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ Termine, Stand %@" + "calendar.source.status.ready" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Termine, Stand %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ events, as of %@" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ events, as of %@" } } } }, - "calendar.source.status.unreadable": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Schnappschuss unlesbar: %@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Snapshot unreadable: %@" + "calendar.source.status.ready %@ %@" : { + "comment" : "Die Anzahl der Termine in der The argument is the string “–”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "calendar.source.status.ready %1$@ %2$@" } } } }, - "calendar.source.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Terminquelle" + "calendar.source.status.unreadable" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schnappschuss unlesbar: %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Calendar source" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snapshot unreadable: %@" } } } }, - "widget.calendar.allDay": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ganztägig" + "calendar.source.status.unreadable %@" : { + "comment" : "A warning message explaining that a Calendarr source is not readable. The argument is a human-readable description of the problem.", + "isCommentAutoGenerated" : true + }, + "calendar.source.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminquelle" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "All day" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar source" } } } }, - "widget.calendar.day.unknown": { - "comment": "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Für diesen Tag liegen keine Daten vor" + "widget.calendar.allDay" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ganztägig" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No data available for this day" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All day" } } } }, - "widget.calendar.denied": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." + "widget.calendar.day.unknown" : { + "comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Für diesen Tag liegen keine Daten vor" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No calendar access.\nAllow it in System Settings." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No data available for this day" } } } }, - "widget.calendar.incompatible": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." + "widget.calendar.denied" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Onyx is too old for this\nCalendarr version. Please update." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No calendar access.\nAllow it in System Settings." } } } }, - "widget.calendar.loggedOut": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "In Calendarr anmelden" + "widget.calendar.incompatible" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign in to Calendarr" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx is too old for this\nCalendarr version. Please update." } } } }, - "widget.calendar.name": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kalender" + "widget.calendar.loggedOut" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Calendarr anmelden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Calendar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to Calendarr" } } } }, - "widget.calendar.neverWritten": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Calendarr einmal öffnen,\ndamit Termine bereitstehen." + "widget.calendar.name" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalender" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Calendarr once\nso events become available." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" } } } }, - "widget.calendar.noUpcoming": { - "comment": "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nichts mehr für heute" + "widget.calendar.neverWritten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing left today" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Calendarr once\nso events become available." } } } }, - "widget.calendar.unreadable": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Termine nicht lesbar.\nKonfigurationsfehler." + "widget.calendar.noUpcoming" : { + "comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nichts mehr für heute" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Events unreadable.\nConfiguration error." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing left today" + } + } + } + }, + "widget.calendar.unreadable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termine nicht lesbar.\nKonfigurationsfehler." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Events unreadable.\nConfiguration error." } } } } }, - "version": "1.1" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/OnyxCore/LoginItemState.swift b/Packages/OnyxKit/Sources/OnyxCore/LoginItemState.swift new file mode 100644 index 0000000..d485870 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxCore/LoginItemState.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Der Zustand des Anmeldeobjekts — die Werte von `SMAppService.Status`, +/// aber ohne ServiceManagement, damit sie prüfbar bleiben. +public enum LoginItemState: Equatable, Sendable { + /// Nicht eingetragen. Ein Aufruf von `register()` behebt es. + case notRegistered + case enabled + /// Eingetragen, aber vom Nutzer in den Systemeinstellungen abgeschaltet. + case requiresApproval + /// Die App findet sich selbst nicht — ein Installationsproblem. + case notFound + case unknown + + public init(statusRawValue: Int) { + switch statusRawValue { + case 0: self = .notRegistered + case 1: self = .enabled + case 2: self = .requiresApproval + case 3: self = .notFound + default: self = .unknown + } + } + + /// Startet Onyx mit? Im Zweifel nein — lieber einen überflüssigen Klick + /// verlangen als behaupten, es sei eingeschaltet, und es startet nicht. + public var isEnabled: Bool { self == .enabled } + + /// Ob der Weg über die Systemeinstellungen führt. + /// + /// Das ist der Zustand, den man nicht verschweigen darf: wer das + /// Anmeldeobjekt dort abgeschaltet hat, kann in Onyx auf den Schalter + /// drücken, so oft er will — erneutes Registrieren ändert daran nichts. + public var needsSystemSettings: Bool { self == .requiresApproval } +} diff --git a/Packages/OnyxKit/Tests/OnyxCoreTests/LoginItemTests.swift b/Packages/OnyxKit/Tests/OnyxCoreTests/LoginItemTests.swift new file mode 100644 index 0000000..6de79b9 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxCoreTests/LoginItemTests.swift @@ -0,0 +1,42 @@ +import Testing +@testable import OnyxCore + +@Suite("Anmeldeobjekt") +struct LoginItemTests { + + @Test("Die Zustände von SMAppService werden vollständig abgebildet") + func mapsAllStatuses() { + #expect(LoginItemState(statusRawValue: 0) == .notRegistered) + #expect(LoginItemState(statusRawValue: 1) == .enabled) + #expect(LoginItemState(statusRawValue: 2) == .requiresApproval) + #expect(LoginItemState(statusRawValue: 3) == .notFound) + } + + @Test("Ein unbekannter Zustand gilt nicht als eingeschaltet") + func unknownIsNotEnabled() { + // Lieber „aus" behaupten und den Nutzer erneut klicken lassen, als + // „an" behaupten und Onyx startet nicht mit. + #expect(!LoginItemState(statusRawValue: 99).isEnabled) + } + + @Test("Genehmigung ausstehend ist nicht dasselbe wie ausgeschaltet") + func approvalIsNotOff() { + // Der eigentliche Punkt. Wer das Anmeldeobjekt in den + // Systemeinstellungen abgeschaltet hat, bekommt `requiresApproval`. + // Zeigt Onyx das als „aus", drückt man den Schalter — und nichts + // passiert, weil erneutes Registrieren daran nichts ändert. Es hilft + // nur der Gang in die Systemeinstellungen, und genau das muss dastehen. + let state = LoginItemState.requiresApproval + #expect(!state.isEnabled) + #expect(state.needsSystemSettings) + } + + @Test("Nur der ausgeschaltete Zustand lässt sich durch Registrieren beheben") + func onlyNotRegisteredIsFixableInApp() { + #expect(!LoginItemState.notRegistered.needsSystemSettings) + #expect(!LoginItemState.enabled.needsSystemSettings) + // Fehlt die Datei, hilft auch die Systemeinstellung nicht — dann stimmt + // etwas mit der Installation nicht. + #expect(!LoginItemState.notFound.needsSystemSettings) + } +} diff --git a/README.md b/README.md index bde62d8..57c88eb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ Tests laufen ohne Xcode: cd Packages/OnyxKit && swift test ``` +Ein notarisiertes DMG baut `Scripts/release.sh`. Es braucht zwei Dinge, die +einmalig einzurichten sind — ein „Developer ID Application“-Zertifikat und ein +notarytool-Zugangsprofil; beides steht im Kopf des Skripts. Das Skript prüft +beides vorweg und bricht ab, statt nach zehn Minuten Bauen zu scheitern. + --- ## Aufbau @@ -73,6 +78,23 @@ privaten Selektor, der bei Apps ohne Menüleiste unzuverlässig ankommt. geladen, einem berechtigten Systemprogramm — im eigenen Prozess greift Apples Entitlement-Prüfung und es käme nichts an. +**Signiert wird im Release anders als im Debug.** Der Helfer und der Adapter +bekommen nur im Release einen sicheren Zeitstempel, und der Adapter wird dort +mit der echten Identität statt ad-hoc signiert. Beides verlangt die +Notarisierung, und beides fällt sonst erst nach dem Hochladen auf. Im Debug +wären es nur Netzanfragen bei jedem Bauen. + +**Die Abdeckung eines Calendarr-Schnappschusses steht nicht in der Datei.** Sie +ist zugesagtes Verhalten des Schreibers (49 Tage nach vorn) und wird von +`CalendarrSnapshotReader` daraus abgeleitet. Sie aus dem frühesten Termin zu +erschließen wäre naheliegend und falsch: mehrtägige Termine ragen ins Fenster +hinein und werden mitgeschrieben, obwohl ihr Anfang nicht abgedeckt ist. Ein +Test hält das fest. + +**Der Audiomixer hat keinen Schalter je Programm mehr.** Am Regler zu ziehen +*ist* die Entscheidung, dieses Programm zu regeln. Ein Schalter davor wäre eine +Hürde, die nichts schützt — der große Ausschalter stellt ohnehin alles zurück. + --- ## Fremdcode diff --git a/Scripts/build-mediaremote-adapter.sh b/Scripts/build-mediaremote-adapter.sh index abe1353..48b0897 100755 --- a/Scripts/build-mediaremote-adapter.sh +++ b/Scripts/build-mediaremote-adapter.sh @@ -70,9 +70,25 @@ xcrun clang -fobjc-arc -O2 -arch arm64 \ -o "$OUT/${NAME}TestClient" \ "$SRC"/src/test/{main,NowPlayingTest}.m -echo "→ ad-hoc signieren" -codesign --force --deep --sign - "$FW" -codesign --force --sign - "$OUT/${NAME}TestClient" +# Signieren. +# +# Ad-hoc genügt zum Entwickeln, aber nicht zum Notarisieren: Apple verlangt, +# dass jede Mach-O-Datei im Bundle mit derselben Developer-ID signiert ist, +# mit Hardened Runtime und sicherem Zeitstempel. Eine ad-hoc signierte +# Bibliothek führt zur Ablehnung — und die merkt man erst nach dem Hochladen. +if [ -n "${SIGN_IDENTITY:-}" ]; then + echo "→ signieren mit $SIGN_IDENTITY" + codesign --force --options runtime --timestamp \ + --sign "$SIGN_IDENTITY" "$FW/Versions/A/$NAME" + codesign --force --options runtime --timestamp \ + --sign "$SIGN_IDENTITY" "$FW" + codesign --force --options runtime --timestamp \ + --sign "$SIGN_IDENTITY" "$OUT/${NAME}TestClient" +else + echo "→ ad-hoc signieren" + codesign --force --deep --sign - "$FW" + codesign --force --sign - "$OUT/${NAME}TestClient" +fi cp "$SRC/bin/mediaremote-adapter.pl" "$OUT/" diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 0000000..e4a8464 --- /dev/null +++ b/Scripts/release.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Baut Onyx als notarisiertes DMG. +# +# Voraussetzungen — beide einmalig einzurichten: +# +# 1. Ein Zertifikat „Developer ID Application: … (PP34X97WS3)" im Schlüsselbund. +# Xcode → Einstellungen → Konten → Manage Certificates → + → +# Developer ID Application. Das Entwicklerzertifikat („Apple Development") +# genügt NICHT: damit signierte Apps lassen sich nicht notarisieren und +# laufen auf keinem fremden Mac. +# +# 2. Ein Zugangsprofil für notarytool: +# xcrun notarytool store-credentials "Onyx" \ +# --apple-id "" --team-id PP34X97WS3 \ +# --password "" +# Das app-spezifische Passwort kommt von appleid.apple.com, nicht das +# normale Kennwort. +# +# Danach genügt: Scripts/release.sh + +set -euo pipefail +export DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="$ROOT/build/release" +PROFILE="${NOTARY_PROFILE:-Onyx}" +TEAM="PP34X97WS3" +APP_NAME="Onyx" + +cd "$ROOT" + +VERSION=$(awk -F'"' '/MARKETING_VERSION/ {print $2; exit}' project.yml) +DMG="$BUILD/${APP_NAME}-${VERSION}.dmg" + +fail() { echo "✗ $*" >&2; exit 1; } +step() { echo; echo "── $* ──"; } + +# ---------------------------------------------------------------- Vorprüfung +# +# Beide Prüfungen vorweg statt unterwegs: ein Fehlschlag nach dem Archivieren +# kostet Minuten, und die Notarisierung scheitert sonst erst nach dem Hochladen. + +step "Vorprüfung" +security find-identity -v -p codesigning | grep -q "Developer ID Application" \ + || fail "Kein „Developer ID Application“-Zertifikat im Schlüsselbund. Siehe Kopf dieses Skripts." + +xcrun notarytool history --keychain-profile "$PROFILE" >/dev/null 2>&1 \ + || fail "Kein notarytool-Profil „$PROFILE“. Siehe Kopf dieses Skripts." + +command -v xcodegen >/dev/null || fail "xcodegen fehlt (brew install xcodegen)" +echo "✓ Zertifikat, Notarisierungsprofil und xcodegen vorhanden" + +step "Projekt erzeugen" +xcodegen generate + +step "Tests" +(cd Packages/OnyxKit && swift test 2>&1 | tail -3) + +# ------------------------------------------------------------------ Bauen +# +# Über ein Archiv und nicht über `build`: nur exportArchive signiert das +# fertige Bundle vollständig mit der Verteilungsidentität, samt aller +# eingebetteten Programme. + +step "Archivieren" +rm -rf "$BUILD" +mkdir -p "$BUILD" +xcodebuild archive \ + -project Onyx.xcodeproj -scheme Onyx -configuration Release \ + -archivePath "$BUILD/$APP_NAME.xcarchive" \ + -allowProvisioningUpdates \ + CODE_SIGN_STYLE=Automatic \ + CODE_SIGN_IDENTITY="Developer ID Application" \ + DEVELOPMENT_TEAM="$TEAM" \ + | grep -E "error:|warning: .*deprecated|ARCHIVE SUCCEEDED" || true + +[ -d "$BUILD/$APP_NAME.xcarchive" ] || fail "Archivieren fehlgeschlagen" + +cat > "$BUILD/export.plist" < + + + methoddeveloper-id + teamID$TEAM + signingStyleautomatic + destinationexport + +PLIST + +step "Exportieren" +xcodebuild -exportArchive \ + -archivePath "$BUILD/$APP_NAME.xcarchive" \ + -exportPath "$BUILD/export" \ + -exportOptionsPlist "$BUILD/export.plist" \ + -allowProvisioningUpdates | tail -3 + +APP="$BUILD/export/$APP_NAME.app" +[ -d "$APP" ] || fail "Export fehlgeschlagen" + +# --------------------------------------------------------------- Nachprüfen +# +# Was hier durchrutscht, lehnt Apple nach dem Hochladen ab. Die drei Punkte +# sind genau die, an denen dieses Projekt eine Sonderbehandlung hat. + +step "Signatur prüfen" +codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -3 + +codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "application-groups" \ + || fail "App-Group fehlt in den Entitlements — Calendarr wäre unerreichbar" + +# Der Helfer trägt seine Kennung in der Binary, nicht in einer Info.plist. +# Stimmt sie nicht, lehnt die App ihren eigenen Helfer ab. +HELPER_ID=$(codesign -d --verbose=2 "$APP/Contents/MacOS/OnyxHelper" 2>&1 \ + | awk -F= '/^Identifier=/ {print $2}') +[ "$HELPER_ID" = "com.scarriffleservices.onyx.helper" ] \ + || fail "Helfer trägt die Kennung „$HELPER_ID“ statt com.scarriffleservices.onyx.helper" + +# Ad-hoc signierte Bibliotheken sind der häufigste Ablehnungsgrund. +if codesign -dv "$APP/Contents/Resources/Vendor/MediaRemoteAdapter.framework" 2>&1 \ + | grep -q "adhoc"; then + fail "MediaRemoteAdapter.framework ist ad-hoc signiert — die Notarisierung lehnt das ab" +fi +echo "✓ Signaturen, Entitlements, Helfer-Kennung und Adapter in Ordnung" + +# ------------------------------------------------------------------- DMG +step "DMG bauen" +STAGE="$BUILD/stage" +rm -rf "$STAGE"; mkdir -p "$STAGE" +cp -R "$APP" "$STAGE/" +ln -s /Applications "$STAGE/Applications" + +rm -f "$DMG" +hdiutil create -volname "$APP_NAME" -srcfolder "$STAGE" -ov -format UDZO "$DMG" | tail -1 + +# Das DMG selbst wird mitsigniert — sonst meldet Gatekeeper beim Öffnen einen +# unbekannten Entwickler, obwohl die App darin einwandfrei ist. +codesign --force --sign "Developer ID Application" --timestamp "$DMG" + +# ---------------------------------------------------------- Notarisierung +step "Notarisieren (das dauert)" +xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait \ + || fail "Notarisierung abgelehnt. Ursache: xcrun notarytool log --keychain-profile $PROFILE" + +step "Ticket anheften" +# Ohne Stapeln braucht der erste Start des Nutzers eine Netzverbindung. +xcrun stapler staple "$DMG" +xcrun stapler validate "$DMG" + +step "Abschließende Prüfung" +spctl -a -t open --context context:primary-signature -v "$DMG" 2>&1 | tail -2 + +echo +echo "✓ Fertig: $DMG" diff --git a/project.yml b/project.yml index 41a6d13..9b597c2 100644 --- a/project.yml +++ b/project.yml @@ -117,7 +117,17 @@ targets: # dem Produktnamen ("OnyxHelper") als Bezeichner. Die App prüft aber # auf com.scarriffleservices.onyx.helper und würde ihren eigenen # Helfer ablehnen. - codesign --force --options runtime --timestamp=none \ + # Zeitstempel im Release, keiner im Debug. + # + # Die Notarisierung verlangt einen sicheren Zeitstempel; ohne ihn + # lehnt Apple das Paket ab, und zwar erst nach dem Hochladen. Im + # Debug wäre er nur eine Netzanfrage bei jedem Bauen. + if [ "$CONFIGURATION" = "Release" ]; then + TIMESTAMP="--timestamp" + else + TIMESTAMP="--timestamp=none" + fi + codesign --force --options runtime $TIMESTAMP \ -i com.scarriffleservices.onyx.helper \ --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$APP/MacOS/OnyxHelper" @@ -138,6 +148,11 @@ targets: # Entitlement-Prüfung und es käme gar nichts an. - name: MediaRemote-Adapter bauen und beilegen script: | + # Im Release mit der echten Identität signieren: ad-hoc signierte + # Bibliotheken lehnt die Notarisierung ab. + if [ "$CONFIGURATION" = "Release" ]; then + export SIGN_IDENTITY="$EXPANDED_CODE_SIGN_IDENTITY" + fi OUT="$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor" \ "$SRCROOT/Scripts/build-mediaremote-adapter.sh" inputFiles: