diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 56579a3..296202e 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -8,38 +8,59 @@ /* Begin PBXBuildFile section */ 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; }; - 1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; }; - 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; + 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.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 */; }; 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; - 5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; }; + 5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; }; 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; }; - 68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; + 68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; }; + 6A972372414D6EFDC81E2661 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B442E8A39C37C8FD92B4CA83 /* main.swift */; }; 7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; + 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; }; A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; }; - B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; + B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; }; - C1F651D2BA69358344927F26 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; }; - C390C5B65769A007A49C3A80 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; }; - CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; }; - EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; + 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 */; }; + 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 */; }; FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 38EC624350BE3AFC906F5004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 32F5E88F3CF35AE8C0E00613 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BABC85A062EFB668A81EBAA7; + remoteInfo = OnyxHelper; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 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 = ""; }; 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 = ""; }; 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; }; 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; + B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderWidgets.swift; sourceTree = ""; }; + F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = ""; }; F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; + FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -48,15 +69,24 @@ buildActionMask = 2147483647; files = ( 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */, - 68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */, - EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */, - 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */, - B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */, - CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */, - 5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */, - 1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */, - C1F651D2BA69358344927F26 /* MetricsProvider in Frameworks */, - C390C5B65769A007A49C3A80 /* NetworkProvider in Frameworks */, + 68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */, + EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */, + 378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */, + B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */, + CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */, + 5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */, + 1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */, + C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */, + C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */, + 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D4D0E94BCED22A73F694B2CB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -74,6 +104,7 @@ isa = PBXGroup; children = ( 0804288DC4A8146DD9F3FC3E /* Onyx.app */, + 8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */, ); name = Products; sourceTree = ""; @@ -82,6 +113,7 @@ isa = PBXGroup; children = ( D6653B90FCB65F334F91F2F1 /* Onyx */, + 6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */, 573275C59A65A7C11CCEC90A /* Packages */, 339A2C4AF25B5612E90B795D /* Products */, ); @@ -95,12 +127,23 @@ path = Packages; sourceTree = ""; }; + 6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */ = { + isa = PBXGroup; + children = ( + B442E8A39C37C8FD92B4CA83 /* main.swift */, + 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */, + ); + path = OnyxHelper; + sourceTree = ""; + }; D6653B90FCB65F334F91F2F1 /* Onyx */ = { isa = PBXGroup; children = ( 0FBA13B97C53F1D0D740A633 /* Resources */, 055263008CB3306A8D09E6D4 /* AppIcon.icon */, B83C4E3CB821F903F3977E88 /* AppModel.swift */, + FC3FDFD387188D36AF8287DE /* FanControl.swift */, + F12041E330231599C6540C21 /* FanSettingsView.swift */, 50A018673EE6BD9C50F1EFA0 /* Info.plist */, 508DECB5C05264E6130C373E /* Localizable.xcstrings */, 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */, @@ -123,15 +166,18 @@ 6B11A5AC704B6429B9484789 /* Sources */, B211CE3C0EF94A8D680E4AE0 /* Resources */, 7FF994FE5530D53BEFBE13FE /* Frameworks */, + E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */, 9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */, ); buildRules = ( ); dependencies = ( + CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */, ); name = Onyx; packageProductDependencies = ( C64A111F449D0F9D36ED46FB /* OnyxCore */, + 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */, 3D680DD645941493A7D3559E /* OnyxDesign */, 3A32CAE6CC4D01D660F0016F /* OnyxNotch */, E66577A40689DF225BCED184 /* OnyxWidgetKit */, @@ -146,6 +192,25 @@ productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; productType = "com.apple.product-type.application"; }; + BABC85A062EFB668A81EBAA7 /* OnyxHelper */ = { + isa = PBXNativeTarget; + buildConfigurationList = 517F123F9F5479E913E7B1AA /* Build configuration list for PBXNativeTarget "OnyxHelper" */; + buildPhases = ( + A9C106AA3316FA03B999A567 /* Sources */, + D4D0E94BCED22A73F694B2CB /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OnyxHelper; + packageProductDependencies = ( + FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */, + ); + productName = OnyxHelper; + productReference = 8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */; + productType = "com.apple.product-type.tool"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -159,6 +224,10 @@ DevelopmentTeam = PP34X97WS3; ProvisioningStyle = Automatic; }; + BABC85A062EFB668A81EBAA7 = { + DevelopmentTeam = PP34X97WS3; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 0A2ACEAA7E2F36634202B241 /* Build configuration list for PBXProject "Onyx" */; @@ -179,6 +248,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( + BABC85A062EFB668A81EBAA7 /* OnyxHelper */, 2EB428ADC48D89BCA28D3EE1 /* Onyx */, ); }; @@ -218,6 +288,24 @@ shellPath = /bin/sh; shellScript = "OUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n"; }; + E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Helfer und Launchd-Plist einbetten"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + 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# Das Kopieren passiert nach Xcodes Signatur und bricht damit das\n# Siegel des App-Bundles (\"nested code is modified or invalid\").\n# Deshalb zum Schluss die App selbst neu signieren — mit denselben\n# Entitlements, sonst verliert sie App-Group, WeatherKit und die\n# TCC-Berechtigungen.\ncodesign --force --options runtime --timestamp=none \\\n --entitlements \"$SRCROOT/Onyx/Onyx.entitlements\" \\\n --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" \\\n \"$BUILT_PRODUCTS_DIR/$WRAPPER_NAME\"\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -226,6 +314,8 @@ buildActionMask = 2147483647; files = ( C041F171FB69F4381309D978 /* AppModel.swift in Sources */, + CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */, + 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */, 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */, 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */, @@ -233,9 +323,44 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A9C106AA3316FA03B999A567 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */, + 6A972372414D6EFDC81E2661 /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BABC85A062EFB668A81EBAA7 /* OnyxHelper */; + targetProxy = 38EC624350BE3AFC906F5004 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 2EBE219F7BAFEEBF99ACB8DE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + INFOPLIST_FILE = OnyxHelper/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx.helper; + PRODUCT_NAME = OnyxHelper; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; 42021FACB216529C4EA156E9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -318,6 +443,24 @@ }; name = Release; }; + B649EAAA14C09F6A9E4A558D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + INFOPLIST_FILE = OnyxHelper/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx.helper; + PRODUCT_NAME = OnyxHelper; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; DEB51EF5615CABC756F24ADD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -419,6 +562,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 517F123F9F5479E913E7B1AA /* Build configuration list for PBXNativeTarget "OnyxHelper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EBE219F7BAFEEBF99ACB8DE /* Debug */, + B649EAAA14C09F6A9E4A558D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; C126F7CB2B14EC196CE8743D /* Build configuration list for PBXNativeTarget "Onyx" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -454,6 +606,10 @@ isa = XCSwiftPackageProductDependency; productName = MetricsProvider; }; + 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = { + isa = XCSwiftPackageProductDependency; + productName = OnyxHelperProtocol; + }; A3E3949D664131D593CBEEDC /* CalendarProvider */ = { isa = XCSwiftPackageProductDependency; productName = CalendarProvider; @@ -474,6 +630,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxWidgetKit; }; + FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = { + isa = XCSwiftPackageProductDependency; + productName = OnyxHelperProtocol; + }; FF4E99C8BF0D8944C314A8AF /* NetworkProvider */ = { isa = XCSwiftPackageProductDependency; productName = NetworkProvider; diff --git a/Onyx/FanControl.swift b/Onyx/FanControl.swift new file mode 100644 index 0000000..7019f19 --- /dev/null +++ b/Onyx/FanControl.swift @@ -0,0 +1,163 @@ +import Foundation +import ServiceManagement +import OSLog +import OnyxHelperProtocol + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "FanControl") + +/// Die App-Seite der Lüftersteuerung. +/// +/// Sie darf **wünschen**, nicht bestimmen. Ob eine Drehzahl gesetzt wird, +/// entscheidet allein das Sicherheitsnetz im Helfer — mit Temperaturen, die er +/// selbst misst. Diese Klasse hält nur die Verbindung und den Herzschlag. +@MainActor +@Observable +public final class FanControl { + + public enum InstallState: Equatable { + case notInstalled + case requiresApproval + case installed + case failed(String) + } + + public private(set) var installState: InstallState = .notInstalled + public private(set) var report: FanStatusReport? + public private(set) var lastError: String? + + private var connection: NSXPCConnection? + private var heartbeatTimer: Timer? + private var statusTimer: Timer? + + private var service: SMAppService { + SMAppService.daemon(plistName: "com.scarriffleservices.onyx.helper.plist") + } + + public init() { refreshInstallState() } + + // MARK: - Einrichtung + + public func refreshInstallState() { + switch service.status { + case .enabled: installState = .installed + case .requiresApproval: installState = .requiresApproval + case .notRegistered, .notFound: installState = .notInstalled + @unknown default: installState = .notInstalled + } + } + + /// Registriert den Helfer. macOS fragt dabei nach Zustimmung. + public func install() { + do { + try service.register() + refreshInstallState() + log.notice("Helfer registriert: \(String(describing: self.installState))") + } catch { + // `requiresApproval` kommt hier als Fehler an und ist keiner: der + // Nutzer muss den Dienst nur noch in den Systemeinstellungen + // freigeben. + refreshInstallState() + if installState != .requiresApproval { + installState = .failed(error.localizedDescription) + log.error("Helfer nicht registrierbar: \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Entfernt den Helfer wieder. Die Lüfter fallen dabei auf Automatik. + public func uninstall() async { + for fan in report?.fans ?? [] { setAutomatic(index: fan.index) } + disconnect() + try? await service.unregister() + refreshInstallState() + } + + /// Öffnet die Stelle, an der macOS den Dienst freigeben lässt. + public func openApprovalSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + // MARK: - Verbindung + + private func proxy() -> OnyxHelperProtocol? { + if connection == nil { + let connection = NSXPCConnection(machServiceName: OnyxHelper.machServiceName, + options: .privileged) + connection.remoteObjectInterface = NSXPCInterface(with: OnyxHelperProtocol.self) + // In beide Richtungen prüfen: die App spricht nur mit einem Helfer, + // der ebenso signiert ist wie sie. Sonst könnte ein untergeschobener + // Dienst unter demselben Namen die Antworten liefern. + try? connection.setCodeSigningRequirement(OnyxHelper.helperRequirement) + + connection.invalidationHandler = { [weak self] in + Task { @MainActor in self?.connection = nil } + } + connection.interruptionHandler = { [weak self] in + Task { @MainActor in self?.connection = nil } + } + connection.resume() + self.connection = connection + } + return connection?.remoteObjectProxyWithErrorHandler { [weak self] error in + Task { @MainActor in + self?.lastError = error.localizedDescription + log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)") + } + } as? OnyxHelperProtocol + } + + private func disconnect() { + heartbeatTimer?.invalidate(); heartbeatTimer = nil + statusTimer?.invalidate(); statusTimer = nil + connection?.invalidate(); connection = nil + report = nil + } + + /// Beginnt zu beobachten und den Herzschlag zu senden. + /// + /// Der Herzschlag ist die Lebensversicherung: bleibt er aus — weil Onyx + /// abstürzt, beendet wird oder hängt — fallen die Lüfter im Helfer nach + /// fünf Sekunden auf Automatik zurück. + public func start() { + guard installState == .installed, heartbeatTimer == nil else { return } + + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + MainActor.assumeIsolated { [weak self] in + self?.proxy()?.heartbeat { _ in } + } + } + statusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + MainActor.assumeIsolated { [weak self] in self?.refreshStatus() } + } + refreshStatus() + } + + public func stop() { disconnect() } + + private func refreshStatus() { + proxy()?.fanStatus { [weak self] data in + Task { @MainActor in self?.apply(data) } + } + } + + private func apply(_ data: Data?) { + guard let data, let decoded = try? JSONDecoder().decode(FanStatusReport.self, from: data) + else { return } + report = decoded + lastError = nil + } + + // MARK: - Steuern + + public func setTarget(index: Int, rpm: Double) { + proxy()?.setFan(index: index, targetRPM: rpm) { [weak self] data in + Task { @MainActor in self?.apply(data) } + } + } + + public func setAutomatic(index: Int) { + proxy()?.setFanAutomatic(index: index) { [weak self] data in + Task { @MainActor in self?.apply(data) } + } + } +} diff --git a/Onyx/FanSettingsView.swift b/Onyx/FanSettingsView.swift new file mode 100644 index 0000000..90d8a5f --- /dev/null +++ b/Onyx/FanSettingsView.swift @@ -0,0 +1,182 @@ +import SwiftUI +import OnyxHelperProtocol + +/// Die Lüftersteuerung in den Einstellungen. +/// +/// Bewusst hier und nicht im Notch-Panel: ein Regler, der die Kühlung des +/// Rechners verstellt, gehört nicht dorthin, wo man mit dem Mauszeiger +/// versehentlich hinkommt. +struct FanSettingsView: View { + @Bindable var control: FanControl + + var body: some View { + Form { + switch control.installState { + case .notInstalled, .failed: + installSection + case .requiresApproval: + approvalSection + case .installed: + controlSection + } + } + .formStyle(.grouped) + .padding() + .onAppear { + control.refreshInstallState() + control.start() + } + .onDisappear { control.stop() } + } + + // MARK: - Einrichtung + + private var installSection: some View { + Section { + Text("fans.intro") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + + // Das Risiko benennen, bevor jemand zustimmt — nicht danach. + Label("fans.warning", systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + + Text("fans.safety") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if case .failed(let message) = control.installState { + Text(message).font(.callout).foregroundStyle(.red) + } + + Button("fans.install") { control.install() } + } + } + + private var approvalSection: some View { + Section { + Label("fans.approval.needed", systemImage: "hand.raised") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + Button("fans.approval.open") { control.openApprovalSettings() } + Button("fans.approval.recheck") { control.refreshInstallState() } + } + } + + // MARK: - Steuerung + + @ViewBuilder + private var controlSection: some View { + if let report = control.report { + Section { + if let hottest = report.hottestCelsius { + HStack { + Label("fans.hottest", systemImage: "thermometer") + Spacer() + Text("\(Int(hottest))°").monospacedDigit() + .foregroundStyle(hottest >= FanSafety.criticalCelsius + ? .red : .secondary) + } + } + ForEach(report.fans, id: \.index) { fan in + FanRow(fan: fan, control: control) + } + } + + Section { + Text("fans.safety.active") + .font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button("fans.uninstall", role: .destructive) { + Task { await control.uninstall() } + } + } + } else { + Section { + HStack { + ProgressView().controlSize(.small) + Text("fans.connecting").foregroundStyle(.secondary) + } + if let error = control.lastError { + Text(error).font(.callout).foregroundStyle(.red) + } + } + } + } +} + +private struct FanRow: View { + let fan: FanStatus + let control: FanControl + + @State private var target: Double = 0 + @State private var editing = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label(name, systemImage: "fan") + Spacer() + Text("\(Int(fan.currentRPM)) U/min").monospacedDigit().foregroundStyle(.secondary) + } + + Picker("", selection: Binding( + get: { fan.isManual }, + set: { manual in + if manual { + control.setTarget(index: fan.index, rpm: max(target, fan.limits.minimum)) + } else { + control.setAutomatic(index: fan.index) + } + })) { + Text("fans.mode.auto").tag(false) + Text("fans.mode.manual").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + + if fan.isManual, fan.limits.isUsable { + HStack { + Text("\(Int(fan.limits.minimum))").font(.caption2).foregroundStyle(.secondary) + Slider(value: $target, + in: fan.limits.minimum...fan.limits.maximum, + onEditingChanged: { active in + editing = active + // Erst beim Loslassen setzen: jede Bewegung des + // Reglers wäre sonst ein SMC-Schreibvorgang. + if !active { control.setTarget(index: fan.index, rpm: target) } + }) + Text("\(Int(fan.limits.maximum))").font(.caption2).foregroundStyle(.secondary) + } + Text("\(Int(target)) U/min").font(.caption).monospacedDigit() + } + + // Ein erzwungener Rückfall wird erklärt, nicht verschwiegen. + if let reason = fan.automaticReason, reason != "noRequest" { + Label(explanation(for: reason), systemImage: "shield.lefthalf.filled") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } } + .onChange(of: fan.targetRPM) { _, new in if !editing { target = new } } + } + + private var name: String { + fan.index == 0 ? String(localized: "fans.left") : String(localized: "fans.right") + } + + private func explanation(for reason: String) -> LocalizedStringKey { + switch reason { + case "watchdog": "fans.reason.watchdog" + case "temperature": "fans.reason.temperature" + case "noTemperature": "fans.reason.noTemperature" + case "unknownLimits": "fans.reason.unknownLimits" + default: "fans.reason.other" + } + } +} diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index fe39f48..954937b 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -772,6 +772,342 @@ } } } + }, + "settings.tab.fans": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" + } + } + } + }, + "fans.intro": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx kann die Lüfterdrehzahl fest vorgeben, statt sie macOS zu überlassen. Dafür wird ein Hilfsdienst mit Systemrechten eingerichtet — Schreibzugriff auf den SMC geht nicht anders." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx can set a fixed fan speed instead of leaving it to macOS. This requires a helper service with system privileges — writing to the SMC is not possible otherwise." + } + } + } + }, + "fans.warning": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine zu niedrige Drehzahl kann den Rechner überhitzen lassen. Die Automatik regelt nach Temperatur, ein fester Wert nicht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "A fan speed set too low can let the Mac overheat. The automatic control adjusts to temperature; a fixed value does not." + } + } + } + }, + "fans.safety": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Deshalb greift ein Sicherheitsnetz, das sich nicht abschalten lässt: nie unter das Minimum der Firmware, Rückfall auf Automatik ab 95 °C, Rückfall wenn Onyx sich fünf Sekunden nicht meldet, und Rückfall beim Beenden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "A safety net you cannot switch off is therefore always active: never below the firmware minimum, back to automatic above 95 °C, back to automatic if Onyx goes silent for five seconds, and back to automatic on quit." + } + } + } + }, + "fans.install": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilfsdienst einrichten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Install helper" + } + } + } + }, + "fans.approval.needed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS wartet auf deine Freigabe. Öffne die Systemeinstellungen und erlaube den Hintergrunddienst von Onyx." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS is waiting for your approval. Open System Settings and allow Onyx's background service." + } + } + } + }, + "fans.approval.open": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" + } + } + } + }, + "fans.approval.recheck": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut prüfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check again" + } + } + } + }, + "fans.connecting": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinde mit dem Hilfsdienst …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting to the helper…" + } + } + } + }, + "fans.hottest": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wärmster Kern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hottest core" + } + } + } + }, + "fans.left": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter links" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Left fan" + } + } + } + }, + "fans.right": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter rechts" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Right fan" + } + } + } + }, + "fans.mode.auto": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatik" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + } + } + }, + "fans.mode.manual": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual" + } + } + } + }, + "fans.safety.active": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Sicherheitsnetz läuft im Hilfsdienst, nicht in Onyx. Es greift auch dann, wenn Onyx abstürzt oder beendet wird." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The safety net runs in the helper, not in Onyx. It works even if Onyx crashes or is quit." + } + } + } + }, + "fans.uninstall": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilfsdienst entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove helper" + } + } + } + }, + "fans.reason.watchdog": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: Onyx hat sich zu lange nicht gemeldet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: Onyx went silent for too long." + } + } + } + }, + "fans.reason.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: zu heiß für eine feste Drehzahl." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: too hot for a fixed speed." + } + } + } + }, + "fans.reason.noTemperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: keine Temperatur messbar, damit lässt sich Sicherheit nicht beurteilen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: no temperature reading, so safety cannot be judged." + } + } + } + }, + "fans.reason.unknownLimits": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: der erlaubte Drehzahlbereich ist nicht lesbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: the permitted speed range cannot be read." + } + } + } + }, + "fans.reason.other": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic." + } + } + } } }, "version": "1.1" diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 2506e65..a992f85 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -39,6 +39,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var metricsModel: MetricsModel? private var networkModel: NetworkModel? private let settingsWindow = SettingsWindowController() + private let fanControl = FanControl() func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -135,6 +136,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { coordinator?.stop() menuBar?.stop() + // Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf + // Sekunden auf Automatik zurück — auch wenn Onyx abstürzt statt sauber + // zu beenden. + fanControl.stop() } /// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt. @@ -258,7 +263,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func showSettings(tab: SettingsTab) { guard let model, let calendarModel, let weatherModel else { return } settingsWindow.show(model: model, calendarModel: calendarModel, - weatherModel: weatherModel, metricsModel: metricsModel, tab: tab) + weatherModel: weatherModel, metricsModel: metricsModel, + fanControl: fanControl, tab: tab) } @objc private func quit() { NSApp.terminate(nil) } diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 595424e..8599b1a 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -5,10 +5,11 @@ import OnyxWidgetKit import CalendarProvider import OnyxMenuBar import MetricsProvider +import OnyxHelperProtocol import WeatherProvider enum SettingsTab: Hashable { - case widgets, display, menubar, permissions + case widgets, display, menubar, fans, permissions } struct SettingsView: View { @@ -16,6 +17,7 @@ struct SettingsView: View { let calendarModel: CalendarModel let weatherModel: WeatherModel let metricsModel: MetricsModel? + let fanControl: FanControl @Binding var selectedTab: SettingsTab var body: some View { @@ -29,6 +31,9 @@ struct SettingsView: View { MenuBarSettings(model: model, metricsModel: metricsModel) .tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") } .tag(SettingsTab.menubar) + FanSettingsView(control: fanControl) + .tabItem { Label("settings.tab.fans", systemImage: "fan") } + .tag(SettingsTab.fans) PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel) .tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") } .tag(SettingsTab.permissions) diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 4280728..89f9048 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -26,7 +26,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { var keepsDockIcon = false func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, - metricsModel: MetricsModel?, tab: SettingsTab = .widgets) { + metricsModel: MetricsModel?, fanControl: FanControl, + tab: SettingsTab = .widgets) { selectedTab = tab keepsDockIcon = model.showsDockIcon @@ -44,6 +45,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { calendarModel: calendarModel, weatherModel: weatherModel, metricsModel: metricsModel, + fanControl: fanControl, selectedTab: Binding( get: { [weak self] in self?.selectedTab ?? .widgets }, set: { [weak self] in self?.selectedTab = $0 }))) diff --git a/OnyxHelper/Info.plist b/OnyxHelper/Info.plist new file mode 100644 index 0000000..bd94b9e --- /dev/null +++ b/OnyxHelper/Info.plist @@ -0,0 +1,18 @@ + + + + + + CFBundleIdentifier + com.scarriffleservices.onyx.helper + CFBundleName + OnyxHelper + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + + diff --git a/OnyxHelper/SMCAccess.swift b/OnyxHelper/SMCAccess.swift new file mode 100644 index 0000000..4935bfc --- /dev/null +++ b/OnyxHelper/SMCAccess.swift @@ -0,0 +1,170 @@ +import Foundation +import IOKit + +/// SMC-Zugriff **mit** Schreibpfad — nur im privilegierten Helfer. +/// +/// Bewusst eine eigene Kopie und keine geteilte Bibliothek mit der App: der +/// Schreibpfad soll gar nicht erst in einem Prozess vorhanden sein, der ohne +/// Sonderrechte läuft und in dem jedes Widget Code ausführt. Was nicht da ist, +/// kann auch nicht versehentlich aufgerufen werden. +/// +/// Byte-Reihenfolge und Struktur stammen aus `docs/spikes/A-smc.md`. +final class SMCAccess { + + private struct Version { var major: UInt8 = 0; var minor: UInt8 = 0 + var build: UInt8 = 0; var reserved: UInt8 = 0 + var release: UInt16 = 0 } + private struct PLimitData { var version: UInt16 = 0; var length: UInt16 = 0 + var cpuPLimit: UInt32 = 0; var gpuPLimit: UInt32 = 0 + var memPLimit: UInt32 = 0 } + private struct KeyInfo { var dataSize: UInt32 = 0; var dataType: UInt32 = 0 + var dataAttributes: UInt8 = 0 } + + private struct Param { + var key: UInt32 = 0 + var vers = Version() + var pLimitData = PLimitData() + var keyInfo = KeyInfo() + var padding: UInt16 = 0 + var result: UInt8 = 0 + var status: UInt8 = 0 + var data8: UInt8 = 0 + var data32: UInt32 = 0 + var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) = + (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + } + + private static let handleYPCEvent: UInt32 = 2 + private static let readKey: UInt8 = 5 + private static let writeKey: UInt8 = 6 + private static let getKeyFromIndex: UInt8 = 8 + private static let getKeyInfo: UInt8 = 9 + + private var connection: io_connect_t = 0 + private var keyInfoCache: [String: KeyInfo] = [:] + + init?() { + let service = IOServiceGetMatchingService(kIOMainPortDefault, + IOServiceMatching("AppleSMC")) + guard service != 0 else { return nil } + defer { IOObjectRelease(service) } + guard IOServiceOpen(service, mach_task_self_, 0, &connection) == kIOReturnSuccess + else { return nil } + } + + deinit { if connection != 0 { IOServiceClose(connection) } } + + // MARK: - Lesen + + func float(_ key: String) -> Double? { + guard let value = read(key), value.bytes.count >= 4 else { return nil } + let bits = UInt32(value.bytes[3]) << 24 | UInt32(value.bytes[2]) << 16 + | UInt32(value.bytes[1]) << 8 | UInt32(value.bytes[0]) + return Double(Float(bitPattern: bits)) + } + + func uint8(_ key: String) -> UInt8? { read(key)?.bytes.first } + + func keys(withPrefix prefix: String) -> [String] { + guard let value = read("#KEY"), value.bytes.count >= 4 else { return [] } + // Big-endian, anders als die Messwerte — siehe Spike A. + let total = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16 + | UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3]) + guard total > 0, total < 100_000 else { return [] } + + var found: [String] = [] + for index in 0.. Bool { + let bits = Float(value).bitPattern + return write(key, bytes: [UInt8(bits & 0xFF), UInt8((bits >> 8) & 0xFF), + UInt8((bits >> 16) & 0xFF), UInt8((bits >> 24) & 0xFF)]) + } + + /// Schreibt ein einzelnes Byte (Betriebsart). + @discardableResult + func writeUInt8(_ key: String, _ value: UInt8) -> Bool { + write(key, bytes: [value]) + } + + private func write(_ key: String, bytes: [UInt8]) -> Bool { + guard let info = keyInfo(for: key) else { return false } + // Nur schreiben, wenn die Größe zur Firmware passt. Ein zu kurzer oder + // zu langer Schreibvorgang landet sonst in benachbarten Feldern. + guard Int(info.dataSize) == bytes.count else { return false } + + var command = Param() + command.key = Self.fourCC(key) + command.keyInfo = info + command.data8 = Self.writeKey + withUnsafeMutableBytes(of: &command.bytes) { raw in + for (index, byte) in bytes.enumerated() { raw[index] = byte } + } + return call(command) != nil + } + + // MARK: - Innereien + + private struct Value { let bytes: [UInt8] } + + private func keyInfo(for key: String) -> KeyInfo? { + if let cached = keyInfoCache[key] { return cached } + var command = Param() + command.key = Self.fourCC(key) + command.data8 = Self.getKeyInfo + guard let output = call(command) else { return nil } + keyInfoCache[key] = output.keyInfo + return output.keyInfo + } + + private func read(_ key: String) -> Value? { + guard let info = keyInfo(for: key) else { return nil } + var command = Param() + command.key = Self.fourCC(key) + command.keyInfo = info + command.data8 = Self.readKey + guard let output = call(command) else { return nil } + let all = withUnsafeBytes(of: output.bytes) { Array($0) } + return Value(bytes: Array(all.prefix(Int(min(info.dataSize, 32))))) + } + + private func call(_ input: Param) -> Param? { + var input = input + var output = Param() + var size = MemoryLayout.stride + let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent, + &input, MemoryLayout.stride, + &output, &size) + guard result == kIOReturnSuccess, output.result == 0 else { return nil } + return output + } + + private static func fourCC(_ string: String) -> UInt32 { + var value: UInt32 = 0 + for character in string.utf8.prefix(4) { value = value << 8 | UInt32(character) } + return value + } + + private static func fourCCString(_ value: UInt32) -> String { + let bytes = [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + return String(bytes: bytes, encoding: .ascii) ?? "????" + } +} diff --git a/OnyxHelper/com.scarriffleservices.onyx.helper.plist b/OnyxHelper/com.scarriffleservices.onyx.helper.plist new file mode 100644 index 0000000..924b34b --- /dev/null +++ b/OnyxHelper/com.scarriffleservices.onyx.helper.plist @@ -0,0 +1,31 @@ + + + + + Label + com.scarriffleservices.onyx.helper + + + BundleProgram + Contents/MacOS/OnyxHelper + + MachServices + + com.scarriffleservices.onyx.helper + + + + + KeepAlive + + RunAtLoad + + + AssociatedBundleIdentifiers + + com.scarriffleservices.onyx + + + diff --git a/OnyxHelper/main.swift b/OnyxHelper/main.swift new file mode 100644 index 0000000..b3b60c8 --- /dev/null +++ b/OnyxHelper/main.swift @@ -0,0 +1,198 @@ +import Foundation +import OSLog +import OnyxHelperProtocol + +private let log = Logger(subsystem: "com.scarriffleservices.onyx.helper", category: "Helper") + +/// Der privilegierte Helfer. +/// +/// Läuft als root und tut genau eine Sache: Lüfterdrehzahlen setzen. Kein +/// Netzwerk, keine Oberfläche, kein Dateizugriff. Je weniger hier steht, desto +/// kleiner die Angriffsfläche eines Dienstes mit Systemrechten. +/// +/// **Er entscheidet selbst, was zulässig ist.** Die App darf einen Wunsch +/// äußern; ob er ausgeführt wird, prüft ausschließlich `FanSafety` hier — mit +/// Temperaturen, die der Helfer selbst misst. +final class FanService: NSObject, OnyxHelperProtocol { + + private let smc: SMCAccess? + /// Wunschdrehzahl je Lüfter. Leer heißt Automatik. + private var requests: [Int: FanRequest] = [:] + private var lastHeartbeat = Date.distantPast + private var lastDecision: [Int: FanDecision] = [:] + private let queue = DispatchQueue(label: "com.scarriffleservices.onyx.helper.fans") + + private lazy var temperatureKeys: [String] = smc?.keys(withPrefix: "Tp") ?? [] + private lazy var fanCount: Int = { + guard let value = smc?.float("FNum") else { return Int(smc?.uint8("FNum") ?? 0) } + return Int(value) + }() + + override init() { + smc = SMCAccess() + super.init() + startControlLoop() + } + + // MARK: - Regelschleife + + /// Läuft ununterbrochen, solange der Helfer lebt. + /// + /// Die Entscheidung wird **jede Sekunde neu** getroffen, nicht nur beim + /// Setzen. Nur so greifen Wachhund und Temperaturwächter auch dann, wenn + /// von der App nie wieder etwas kommt. + private func startControlLoop() { + queue.async { [weak self] in + let timer = Timer(timeInterval: 1, repeats: true) { _ in self?.apply() } + RunLoop.current.add(timer, forMode: .common) + RunLoop.current.run() + } + } + + private func apply() { + guard let smc, fanCount > 0 else { return } + let hottest = hottestTemperature() + + for index in 0.. Double? { + guard let smc, !temperatureKeys.isEmpty else { return nil } + return temperatureKeys.compactMap { key -> Double? in + guard let value = smc.float(key), value > 5, value < 150 else { return nil } + return value + }.max() + } + + /// Beim Beenden alles zurückstellen. + /// + /// Ohne das blieben die Lüfter nach einem Abmelden oder Neustart des + /// Dienstes auf ihrem letzten Wert stehen — bis zum nächsten Neustart des + /// Rechners. + func restoreAutomatic() { + guard let smc else { return } + for index in 0.. Void) { reply(OnyxHelper.version) } + + func heartbeat(reply: @escaping (Bool) -> Void) { + lastHeartbeat = Date() + reply(true) + } + + func fanStatus(reply: @escaping (Data?) -> Void) { reply(report()) } + + func setFan(index: Int, targetRPM: Double, reply: @escaping (Data?) -> Void) { + lastHeartbeat = Date() + requests[index] = FanRequest(targetRPM: targetRPM) + apply() + reply(report()) + } + + func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) { + lastHeartbeat = Date() + requests[index] = nil + apply() + reply(report()) + } + + private func report() -> Data? { + guard let smc else { return nil } + let fans = (0.. FanStatus in + let decision = lastDecision[index] + var reason: String? + var manual = false + if case .manual = decision { manual = true } + if case .automatic(let value) = decision { reason = value.rawValue } + + return FanStatus(index: index, + currentRPM: smc.float("F\(index)Ac") ?? 0, + targetRPM: smc.float("F\(index)Tg") ?? 0, + limits: FanLimits(minimum: smc.float("F\(index)Mn") ?? 0, + maximum: smc.float("F\(index)Mx") ?? 0), + isManual: manual, + automaticReason: reason) + } + return try? JSONEncoder().encode( + FanStatusReport(fans: fans, hottestCelsius: hottestTemperature(), + helperVersion: OnyxHelper.version)) + } +} + +// MARK: - XPC + +final class Listener: NSObject, NSXPCListenerDelegate { + let service = FanService() + + func listener(_ listener: NSXPCListener, + shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + // Ohne diese Prüfung könnte **jedes** Programm auf dem Rechner die + // Lüfter steuern: der Mach-Dienst eines root-Daemons ist systemweit + // sichtbar, und wer ihn anspricht, spricht mit root. + do { + try connection.setCodeSigningRequirement(OnyxHelper.clientRequirement) + } catch { + log.error("Verbindung abgelehnt: Signaturanforderung nicht setzbar") + return false + } + + connection.exportedInterface = NSXPCInterface(with: OnyxHelperProtocol.self) + connection.exportedObject = service + connection.resume() + return true + } +} + +let listener = NSXPCListener(machServiceName: OnyxHelper.machServiceName) +let delegate = Listener() +listener.delegate = delegate +listener.resume() + +// Beim Beenden — auch durch launchd — die Lüfter freigeben. +for signalNumber in [SIGTERM, SIGINT] { + signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) + source.setEventHandler { + delegate.service.restoreAutomatic() + exit(0) + } + source.resume() +} + +log.notice("Onyx-Helfer bereit") +RunLoop.main.run() diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index 5c599db..4d76efd 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -9,6 +9,7 @@ let package = Package( platforms: [.macOS(.v26)], products: [ .library(name: "OnyxCore", targets: ["OnyxCore"]), + .library(name: "OnyxHelperProtocol", targets: ["OnyxHelperProtocol"]), .library(name: "OnyxDesign", targets: ["OnyxDesign"]), .library(name: "OnyxNotch", targets: ["OnyxNotch"]), .library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]), @@ -26,6 +27,12 @@ let package = Package( .target(name: "OnyxCore", resources: [.process("Localizable.xcstrings")]), .testTarget(name: "OnyxCoreTests", dependencies: ["OnyxCore"]), + // Geteilt zwischen App und privilegiertem Helfer — deshalb ohne jede + // Abhängigkeit: der Helfer läuft als root und soll so wenig Code wie + // möglich enthalten. + .target(name: "OnyxHelperProtocol"), + .testTarget(name: "OnyxHelperProtocolTests", dependencies: ["OnyxHelperProtocol"]), + .target(name: "OnyxDesign"), .target(name: "OnyxNotch", dependencies: ["OnyxDesign"]), diff --git a/Packages/OnyxKit/Sources/OnyxHelperProtocol/FanSafety.swift b/Packages/OnyxKit/Sources/OnyxHelperProtocol/FanSafety.swift new file mode 100644 index 0000000..f690e07 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxHelperProtocol/FanSafety.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Der von der Firmware erlaubte Drehzahlbereich eines Lüfters. +public struct FanLimits: Equatable, Sendable, Codable { + public let minimum: Double + public let maximum: Double + + public init(minimum: Double, maximum: Double) { + self.minimum = minimum + self.maximum = maximum + } + + /// Ob die Werte überhaupt brauchbar sind. Nicht lesbare Keys ergeben Nullen. + public var isUsable: Bool { minimum > 0 && maximum > minimum } +} + +public struct FanRequest: Equatable, Sendable, Codable { + public let targetRPM: Double + public init(targetRPM: Double) { self.targetRPM = targetRPM } +} + +public enum FanDecision: Equatable, Sendable { + case manual(rpm: Double) + case automatic(reason: AutomaticReason) + + public enum AutomaticReason: String, Equatable, Sendable { + /// Niemand will etwas anderes. + case noRequest + /// Die App hat sich zu lange nicht gemeldet. + case watchdog + /// Es ist zu heiß für eine feste Drehzahl. + case temperature + /// Ohne Temperaturmesswert lässt sich Sicherheit nicht beurteilen. + case noTemperature + /// Der erlaubte Bereich ist unbekannt. + case unknownLimits + } +} + +/// Entscheidet, ob eine angeforderte Drehzahl gesetzt werden darf. +/// +/// Diese Regeln sind **nicht abschaltbar** und liegen im Helfer, nicht in der +/// Oberfläche. Die Oberfläche kann abstürzen, eingefroren oder beendet sein, +/// während die Lüfter auf einem festen Wert stehen — genau dafür gibt es das +/// Netz. Als reine Funktion ist es vollständig prüfbar, ohne Hardware. +public enum FanSafety { + + /// Meldet sich die App länger nicht, übernimmt die Automatik. + public static let watchdogTimeout: TimeInterval = 5 + + /// Ab hier zählt nur noch Kühlung. Apple Silicon drosselt in dieser Gegend + /// von selbst; eine festgenagelte Drehzahl verschlimmert die Lage, weil sie + /// nicht nachregelt. + public static let criticalCelsius: Double = 95 + + public static func decide(request: FanRequest?, + lastHeartbeat: Date, + now: Date, + hottestCelsius: Double?, + limits: FanLimits) -> FanDecision { + // Rangfolge: erst die Gründe, die Schaden verhindern, dann der Wunsch. + guard let request else { return .automatic(reason: .noRequest) } + + guard let hottest = hottestCelsius else { + // Ohne Temperatur lässt sich nicht beurteilen, ob eine feste + // Drehzahl gerade gefährlich ist. Blind weiterdrehen wäre falsch. + return .automatic(reason: .noTemperature) + } + guard hottest < criticalCelsius else { return .automatic(reason: .temperature) } + guard now.timeIntervalSince(lastHeartbeat) <= watchdogTimeout else { + return .automatic(reason: .watchdog) + } + guard limits.isUsable else { return .automatic(reason: .unknownLimits) } + + // Nie unter das Firmware-Minimum: ein Lüfter, der langsamer läuft als + // vorgesehen, kann den Rechner überhitzen, ohne dass die Automatik + // eingreift — die ist ja gerade abgeschaltet. + return .manual(rpm: min(max(request.targetRPM, limits.minimum), limits.maximum)) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxHelperProtocol/HelperProtocol.swift b/Packages/OnyxKit/Sources/OnyxHelperProtocol/HelperProtocol.swift new file mode 100644 index 0000000..e942de8 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxHelperProtocol/HelperProtocol.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Die Schnittstelle zwischen App und privilegiertem Helfer. +/// +/// **Die App sagt dem Helfer nicht, wie warm es ist.** Der Helfer misst selbst. +/// Andernfalls hinge das Sicherheitsnetz an der Ehrlichkeit eines Prozesses, +/// der abstürzen, hängen oder — bei einer manipulierten Kopie — schlicht lügen +/// kann. Der Herzschlag sagt deshalb nur „ich lebe noch", nichts weiter. +@objc public protocol OnyxHelperProtocol { + + /// Version des Helfers. Läuft nach einem App-Update noch die alte, muss sie + /// ersetzt werden — sonst passen die Aufrufe nicht mehr zusammen. + func version(reply: @escaping (String) -> Void) + + /// Aktueller Zustand aller Lüfter, als JSON-kodierter ``FanStatus``. + /// + /// JSON statt eines eigenen Typs: `NSSecureCoding` über XPC verlangt + /// registrierte Klassen auf beiden Seiten, und dafür ist die Datenmenge + /// hier zu klein. + func fanStatus(reply: @escaping (Data?) -> Void) + + /// Setzt eine Wunschdrehzahl. Der Helfer prüft sie gegen sein + /// Sicherheitsnetz und kann sie begrenzen oder ablehnen. + func setFan(index: Int, targetRPM: Double, reply: @escaping (Data?) -> Void) + + /// Zurück zur Automatik. + func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) + + /// „Ich lebe noch." Bleibt er aus, fallen alle Lüfter auf Automatik zurück. + func heartbeat(reply: @escaping (Bool) -> Void) +} + +/// Was der Helfer über einen Lüfter berichtet. +public struct FanStatus: Codable, Equatable, Sendable { + public let index: Int + public let currentRPM: Double + public let targetRPM: Double + public let limits: FanLimits + public let isManual: Bool + /// Warum gerade die Automatik läuft — `nil` im manuellen Betrieb. + public let automaticReason: String? + + public init(index: Int, currentRPM: Double, targetRPM: Double, limits: FanLimits, + isManual: Bool, automaticReason: String?) { + self.index = index + self.currentRPM = currentRPM + self.targetRPM = targetRPM + self.limits = limits + self.isManual = isManual + self.automaticReason = automaticReason + } +} + +public struct FanStatusReport: Codable, Equatable, Sendable { + public let fans: [FanStatus] + public let hottestCelsius: Double? + public let helperVersion: String + + public init(fans: [FanStatus], hottestCelsius: Double?, helperVersion: String) { + self.fans = fans + self.hottestCelsius = hottestCelsius + self.helperVersion = helperVersion + } +} + +public enum OnyxHelper { + /// Muss zum Namen der Plist unter `Contents/Library/LaunchDaemons` passen. + public static let machServiceName = "com.scarriffleservices.onyx.helper" + public static let version = "1" + + /// Nur ein Prozess mit exakt dieser Signatur darf mit dem Helfer sprechen. + /// + /// Ohne diese Prüfung könnte **jedes** Programm auf dem Rechner die Lüfter + /// eines root-Dienstes steuern — der Mach-Dienst ist systemweit sichtbar. + public static let clientRequirement = + "anchor apple generic and identifier \"com.scarriffleservices.onyx\" " + + "and certificate leaf[subject.OU] = \"PP34X97WS3\"" + + /// Und die App spricht nur mit einem Helfer, der ebenso signiert ist. + public static let helperRequirement = + "anchor apple generic and identifier \"com.scarriffleservices.onyx.helper\" " + + "and certificate leaf[subject.OU] = \"PP34X97WS3\"" +} diff --git a/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/FanSafetyTests.swift b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/FanSafetyTests.swift new file mode 100644 index 0000000..3b2ccb1 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/FanSafetyTests.swift @@ -0,0 +1,139 @@ +import Testing +import Foundation +@testable import OnyxHelperProtocol + +// Das Sicherheitsnetz ist der einzige Teil dieses Projekts, dessen Versagen +// Hardware kosten kann. Es liegt deshalb als reine Funktion vor, wird hier +// vollständig durchgeprüft und sitzt im Helfer — nicht in der Oberfläche, die +// abstürzen, eingefroren oder beendet sein kann, während die Lüfter stehen. + +@Suite("Lüfter-Sicherheitsnetz") +struct FanSafetyTests { + + private let limits = FanLimits(minimum: 2317, maximum: 7826) + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func decide(rpm: Double?, secondsSinceHeartbeat: TimeInterval = 0, + hottest: Double? = 50) -> FanDecision { + FanSafety.decide(request: rpm.map { FanRequest(targetRPM: $0) }, + lastHeartbeat: now.addingTimeInterval(-secondsSinceHeartbeat), + now: now, hottestCelsius: hottest, limits: limits) + } + + // MARK: - Der Normalfall + + @Test("Ohne Anforderung läuft die Automatik") + func noRequestMeansAuto() { + #expect(decide(rpm: nil) == .automatic(reason: .noRequest)) + } + + @Test("Eine Anforderung im gültigen Bereich wird durchgereicht") + func validRequestIsHonoured() { + #expect(decide(rpm: 4000) == .manual(rpm: 4000)) + } + + // MARK: - Grenzen + + @Test("Unter dem Firmware-Minimum wird auf das Minimum angehoben") + func belowMinimumIsRaised() { + // Der wichtigste Einzelfall: ein Lüfter, der langsamer läuft als vom + // Hersteller vorgesehen, kann den Rechner überhitzen, ohne dass die + // Automatik eingreift — sie ist ja abgeschaltet. + #expect(decide(rpm: 500) == .manual(rpm: 2317)) + } + + @Test("Null wird nicht als 'aus' verstanden, sondern angehoben") + func zeroIsNotOff() { + #expect(decide(rpm: 0) == .manual(rpm: 2317)) + } + + @Test("Ein negativer Wert wird ebenfalls angehoben") + func negativeIsRaised() { + #expect(decide(rpm: -1000) == .manual(rpm: 2317)) + } + + @Test("Über dem Maximum wird gedeckelt") + func aboveMaximumIsCapped() { + #expect(decide(rpm: 99999) == .manual(rpm: 7826)) + } + + @Test("Genau auf den Grenzen bleibt der Wert unverändert") + func exactBoundsPassThrough() { + #expect(decide(rpm: 2317) == .manual(rpm: 2317)) + #expect(decide(rpm: 7826) == .manual(rpm: 7826)) + } + + // MARK: - Wachhund + + @Test("Schweigt die App zu lange, fällt der Lüfter auf Automatik zurück") + func watchdogReturnsToAuto() { + // Der Fall, für den das Netz vor allem da ist: Onyx wird hart beendet + // oder stürzt ab, während die Lüfter auf manuell stehen. Ohne Wachhund + // bliebe die Einstellung bis zum Neustart bestehen. + #expect(decide(rpm: 4000, secondsSinceHeartbeat: 6) + == .automatic(reason: .watchdog)) + } + + @Test("Kurz vor Ablauf gilt die Anforderung noch") + func justBeforeTimeoutStillManual() { + #expect(decide(rpm: 4000, secondsSinceHeartbeat: 4.9) == .manual(rpm: 4000)) + } + + @Test("Genau auf der Frist gilt sie noch") + func exactlyAtTimeoutStillManual() { + #expect(decide(rpm: 4000, secondsSinceHeartbeat: FanSafety.watchdogTimeout) + == .manual(rpm: 4000)) + } + + // MARK: - Temperaturwächter + + @Test("Über dem Grenzwert übernimmt die Automatik, egal was angefordert war") + func temperatureOverridesRequest() { + #expect(decide(rpm: 2400, hottest: 96) == .automatic(reason: .temperature)) + } + + @Test("Der Temperaturwächter schlägt auch eine Anforderung auf Höchstdrehzahl") + func temperatureOverridesEvenMaximum() { + // Auch bei voller Drehzahl übernimmt die Automatik: sie kennt + // Drosselung und Lastverteilung, ein festgenagelter Wert nicht. + #expect(decide(rpm: 7826, hottest: 100) == .automatic(reason: .temperature)) + } + + @Test("Knapp unter dem Grenzwert bleibt es bei der Anforderung") + func justBelowThresholdStaysManual() { + #expect(decide(rpm: 3000, hottest: 94.9) == .manual(rpm: 3000)) + } + + @Test("Ohne Temperaturmesswert wird nicht gesteuert") + func missingTemperatureIsUnsafe() { + // Ohne Temperatur lässt sich nicht beurteilen, ob eine feste Drehzahl + // gerade gefährlich ist. Blind weiterzudrehen wäre die falsche Antwort. + #expect(decide(rpm: 3000, hottest: nil) == .automatic(reason: .noTemperature)) + } + + // MARK: - Rangfolge + + @Test("Temperatur schlägt Wachhund — beide führen zur Automatik, der Grund zählt") + func temperatureWinsOverWatchdog() { + let decision = FanSafety.decide(request: FanRequest(targetRPM: 3000), + lastHeartbeat: now.addingTimeInterval(-60), + now: now, hottestCelsius: 99, limits: limits) + #expect(decision == .automatic(reason: .temperature)) + } + + @Test("Unsinnige Grenzen führen zur Automatik statt zu einem geratenen Wert") + func brokenLimitsFallBack() { + // Kommt vor, wenn die Firmware-Keys nicht lesbar sind. Dann ist der + // erlaubte Bereich unbekannt, und eine Drehzahl zu setzen wäre geraten. + let decision = FanSafety.decide(request: FanRequest(targetRPM: 3000), + lastHeartbeat: now, now: now, hottestCelsius: 50, + limits: FanLimits(minimum: 0, maximum: 0)) + #expect(decision == .automatic(reason: .unknownLimits)) + } + + @Test("Die Grenzwerte entsprechen dem Plan") + func constantsMatchPlan() { + #expect(FanSafety.watchdogTimeout == 5) + #expect(FanSafety.criticalCelsius == 95) + } +} diff --git a/project.yml b/project.yml index 0aca630..442783d 100644 --- a/project.yml +++ b/project.yml @@ -29,14 +29,43 @@ packages: path: Packages/OnyxKit targets: + # Der privilegierte Helfer. Läuft als root und tut genau eine Sache: + # Lüfterdrehzahlen setzen. Kein Netzwerk, keine Oberfläche, kein + # Dateizugriff — je weniger hier steht, desto kleiner die Angriffsfläche. + OnyxHelper: + type: tool + platform: macOS + sources: + - path: OnyxHelper + excludes: + - "*.plist" + dependencies: + - package: OnyxKit + product: OnyxHelperProtocol + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx.helper + PRODUCT_NAME: OnyxHelper + CODE_SIGN_STYLE: Automatic + SKIP_INSTALL: YES + # Kommandozeilenprogramme haben kein Bundle. Die Kennung muss deshalb + # in die Binary selbst — sonst signiert codesign sie unter dem + # Dateinamen und die Signaturprüfung zwischen App und Helfer scheitert. + INFOPLIST_FILE: OnyxHelper/Info.plist + CREATE_INFOPLIST_SECTION_IN_BINARY: YES + Onyx: type: application platform: macOS sources: - path: Onyx dependencies: + - target: OnyxHelper + embed: false - package: OnyxKit product: OnyxCore + - package: OnyxKit + product: OnyxHelperProtocol - package: OnyxKit product: OnyxDesign - package: OnyxKit @@ -56,6 +85,39 @@ targets: - package: OnyxKit product: NetworkProvider postBuildScripts: + # Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService + # sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort + # den Pfad zum Programm. + - name: Helfer und Launchd-Plist einbetten + script: | + APP="$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH" + mkdir -p "$APP/MacOS" "$APP/Library/LaunchDaemons" + cp "$BUILT_PRODUCTS_DIR/OnyxHelper" "$APP/MacOS/OnyxHelper" + cp "$SRCROOT/OnyxHelper/com.scarriffleservices.onyx.helper.plist" \ + "$APP/Library/LaunchDaemons/" + # Der Helfer wird eigenständig signiert — die Signatur der App deckt + # ihn nicht ab, und ohne gültige eigene Signatur lehnt launchd ihn ab. + # + # `-i` ist Pflicht: Xcode signiert Kommandozeilenprogramme sonst mit + # 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 \ + -i com.scarriffleservices.onyx.helper \ + --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$APP/MacOS/OnyxHelper" + + # Das Kopieren passiert nach Xcodes Signatur und bricht damit das + # Siegel des App-Bundles ("nested code is modified or invalid"). + # Deshalb zum Schluss die App selbst neu signieren — mit denselben + # Entitlements, sonst verliert sie App-Group, WeatherKit und die + # TCC-Berechtigungen. + codesign --force --options runtime --timestamp=none \ + --entitlements "$SRCROOT/Onyx/Onyx.entitlements" \ + --sign "$EXPANDED_CODE_SIGN_IDENTITY" \ + "$BUILT_PRODUCTS_DIR/$WRAPPER_NAME" + # Bewusst ohne outputFiles: mit ihnen überspringt Xcode den Schritt, + # solange die Plist unverändert ist — auch wenn sich das Programm oder + # die Signaturvorgaben geändert haben. # Der MediaRemote-Adapter wird aus dem mitgelieferten Quellcode gebaut, # nicht als fertige Binary eingecheckt: so ist nachvollziehbar, was da # läuft, und die Ad-hoc-Signatur passt zum restlichen Bundle.