Compare commits
90 Commits
40bd83e49e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee848b86cc | ||
|
|
c94c6166f5 | ||
|
|
1432ef3a6f | ||
|
|
9975694d6a | ||
|
|
0df566d531 | ||
|
|
fde3e864c0 | ||
|
|
e2b86c0216 | ||
|
|
3c36092712 | ||
|
|
c8f3054036 | ||
|
|
102c870835 | ||
|
|
928ee90b5e | ||
|
|
d90352a86b | ||
|
|
c6f0a5a848 | ||
|
|
152d36c904 | ||
|
|
a2be84f4d5 | ||
|
|
ca9e272ad8 | ||
|
|
2c3b3cdd55 | ||
|
|
6c92e7fed6 | ||
|
|
2aa60eedf9 | ||
|
|
7a3d2c1c39 | ||
|
|
9df2ddb80b | ||
|
|
e732394c6c | ||
|
|
ed8b095377 | ||
|
|
d001e439a6 | ||
|
|
ed5bd01482 | ||
|
|
504c518cfb | ||
|
|
312514e6ce | ||
|
|
c990ea558b | ||
|
|
80b083259e | ||
|
|
2bf76eb060 | ||
|
|
782d2af4fd | ||
|
|
e2f2ea3427 | ||
|
|
2490a90519 | ||
|
|
49e1a46928 | ||
|
|
ab96086db7 | ||
|
|
784b210bc2 | ||
|
|
090f570aba | ||
|
|
cfbbf91068 | ||
|
|
d7125e28f6 | ||
|
|
57093cb63b | ||
|
|
4c96aced5a | ||
|
|
43ce40676b | ||
|
|
c0c5809c96 | ||
|
|
c44bb3af30 | ||
|
|
e79191d033 | ||
|
|
8545cc421c | ||
|
|
20963f53e2 | ||
|
|
5d261ed3d8 | ||
|
|
8f0831391e | ||
|
|
a84e5a0c8e | ||
|
|
bf799d57d9 | ||
|
|
68e53d3f6f | ||
|
|
eff3c30a71 | ||
|
|
9968236a37 | ||
|
|
4b5e86b3f4 | ||
|
|
667fb61c96 | ||
|
|
70a665f532 | ||
|
|
386eb6ae70 | ||
|
|
5ba9e551a4 | ||
|
|
a54b332723 | ||
|
|
b2f69f34ae | ||
|
|
c9366302fc | ||
|
|
453353de58 | ||
|
|
b22a44ba70 | ||
|
|
93fe95827f | ||
|
|
a0342205fd | ||
|
|
8308536227 | ||
|
|
28b1b4270e | ||
|
|
c71ddb7ce3 | ||
|
|
bfa8c9dab1 | ||
|
|
f48b29e53e | ||
|
|
d73b789fb7 | ||
|
|
aa8e903037 | ||
|
|
7fcca61d70 | ||
|
|
d85425671d | ||
|
|
a4cc75ab6e | ||
|
|
594d7c422a | ||
|
|
a198d88104 | ||
|
|
c0874c83b7 | ||
|
|
7f6ded7de0 | ||
|
|
d0692d0f86 | ||
|
|
95bc3b1931 | ||
|
|
433cdcc3dd | ||
|
|
03dd7b945e | ||
|
|
4b559ba840 | ||
|
|
3d21cac3b7 | ||
|
|
a557c81baf | ||
|
|
fc92d226b4 | ||
|
|
d345a4fcab | ||
|
|
873c763d50 |
@@ -8,30 +8,83 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
||||||
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
|
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 */; };
|
||||||
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
|
||||||
|
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||||
|
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; };
|
||||||
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
||||||
|
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
|
||||||
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
|
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
|
||||||
5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; };
|
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7A866E72010907DF4BEF025C /* bolt-dark.svg */; };
|
||||||
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
|
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
||||||
|
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; };
|
||||||
|
6A972372414D6EFDC81E2661 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B442E8A39C37C8FD92B4CA83 /* main.swift */; };
|
||||||
|
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89309D9F5722FA1B91CF2905 /* FanWidget.swift */; };
|
||||||
|
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; };
|
||||||
|
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; };
|
||||||
|
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */; };
|
||||||
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
|
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 */; };
|
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
||||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */; };
|
||||||
|
B0FCCD92AF22F1636482EACD /* sound-waves.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */; };
|
||||||
|
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 */; };
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
||||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; };
|
||||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
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 */; };
|
||||||
|
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */; };
|
||||||
|
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
|
||||||
|
F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; };
|
||||||
|
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
||||||
/* End PBXBuildFile section */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = "<group>"; };
|
||||||
|
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOptionsView.swift; sourceTree = "<group>"; };
|
||||||
|
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
|
||||||
|
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = "<group>"; };
|
||||||
|
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
|
||||||
45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCAccess.swift; sourceTree = "<group>"; };
|
||||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixerMenuBarModule.swift; sourceTree = "<group>"; };
|
||||||
|
63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
|
7A866E72010907DF4BEF025C /* bolt-dark.svg */ = {isa = PBXFileReference; path = "bolt-dark.svg"; sourceTree = "<group>"; };
|
||||||
821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; };
|
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 = "<group>"; };
|
87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = "<group>"; };
|
||||||
|
89309D9F5722FA1B91CF2905 /* FanWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanWidget.swift; sourceTree = "<group>"; };
|
||||||
|
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */ = {isa = PBXFileReference; path = "sound-waves.svg"; sourceTree = "<group>"; };
|
||||||
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
|
B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = "<group>"; };
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||||
CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderWidgets.swift; sourceTree = "<group>"; };
|
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
|
||||||
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
|
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
|
||||||
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -40,11 +93,26 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */,
|
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */,
|
||||||
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */,
|
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */,
|
||||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */,
|
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */,
|
||||||
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */,
|
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */,
|
||||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */,
|
B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */,
|
||||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider 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 */,
|
||||||
|
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
||||||
|
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D4D0E94BCED22A73F694B2CB /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -62,6 +130,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0804288DC4A8146DD9F3FC3E /* Onyx.app */,
|
0804288DC4A8146DD9F3FC3E /* Onyx.app */,
|
||||||
|
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -70,6 +139,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6653B90FCB65F334F91F2F1 /* Onyx */,
|
D6653B90FCB65F334F91F2F1 /* Onyx */,
|
||||||
|
6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */,
|
||||||
573275C59A65A7C11CCEC90A /* Packages */,
|
573275C59A65A7C11CCEC90A /* Packages */,
|
||||||
339A2C4AF25B5612E90B795D /* Products */,
|
339A2C4AF25B5612E90B795D /* Products */,
|
||||||
);
|
);
|
||||||
@@ -83,18 +153,42 @@
|
|||||||
path = Packages;
|
path = Packages;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B442E8A39C37C8FD92B4CA83 /* main.swift */,
|
||||||
|
4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */,
|
||||||
|
);
|
||||||
|
path = OnyxHelper;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6653B90FCB65F334F91F2F1 /* Onyx */ = {
|
D6653B90FCB65F334F91F2F1 /* Onyx */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0FBA13B97C53F1D0D740A633 /* Resources */,
|
0FBA13B97C53F1D0D740A633 /* Resources */,
|
||||||
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
||||||
|
7A866E72010907DF4BEF025C /* bolt-dark.svg */,
|
||||||
|
B8214C5599D1857E832447EC /* bolt-light.svg */,
|
||||||
|
085D860E654E09E4CD6E535A /* chip.svg */,
|
||||||
|
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */,
|
||||||
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
|
||||||
|
F12041E330231599C6540C21 /* FanSettingsView.swift */,
|
||||||
|
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
|
||||||
|
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */,
|
||||||
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
||||||
|
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
||||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
||||||
|
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
||||||
|
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
||||||
|
63A5510538846C8427AEB831 /* OnboardingView.swift */,
|
||||||
|
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
|
||||||
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
|
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
|
||||||
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
|
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
|
||||||
CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */,
|
|
||||||
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
||||||
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
||||||
|
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
||||||
|
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
|
||||||
);
|
);
|
||||||
path = Onyx;
|
path = Onyx;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -109,24 +203,53 @@
|
|||||||
6B11A5AC704B6429B9484789 /* Sources */,
|
6B11A5AC704B6429B9484789 /* Sources */,
|
||||||
B211CE3C0EF94A8D680E4AE0 /* Resources */,
|
B211CE3C0EF94A8D680E4AE0 /* Resources */,
|
||||||
7FF994FE5530D53BEFBE13FE /* Frameworks */,
|
7FF994FE5530D53BEFBE13FE /* Frameworks */,
|
||||||
|
E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */,
|
||||||
|
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Onyx;
|
name = Onyx;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
C64A111F449D0F9D36ED46FB /* OnyxCore */,
|
C64A111F449D0F9D36ED46FB /* OnyxCore */,
|
||||||
|
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */,
|
||||||
3D680DD645941493A7D3559E /* OnyxDesign */,
|
3D680DD645941493A7D3559E /* OnyxDesign */,
|
||||||
3A32CAE6CC4D01D660F0016F /* OnyxNotch */,
|
3A32CAE6CC4D01D660F0016F /* OnyxNotch */,
|
||||||
E66577A40689DF225BCED184 /* OnyxWidgetKit */,
|
E66577A40689DF225BCED184 /* OnyxWidgetKit */,
|
||||||
A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */,
|
A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */,
|
||||||
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
||||||
|
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
|
||||||
|
349226D70F51196CCD1C94F8 /* MediaProvider */,
|
||||||
|
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
|
||||||
|
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
||||||
|
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
||||||
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
|
||||||
);
|
);
|
||||||
productName = Onyx;
|
productName = Onyx;
|
||||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -140,6 +263,10 @@
|
|||||||
DevelopmentTeam = PP34X97WS3;
|
DevelopmentTeam = PP34X97WS3;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
|
BABC85A062EFB668A81EBAA7 = {
|
||||||
|
DevelopmentTeam = PP34X97WS3;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 0A2ACEAA7E2F36634202B241 /* Build configuration list for PBXProject "Onyx" */;
|
buildConfigurationList = 0A2ACEAA7E2F36634202B241 /* Build configuration list for PBXProject "Onyx" */;
|
||||||
@@ -160,6 +287,7 @@
|
|||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
|
BABC85A062EFB668A81EBAA7 /* OnyxHelper */,
|
||||||
2EB428ADC48D89BCA28D3EE1 /* Onyx */,
|
2EB428ADC48D89BCA28D3EE1 /* Onyx */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -170,28 +298,119 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */,
|
||||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */,
|
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */,
|
||||||
|
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */,
|
||||||
|
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */,
|
||||||
|
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */,
|
||||||
|
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */,
|
||||||
|
F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */,
|
||||||
|
B0FCCD92AF22F1636482EACD /* sound-waves.svg in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/Scripts/build-mediaremote-adapter.sh",
|
||||||
|
);
|
||||||
|
name = "MediaRemote-Adapter bauen und beilegen";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/quartz.svg",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
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;
|
||||||
|
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.\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 */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
6B11A5AC704B6429B9484789 /* Sources */ = {
|
6B11A5AC704B6429B9484789 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
||||||
|
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */,
|
||||||
|
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
|
||||||
|
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
|
||||||
|
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
|
||||||
|
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
|
||||||
|
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
|
||||||
|
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
|
||||||
|
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */,
|
||||||
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
||||||
5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */,
|
|
||||||
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
||||||
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
A9C106AA3316FA03B999A567 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */,
|
||||||
|
6A972372414D6EFDC81E2661 /* main.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = BABC85A062EFB668A81EBAA7 /* OnyxHelper */;
|
||||||
|
targetProxy = 38EC624350BE3AFC906F5004 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration 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 */ = {
|
42021FACB216529C4EA156E9 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -264,7 +483,6 @@
|
|||||||
MARKETING_VERSION = 0.1.0;
|
MARKETING_VERSION = 0.1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_CODE_SIGN_FLAGS = "--deep";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -274,6 +492,24 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */ = {
|
DEB51EF5615CABC756F24ADD /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -334,7 +570,6 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
OTHER_CODE_SIGN_FLAGS = "--deep";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
@@ -375,6 +610,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Debug;
|
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" */ = {
|
C126F7CB2B14EC196CE8743D /* Build configuration list for PBXNativeTarget "Onyx" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@@ -394,6 +638,10 @@
|
|||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
349226D70F51196CCD1C94F8 /* MediaProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MediaProvider;
|
||||||
|
};
|
||||||
3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = {
|
3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxNotch;
|
productName = OnyxNotch;
|
||||||
@@ -402,6 +650,18 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxDesign;
|
productName = OnyxDesign;
|
||||||
};
|
};
|
||||||
|
5D8203CC3A09D838A4AC385D /* AudioProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = AudioProvider;
|
||||||
|
};
|
||||||
|
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MetricsProvider;
|
||||||
|
};
|
||||||
|
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = OnyxHelperProtocol;
|
||||||
|
};
|
||||||
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
|
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = CalendarProvider;
|
productName = CalendarProvider;
|
||||||
@@ -414,10 +674,26 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxCore;
|
productName = OnyxCore;
|
||||||
};
|
};
|
||||||
|
D4578EB64F7A3372B49BC50C /* WeatherProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = WeatherProvider;
|
||||||
|
};
|
||||||
E66577A40689DF225BCED184 /* OnyxWidgetKit */ = {
|
E66577A40689DF225BCED184 /* OnyxWidgetKit */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxWidgetKit;
|
productName = OnyxWidgetKit;
|
||||||
};
|
};
|
||||||
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ShelfProvider;
|
||||||
|
};
|
||||||
|
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = OnyxHelperProtocol;
|
||||||
|
};
|
||||||
|
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = NetworkProvider;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */;
|
rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */;
|
||||||
|
|||||||
1
Onyx/AppIcon.icon/Assets/quartz (2).svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#d4d7d8" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#e9e9e9" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#e9e9e9" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d4d4d4" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#e5e5e5" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#d4d7d8" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#e9e9e9" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d4d4d4" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#e9e9e9" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#f4f4f4" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
1
Onyx/AppIcon.icon/Assets/quartz (4).svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#505050" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#7a7a7a" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#7a7a7a" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#5d5d5d" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#5b5b5b" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#505050" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#7a7a7a" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#5d5d5d" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#7a7a7a" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#434242" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
67
Onyx/AppIcon.icon/icon.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : {
|
||||||
|
"automatic-gradient" : "display-p3:0.23039,0.38246,0.66250,1.00000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : {
|
||||||
|
"automatic-gradient" : "display-p3:0.26369,0.36702,0.79660,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"hidden" : false,
|
||||||
|
"image-name" : "quartz (2).svg",
|
||||||
|
"name" : "quartz (2)",
|
||||||
|
"opacity-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.39,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name" : "quartz (4).svg",
|
||||||
|
"name" : "quartz (4)",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.39,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
import OnyxNotch
|
import OnyxNotch
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
|
import OnyxMenuBar
|
||||||
|
|
||||||
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
|
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
|
||||||
///
|
///
|
||||||
@@ -39,9 +41,54 @@ final class AppModel {
|
|||||||
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
|
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob Onyx im Dock auftaucht.
|
||||||
|
///
|
||||||
|
/// Standardmäßig nicht: Onyx ist ein Werkzeug, das in der Notch und in der
|
||||||
|
/// Menüleiste lebt, hat kein Hauptfenster und wäre im Dock ein Symbol, das
|
||||||
|
/// beim Anklicken nichts öffnet. Es taucht dann auch im Programmumschalter
|
||||||
|
/// auf und steht dort zwischen den Programmen, mit denen man arbeitet.
|
||||||
|
var showsDockIcon: Bool {
|
||||||
|
didSet {
|
||||||
|
guard showsDockIcon != oldValue else { return }
|
||||||
|
defaults.set(showsDockIcon, forKey: Keys.showsDockIcon)
|
||||||
|
NSApp.setActivationPolicy(showsDockIcon ? .regular : .accessory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Menüleisten-Controller. Wird nach dem Aufbau gesetzt, damit
|
||||||
|
/// Änderungen aus den Einstellungen sofort durchschlagen.
|
||||||
|
weak var menuBar: MenuBarController?
|
||||||
|
|
||||||
|
/// Die gespeicherten Einstellungen eines Menüleisten-Moduls.
|
||||||
|
///
|
||||||
|
/// Standardmäßig ist **kein** Modul an. Sechs Symbole beim ersten Start
|
||||||
|
/// wären eine Zumutung — wer sie will, schaltet sie einzeln ein.
|
||||||
|
func menuBarSettings(for moduleID: String) -> MenuBarModuleSettings {
|
||||||
|
guard let data = defaults.data(forKey: Keys.menuBarModule(moduleID)),
|
||||||
|
let decoded = try? JSONDecoder().decode(MenuBarModuleSettings.self, from: data)
|
||||||
|
else { return MenuBarModuleSettings() }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMenuBarSettings(_ settings: MenuBarModuleSettings, for moduleID: String) {
|
||||||
|
if let data = try? JSONEncoder().encode(settings) {
|
||||||
|
defaults.set(data, forKey: Keys.menuBarModule(moduleID))
|
||||||
|
}
|
||||||
|
menuBar?.setEnabled(settings.isEnabled, forModuleID: moduleID)
|
||||||
|
menuBar?.setPresentation(settings.presentation, forModuleID: moduleID)
|
||||||
|
menuBarRevision += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zählt hoch, wenn sich ein Modul ändert — die Einstellungen zeichnen sich
|
||||||
|
/// daran neu, ohne dass jedes Modul einzeln beobachtet werden müsste.
|
||||||
|
private(set) var menuBarRevision = 0
|
||||||
|
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
|
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
|
||||||
static let displayPolicy = "onyx.displayPolicy"
|
static let displayPolicy = "onyx.displayPolicy"
|
||||||
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
||||||
|
static let showsDockIcon = "onyx.showsDockIcon"
|
||||||
|
static let triggerArea = "onyx.triggerArea"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
|
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
|
||||||
@@ -57,11 +104,40 @@ final class AppModel {
|
|||||||
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
|
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
|
||||||
// zurück, wenn die Notch einmal nicht reagiert.
|
// zurück, wenn die Notch einmal nicht reagiert.
|
||||||
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
|
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
|
||||||
|
self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false
|
||||||
|
self.triggerArea = (defaults.data(forKey: Keys.triggerArea)
|
||||||
|
.flatMap { try? JSONDecoder().decode(TriggerArea.self, from: $0) }) ?? .standard
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Widgets
|
// MARK: - Widgets
|
||||||
|
|
||||||
var panelContentSize: CGSize { WidgetGrid.panelSize(for: layout) }
|
/// Die Auslösefläche der Notch. Wirkt sofort.
|
||||||
|
var triggerArea: TriggerArea {
|
||||||
|
didSet {
|
||||||
|
guard triggerArea != oldValue else { return }
|
||||||
|
if let data = try? JSONEncoder().encode(triggerArea) {
|
||||||
|
defaults.set(data, forKey: Keys.triggerArea)
|
||||||
|
}
|
||||||
|
onTriggerAreaChanged?(triggerArea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var onTriggerAreaChanged: ((TriggerArea) -> Void)?
|
||||||
|
|
||||||
|
var panelContentSize: CGSize {
|
||||||
|
WidgetGrid.panelSize(for: layout, availableWidth: Self.availableWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie breit das Panel höchstens werden darf.
|
||||||
|
///
|
||||||
|
/// Der **schmalste** aktive Bildschirm gibt den Ausschlag: dasselbe Panel
|
||||||
|
/// erscheint auf allen Displays, und eines, das auf dem Notebook über den
|
||||||
|
/// Rand ragt, wäre auf dem großen Monitor kein Trost. Der Abzug lässt Luft
|
||||||
|
/// zum Rand — ein Panel, das exakt bis zur Kante geht, wirkt eingeklemmt.
|
||||||
|
static var availableWidth: CGFloat {
|
||||||
|
let narrowest = NSScreen.screens.map(\.frame.width).min() ?? 1440
|
||||||
|
return narrowest - 120
|
||||||
|
}
|
||||||
|
|
||||||
func isInPanel(_ widgetID: String) -> Bool {
|
func isInPanel(_ widgetID: String) -> Bool {
|
||||||
layout.contains { $0.widgetID == widgetID }
|
layout.contains { $0.widgetID == widgetID }
|
||||||
@@ -69,19 +145,13 @@ final class AppModel {
|
|||||||
|
|
||||||
func add(_ widget: any OnyxWidget) {
|
func add(_ widget: any OnyxWidget) {
|
||||||
guard !isInPanel(widget.id) else { return }
|
guard !isInPanel(widget.id) else { return }
|
||||||
let size = widget.supportedSizes.first ?? .small
|
layout.append(WidgetPlacement(widgetID: widget.id))
|
||||||
layout.append(WidgetPlacement(widgetID: widget.id, size: size))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(_ widgetID: String) {
|
func remove(_ widgetID: String) {
|
||||||
layout.removeAll { $0.widgetID == widgetID }
|
layout.removeAll { $0.widgetID == widgetID }
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSize(_ size: WidgetSize, for widgetID: String) {
|
|
||||||
guard let index = layout.firstIndex(where: { $0.widgetID == widgetID }) else { return }
|
|
||||||
layout[index].size = size
|
|
||||||
}
|
|
||||||
|
|
||||||
func move(fromOffsets source: IndexSet, toOffset destination: Int) {
|
func move(fromOffsets source: IndexSet, toOffset destination: Int) {
|
||||||
layout.move(fromOffsets: source, toOffset: destination)
|
layout.move(fromOffsets: source, toOffset: destination)
|
||||||
}
|
}
|
||||||
|
|||||||
142
Onyx/ColorSettingsView.swift
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
|
||||||
|
/// Die Farbwahl.
|
||||||
|
///
|
||||||
|
/// Drei Farben, nicht zehn: die Akzentfarbe, mit der Onyx sich selbst zeichnet,
|
||||||
|
/// und das Paar für Herunter- und Hochladen. Die Signalfarben stehen bewusst
|
||||||
|
/// nicht hier — Gelb heißt Warnung, Rot heißt kritisch, und wer das umstellen
|
||||||
|
/// kann, kann die Bedeutung zerstören.
|
||||||
|
struct ColorSettingsView: View {
|
||||||
|
|
||||||
|
@Bindable var theme: OnyxTheme
|
||||||
|
|
||||||
|
/// Ein paar Vorschläge, damit man nicht im Farbwähler anfangen muss.
|
||||||
|
///
|
||||||
|
/// Alle gedämpft: Onyx ist eine dunkle Fläche, und eine volle Sättigung
|
||||||
|
/// darauf sticht, statt zu leiten.
|
||||||
|
private static let suggestions: [(name: LocalizedStringKey, hex: String)] = [
|
||||||
|
("color.suggestion.onyx", "#7FA8FF"),
|
||||||
|
("color.suggestion.mint", "#6FCF97"),
|
||||||
|
("color.suggestion.amber", "#E8B44A"),
|
||||||
|
("color.suggestion.coral", "#E5675C"),
|
||||||
|
("color.suggestion.lilac", "#B79CFF"),
|
||||||
|
("color.suggestion.slate", "#9AA6B2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("color.section.accent") {
|
||||||
|
ColorPicker(selection: $theme.accent, supportsOpacity: false) {
|
||||||
|
Text("color.accent")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Self.suggestions, id: \.hex) { suggestion in
|
||||||
|
Swatch(hex: suggestion.hex,
|
||||||
|
name: suggestion.name,
|
||||||
|
isSelected: theme.accent.onyxHex == suggestion.hex) {
|
||||||
|
theme.accent = Color(onyxHex: suggestion.hex) ?? theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit dem Farbfleck daneben: welche Farbe das System gerade
|
||||||
|
// führt, weiß man nicht auswendig — und dann wirkt das
|
||||||
|
// Ergebnis wie ein Fehler statt wie eine Übernahme.
|
||||||
|
Button { theme.adoptSystemAccent() } label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("color.adoptSystem")
|
||||||
|
Circle()
|
||||||
|
.fill(Color(nsColor: .controlAccentColor))
|
||||||
|
.frame(width: 11, height: 11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("color.accent.hint")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("color.section.network") {
|
||||||
|
ColorPicker(selection: $theme.download, supportsOpacity: false) {
|
||||||
|
Text("color.download")
|
||||||
|
}
|
||||||
|
ColorPicker(selection: $theme.upload, supportsOpacity: false) {
|
||||||
|
Text("color.upload")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
let previous = theme.download
|
||||||
|
theme.download = theme.upload
|
||||||
|
theme.upload = previous
|
||||||
|
} label: {
|
||||||
|
Text("color.swap")
|
||||||
|
}
|
||||||
|
Text("color.network.hint")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("color.section.fixed") {
|
||||||
|
// Sichtbar, aber nicht bedienbar: sonst sucht man die
|
||||||
|
// Warnfarbe hier und findet sie nirgends.
|
||||||
|
FixedRow(color: Onyx.Color.positive, label: "color.fixed.positive")
|
||||||
|
FixedRow(color: Onyx.Color.warning, label: "color.fixed.warning")
|
||||||
|
FixedRow(color: Onyx.Color.critical, label: "color.fixed.critical")
|
||||||
|
Text("color.fixed.hint")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button { theme.reset() } label: {
|
||||||
|
Text("color.reset")
|
||||||
|
}
|
||||||
|
.disabled(theme.isDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||||
|
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Farbfeld zum Anklicken.
|
||||||
|
private struct Swatch: View {
|
||||||
|
let hex: String
|
||||||
|
let name: LocalizedStringKey
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(onyxHex: hex) ?? .gray)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.overlay {
|
||||||
|
// Der Ring statt eines Hakens: ein Haken auf einer dunklen
|
||||||
|
// Farbe ist nicht zu sehen, auf einer hellen nicht auf
|
||||||
|
// einer dunklen Fläche.
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(.primary, lineWidth: isSelected ? 2 : 0)
|
||||||
|
.padding(-3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(Text(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FixedRow: View {
|
||||||
|
let color: Color
|
||||||
|
let label: LocalizedStringKey
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Circle().fill(color).frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
Onyx/FanControl.swift
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
|
import OSLog
|
||||||
|
import OnyxHelperProtocol
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
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: ChargeLimitReading {
|
||||||
|
|
||||||
|
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?
|
||||||
|
/// Ob die Registrierung in diesem Programmlauf schon einmal erneuert wurde.
|
||||||
|
/// Einmal ist Selbstheilung, immer wieder wäre eine Schleife.
|
||||||
|
private var hasRepairedRegistration = false
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/// Die XPC-Verbindung.
|
||||||
|
///
|
||||||
|
/// **Jeder Rückruf hier ist `@Sendable`, und das ist kein Feinschliff.**
|
||||||
|
/// Ohne die Auszeichnung erbt der Abschluss die MainActor-Isolation dieser
|
||||||
|
/// Methode. XPC ruft ihn aber auf seiner eigenen Warteschlange, Swift 6
|
||||||
|
/// prüft das zur Laufzeit — und beendet den Prozess:
|
||||||
|
///
|
||||||
|
/// Thread 8: com.apple.NSXPCConnection.m-user…onyx.helper
|
||||||
|
/// swift_task_isCurrentExecutorWithFlags
|
||||||
|
/// closure #3 in FanControl.proxy()
|
||||||
|
/// EXC_BREAKPOINT
|
||||||
|
///
|
||||||
|
/// Das `Task { @MainActor in … }` im Rumpf half nicht: die Prüfung
|
||||||
|
/// geschieht beim **Betreten** des Abschlusses, nicht beim Zugriff.
|
||||||
|
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 = { @Sendable [weak self] in
|
||||||
|
Task { @MainActor in self?.connection = nil }
|
||||||
|
}
|
||||||
|
connection.interruptionHandler = { @Sendable [weak self] in
|
||||||
|
Task { @MainActor in self?.connection = nil }
|
||||||
|
}
|
||||||
|
connection.resume()
|
||||||
|
self.connection = connection
|
||||||
|
}
|
||||||
|
return connection?.remoteObjectProxyWithErrorHandler { @Sendable [weak self] error in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.lastError = error.localizedDescription
|
||||||
|
log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)")
|
||||||
|
await self?.repairRegistration()
|
||||||
|
}
|
||||||
|
} as? OnyxHelperProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet den Helfer ab und wieder an.
|
||||||
|
///
|
||||||
|
/// **Der Fall, für den das da ist: die App ist umgezogen.** Aus dem
|
||||||
|
/// Ordner „Downloads" nach „Programme", oder vom Bauverzeichnis in ein
|
||||||
|
/// installiertes Bundle. macOS bindet den privilegierten Dienst beim
|
||||||
|
/// Anmelden an das Programm, das ihn angemeldet hat — nach dem Umzug ist
|
||||||
|
/// das ein anderes Bundle. Der alte Dienst läuft weiter, redet aber nicht
|
||||||
|
/// mit dem neuen Programm.
|
||||||
|
///
|
||||||
|
/// Von außen ist das die verwirrendste Kombination, die es gibt: der
|
||||||
|
/// Zustand meldet „eingerichtet", und trotzdem kommt bei jedem Zugriff
|
||||||
|
/// „Kommunikation nicht möglich". Ohne diese Reparatur bleibt nur, den
|
||||||
|
/// Helfer von Hand zu entfernen und neu einzurichten — und darauf muss
|
||||||
|
/// man erst einmal kommen.
|
||||||
|
///
|
||||||
|
/// Danach steht der Dienst auf „wartet auf Freigabe": das Verschieben
|
||||||
|
/// eines Programms mit Root-Dienst darf niemand still hinnehmen, auch
|
||||||
|
/// diese App nicht.
|
||||||
|
private func repairRegistration() async {
|
||||||
|
guard !hasRepairedRegistration, installState == .installed else { return }
|
||||||
|
hasRepairedRegistration = true
|
||||||
|
log.notice("Helfer nicht erreichbar — Registrierung wird erneuert")
|
||||||
|
|
||||||
|
disconnect()
|
||||||
|
try? await service.unregister()
|
||||||
|
// launchd räumt den alten Dienst nicht im selben Atemzug ab.
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
install()
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
refreshChargeLimit()
|
||||||
|
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 { @Sendable [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) { @Sendable [weak self] data in
|
||||||
|
Task { @MainActor in self?.apply(data) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setAutomatic(index: Int) {
|
||||||
|
proxy()?.setFanAutomatic(index: index) { @Sendable [weak self] data in
|
||||||
|
Task { @MainActor in self?.apply(data) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ladelimit
|
||||||
|
|
||||||
|
/// Das eingestellte Ladelimit in Prozent. `nil` heißt: keines, oder noch
|
||||||
|
/// nicht gelesen.
|
||||||
|
public private(set) var chargeLimit: Int?
|
||||||
|
|
||||||
|
public func refreshChargeLimit() {
|
||||||
|
proxy()?.chargeLimit { @Sendable [weak self] percent in
|
||||||
|
Task { @MainActor in self?.chargeLimit = percent > 0 ? percent : nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt das Limit und übernimmt, was der Helfer **zurückgelesen** hat —
|
||||||
|
/// nicht das, was gewünscht war. Ein Schreibvorgang, den der SMC verwirft,
|
||||||
|
/// darf sich in der Oberfläche nicht als Erfolg zeigen.
|
||||||
|
/// Ohne laufenden Helfer kommt kein Wert — dann steht ein Strich da statt
|
||||||
|
/// einer Zahl, die es nicht gibt.
|
||||||
|
public var canReadCharge: Bool { installState == .installed }
|
||||||
|
}
|
||||||
193
Onyx/FanSettingsView.swift
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import MetricsProvider
|
||||||
|
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
|
||||||
|
// Das Ladelimit hängt am selben Helfer wie die Lüfter — beides
|
||||||
|
// ist Hardwaresteuerung über den SMC, und beides ist erst
|
||||||
|
// erreichbar, wenn der Helfer läuft.
|
||||||
|
Section("charge.section") {
|
||||||
|
ChargeLimitControl(control: control, roomy: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||||
|
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.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(Onyx.Color.warning)
|
||||||
|
.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(Onyx.Color.critical)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? Onyx.Color.critical : .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(Onyx.Color.critical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(Onyx.Color.warning)
|
||||||
|
.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
Onyx/FanWidget.swift
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
import OnyxMenuBar
|
||||||
|
import OnyxHelperProtocol
|
||||||
|
|
||||||
|
/// Die Lüfter — als Panel-Kachel und als Menüleistenmodul.
|
||||||
|
///
|
||||||
|
/// Beide zeigen dieselbe Ansicht auf derselben Datenquelle. Die Steuerung nur
|
||||||
|
/// in den Einstellungen zu verstecken war falsch: Lüfter regelt man, wenn es
|
||||||
|
/// gerade laut ist, und dann will man nicht erst ein Fenster suchen.
|
||||||
|
struct FanView: View {
|
||||||
|
let control: FanControl
|
||||||
|
/// Im Popover ist Platz für die Erklärung, in der Kachel nicht.
|
||||||
|
let roomy: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
switch control.installState {
|
||||||
|
case .installed:
|
||||||
|
if let report = control.report, !report.fans.isEmpty {
|
||||||
|
ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in
|
||||||
|
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
||||||
|
}
|
||||||
|
if let hottest = report.hottestCelsius {
|
||||||
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "thermometer.medium")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text("\(Int(hottest.rounded())) °C")
|
||||||
|
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notice("fans.noData", symbol: "fan")
|
||||||
|
}
|
||||||
|
case .requiresApproval:
|
||||||
|
notice("fans.approval.needed", symbol: "hand.raised") {
|
||||||
|
control.openApprovalSettings()
|
||||||
|
}
|
||||||
|
case .notInstalled:
|
||||||
|
notice("fans.notInstalled", symbol: "fan") { control.install() }
|
||||||
|
case .failed(let reason):
|
||||||
|
Text(reason)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.critical)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.onAppear { control.refreshInstallState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func notice(_ key: LocalizedStringKey, symbol: String,
|
||||||
|
action: (() -> Void)? = nil) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: 15, weight: .light))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Text(key)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let action {
|
||||||
|
Button(action: action) {
|
||||||
|
Text("fans.install")
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.surface)
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 4)
|
||||||
|
.background(Onyx.Color.accent, in: .capsule)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FanRow: View {
|
||||||
|
let control: FanControl
|
||||||
|
let index: Int
|
||||||
|
let fan: FanStatus
|
||||||
|
let roomy: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("fans.fan \(fan.index + 1)")
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
Text("fans.rpm \(Int(fan.currentRPM))")
|
||||||
|
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
|
||||||
|
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
|
||||||
|
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
|
||||||
|
// aus wie ein Link und nicht wie ein Zustand.
|
||||||
|
Button { control.setAutomatic(index: index) } label: {
|
||||||
|
Text("fans.auto")
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
|
||||||
|
: Onyx.Color.surface)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background {
|
||||||
|
Capsule()
|
||||||
|
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
|
||||||
|
.overlay {
|
||||||
|
Capsule().strokeBorder(
|
||||||
|
fan.isManual ? Onyx.Color.hairline : .clear,
|
||||||
|
lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!fan.isManual)
|
||||||
|
|
||||||
|
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
|
||||||
|
// Helfer keine brauchbaren Grenzen — etwa weil der Lüfter steht
|
||||||
|
// und der SMC nichts hergibt —, gibt es nichts zu ziehen.
|
||||||
|
if fan.limits.maximum > fan.limits.minimum {
|
||||||
|
Slider(value: Binding(
|
||||||
|
get: { min(max(fan.targetRPM, fan.limits.minimum), fan.limits.maximum) },
|
||||||
|
set: { control.setTarget(index: index, rpm: $0) }),
|
||||||
|
in: fan.limits.minimum...fan.limits.maximum)
|
||||||
|
.controlSize(.mini)
|
||||||
|
} else {
|
||||||
|
Text("fans.noRange")
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Panel-Kachel
|
||||||
|
|
||||||
|
struct FanWidget: OnyxWidget {
|
||||||
|
let id = "fans"
|
||||||
|
var displayName: String { String(localized: "widget.fans.name") }
|
||||||
|
let symbolName = "fan"
|
||||||
|
|
||||||
|
private let control: FanControl
|
||||||
|
|
||||||
|
init(control: FanControl) { self.control = control }
|
||||||
|
|
||||||
|
func makeView() -> AnyView {
|
||||||
|
AnyView(ScrollView { FanView(control: control, roomy: false) }
|
||||||
|
.scrollIndicators(.never))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Menüleistenmodul
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FanMenuBarModule: MenuBarModule {
|
||||||
|
let id = "fans"
|
||||||
|
var displayName: String { String(localized: "widget.fans.name") }
|
||||||
|
|
||||||
|
var shortLabel: String { String(localized: "fans.menubar.short") }
|
||||||
|
|
||||||
|
private let control: FanControl
|
||||||
|
private var view: FanStatusView?
|
||||||
|
private var isActive = false
|
||||||
|
|
||||||
|
init(control: FanControl) { self.control = control }
|
||||||
|
|
||||||
|
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||||
|
let view = FanStatusView(presentation: presentation)
|
||||||
|
view.update(rpm: control.report?.fans.map(\.currentRPM).max())
|
||||||
|
self.view = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePopoverView() -> AnyView {
|
||||||
|
AnyView(FanView(control: control, roomy: true)
|
||||||
|
.padding(14)
|
||||||
|
.frame(width: 260))
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
isActive = true
|
||||||
|
control.start()
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate() {
|
||||||
|
isActive = false
|
||||||
|
view = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keine eigene Messschleife: `FanControl` fragt den Helfer ohnehin ab, hier
|
||||||
|
/// wird nur zugehört. `withObservationTracking` meldet sich **einmal** und
|
||||||
|
/// muss danach neu eingerichtet werden.
|
||||||
|
private func startObserving() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = control.report
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, isActive else { return }
|
||||||
|
view?.update(rpm: control.report?.fans.map(\.currentRPM).max())
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Drehzahl in der Menüleiste.
|
||||||
|
private final class FanStatusView: NSView, WidthReporting {
|
||||||
|
private let presentation: MenuBarPresentation
|
||||||
|
private var rpm: Double?
|
||||||
|
|
||||||
|
init(presentation: MenuBarPresentation) {
|
||||||
|
self.presentation = presentation
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
NSSize(width: MenuBarText.width(for: reference, includesSymbol: true), height: 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die breiteste Zahl, die vorkommen kann — nicht die aktuelle. Sonst
|
||||||
|
/// änderte das Element bei jeder Messung seine Breite.
|
||||||
|
private var reference: String { presentation == .symbol ? "" : "9999" }
|
||||||
|
|
||||||
|
/// Der Lüfter zeigt ohnehin Symbol und Zahl — die neuen Darstellungen
|
||||||
|
/// ändern daran nichts, sie stehen nur in der Auswahl nicht zur Verfügung.
|
||||||
|
|
||||||
|
var onWidthChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
|
func update(rpm: Double?) {
|
||||||
|
self.rpm = rpm
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
let color = NSColor.labelColor
|
||||||
|
|
||||||
|
if presentation == .symbol {
|
||||||
|
MenuBarText.drawSymbol("fan", color: color,
|
||||||
|
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
|
||||||
|
width: 15, height: 15))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
MenuBarText.drawSymbol("fan", color: color,
|
||||||
|
in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13))
|
||||||
|
MenuBarText.draw(rpm.map { String(Int($0.rounded())) } ?? "–",
|
||||||
|
color: color,
|
||||||
|
in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,14 @@
|
|||||||
<string>Onyx zeigt das Wetter an deinem aktuellen Standort. Du kannst stattdessen auch einen festen Ort wählen.</string>
|
<string>Onyx zeigt das Wetter an deinem aktuellen Standort. Du kannst stattdessen auch einen festen Ort wählen.</string>
|
||||||
<key>NSAppleEventsUsageDescription</key>
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
<string>Onyx steuert Spotify und Musik, wenn die universelle Medienschnittstelle nicht verfügbar ist.</string>
|
<string>Onyx steuert Spotify und Musik, wenn die universelle Medienschnittstelle nicht verfügbar ist.</string>
|
||||||
|
<key>NSDesktopFolderUsageDescription</key>
|
||||||
|
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche, damit man den nachgebildeten Bildschirm wiedererkennt. Liegt das Bild auf dem Schreibtisch, braucht Onyx dafür einmal deine Erlaubnis.</string>
|
||||||
|
<key>NSDocumentsFolderUsageDescription</key>
|
||||||
|
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Dokumente", braucht Onyx dafür einmal deine Erlaubnis.</string>
|
||||||
|
<key>NSDownloadsFolderUsageDescription</key>
|
||||||
|
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Downloads", braucht Onyx dafür einmal deine Erlaubnis.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts.</string>
|
||||||
<key>NSAudioCaptureUsageDescription</key>
|
<key>NSAudioCaptureUsageDescription</key>
|
||||||
<string>Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.</string>
|
<string>Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.</string>
|
||||||
|
|
||||||
|
|||||||
55
Onyx/LaunchAtLogin.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Onyx/MenuBarIcon.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve" width="512" height="512">
|
||||||
|
<g>
|
||||||
|
<path style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d=" 		M197.235,67.578L115.2,158.68c-1.702,1.89-2.922,4.165-3.553,6.629L56.541,380.415c-0.943,3.68,0.176,7.582,2.926,10.203 		l116.456,110.985c1.952,1.86,4.545,2.897,7.241,2.897h145.672c2.696,0,5.289-1.036,7.241-2.896l116.456-110.986 		c2.75-2.621,3.869-6.523,2.926-10.203l-55.107-215.106c-0.631-2.464-1.851-4.739-3.553-6.629l-133-147.698 		c-4.169-4.63-11.43-4.63-15.599,0l-27.346,30.366"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="256" y1="190.51" x2="256" y2="7.5"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="399.03" y1="161.9" x2="316.4" y2="242.94"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="195.57" y1="242.91" x2="112.97" y2="161.9"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="455.33" y1="386.08" x2="354.95" y2="368.57"/>
|
||||||
|
|
||||||
|
<polyline style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" points=" 		297.8,415.14 332.74,503.75 332.75,503.75 	"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="214.18" y1="415.2" x2="179.26" y2="503.75"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="153.05" y1="368.51" x2="56.66" y2="386.07"/>
|
||||||
|
<path style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d=" 		M344.163,329.755l10.821,34.288c0.912,2.891-0.044,6.047-2.409,7.943l-51.664,41.433c-1.863,1.494-4.181,2.31-6.568,2.31h-76.882 		c-2.267,0-4.474-0.734-6.289-2.092l-55.56-41.575c-2.565-1.919-3.591-5.278-2.536-8.302l41.358-118.549 		c0.581-1.665,1.572-3.156,2.882-4.337l53.764-48.468c2.796-2.52,7.046-2.52,9.841,0l53.638,48.355 		c1.389,1.252,2.418,2.853,2.981,4.637l16.211,51.366"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
139
Onyx/MixerMenuBarModule.swift
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import AudioProvider
|
||||||
|
import OnyxMenuBar
|
||||||
|
import OnyxDesign
|
||||||
|
|
||||||
|
/// Der Lautstärkemixer als eigenes Element in der Menüleiste.
|
||||||
|
///
|
||||||
|
/// Bewusst getrennt vom Onyx-Element: den Ton einer einzelnen App leiser zu
|
||||||
|
/// drehen ist etwas, das man mitten in etwas anderem tut. Ein Umweg über ein
|
||||||
|
/// Menü und dann in ein Panel ist dafür zwei Schritte zu weit.
|
||||||
|
@MainActor
|
||||||
|
final class MixerMenuBarModule: MenuBarModule {
|
||||||
|
let id = "audio"
|
||||||
|
var displayName: String { String(localized: "mixer.menubar.name") }
|
||||||
|
|
||||||
|
var shortLabel: String { String(localized: "mixer.menubar.short") }
|
||||||
|
|
||||||
|
private let mixer: AudioMixer
|
||||||
|
private let system: SystemAudio
|
||||||
|
private var view: MixerStatusView?
|
||||||
|
private var isActive = false
|
||||||
|
|
||||||
|
init(mixer: AudioMixer, system: SystemAudio) {
|
||||||
|
self.mixer = mixer
|
||||||
|
self.system = system
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||||
|
let view = MixerStatusView()
|
||||||
|
view.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count)
|
||||||
|
self.view = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pegel nur, solange das Popover offen ist.
|
||||||
|
///
|
||||||
|
/// Der Mikrofonpegel schaltet den orangen Punkt an — das darf keine
|
||||||
|
/// Sekunde länger dauern als das Hinsehen. An der View ließ es sich nicht
|
||||||
|
/// festmachen: `onDisappear` bleibt beim Popover aus, und der Punkt blieb
|
||||||
|
/// dauerhaft stehen.
|
||||||
|
func popoverDidOpen() { system.levels.addConsumer() }
|
||||||
|
func popoverDidClose() { system.levels.removeConsumer() }
|
||||||
|
|
||||||
|
func makePopoverView() -> AnyView {
|
||||||
|
AnyView(
|
||||||
|
ScrollView {
|
||||||
|
MixerView(mixer: mixer, system: system)
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.automatic)
|
||||||
|
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
|
||||||
|
// Eine Zeile aus Symbol, Stummschalter, Regler und Wert braucht
|
||||||
|
// Platz, und ein Regler von hundert Punkten Länge lässt sich nicht
|
||||||
|
// fein einstellen.
|
||||||
|
.frame(width: 420)
|
||||||
|
.frame(maxHeight: 620)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
isActive = true
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate() {
|
||||||
|
isActive = false
|
||||||
|
view = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Mixer misst ohnehin; hier wird nur zugehört. `withObservationTracking`
|
||||||
|
/// meldet sich einmal und muss danach neu eingerichtet werden.
|
||||||
|
private func startObserving() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = mixer.isEnabled
|
||||||
|
_ = mixer.managed
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, isActive else { return }
|
||||||
|
view?.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count)
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Glyph, mehr nicht.
|
||||||
|
///
|
||||||
|
/// Eine Zahl wäre hier sinnlos: „welche Lautstärke" hat bei einem Mischpult
|
||||||
|
/// keine einzelne Antwort. Der Zustand, der zählt, ist ob überhaupt etwas
|
||||||
|
/// geregelt wird — und den zeigt die Deckkraft.
|
||||||
|
private final class MixerStatusView: NSView {
|
||||||
|
private var isEnabled = false
|
||||||
|
private var managed = 0
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22))
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
NSSize(width: MenuBarText.minimumWidth, height: 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(isEnabled: Bool, managed: Int) {
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
self.managed = managed
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
// Volle Deckkraft, auch ausgeschaltet.
|
||||||
|
//
|
||||||
|
// Die Abdunklung war meine Idee und ein Fehlgriff: in der Menüleiste
|
||||||
|
// liegt oft ein heller Schreibtischhintergrund, und ein zu 55 % blasses
|
||||||
|
// Glyph ist dort praktisch weg. Ob der Mixer läuft, erfährt man beim
|
||||||
|
// Klick — dafür muss man das Symbol aber erst finden.
|
||||||
|
let rect = NSRect(x: bounds.midX - 8, y: bounds.midY - 8, width: 16, height: 16)
|
||||||
|
|
||||||
|
if let glyph = Self.glyph {
|
||||||
|
// `labelColor` und nicht Weiß: unter einer hellen Menüleiste wäre
|
||||||
|
// ein weißes Symbol unsichtbar. Auf dunklem Grund ist labelColor
|
||||||
|
// weiß, also genau das Gewünschte.
|
||||||
|
MenuBarText.drawTemplate(glyph, color: .labelColor, in: rect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
MenuBarText.drawSymbol("slider.horizontal.3", color: .labelColor, in: rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die eigene Vorlage aus dem Bundle. Einmal geladen und behalten — das
|
||||||
|
/// Zeichnen läuft bei jeder Änderung, das Einlesen einer SVG nicht.
|
||||||
|
private static let glyph: NSImage? = {
|
||||||
|
guard let url = Bundle.main.url(forResource: "sound-waves", withExtension: "svg")
|
||||||
|
else { return nil }
|
||||||
|
return NSImage(contentsOf: url)
|
||||||
|
}()
|
||||||
|
}
|
||||||
210
Onyx/OnboardingView.swift
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
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)
|
||||||
|
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx)
|
||||||
|
.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 ? Onyx.Color.positive : .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(Onyx.Color.positive)
|
||||||
|
.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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
95
Onyx/OnboardingWindowController.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
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
|
||||||
|
// Nicht direkt unter die Notch: wer den Schließknopf trifft,
|
||||||
|
// fährt sonst durch die Auslösefläche.
|
||||||
|
if let screen = NSScreen.main {
|
||||||
|
let visible = screen.visibleFrame
|
||||||
|
window.setFrameOrigin(NSPoint(x: visible.midX - window.frame.width / 2,
|
||||||
|
y: visible.maxY - 60 - window.frame.height))
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie bei den Einstellungen: ausblenden statt schließen, damit ein
|
||||||
|
/// Hilfsprogramm mit „letztes Fenster zu = App beenden" Onyx nicht mit
|
||||||
|
/// wegräumt.
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
|
defaults.set(true, forKey: Self.doneKey)
|
||||||
|
hide()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hide() {
|
||||||
|
window?.orderOut(nil)
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,47 @@
|
|||||||
der Host mit Team-Zertifikat. Ohne das wird es nicht geladen. -->
|
der Host mit Team-Zertifikat. Ohne das wird es nicht geladen. -->
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
||||||
|
<!-- Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements —
|
||||||
|
auch ohne Sandbox. Das ist die verbreitete Fehlannahme: sie gelten
|
||||||
|
nicht nur für sandboxed Apps.
|
||||||
|
|
||||||
|
Fehlt eine davon, weigert sich macOS, den Berechtigungsdialog
|
||||||
|
überhaupt anzuzeigen. Die Anfrage kommt kommentarlos mit `false`
|
||||||
|
zurück, der Status bleibt `notDetermined`, und die App taucht in den
|
||||||
|
Systemeinstellungen nie auf — dort lässt sich auch nichts von Hand
|
||||||
|
nachtragen. Der Grund steht ausschließlich im Protokoll von tccd:
|
||||||
|
|
||||||
|
"Prompting policy for hardened runtime; service: kTCCServiceCalendar
|
||||||
|
requires entitlement com.apple.security.personal-information.calendars
|
||||||
|
but it is missing"
|
||||||
|
|
||||||
|
Nachgemessen mit einer minimalen Test-App: ohne die Entitlement kein
|
||||||
|
Dialog, mit ihr sofort. -->
|
||||||
|
|
||||||
|
<!-- Kalender-Widget (Phase 3) -->
|
||||||
|
<key>com.apple.security.personal-information.calendars</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Wetter am aktuellen Standort (Phase 3) -->
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- AppleScript-Rückfall für Spotify und Musik (Phase 4) -->
|
||||||
|
<key>com.apple.security.automation.apple-events</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Per-App-Lautstärke über Core-Audio-Taps (Phase 7). Aufgezeichnet wird
|
||||||
|
nichts; die Berechtigung heißt nur so. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Wetterdaten von Apple.
|
||||||
|
Anders als die Einträge darüber genügt hier die Entitlement allein
|
||||||
|
nicht: sie erzwingt ein echtes Provisioning-Profil, und die App-ID
|
||||||
|
com.scarriffleservices.onyx muss im Developer-Portal die
|
||||||
|
WeatherKit-Capability tragen. -->
|
||||||
|
<key>com.apple.developer.weatherkit</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import OnyxNotch
|
|||||||
import OnyxMenuBar
|
import OnyxMenuBar
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import WeatherProvider
|
||||||
|
import MediaProvider
|
||||||
|
import MetricsProvider
|
||||||
|
import NetworkProvider
|
||||||
|
import AudioProvider
|
||||||
|
import ShelfProvider
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct OnyxApp: App {
|
struct OnyxApp: App {
|
||||||
@@ -30,7 +39,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var menuBar: MenuBarController?
|
private var menuBar: MenuBarController?
|
||||||
private var onyxItem: NSStatusItem?
|
private var onyxItem: NSStatusItem?
|
||||||
private var calendarModel: CalendarModel?
|
private var calendarModel: CalendarModel?
|
||||||
|
private var weatherModel: WeatherModel?
|
||||||
|
private var mediaModel: MediaModel?
|
||||||
|
private var metricsModel: MetricsModel?
|
||||||
|
private var networkModel: NetworkModel?
|
||||||
|
private var shelfStore: ShelfStore?
|
||||||
private let settingsWindow = SettingsWindowController()
|
private let settingsWindow = SettingsWindowController()
|
||||||
|
private let fanControl = FanControl()
|
||||||
|
private let audioMixer = AudioMixer()
|
||||||
|
private let systemAudio = SystemAudio()
|
||||||
|
private let launchAtLogin = LaunchAtLogin()
|
||||||
|
private let onboarding = OnboardingWindowController()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
@@ -48,8 +67,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
let model = AppModel()
|
let model = AppModel()
|
||||||
self.model = model
|
self.model = model
|
||||||
|
// Die gespeicherte Wahl gleich anwenden — sonst steht Onyx nach einem
|
||||||
|
// Neustart wieder ohne Dock-Symbol da, obwohl es eingeschaltet ist.
|
||||||
|
NSApp.setActivationPolicy(model.showsDockIcon ? .regular : .accessory)
|
||||||
|
|
||||||
let coordinator = NotchCoordinator(policy: model.displayPolicy) { _, presentation in
|
let coordinator = NotchCoordinator(policy: model.displayPolicy,
|
||||||
|
trigger: model.triggerArea) { _, presentation in
|
||||||
NotchHostingView(
|
NotchHostingView(
|
||||||
rootView: NotchContainer(presentation: presentation) {
|
rootView: NotchContainer(presentation: presentation) {
|
||||||
NotchPanelContent(model: model)
|
NotchPanelContent(model: model)
|
||||||
@@ -63,14 +86,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem
|
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem
|
||||||
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
|
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
|
||||||
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
|
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
|
||||||
|
model.onTriggerAreaChanged = { [weak self] area in
|
||||||
|
self?.coordinator?.trigger = area
|
||||||
|
}
|
||||||
model.onDisplayPolicyChanged = { [weak self] policy in
|
model.onDisplayPolicyChanged = { [weak self] policy in
|
||||||
self?.coordinator?.policy = policy
|
self?.coordinator?.policy = policy
|
||||||
self?.applyContentSize()
|
self?.applyContentSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
let menuBar = MenuBarController()
|
let menuBar = MenuBarController()
|
||||||
|
if let metricsModel {
|
||||||
|
for metric in MetricKind.allCases {
|
||||||
|
menuBar.register(MetricMenuBarModule(metric: metric, model: metricsModel),
|
||||||
|
settings: model.menuBarSettings(for: "metric.\(metric.rawValue)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let networkModel {
|
||||||
|
menuBar.register(NetworkMenuBarModule(model: networkModel),
|
||||||
|
settings: model.menuBarSettings(for: "network"))
|
||||||
|
}
|
||||||
|
// Der Ausgangspegel muss die Programme auslassen, die der Mixer bereits
|
||||||
|
// abgreift — nur der Mixer weiß, welche das sind.
|
||||||
|
systemAudio.levels.mutedProcesses = { [weak audioMixer] in
|
||||||
|
audioMixer?.tappedProcessIDs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte:
|
||||||
|
// beides regelt man mitten in etwas anderem, und dafür ist der Weg
|
||||||
|
// über ein Fenster zu weit.
|
||||||
|
menuBar.register(MixerMenuBarModule(mixer: audioMixer, system: systemAudio),
|
||||||
|
settings: model.menuBarSettings(for: "audio"))
|
||||||
|
menuBar.register(FanMenuBarModule(control: fanControl),
|
||||||
|
settings: model.menuBarSettings(for: "fans"))
|
||||||
menuBar.start()
|
menuBar.start()
|
||||||
self.menuBar = menuBar
|
self.menuBar = menuBar
|
||||||
|
model.menuBar = menuBar
|
||||||
|
|
||||||
|
// Der Mixer läuft nur an, wenn das Häkchen gesetzt ist — er greift in
|
||||||
|
// den Tonweg ein, und das soll niemandem ungefragt passieren.
|
||||||
|
audioMixer.enableIfWanted()
|
||||||
|
|
||||||
installOnyxStatusItem()
|
installOnyxStatusItem()
|
||||||
observeMenuBarPreference()
|
observeMenuBarPreference()
|
||||||
@@ -86,11 +140,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
/// dort freischalten könnte. Genau dieser Zustand ist beim Testen
|
/// dort freischalten könnte. Genau dieser Zustand ist beim Testen
|
||||||
/// aufgetreten.
|
/// aufgetreten.
|
||||||
private func requestPermissions() {
|
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
|
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
|
||||||
// das Widget den Berechtigungshinweis statt einer leeren Liste.
|
// das Widget den Berechtigungshinweis statt einer leeren Liste.
|
||||||
calendarModel.refresh()
|
calendarModel.refresh()
|
||||||
guard !calendarModel.hasAccess else { return }
|
|
||||||
|
// 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.
|
// Nicht im Hintergrund anfragen.
|
||||||
//
|
//
|
||||||
@@ -102,24 +171,77 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// `NSApp.activate` davor hilft nicht: Aktivierung wirkt asynchron und
|
// `NSApp.activate` davor hilft nicht: Aktivierung wirkt asynchron und
|
||||||
// ist beim Aufruf noch nicht durch.
|
// ist beim Aufruf noch nicht durch.
|
||||||
//
|
//
|
||||||
// Deshalb der ehrliche Weg: beim ersten Start das Einstellungsfenster
|
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
|
||||||
// öffnen. Dort ist ein echtes Fenster im Vordergrund, der Dialog
|
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
|
||||||
// erscheint zuverlässig — und der Nutzer weiß, warum gefragt wird.
|
// genügt der Berechtigungsbereich der Einstellungen.
|
||||||
showSettings(tab: .permissions)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ausdrücklich `false`, auch wenn es der Standard ist.
|
||||||
|
///
|
||||||
|
/// Onyx lebt in der Menüleiste und in der Notch. Dass das letzte Fenster
|
||||||
|
/// zugeht, sagt über das Beenden nichts aus — und die Voreinstellung darf
|
||||||
|
/// sich nicht unbemerkt ändern.
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
coordinator?.stop()
|
coordinator?.stop()
|
||||||
menuBar?.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()
|
||||||
|
// Ohne das bleiben die abgegriffenen Programme stumm zurück: der Tap
|
||||||
|
// schaltet das Original stumm, und ohne laufenden Mixer gibt niemand
|
||||||
|
// mehr etwas aus.
|
||||||
|
audioMixer.disable()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt.
|
|
||||||
private func registerWidgets() {
|
private func registerWidgets() {
|
||||||
let calendarModel = CalendarModel(source: EventKitSource())
|
let calendarModel = CalendarModel()
|
||||||
self.calendarModel = calendarModel
|
self.calendarModel = calendarModel
|
||||||
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
||||||
|
|
||||||
PlaceholderWidgets.registerAll()
|
let weatherModel = WeatherModel()
|
||||||
|
self.weatherModel = weatherModel
|
||||||
|
WidgetRegistry.shared.register(WeatherWidget(model: weatherModel))
|
||||||
|
|
||||||
|
let mediaModel = MediaModel()
|
||||||
|
self.mediaModel = mediaModel
|
||||||
|
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
|
||||||
|
|
||||||
|
let metricsModel = MetricsModel()
|
||||||
|
// Der Akku bekommt seinen Ladelimit-Regler über den Helfer, den sonst
|
||||||
|
// die Lüfter benutzen — es ist derselbe SMC-Zugang.
|
||||||
|
metricsModel.chargeControl = fanControl
|
||||||
|
self.metricsModel = metricsModel
|
||||||
|
for metric in MetricKind.allCases {
|
||||||
|
WidgetRegistry.shared.register(MetricWidget(metric: metric, model: metricsModel))
|
||||||
|
}
|
||||||
|
|
||||||
|
let networkModel = NetworkModel()
|
||||||
|
self.networkModel = networkModel
|
||||||
|
WidgetRegistry.shared.register(NetworkWidget(model: networkModel))
|
||||||
|
|
||||||
|
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer, system: systemAudio))
|
||||||
|
|
||||||
|
WidgetRegistry.shared.register(FanWidget(control: fanControl))
|
||||||
|
|
||||||
|
let shelfStore = ShelfStore()
|
||||||
|
self.shelfStore = shelfStore
|
||||||
|
// AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je
|
||||||
|
// eine leere Ablegefläche waren ein Drittel des Panels.
|
||||||
|
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyContentSize() {
|
private func applyContentSize() {
|
||||||
@@ -130,21 +252,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
// MARK: - Menüleiste
|
// MARK: - Menüleiste
|
||||||
|
|
||||||
/// Das feste Onyx-Element: Panel öffnen, Einstellungen, Beenden. Es ist auch
|
/// Das feste Onyx-Element.
|
||||||
/// das Sicherheitsnetz, falls die Notch einmal nicht reagiert.
|
///
|
||||||
|
/// **Kein „Panel öffnen" im Menü**, aber ganz normal ein Menü.
|
||||||
|
///
|
||||||
|
/// Der Eintrag beschrieb einen Umweg um die eigentliche Bedienung herum —
|
||||||
|
/// das Panel öffnet sich, wenn man die Notch berührt. Ihn durch „Linksklick
|
||||||
|
/// fährt das Panel aus" zu ersetzen war aber schlechter als das Problem:
|
||||||
|
/// dieser Klick ging als Fixieren durch, und danach stand das Panel offen
|
||||||
|
/// und reagierte auf nichts. Ein Statuselement zeigt bei einem Klick sein
|
||||||
|
/// Menü. Alles andere überrascht.
|
||||||
private func installOnyxStatusItem() {
|
private func installOnyxStatusItem() {
|
||||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
item.autosaveName = "onyx.menubar.main"
|
item.autosaveName = "onyx.menubar.main"
|
||||||
item.button?.image = NSImage(systemSymbolName: "circle.hexagongrid.fill",
|
item.button?.image = Self.menuBarIcon()
|
||||||
accessibilityDescription: "Onyx")
|
|
||||||
item.button?.image?.isTemplate = true
|
|
||||||
|
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
menu.addItem(withTitle: String(localized: "menu.openPanel"),
|
|
||||||
action: #selector(togglePanel), keyEquivalent: "").target = self
|
|
||||||
menu.addItem(.separator())
|
|
||||||
menu.addItem(withTitle: String(localized: "menu.settings"),
|
menu.addItem(withTitle: String(localized: "menu.settings"),
|
||||||
action: #selector(openSettings), keyEquivalent: ",").target = self
|
action: #selector(openSettings), keyEquivalent: ",").target = self
|
||||||
|
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
|
||||||
|
action: #selector(openOnboarding), keyEquivalent: "").target = self
|
||||||
menu.addItem(.separator())
|
menu.addItem(.separator())
|
||||||
menu.addItem(withTitle: String(localized: "menu.quit"),
|
menu.addItem(withTitle: String(localized: "menu.quit"),
|
||||||
action: #selector(quit), keyEquivalent: "q").target = self
|
action: #selector(quit), keyEquivalent: "q").target = self
|
||||||
@@ -153,6 +280,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
onyxItem = item
|
onyxItem = item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func openOnboarding() { showOnboarding() }
|
||||||
|
|
||||||
|
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
|
||||||
|
///
|
||||||
|
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
|
||||||
|
/// Farbverlauf mit, und die gehört in Dock und Finder, nicht in die
|
||||||
|
/// Menüleiste. Dort steht sie als farbiger Klotz zwischen lauter
|
||||||
|
/// Strichsymbolen.
|
||||||
|
///
|
||||||
|
/// **Eine eigene Konturfassung, nicht das App-Icon.**
|
||||||
|
///
|
||||||
|
/// Das Glyph des App-Icons besteht aus deckenden Flächen in sehr dunklen
|
||||||
|
/// Tönen. Auf dem blauen Verlauf des Icons liest es sich als Kristall,
|
||||||
|
/// allein auf einer dunklen Menüleiste ist es schwarz auf schwarz. Und als
|
||||||
|
/// Template-Bild — der einzige Weg, macOS zum Einfärben zu bewegen —
|
||||||
|
/// verschmelzen die Facetten zu einer flachen Silhouette, weil dabei nur
|
||||||
|
/// der Alphakanal zählt.
|
||||||
|
///
|
||||||
|
/// `MenuBarIcon.svg` ist deshalb als Kontur gezeichnet: zwischen den Linien
|
||||||
|
/// ist nichts, also bleibt beim Einfärben die Struktur erhalten. Das Symbol
|
||||||
|
/// wird damit automatisch weiß auf dunkler und schwarz auf heller
|
||||||
|
/// Menüleiste, ohne zweite Datei für den zweiten Fall.
|
||||||
|
///
|
||||||
|
/// 18 pt ist die übliche Höhe; die Leiste ist 22 pt hoch und braucht oben
|
||||||
|
/// und unten Luft.
|
||||||
|
private static func menuBarIcon() -> NSImage {
|
||||||
|
let size = NSSize(width: 18, height: 18)
|
||||||
|
|
||||||
|
if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "svg"),
|
||||||
|
let glyph = NSImage(contentsOf: url) {
|
||||||
|
let image = NSImage(size: size, flipped: false) { rect in
|
||||||
|
glyph.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
image.isTemplate = true
|
||||||
|
image.accessibilityDescription = "Onyx"
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Menüleisteneintrag ohne Bild wäre unsichtbar und die App darüber
|
||||||
|
// unerreichbar — deshalb ein Rückfall statt eines leeren Knopfs.
|
||||||
|
let fallback = NSImage(systemSymbolName: "circle.hexagongrid.fill",
|
||||||
|
accessibilityDescription: "Onyx") ?? NSImage()
|
||||||
|
fallback.isTemplate = true
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
private func observeMenuBarPreference() {
|
private func observeMenuBarPreference() {
|
||||||
guard let model else { return }
|
guard let model else { return }
|
||||||
withObservationTracking {
|
withObservationTracking {
|
||||||
@@ -171,8 +345,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
@objc private func openSettings() { showSettings(tab: .widgets) }
|
@objc private func openSettings() { showSettings(tab: .widgets) }
|
||||||
|
|
||||||
private func showSettings(tab: SettingsTab) {
|
private func showSettings(tab: SettingsTab) {
|
||||||
guard let model, let calendarModel else { return }
|
guard let model, let calendarModel, let weatherModel else { return }
|
||||||
settingsWindow.show(model: model, calendarModel: calendarModel, tab: tab)
|
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||||
|
weatherModel: weatherModel, metricsModel: metricsModel,
|
||||||
|
shelfStore: shelfStore, fanControl: fanControl,
|
||||||
|
launchAtLogin: launchAtLogin,
|
||||||
|
showOnboarding: { [weak self] in self?.showOnboarding() },
|
||||||
|
tab: tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quit() { NSApp.terminate(nil) }
|
@objc private func quit() { NSApp.terminate(nil) }
|
||||||
@@ -184,6 +363,9 @@ private struct NotchPanelContent: View {
|
|||||||
let model: AppModel
|
let model: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WidgetGrid(placements: model.layout)
|
WidgetGrid(placements: model.layout, availableWidth: AppModel.availableWidth)
|
||||||
|
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import OnyxDesign
|
|
||||||
import OnyxWidgetKit
|
|
||||||
|
|
||||||
/// Vorläufige Widgets für Phase 2.
|
|
||||||
///
|
|
||||||
/// Sie tragen keine echten Daten — ihr Zweck ist zu belegen, dass Registry,
|
|
||||||
/// Layout-Engine, Persistenz und Panelgröße zusammenspielen, bevor die Provider
|
|
||||||
/// dazukommen. Jedes wird in seiner Phase durch das echte Widget ersetzt; die
|
|
||||||
/// Kennungen bleiben dabei gleich, damit gespeicherte Layouts weitergelten.
|
|
||||||
struct PlaceholderWidget: OnyxWidget {
|
|
||||||
let id: String
|
|
||||||
let displayName: String
|
|
||||||
let symbolName: String
|
|
||||||
let supportedSizes: [WidgetSize]
|
|
||||||
/// In welcher Phase dieses Widget echt wird.
|
|
||||||
let phase: String
|
|
||||||
|
|
||||||
func makeView(size: WidgetSize) -> AnyView {
|
|
||||||
AnyView(
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: symbolName)
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(Onyx.Color.accent)
|
|
||||||
Text(displayName)
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(phase)
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
enum PlaceholderWidgets {
|
|
||||||
/// Registriert nur noch die Karten, deren echte Fassung fehlt. Sobald ein
|
|
||||||
/// Widget umgesetzt ist, verschwindet es hier — die Kennung bleibt gleich,
|
|
||||||
/// damit gespeicherte Layouts weitergelten.
|
|
||||||
static func registerAll() {
|
|
||||||
let widgets: [PlaceholderWidget] = [
|
|
||||||
.init(id: "weather", displayName: "Wetter", symbolName: "cloud.sun",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 3"),
|
|
||||||
.init(id: "media", displayName: "Medien", symbolName: "play.circle",
|
|
||||||
supportedSizes: [.medium, .wide], phase: "Phase 4"),
|
|
||||||
.init(id: "cpu", displayName: "CPU", symbolName: "cpu",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 5"),
|
|
||||||
.init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 5"),
|
|
||||||
.init(id: "memory", displayName: "Speicher", symbolName: "memorychip",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 5"),
|
|
||||||
.init(id: "battery", displayName: "Akku", symbolName: "battery.100",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 5"),
|
|
||||||
.init(id: "sensors", displayName: "Sensoren", symbolName: "thermometer",
|
|
||||||
supportedSizes: [.medium, .large], phase: "Phase 5"),
|
|
||||||
.init(id: "network", displayName: "Netzwerk", symbolName: "network",
|
|
||||||
supportedSizes: [.small, .medium, .large], phase: "Phase 5"),
|
|
||||||
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 6"),
|
|
||||||
.init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3",
|
|
||||||
supportedSizes: [.medium, .large], phase: "Phase 7"),
|
|
||||||
.init(id: "shelf", displayName: "Ablage", symbolName: "tray.full",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 8"),
|
|
||||||
.init(id: "airdrop", displayName: "AirDrop", symbolName: "shareplay",
|
|
||||||
supportedSizes: [.small], phase: "Phase 8"),
|
|
||||||
]
|
|
||||||
widgets.forEach { WidgetRegistry.shared.register($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,29 +3,107 @@ import OnyxDesign
|
|||||||
import OnyxNotch
|
import OnyxNotch
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import ShelfProvider
|
||||||
|
import OnyxMenuBar
|
||||||
|
import MetricsProvider
|
||||||
|
import OnyxHelperProtocol
|
||||||
|
import WeatherProvider
|
||||||
|
|
||||||
enum SettingsTab: Hashable {
|
enum SettingsTab: Hashable {
|
||||||
case widgets, display, permissions
|
case widgets, display, colors, sources, menubar, fans, permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let calendarModel: CalendarModel
|
let calendarModel: CalendarModel
|
||||||
|
let weatherModel: WeatherModel
|
||||||
|
let metricsModel: MetricsModel?
|
||||||
|
let shelfStore: ShelfStore?
|
||||||
|
let fanControl: FanControl
|
||||||
|
let launchAtLogin: LaunchAtLogin
|
||||||
|
let showOnboarding: () -> Void
|
||||||
|
/// Der gewählte Reiter liegt im Fenster-Controller, damit ein erneutes
|
||||||
|
/// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt.
|
||||||
@Binding var selectedTab: SettingsTab
|
@Binding var selectedTab: SettingsTab
|
||||||
|
|
||||||
|
/// Die Reiter in der Reihenfolge, in der sie oben stehen.
|
||||||
|
private static let tabs: [(tab: SettingsTab, label: LocalizedStringKey)] = [
|
||||||
|
(.widgets, "settings.tab.widgets"),
|
||||||
|
(.display, "settings.tab.display"),
|
||||||
|
(.colors, "settings.tab.colors"),
|
||||||
|
(.sources, "settings.tab.options"),
|
||||||
|
(.menubar, "settings.tab.menubar"),
|
||||||
|
(.fans, "settings.tab.fans"),
|
||||||
|
(.permissions, "settings.tab.permissions"),
|
||||||
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
// Eigene Reiterleiste statt `TabView`.
|
||||||
WidgetSettings(model: model)
|
//
|
||||||
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
|
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs
|
||||||
.tag(SettingsTab.widgets)
|
// Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf.
|
||||||
DisplaySettings(model: model)
|
// Abstand nach oben half nicht: der Trenner wandert mit. Also die
|
||||||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst.
|
||||||
.tag(SettingsTab.display)
|
VStack(spacing: 0) {
|
||||||
PermissionSettings(calendarModel: calendarModel)
|
Picker("", selection: $selectedTab) {
|
||||||
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
|
ForEach(Self.tabs, id: \.tab) { entry in
|
||||||
.tag(SettingsTab.permissions)
|
Text(entry.label).tag(entry.tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
// Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund
|
||||||
|
// 880 Punkte, und mit `fixedSize` nahm sich die Leiste die auch
|
||||||
|
// dann, wenn das Fenster schmaler war — sie ragte dann links und
|
||||||
|
// rechts über die Kante hinaus. Das Mindestmaß des Fensters sorgt
|
||||||
|
// dafür, dass hier trotzdem nichts gestaucht wird.
|
||||||
|
.frame(maxWidth: 1000)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
// Eine Fläche für alles.
|
||||||
|
//
|
||||||
|
// Vorher hatte jeder Bereich seinen eigenen Untergrund: die
|
||||||
|
// Formulare bringen einen mit, der Rest des Fensters hat den des
|
||||||
|
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
|
||||||
|
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
// Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters.
|
||||||
|
//
|
||||||
|
// Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder
|
||||||
|
// View — SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter
|
||||||
|
// und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen
|
||||||
|
// galt, während der Farbwähler daneben schon die neue zeigte.
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx)
|
||||||
|
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
|
||||||
|
// wachsen, der Inhalt aber nicht mit.
|
||||||
|
.frame(minWidth: 740, minHeight: 460)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch selectedTab {
|
||||||
|
case .widgets:
|
||||||
|
WidgetSettings(model: model, shelfStore: shelfStore)
|
||||||
|
case .display:
|
||||||
|
DisplaySettings(model: model)
|
||||||
|
case .colors:
|
||||||
|
ColorSettingsView(theme: OnyxTheme.shared)
|
||||||
|
case .sources:
|
||||||
|
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||||
|
shelfStore: shelfStore)
|
||||||
|
case .menubar:
|
||||||
|
MenuBarSettings(model: model, metricsModel: metricsModel)
|
||||||
|
case .fans:
|
||||||
|
FanSettingsView(control: fanControl)
|
||||||
|
case .permissions:
|
||||||
|
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||||
|
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
|
||||||
}
|
}
|
||||||
.frame(width: 520, height: 420)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,50 +117,312 @@ struct SettingsView: View {
|
|||||||
/// Systemeinstellungen.
|
/// Systemeinstellungen.
|
||||||
private struct PermissionSettings: View {
|
private struct PermissionSettings: View {
|
||||||
let calendarModel: CalendarModel
|
let calendarModel: CalendarModel
|
||||||
@State private var granted = false
|
let weatherModel: WeatherModel
|
||||||
|
let launchAtLogin: LaunchAtLogin
|
||||||
|
let showOnboarding: () -> Void
|
||||||
|
@State private var calendarGranted = false
|
||||||
|
|
||||||
|
private var locationState: PermissionRow.State {
|
||||||
|
switch weatherModel.locationAuthorization {
|
||||||
|
case .granted: .granted
|
||||||
|
case .denied: .denied
|
||||||
|
case .undetermined: .undetermined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
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 {
|
HStack {
|
||||||
Label("settings.permissions.calendar", systemImage: "calendar")
|
Text("settings.launchAtLogin.blocked")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Spacer()
|
Spacer()
|
||||||
if granted {
|
Button("settings.permissions.openSystem") {
|
||||||
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
|
launchAtLogin.openSystemSettings()
|
||||||
.foregroundStyle(.green)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("settings.onboarding.show", action: showOnboarding)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
PermissionRow(
|
||||||
|
title: "settings.permissions.calendar",
|
||||||
|
symbol: "calendar",
|
||||||
|
state: calendarGranted ? .granted
|
||||||
|
: (calendarModel.authorization == .denied ? .denied : .undetermined),
|
||||||
|
explanation: "settings.permissions.calendar.why",
|
||||||
|
pane: .calendar,
|
||||||
|
request: { calendarGranted = await calendarModel.requestAccessNow() })
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
PermissionRow(
|
||||||
|
title: "settings.permissions.location",
|
||||||
|
symbol: "location",
|
||||||
|
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
|
||||||
|
// sich, während dieses Fenster offen steht.
|
||||||
|
state: locationState,
|
||||||
|
explanation: "settings.permissions.location.why",
|
||||||
|
pane: .location,
|
||||||
|
request: { weatherModel.requestLocationAuthorization() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||||
|
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.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
|
||||||
|
// Dialog nur, wenn eine App im Vordergrund ist, und Aktivierung
|
||||||
|
// wirkt asynchron.
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
calendarGranted = await calendarModel.requestAccessNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Berechtigung mit ihrem Zustand und dem Weg, der von dort weiterführt.
|
||||||
|
///
|
||||||
|
/// Die drei Zustände brauchen drei verschiedene Antworten, und genau daran ist
|
||||||
|
/// die erste Fassung gescheitert: „noch nie gefragt" lässt sich mit einer
|
||||||
|
/// Anfrage lösen, „abgelehnt" nur über die Systemeinstellungen, und „erteilt"
|
||||||
|
/// braucht gar nichts.
|
||||||
|
private struct PermissionRow: View {
|
||||||
|
enum State { case granted, denied, undetermined }
|
||||||
|
enum Pane {
|
||||||
|
case calendar, location
|
||||||
|
|
||||||
|
/// Jede Berechtigung führt in ihren eigenen Bereich. Ein gemeinsamer
|
||||||
|
/// Knopf, der immer im Kalender landet, ist schlimmer als keiner.
|
||||||
|
var url: URL? {
|
||||||
|
switch self {
|
||||||
|
case .calendar:
|
||||||
|
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")
|
||||||
|
case .location:
|
||||||
|
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
let symbol: String
|
||||||
|
let state: State
|
||||||
|
let explanation: LocalizedStringKey
|
||||||
|
let pane: Pane
|
||||||
|
let request: () async -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Label(title, systemImage: symbol)
|
||||||
|
Spacer()
|
||||||
|
switch state {
|
||||||
|
case .granted:
|
||||||
|
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
|
||||||
|
// Akzentstil färbt sonst darüber. Grün heißt hier „erteilt"
|
||||||
|
// und ist keine Geschmacksfrage.
|
||||||
|
Label {
|
||||||
|
Text("settings.permissions.granted")
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Onyx.Color.positive)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Onyx.Color.positive)
|
||||||
.labelStyle(.titleAndIcon)
|
.labelStyle(.titleAndIcon)
|
||||||
} else {
|
case .undetermined:
|
||||||
Button("settings.permissions.request") {
|
Button("settings.permissions.request") { Task { await request() } }
|
||||||
Task {
|
case .denied:
|
||||||
let ok = await calendarModel.requestAccessNow()
|
// macOS fragt nach einer Ablehnung nicht mehr — ein
|
||||||
granted = ok
|
// Anfragen-Knopf wäre hier eine Lüge.
|
||||||
// macOS fragt nach einer Ablehnung nicht erneut.
|
Button("settings.permissions.openSystem") {
|
||||||
// Dann hilft nur noch der Weg über die
|
if let url = pane.url { NSWorkspace.shared.open(url) }
|
||||||
// Systemeinstellungen — also dorthin führen.
|
|
||||||
if !ok { openPrivacySettings() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Text(explanation)
|
||||||
Text("settings.permissions.calendar.why")
|
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
// MARK: - Menüleiste
|
||||||
Button("settings.permissions.openSystem") { openPrivacySettings() }
|
|
||||||
|
/// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart.
|
||||||
|
///
|
||||||
|
/// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine
|
||||||
|
/// Zumutung, und die Menüleiste ist ohnehin knapp — auf einem Notch-Display
|
||||||
|
/// blockiert die Kamera zusätzlich die Mitte.
|
||||||
|
private struct MenuBarSettings: View {
|
||||||
|
@Bindable var model: AppModel
|
||||||
|
let metricsModel: MetricsModel?
|
||||||
|
|
||||||
|
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
|
||||||
|
("metric.cpu", "metric.cpu", "cpu"),
|
||||||
|
("metric.gpu", "metric.gpu", "cpu.fill"),
|
||||||
|
("metric.memory", "metric.memory", "memorychip"),
|
||||||
|
("metric.battery", "metric.battery", "battery.100"),
|
||||||
|
("metric.temperature", "metric.sensors", "thermometer"),
|
||||||
|
("network", "network.name", "network"),
|
||||||
|
("audio", "mixer.menubar.name", "slider.horizontal.3"),
|
||||||
|
("fans", "widget.fans.name", "fan"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("settings.menubar.hint")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(Self.modules, id: \.id) { module in
|
||||||
|
ModuleRow(model: model, id: module.id,
|
||||||
|
label: module.label, symbol: module.symbol,
|
||||||
|
metricsModel: metricsModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.listStyle(.inset)
|
||||||
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.onAppear { granted = calendarModel.hasAccess }
|
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
|
||||||
|
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
|
||||||
|
.id(model.menuBarRevision)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openPrivacySettings() {
|
private struct ModuleRow: View {
|
||||||
guard let url = URL(string:
|
@Bindable var model: AppModel
|
||||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return }
|
let id: String
|
||||||
NSWorkspace.shared.open(url)
|
let label: LocalizedStringKey
|
||||||
|
let symbol: String
|
||||||
|
let metricsModel: MetricsModel?
|
||||||
|
|
||||||
|
/// Welche Darstellungen dieses Modul beherrscht.
|
||||||
|
static func presentations(for id: String) -> [MenuBarPresentation] {
|
||||||
|
switch id {
|
||||||
|
// Ein Mischpult hat keinen einzelnen Messwert.
|
||||||
|
case "audio": []
|
||||||
|
// Drehzahl als Zahl oder nur das Symbol — ein Verlauf der Drehzahl
|
||||||
|
// sagt in 28 Punkten Breite nichts.
|
||||||
|
case "fans": [.value, .symbol]
|
||||||
|
// Der Akku kann alles, und gerade dort will man Symbol oder Kürzel:
|
||||||
|
// eine nackte Prozentzahl neben vier anderen sagt nichts.
|
||||||
|
case "metric.battery": [.symbolAndValue, .labelAndValue, .value, .symbol,
|
||||||
|
.graph, .valueAndGraph]
|
||||||
|
default: MenuBarPresentation.allCases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zu welcher Hardwaregröße dieses Modul gehört — `nil` beim Netzwerk.
|
||||||
|
private var metricKind: MetricKind? {
|
||||||
|
guard id.hasPrefix("metric.") else { return nil }
|
||||||
|
return MetricKind(rawValue: String(id.dropFirst("metric.".count)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let settings = model.menuBarSettings(for: id)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { settings.isEnabled },
|
||||||
|
set: { enabled in
|
||||||
|
var updated = settings
|
||||||
|
updated.isEnabled = enabled
|
||||||
|
model.setMenuBarSettings(updated, for: id)
|
||||||
|
})) {
|
||||||
|
Label(label, systemImage: symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das
|
||||||
|
// stille Maximum über alle Sensoren — eine Zahl, von der niemand
|
||||||
|
// weiß, woher sie kommt.
|
||||||
|
if id == "metric.temperature", let metricsModel {
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { metricsModel.selectedSensorKey ?? "" },
|
||||||
|
set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) {
|
||||||
|
Text("settings.menubar.sensor.hottest").tag("")
|
||||||
|
ForEach(metricsModel.snapshot.sensors) { sensor in
|
||||||
|
Text(sensor.name).tag(sensor.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 150)
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte
|
||||||
|
// stammen aus derselben Messung — ein zweites Element in der
|
||||||
|
// ohnehin knappen Menüleiste wäre Verschwendung.
|
||||||
|
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { metricsModel.displayMode(for: metric) },
|
||||||
|
set: { metricsModel.setDisplayMode($0, for: metric) })) {
|
||||||
|
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
|
||||||
|
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 110)
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
|
||||||
|
// keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen
|
||||||
|
// könnte — eine Auswahl ohne Wirkung wäre eine Falle.
|
||||||
|
if !Self.presentations(for: id).isEmpty {
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { settings.presentation },
|
||||||
|
set: { presentation in
|
||||||
|
var updated = settings
|
||||||
|
updated.presentation = presentation
|
||||||
|
model.setMenuBarSettings(updated, for: id)
|
||||||
|
})) {
|
||||||
|
ForEach(Self.presentations(for: id)) { presentation in
|
||||||
|
Text(presentation.localizedName).tag(presentation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 150)
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MenuBarPresentation {
|
||||||
|
var localizedName: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .value: "settings.menubar.value"
|
||||||
|
case .graph: "settings.menubar.graph"
|
||||||
|
case .bars: "settings.menubar.bars"
|
||||||
|
case .valueAndGraph: "settings.menubar.valueAndGraph"
|
||||||
|
case .symbol: "settings.menubar.symbol"
|
||||||
|
case .labelAndValue: "settings.menubar.labelAndValue"
|
||||||
|
case .symbolAndValue: "settings.menubar.symbolAndValue"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +430,7 @@ private struct PermissionSettings: View {
|
|||||||
|
|
||||||
private struct WidgetSettings: View {
|
private struct WidgetSettings: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
|
let shelfStore: ShelfStore?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -128,6 +469,7 @@ private struct WidgetSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
}
|
}
|
||||||
@@ -135,6 +477,39 @@ private struct WidgetSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Einstellungen der Ablage.
|
||||||
|
///
|
||||||
|
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus:
|
||||||
|
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
|
||||||
|
/// Papierkorb mit Zeitschaltuhr.
|
||||||
|
private struct ShelfSettingsRows: View {
|
||||||
|
@Bindable var store: ShelfStore
|
||||||
|
|
||||||
|
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker(selection: $store.autoCleanupDays) {
|
||||||
|
ForEach(Self.choices, id: \.self) { days in
|
||||||
|
if let days {
|
||||||
|
Text("settings.shelf.days \(days)").tag(Optional(days))
|
||||||
|
} else {
|
||||||
|
Text("settings.shelf.never").tag(Int?.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("settings.shelf.cleanup")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("settings.shelf.count \(store.items.count)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("settings.shelf.reveal") { store.revealInFinder() }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct PlacementRow: View {
|
private struct PlacementRow: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let placement: WidgetPlacement
|
let placement: WidgetPlacement
|
||||||
@@ -146,18 +521,6 @@ private struct PlacementRow: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Nur die Größen anbieten, die das Widget auch sinnvoll ausfüllt.
|
|
||||||
// Ein Mini-Monat in 1×1 wäre unleserlich.
|
|
||||||
Picker("", selection: Binding(
|
|
||||||
get: { placement.size },
|
|
||||||
set: { model.setSize($0, for: placement.widgetID) })) {
|
|
||||||
ForEach(widget.supportedSizes) { size in
|
|
||||||
Text(size.localizedName).tag(size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 130)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
model.remove(placement.widgetID)
|
model.remove(placement.widgetID)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -189,24 +552,30 @@ private struct DisplaySettings: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
|
||||||
|
// niemandem etwas, ein nachgebildeter Bildschirm schon.
|
||||||
|
TriggerAreaSettings(trigger: $model.triggerArea)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
|
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
|
||||||
Text("settings.display.menuBarIcon.hint")
|
Text("settings.display.menuBarIcon.hint")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
|
||||||
|
Text("settings.display.dockIcon.hint")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||||
|
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WidgetSize {
|
|
||||||
var localizedName: LocalizedStringKey {
|
|
||||||
switch self {
|
|
||||||
case .small: "settings.size.small"
|
|
||||||
case .medium: "settings.size.medium"
|
|
||||||
case .large: "settings.size.large"
|
|
||||||
case .wide: "settings.size.wide"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import WeatherProvider
|
||||||
|
import MetricsProvider
|
||||||
|
import ShelfProvider
|
||||||
|
|
||||||
/// Führt das Einstellungsfenster selbst.
|
/// Führt das Einstellungsfenster selbst.
|
||||||
///
|
///
|
||||||
@@ -12,30 +16,82 @@ import CalendarProvider
|
|||||||
///
|
///
|
||||||
/// Ein eigenes `NSWindow` hat diese Abhängigkeit nicht. Es kostet ein paar
|
/// Ein eigenes `NSWindow` hat diese Abhängigkeit nicht. Es kostet ein paar
|
||||||
/// Zeilen mehr und funktioniert dafür.
|
/// Zeilen mehr und funktioniert dafür.
|
||||||
|
///
|
||||||
|
/// `@Observable`, und das ist keine Kür: der gewählte Reiter liegt hier, weil
|
||||||
|
/// ein erneutes Öffnen ihn setzen können muss. Ohne Beobachtung bemerkt die
|
||||||
|
/// View die Änderung nicht — weder die von außen noch die eigene. Genau daran
|
||||||
|
/// hing das Fenster, in dem der Reiter oben umsprang und der Inhalt stehen
|
||||||
|
/// blieb.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@Observable
|
||||||
final class SettingsWindowController: NSObject, NSWindowDelegate {
|
final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||||
|
|
||||||
private var window: NSWindow?
|
@ObservationIgnored private var window: NSWindow?
|
||||||
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
|
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
|
||||||
private var selectedTab: SettingsTab = .widgets
|
private var selectedTab: SettingsTab = .widgets
|
||||||
|
|
||||||
func show(model: AppModel, calendarModel: CalendarModel, tab: SettingsTab = .widgets) {
|
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
|
||||||
|
/// zurückgeschaltet wird.
|
||||||
|
var keepsDockIcon = false
|
||||||
|
|
||||||
|
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
||||||
|
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
|
||||||
|
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
|
||||||
|
tab: SettingsTab = .widgets) {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
|
keepsDockIcon = model.showsDockIcon
|
||||||
|
|
||||||
|
// Solange ein Fenster offen ist, gehört Onyx in den Dock.
|
||||||
|
//
|
||||||
|
// Als reine `.accessory`-App hat sie dort keinen Eintrag — das ist im
|
||||||
|
// Hintergrund richtig, aber mit sichtbarem Fenster verwirrend: das
|
||||||
|
// Fenster lässt sich dann weder über den Dock noch über ⌘-Tab
|
||||||
|
// zurückholen, wenn es hinter etwas anderes rutscht.
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
|
||||||
if window == nil {
|
if window == nil {
|
||||||
let hosting = NSHostingController(
|
let hosting = NSHostingController(
|
||||||
rootView: SettingsView(model: model,
|
rootView: SettingsView(model: model,
|
||||||
calendarModel: calendarModel,
|
calendarModel: calendarModel,
|
||||||
|
weatherModel: weatherModel,
|
||||||
|
metricsModel: metricsModel,
|
||||||
|
shelfStore: shelfStore,
|
||||||
|
fanControl: fanControl,
|
||||||
|
launchAtLogin: launchAtLogin,
|
||||||
|
showOnboarding: showOnboarding,
|
||||||
selectedTab: Binding(
|
selectedTab: Binding(
|
||||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
get: { [weak self] in self?.selectedTab ?? .widgets },
|
||||||
set: { [weak self] in self?.selectedTab = $0 })))
|
set: { [weak self] in self?.selectedTab = $0 })))
|
||||||
|
|
||||||
let window = NSWindow(contentViewController: hosting)
|
let window = NSWindow(contentViewController: hosting)
|
||||||
window.title = "Onyx"
|
window.title = "Onyx"
|
||||||
window.styleMask = [.titled, .closable, .miniaturizable]
|
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie
|
||||||
|
// verschwindet.
|
||||||
|
//
|
||||||
|
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht
|
||||||
|
// unter dem Titelbalken zusätzlich einen Trenner quer durchs
|
||||||
|
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen
|
||||||
|
// Titel auf eine Trennlinie.
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.titlebarSeparatorStyle = .none
|
||||||
|
// Veränderbar, und das ist keine Kür: der Bereich „Displays" mit
|
||||||
|
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
|
||||||
|
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
|
||||||
|
// Bildschirm frei ist, ist eine Zumutung.
|
||||||
|
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||||
|
window.setContentSize(NSSize(width: 920, height: 680))
|
||||||
|
// Breit genug für die Reiterleiste.
|
||||||
|
//
|
||||||
|
// Sieben Reiter mit deutschen Beschriftungen brauchen rund 880
|
||||||
|
// Punkte — mehr, als man beim Nachrechnen der reinen Textbreiten
|
||||||
|
// herausbekommt: macOS legt je Reiter Innenabstand und Trenner
|
||||||
|
// dazu. Ist das Fenster schmaler, ragt die Leiste über beide
|
||||||
|
// Kanten hinaus, und der erste wie der letzte Reiter sind nicht
|
||||||
|
// mehr erreichbar.
|
||||||
|
window.contentMinSize = NSSize(width: 900, height: 460)
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
window.center()
|
Self.place(window)
|
||||||
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
||||||
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
||||||
window.setFrameAutosaveName("onyx.settings")
|
window.setFrameAutosaveName("onyx.settings")
|
||||||
@@ -48,9 +104,46 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
window?.makeKeyAndOrderFront(nil)
|
window?.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowWillClose(_ notification: Notification) {
|
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
|
||||||
// Zurück in den Hintergrund: Onyx hat sonst weiter den Fokus, obwohl
|
/// klebt.
|
||||||
// kein Fenster mehr offen ist.
|
///
|
||||||
|
/// `center()` setzt etwas oberhalb der Mitte — bei einem 452 Punkt hohen
|
||||||
|
/// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer
|
||||||
|
/// ihn treffen will, fährt durch die Auslösefläche der Notch und hat
|
||||||
|
/// plötzlich das Panel vor sich.
|
||||||
|
private static func place(_ window: NSWindow) {
|
||||||
|
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
|
||||||
|
let visible = screen.visibleFrame
|
||||||
|
let size = window.frame.size
|
||||||
|
// Wirklich mittig, nicht „etwas unterhalb der Notch".
|
||||||
|
//
|
||||||
|
// `center()` setzt oberhalb der Bildschirmmitte, und mein erster
|
||||||
|
// Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag
|
||||||
|
// weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die
|
||||||
|
// Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete.
|
||||||
|
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
|
||||||
|
y: visible.midY - size.height / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der rote Knopf blendet aus, statt zu schließen.
|
||||||
|
///
|
||||||
|
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
|
||||||
|
/// „fertig". Manche Hilfsprogramme erzwingen aber genau diese Gleichung und
|
||||||
|
/// beenden eine App, sobald ihr letztes Fenster verschwindet — dann wäre
|
||||||
|
/// Onyx nach einem Blick in die Einstellungen weg, samt Menüleiste und
|
||||||
|
/// Notch. Ein Fenster, das nie wirklich schließt, gibt dafür keinen Anlass.
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
|
hide()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hide() {
|
||||||
|
window?.orderOut(nil)
|
||||||
|
// Zurück in den Hintergrund — es sei denn, der Dock-Eintrag ist
|
||||||
|
// ausdrücklich gewünscht.
|
||||||
|
if !keepsDockIcon {
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
}
|
||||||
NSApp.hide(nil)
|
NSApp.hide(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
381
Onyx/WidgetOptionsView.swift
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import CalendarProvider
|
||||||
|
import WeatherProvider
|
||||||
|
import ShelfProvider
|
||||||
|
|
||||||
|
/// Die Stellschrauben der einzelnen Widgets, an einem Ort.
|
||||||
|
///
|
||||||
|
/// Vorher hieß der Bereich „Quellen" und enthielt nur die Kalenderquelle. Das
|
||||||
|
/// war die Sicht des Programmierers: für den Nutzer ist die Quelle eine
|
||||||
|
/// Einstellung **des Kalenderwidgets** unter mehreren — welche Kalender
|
||||||
|
/// mitzählen, gehört genauso dazu, und beim Wetter der Ort. Ein Abschnitt je
|
||||||
|
/// Widget ist die Ordnung, die man sucht.
|
||||||
|
struct WidgetOptionsView: View {
|
||||||
|
let calendarModel: CalendarModel
|
||||||
|
let weatherModel: WeatherModel
|
||||||
|
let shelfStore: ShelfStore?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("options.calendar") {
|
||||||
|
CalendarOptions(model: calendarModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("options.weather") {
|
||||||
|
WeatherOptions(model: weatherModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let shelfStore {
|
||||||
|
Section("options.shelf") {
|
||||||
|
ShelfOptions(store: shelfStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||||
|
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kalender
|
||||||
|
|
||||||
|
private struct CalendarOptions: View {
|
||||||
|
@Bindable var model: CalendarModel
|
||||||
|
@State private var calendarrState: CalendarSourceState?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker(selection: $model.kind) {
|
||||||
|
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
||||||
|
Text(kind.localizedName).tag(kind)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("options.calendar.source")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Picker(selection: $model.showsMonthGrid) {
|
||||||
|
Text("options.calendar.agenda").tag(false)
|
||||||
|
Text("options.calendar.month").tag(true)
|
||||||
|
} label: {
|
||||||
|
Text("options.calendar.display")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
// Welche Kalender mitzählen. Gespeichert werden die **abgewählten**:
|
||||||
|
// kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn
|
||||||
|
// suchen muss, ohne zu wissen, dass es ihn gibt.
|
||||||
|
if model.availableCalendars.isEmpty {
|
||||||
|
Text("options.calendar.noneFound")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(model.availableCalendars) { calendar in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { !model.hiddenCalendarIDs.contains(calendar.id) },
|
||||||
|
set: { shown in
|
||||||
|
if shown { model.hiddenCalendarIDs.remove(calendar.id) }
|
||||||
|
else { model.hiddenCalendarIDs.insert(calendar.id) }
|
||||||
|
})) {
|
||||||
|
Label {
|
||||||
|
Text(calendar.title)
|
||||||
|
} icon: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: calendar.colorHex) ?? .secondary)
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.kind != .appleCalendar {
|
||||||
|
CalendarrServerSettings(model: model)
|
||||||
|
CalendarrStatus(state: calendarrState,
|
||||||
|
isLoading: model.isLoading,
|
||||||
|
onRefresh: { model.refresh() })
|
||||||
|
.task(id: model.kind) {
|
||||||
|
guard model.kind != .appleCalendar else { return }
|
||||||
|
let now = Date()
|
||||||
|
calendarrState = await CalendarrSource().load(
|
||||||
|
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
|
||||||
|
end: now.addingTimeInterval(60 * 86_400)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
|
||||||
|
///
|
||||||
|
/// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts — es liest den
|
||||||
|
/// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine
|
||||||
|
/// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat,
|
||||||
|
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen.
|
||||||
|
private struct CalendarrStatus: View {
|
||||||
|
let state: CalendarSourceState?
|
||||||
|
let isLoading: Bool
|
||||||
|
let onRefresh: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
message
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
if case .ready(let window) = state {
|
||||||
|
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
reloadButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var message: some View {
|
||||||
|
switch state {
|
||||||
|
case .ready(let window):
|
||||||
|
Text("options.calendar.calendarrEvents \(window.events.count)")
|
||||||
|
case .neverWritten:
|
||||||
|
Text("calendar.source.status.neverWritten").font(.callout).foregroundStyle(.secondary)
|
||||||
|
case .loggedOut:
|
||||||
|
Text("calendar.source.status.loggedOut").font(.callout).foregroundStyle(.secondary)
|
||||||
|
case .incompatible:
|
||||||
|
Text("calendar.source.status.incompatible").font(.callout)
|
||||||
|
.foregroundStyle(Onyx.Color.warning)
|
||||||
|
case .unreadable(let reason):
|
||||||
|
Text(reason).font(.callout).foregroundStyle(Onyx.Color.warning)
|
||||||
|
case .denied:
|
||||||
|
Text("calendar.source.status.denied").font(.callout)
|
||||||
|
.foregroundStyle(Onyx.Color.warning)
|
||||||
|
case .none:
|
||||||
|
Text("calendar.source.status.loading").font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feste Breite, damit die Zeile beim Laden nicht zuckt: der Kreisel ist
|
||||||
|
/// nicht so breit wie der Pfeil.
|
||||||
|
private var reloadButton: some View {
|
||||||
|
Button(action: onRefresh) {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(isLoading)
|
||||||
|
.help(Text("options.calendar.reload"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wetter
|
||||||
|
|
||||||
|
private struct WeatherOptions: View {
|
||||||
|
let model: WeatherModel
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var searching = false
|
||||||
|
@State private var notFound = false
|
||||||
|
|
||||||
|
private var usesCurrentLocation: Bool { model.place.isCurrent }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { usesCurrentLocation },
|
||||||
|
set: { model.place = $0 ? .current : model.place })) {
|
||||||
|
Text("options.weather.automatic")
|
||||||
|
}
|
||||||
|
.disabled(usesCurrentLocation)
|
||||||
|
|
||||||
|
if usesCurrentLocation {
|
||||||
|
// Ohne Ortungsberechtigung nützt die Automatik nichts — und das
|
||||||
|
// steht hier, statt dass das Widget stumm leer bleibt.
|
||||||
|
if model.locationIsDenied {
|
||||||
|
Text("options.weather.needsLocation")
|
||||||
|
.font(.callout).foregroundStyle(Onyx.Color.warning)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} else if case .fixed(let name, _, _) = model.place {
|
||||||
|
LabeledContent {
|
||||||
|
Text(name).foregroundStyle(.secondary)
|
||||||
|
} label: {
|
||||||
|
Text("options.weather.place")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField("options.weather.search", text: $query)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit { Task { await search() } }
|
||||||
|
if searching {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("options.weather.set") { Task { await search() } }
|
||||||
|
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notFound {
|
||||||
|
Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func search() async {
|
||||||
|
searching = true
|
||||||
|
notFound = false
|
||||||
|
defer { searching = false }
|
||||||
|
if let found = await model.selectPlace(named: query) {
|
||||||
|
query = ""
|
||||||
|
_ = found
|
||||||
|
} else {
|
||||||
|
notFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ablage
|
||||||
|
|
||||||
|
private struct ShelfOptions: View {
|
||||||
|
@Bindable var store: ShelfStore
|
||||||
|
|
||||||
|
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker(selection: $store.autoCleanupDays) {
|
||||||
|
ForEach(Self.choices, id: \.self) { days in
|
||||||
|
if let days {
|
||||||
|
Text("settings.shelf.days \(days)").tag(Optional(days))
|
||||||
|
} else {
|
||||||
|
Text("settings.shelf.never").tag(Int?.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("settings.shelf.cleanup")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("settings.shelf.reveal") { store.revealInFinder() }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Color {
|
||||||
|
/// Die Farbe eines Kalenders, wie die Quelle sie liefert.
|
||||||
|
init?(hex: String?) {
|
||||||
|
guard let hex, hex.hasPrefix("#"), hex.count == 7,
|
||||||
|
let value = Int(hex.dropFirst(), radix: 16) else { return nil }
|
||||||
|
self.init(red: Double((value >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((value >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(value & 0xFF) / 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Anmeldung am Calendarr-Server.
|
||||||
|
///
|
||||||
|
/// Der Schnappschuss im gemeinsamen Ordner ist nur so aktuell wie der letzte
|
||||||
|
/// Start der Calendarr-App. Wer die Zugangsdaten hier einträgt, holt Onyx die
|
||||||
|
/// Termine selbst — und dann ist es egal, ob Calendarr überhaupt läuft.
|
||||||
|
///
|
||||||
|
/// Adresse und Benutzername stehen in den Einstellungen, das Passwort im
|
||||||
|
/// Schlüsselbund.
|
||||||
|
private struct CalendarrServerSettings: View {
|
||||||
|
|
||||||
|
let model: CalendarModel
|
||||||
|
|
||||||
|
@State private var credentials = CalendarrCredentials()
|
||||||
|
@State private var server = ""
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var totpCode = ""
|
||||||
|
@State private var isChecking = false
|
||||||
|
@State private var failure: String?
|
||||||
|
|
||||||
|
private var account: CalendarrAccount? { credentials.account }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let account {
|
||||||
|
LabeledContent {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(account.username).foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
credentials.clear()
|
||||||
|
model.calendarrAccountChanged()
|
||||||
|
} label: {
|
||||||
|
Text("options.calendar.server.disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
Text(account.server.host() ?? account.server.absoluteString)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Onyx.Color.positive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Drei Felder und ein Knopf. Geprüft wird beim Verbinden, nicht
|
||||||
|
// erst beim nächsten Laden: ein Tippfehler, den man am Folgetag
|
||||||
|
// bemerkt, ist ein vermeidbarer.
|
||||||
|
TextField(text: $server) { Text("options.calendar.server.address") }
|
||||||
|
.textContentType(.URL)
|
||||||
|
TextField(text: $username) { Text("options.calendar.server.username") }
|
||||||
|
.textContentType(.username)
|
||||||
|
SecureField(text: $password) { Text("options.calendar.server.password") }
|
||||||
|
.textContentType(.password)
|
||||||
|
// Nur ausfüllen, wer einen zweiten Faktor hat. Ein Pflichtfeld
|
||||||
|
// wäre es für alle anderen eine Frage, die sie nicht beantworten
|
||||||
|
// können.
|
||||||
|
TextField(text: $totpCode) { Text("options.calendar.server.totp") }
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if let failure {
|
||||||
|
Text(failure).font(.callout).foregroundStyle(Onyx.Color.warning)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else {
|
||||||
|
Text("options.calendar.server.hint")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Button(action: connect) {
|
||||||
|
if isChecking {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("options.calendar.server.connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isChecking || server.isEmpty || username.isEmpty || password.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect() {
|
||||||
|
guard let url = CalendarrLiveDecoder.server(from: server) else {
|
||||||
|
failure = CalendarrLiveError.badServer.errorDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isChecking = true
|
||||||
|
failure = nil
|
||||||
|
Task {
|
||||||
|
defer { isChecking = false }
|
||||||
|
switch await CalendarrLiveSource.check(server: url, username: username,
|
||||||
|
password: password,
|
||||||
|
totpCode: totpCode) {
|
||||||
|
case .success(let token):
|
||||||
|
credentials.save(server: url, username: username,
|
||||||
|
password: password, token: token)
|
||||||
|
// Passwort und Code nicht im Formular stehen lassen — beide
|
||||||
|
// liegen jetzt dort, wo sie hingehören.
|
||||||
|
password = ""
|
||||||
|
totpCode = ""
|
||||||
|
model.calendarrAccountChanged()
|
||||||
|
case .failure(let error):
|
||||||
|
failure = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Onyx/bolt-dark.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<polygon points="426.667,213.333 284.444,213.333 284.444,0 85.333,298.667 227.556,298.667 227.556,512 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 666 B |
1
Onyx/bolt-light.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path d="M426.667 213.333H284.444V0L85.333 298.667h142.223V512z" fill="#ffffff" opacity="1" data-original="#000000" class=""></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 373 B |
1
Onyx/chip.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="512pt" viewBox="0 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="m150 150h212v212h-212zm0 0"/><path d="m497 211c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-26c0-19.300781-15.699219-35-35-35h-26v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-26c-19.300781 0-35 15.699219-35 35v26h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v26c0 19.300781 15.699219 35 35 35h26v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h26c19.300781 0 35-15.699219 35-35v-26h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30zm-105 166c0 8.285156-6.714844 15-15 15h-242c-8.285156 0-15-6.714844-15-15v-242c0-8.285156 6.714844-15 15-15h242c8.285156 0 15 6.714844 15 15zm0 0"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
Onyx/graphic-card.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m482.807 132.033h-416.747v-31.976c0-4.418-3.582-8-8-8h-50.06c-4.418 0-8 3.582-8 8s3.582 8 8 8h42.06v56.593h-18.713c-4.418 0-8 3.582-8 8v63.691c0 4.418 3.582 8 8 8h18.713v19.21h-18.713c-4.418 0-8 3.582-8 8v63.691c0 4.418 3.582 8 8 8h18.713v68.699c0 4.418 3.582 8 8 8s8-3.582 8-8v-36.081h30.802v32.508c0 4.418 3.582 8 8 8h111.592c4.418 0 8-3.582 8-8v-32.508h26.387v32.508c0 4.418 3.582 8 8 8h198.358c4.418 0 8-3.582 8-8v-32.508h17.608c16.097 0 29.193-13.096 29.193-29.193v-185.44c0-16.098-13.096-29.194-29.193-29.194zm-443.46 96.309v-47.692h10.713v47.691h-10.713zm0 98.901v-47.691h10.713v47.691zm169.107 73.125h-21.198v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-21.197v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-21.197v-24.508h95.592zm240.745 0h-17.062v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.059v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.06v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.059v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.059v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.06v-24.508h182.358v24.508zm46.801-53.701c0 7.274-5.918 13.193-13.193 13.193h-416.747v-24.607c0-.003 0-.007 0-.01s0-.006 0-.01v-63.672c0-.003 0-.007 0-.01s0-.006 0-.01v-35.19c0-.003 0-.007 0-.01s0-.006 0-.01v-63.671c0-.003 0-.007 0-.01s0-.006 0-.01v-24.607h416.747c7.275 0 13.193 5.919 13.193 13.193zm-110.99-185.502c-51.16 0-92.782 41.621-92.782 92.781s41.622 92.781 92.782 92.781 92.781-41.621 92.781-92.781-41.621-92.781-92.781-92.781zm-41.818 157.126c17.407-5.223 32.416-17.168 41.284-33.275.178.003.355.014.534.014 2.446 0 4.823-.293 7.107-.83 3.553 16.244-.643 33.591-11.516 46.392-13.744-.782-26.524-5.203-37.409-12.301zm-33.3-48.457c10.281 5.547 21.821 8.447 33.451 8.447 6.489 0 13.003-.909 19.337-2.746 1.781 1.841 3.783 3.464 5.969 4.823-8.966 13.989-24.18 23.285-40.903 24.663-8.757-9.807-15.039-21.862-17.854-35.187zm116.937-80.231c-17.408 5.223-32.417 17.168-41.285 33.275-.178-.003-.355-.014-.534-.014-2.446 0-4.823.293-7.107.829-3.553-16.245.644-33.592 11.517-46.393 13.745.783 26.524 5.204 37.409 12.303zm-41.819 79.426c-8.317 0-15.083-6.767-15.083-15.083s6.766-15.082 15.083-15.082c8.316 0 15.083 6.766 15.083 15.082s-6.766 15.083-15.083 15.083zm-21.588-37.414c-1.841 1.781-3.465 3.784-4.824 5.969-13.989-8.966-23.285-24.18-24.663-40.904 9.803-8.752 21.853-15.032 35.171-17.849-8.638 16.017-10.813 35.102-5.684 52.784zm-9.481 21.799c-.003.178-.014.354-.014.533 0 2.446.293 4.823.83 7.108-16.245 3.552-33.592-.645-46.393-11.517.782-13.745 5.203-26.524 12.302-37.41 5.223 17.407 17.168 32.417 33.275 41.286zm52.657 22.864c1.841-1.781 3.465-3.783 4.824-5.969 13.989 8.966 23.285 24.18 24.662 40.904-9.803 8.752-21.853 15.032-35.17 17.849 8.638-16.017 10.813-35.103 5.684-52.784zm9.481-21.798c.003-.178.014-.355.014-.534 0-2.445-.293-4.822-.829-7.106 16.245-3.552 33.592.645 46.392 11.517-.782 13.744-5.203 26.524-12.302 37.409-5.223-17.408-17.168-32.417-33.275-41.286zm-8.737-22.12c-1.781-1.842-3.784-3.465-5.969-4.824 8.966-13.989 24.18-23.286 40.904-24.663 8.752 9.803 15.032 21.853 17.849 35.171-16.017-8.64-35.103-10.814-52.784-5.684zm-230.291-71.195c-51.16 0-92.781 41.621-92.781 92.781s41.622 92.781 92.781 92.781 92.781-41.621 92.781-92.781-41.622-92.781-92.781-92.781zm-41.818 157.125c17.407-5.223 32.416-17.168 41.284-33.275.178.003.355.014.534.014 2.446 0 4.823-.293 7.107-.829 3.553 16.244-.643 33.591-11.516 46.392-13.745-.782-26.524-5.203-37.409-12.302zm-33.3-48.455c10.281 5.547 21.821 8.446 33.45 8.446 6.489 0 13.003-.909 19.337-2.746 1.78 1.841 3.783 3.464 5.968 4.823-8.966 13.989-24.18 23.285-40.903 24.663-8.755-9.807-15.037-21.862-17.852-35.186zm116.937-80.232c-17.408 5.223-32.417 17.168-41.286 33.275-.178-.003-.355-.014-.534-.014-2.445 0-4.823.293-7.106.829-3.553-16.245.644-33.592 11.517-46.393 13.744.783 26.523 5.204 37.409 12.303zm-41.82 79.426c-8.316 0-15.083-6.767-15.083-15.083s6.766-15.082 15.083-15.082 15.083 6.766 15.083 15.082-6.766 15.083-15.083 15.083zm-21.587-37.414c-1.842 1.781-3.465 3.784-4.824 5.97-13.988-8.966-23.285-24.18-24.662-40.904 9.803-8.752 21.853-15.032 35.171-17.849-8.639 16.016-10.814 35.101-5.685 52.783zm-9.482 21.799c-.003.178-.013.354-.013.533 0 2.446.293 4.823.83 7.108-16.245 3.552-33.591-.645-46.392-11.516.782-13.745 5.203-26.525 12.302-37.41 5.222 17.407 17.166 32.416 33.273 41.285zm52.657 22.864c1.841-1.781 3.465-3.784 4.824-5.969 13.989 8.966 23.285 24.18 24.663 40.904-9.803 8.752-21.853 15.032-35.171 17.849 8.639-16.017 10.813-35.103 5.684-52.784zm9.481-21.798c.003-.178.014-.354.014-.533 0-2.445-.293-4.822-.829-7.106 16.245-3.552 33.592.645 46.393 11.517-.783 13.744-5.203 26.524-12.302 37.409-5.224-17.409-17.169-32.419-33.276-41.287zm-8.736-22.12c-1.781-1.842-3.784-3.465-5.97-4.824 8.966-13.989 24.181-23.286 40.905-24.663 8.752 9.803 15.032 21.853 17.848 35.172-16.016-8.64-35.101-10.814-52.783-5.685z"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
1
Onyx/sound-waves.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Bahan_copy_2" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Bahan copy 2"><g fill="rgb(0,0,0)"><path d="m7.72 24.83a3.72574 3.72574 0 0 0 -3.72 3.72v6.9a3.715 3.715 0 1 0 7.43 0v-6.9a3.7172 3.7172 0 0 0 -3.71-3.72z"/><path d="m19.86 19a3.72575 3.72575 0 0 0 -3.72 3.72v18.56a3.715 3.715 0 1 0 7.43 0v-18.56a3.71725 3.71725 0 0 0 -3.71-3.72z"/><path d="m32 5.04a3.72579 3.72579 0 0 0 -3.72 3.72v46.48a3.72 3.72 0 1 0 7.43994 0v-46.48a3.72579 3.72579 0 0 0 -3.71994-3.72z"/><path d="m44.14 11.92a3.7173 3.7173 0 0 0 -3.71 3.72v32.72a3.715 3.715 0 1 0 7.43 0v-32.72a3.7258 3.7258 0 0 0 -3.72-3.72z"/><path d="m56.28 17.99a3.71721 3.71721 0 0 0 -3.71 3.72v20.58a3.715 3.715 0 1 0 7.43 0v-20.58a3.72575 3.72575 0 0 0 -3.72-3.72z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 790 B |
18
OnyxHelper/Info.plist
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Wird als __TEXT,__info_plist in die Binary eingebettet.
|
||||||
|
Ohne sie leitet codesign den Bezeichner aus dem Dateinamen ab
|
||||||
|
("OnyxHelper"), und die Signaturprüfung zwischen App und Helfer
|
||||||
|
schlägt fehl — die App würde ihren eigenen Helfer ablehnen. -->
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.scarriffleservices.onyx.helper</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>OnyxHelper</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
176
OnyxHelper/SMCAccess.swift
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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..<total {
|
||||||
|
var command = Param()
|
||||||
|
command.data8 = Self.getKeyFromIndex
|
||||||
|
command.data32 = index
|
||||||
|
guard let output = call(command) else { continue }
|
||||||
|
let name = Self.fourCCString(output.key)
|
||||||
|
if name.hasPrefix(prefix) { found.append(name) }
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schreiben
|
||||||
|
|
||||||
|
/// Schreibt einen `flt`-Wert (Zieldrehzahl).
|
||||||
|
@discardableResult
|
||||||
|
func writeFloat(_ key: String, _ value: Double) -> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest die Rohbytes eines Keys — für `CHLT`, dessen Typ `hex_` ist und
|
||||||
|
/// das sich damit keiner der Zahlenfassungen zuordnen lässt.
|
||||||
|
func readBytes(_ key: String) -> [UInt8]? {
|
||||||
|
read(key)?.bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Param>.stride
|
||||||
|
let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
|
||||||
|
&input, MemoryLayout<Param>.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) ?? "????"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
OnyxHelper/com.scarriffleservices.onyx.helper.plist
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.scarriffleservices.onyx.helper</string>
|
||||||
|
|
||||||
|
<!-- Relativ zum App-Bundle. SMAppService löst den Pfad selbst auf; ein
|
||||||
|
absoluter Pfad würde brechen, sobald die App verschoben wird. -->
|
||||||
|
<key>BundleProgram</key>
|
||||||
|
<string>Contents/MacOS/OnyxHelper</string>
|
||||||
|
|
||||||
|
<key>MachServices</key>
|
||||||
|
<dict>
|
||||||
|
<key>com.scarriffleservices.onyx.helper</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<!-- Auf Abruf starten, nicht dauerhaft laufen: der Helfer wird erst
|
||||||
|
gebraucht, wenn jemand die Lüfter steuern will. -->
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<false/>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<key>AssociatedBundleIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.scarriffleservices.onyx</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
212
OnyxHelper/main.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
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..<fanCount {
|
||||||
|
let limits = FanLimits(minimum: smc.float("F\(index)Mn") ?? 0,
|
||||||
|
maximum: smc.float("F\(index)Mx") ?? 0)
|
||||||
|
let decision = FanSafety.decide(request: requests[index],
|
||||||
|
lastHeartbeat: lastHeartbeat,
|
||||||
|
now: Date(),
|
||||||
|
hottestCelsius: hottest,
|
||||||
|
limits: limits)
|
||||||
|
|
||||||
|
// Nur schreiben, wenn sich etwas ändert: jeder SMC-Schreibvorgang
|
||||||
|
// ist ein Eingriff, und eine Sekunde später denselben Wert erneut
|
||||||
|
// zu setzen bringt nichts.
|
||||||
|
guard decision != lastDecision[index] else { continue }
|
||||||
|
lastDecision[index] = decision
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .manual(let rpm):
|
||||||
|
smc.writeUInt8("F\(index)md", 1)
|
||||||
|
smc.writeFloat("F\(index)Tg", rpm)
|
||||||
|
log.notice("Lüfter \(index): manuell \(Int(rpm)) U/min")
|
||||||
|
case .automatic(let reason):
|
||||||
|
smc.writeUInt8("F\(index)md", 0)
|
||||||
|
if reason != .noRequest {
|
||||||
|
// Ein erzwungener Rückfall ist ein Ereignis, das man später
|
||||||
|
// nachvollziehen können muss.
|
||||||
|
log.error("Lüfter \(index): Rückfall auf Automatik — \(reason.rawValue, privacy: .public)")
|
||||||
|
requests[index] = nil
|
||||||
|
} else {
|
||||||
|
log.notice("Lüfter \(index): Automatik")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hottestTemperature() -> 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..<fanCount { smc.writeUInt8("F\(index)md", 0) }
|
||||||
|
log.notice("Alle Lüfter auf Automatik zurückgestellt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schnittstelle
|
||||||
|
|
||||||
|
func version(reply: @escaping (String) -> 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ladelimit
|
||||||
|
|
||||||
|
/// Der Key trägt drei Bytes; nur das erste wird verändert. Siehe
|
||||||
|
/// `ChargeSafety` für die Messung, auf der das beruht.
|
||||||
|
func chargeLimit(reply: @escaping (Int) -> Void) {
|
||||||
|
lastHeartbeat = Date()
|
||||||
|
guard let smc, let bytes = smc.readBytes(ChargeSafety.key),
|
||||||
|
let percent = ChargeSafety.decode(bytes) else {
|
||||||
|
reply(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
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..<fanCount).map { index -> 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()
|
||||||
@@ -9,11 +9,18 @@ let package = Package(
|
|||||||
platforms: [.macOS(.v26)],
|
platforms: [.macOS(.v26)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "OnyxCore", targets: ["OnyxCore"]),
|
.library(name: "OnyxCore", targets: ["OnyxCore"]),
|
||||||
|
.library(name: "OnyxHelperProtocol", targets: ["OnyxHelperProtocol"]),
|
||||||
.library(name: "OnyxDesign", targets: ["OnyxDesign"]),
|
.library(name: "OnyxDesign", targets: ["OnyxDesign"]),
|
||||||
.library(name: "OnyxNotch", targets: ["OnyxNotch"]),
|
.library(name: "OnyxNotch", targets: ["OnyxNotch"]),
|
||||||
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
|
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
|
||||||
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
|
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
|
||||||
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
|
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
|
||||||
|
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
|
||||||
|
.library(name: "MediaProvider", targets: ["MediaProvider"]),
|
||||||
|
.library(name: "MetricsProvider", targets: ["MetricsProvider"]),
|
||||||
|
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
||||||
|
.library(name: "AudioProvider", targets: ["AudioProvider"]),
|
||||||
|
.library(name: "ShelfProvider", targets: ["ShelfProvider"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||||
@@ -22,11 +29,20 @@ let package = Package(
|
|||||||
.target(name: "OnyxCore", resources: [.process("Localizable.xcstrings")]),
|
.target(name: "OnyxCore", resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "OnyxCoreTests", dependencies: ["OnyxCore"]),
|
.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: "OnyxDesign"),
|
||||||
|
|
||||||
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"]),
|
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]),
|
.testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]),
|
||||||
|
|
||||||
|
.testTarget(name: "OnyxDesignTests", dependencies: ["OnyxDesign"]),
|
||||||
|
|
||||||
.target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]),
|
.target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]),
|
||||||
.testTarget(name: "OnyxWidgetKitTests", dependencies: ["OnyxWidgetKit"]),
|
.testTarget(name: "OnyxWidgetKitTests", dependencies: ["OnyxWidgetKit"]),
|
||||||
|
|
||||||
@@ -36,5 +52,29 @@ let package = Package(
|
|||||||
.target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
.target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
resources: [.process("Localizable.xcstrings")]),
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]),
|
.testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]),
|
||||||
|
|
||||||
|
.target(name: "WeatherProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "WeatherProviderTests", dependencies: ["WeatherProvider"]),
|
||||||
|
|
||||||
|
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
|
||||||
|
|
||||||
|
.target(name: "MetricsProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "OnyxHelperProtocol"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "MetricsProviderTests", dependencies: ["MetricsProvider"]),
|
||||||
|
|
||||||
|
.target(name: "NetworkProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "MetricsProvider"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "NetworkProviderTests", dependencies: ["NetworkProvider"]),
|
||||||
|
|
||||||
|
.target(name: "AudioProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
|
||||||
|
|
||||||
|
.target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "ShelfProviderTests", dependencies: ["ShelfProvider"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
436
Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import CoreAudio
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
|
||||||
|
|
||||||
|
/// Der Per-Programm-Lautstärkemixer.
|
||||||
|
///
|
||||||
|
/// **Er ist standardmäßig aus.** Eingeschaltet greift er in den Tonweg jedes
|
||||||
|
/// gewählten Programms ein — geht dabei etwas schief, ist das Programm still.
|
||||||
|
/// Ein Werkzeug, das ungefragt zwischen Musik und Lautsprecher tritt, hat man
|
||||||
|
/// sich nicht ausgesucht.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class AudioMixer {
|
||||||
|
|
||||||
|
public private(set) var processes: [AudioProcess] = []
|
||||||
|
/// Die Programme, wie sie in der Oberfläche stehen — Hilfsprozesse mit
|
||||||
|
/// ihrem Programm zusammengefasst.
|
||||||
|
public private(set) var groups: [AudioProcessGroup] = []
|
||||||
|
public private(set) var isEnabled = false
|
||||||
|
public private(set) var lastError: String?
|
||||||
|
|
||||||
|
/// Programme, die vom Mixer bearbeitet werden. Alle anderen laufen
|
||||||
|
/// unverändert am Mixer vorbei.
|
||||||
|
public private(set) var managed: Set<String> = []
|
||||||
|
|
||||||
|
private var store = VolumeStore()
|
||||||
|
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.
|
||||||
|
private var lastHeard: [String: Date] = [:]
|
||||||
|
|
||||||
|
/// Ob der Mixer beim Programmstart von selbst anläuft.
|
||||||
|
///
|
||||||
|
/// Standardmäßig aus: der Mixer greift in den Tonweg ein, und das soll
|
||||||
|
/// niemandem ungefragt passieren. Wer ihn täglich benutzt, spart sich mit
|
||||||
|
/// dem Häkchen den Klick.
|
||||||
|
public var startsAutomatically: Bool {
|
||||||
|
didSet {
|
||||||
|
guard startsAutomatically != oldValue else { return }
|
||||||
|
defaults.set(startsAutomatically, forKey: Keys.autostart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die eigene Bundle-Kennung — der Mixer darf sich nicht selbst abgreifen.
|
||||||
|
private static let ownBundleID = Bundle.main.bundleIdentifier
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let volumes = "onyx.audio.volumes"
|
||||||
|
static let muted = "onyx.audio.muted"
|
||||||
|
static let overdrive = "onyx.audio.overdrive"
|
||||||
|
static let managed = "onyx.audio.managed"
|
||||||
|
static let autostart = "onyx.audio.autostart"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
let volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:]
|
||||||
|
let muted = defaults.stringArray(forKey: Keys.muted) ?? []
|
||||||
|
let overdrive = defaults.stringArray(forKey: Keys.overdrive) ?? []
|
||||||
|
store = VolumeStore(volumes: volumes, muted: muted, overdrive: overdrive)
|
||||||
|
// Beim Laden aussieben statt nur beim Anzeigen: hat sich der Mixer
|
||||||
|
// einmal selbst in die Auswahl geschrieben, säße die Rückkopplung sonst
|
||||||
|
// dauerhaft in den Einstellungen und käme bei jedem Start zurück.
|
||||||
|
let stored = defaults.stringArray(forKey: Keys.managed) ?? []
|
||||||
|
managed = Set(stored.filter {
|
||||||
|
!ProcessVisibility.isExcluded(bundleID: $0, ownBundleID: Self.ownBundleID)
|
||||||
|
})
|
||||||
|
startsAutomatically = defaults.bool(forKey: Keys.autostart)
|
||||||
|
// Gleich zurückschreiben, wenn etwas herausgefallen ist: sonst steht der
|
||||||
|
// alte Eintrag weiter in den Einstellungen und wirkt bei der nächsten
|
||||||
|
// Fehlersuche wie ein Zustand, den es noch gibt.
|
||||||
|
if managed.count != stored.count { defaults.set(Array(managed), forKey: Keys.managed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beim Programmstart aufrufen. Läuft nur an, wenn das Häkchen gesetzt ist.
|
||||||
|
public func enableIfWanted() {
|
||||||
|
guard startsAutomatically else { return }
|
||||||
|
enable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ein und aus
|
||||||
|
|
||||||
|
public func enable() {
|
||||||
|
guard !isEnabled else { return }
|
||||||
|
isEnabled = true
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.refresh() }
|
||||||
|
}
|
||||||
|
// Der bekannte Fehlerfall aus Spike C: Taps liefern nach einiger Zeit
|
||||||
|
// nur noch Nullen. Von außen sieht das aus, als wäre das Programm
|
||||||
|
// stumm — und genau das ist es dann auch, weil der Tap das Original
|
||||||
|
// stummschaltet.
|
||||||
|
watchdogTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.checkForStalledTaps() }
|
||||||
|
}
|
||||||
|
observeDeviceChanges()
|
||||||
|
log.notice("Mixer eingeschaltet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notausschalter. Stellt den unveränderten Systemzustand wieder her.
|
||||||
|
///
|
||||||
|
/// Muss immer erreichbar sein und darf nie fehlschlagen: wenn der Ton weg
|
||||||
|
/// ist, ist das der Knopf, den man sucht.
|
||||||
|
public func disable() {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Programme
|
||||||
|
|
||||||
|
private func refresh() {
|
||||||
|
let all = Self.currentProcesses()
|
||||||
|
let now = Date()
|
||||||
|
// Core Audio führt Dutzende Systemdienste, die theoretisch Ton ausgeben
|
||||||
|
// könnten. In einem Mischpult sind sie nur Rauschen — gezeigt wird, was
|
||||||
|
// klingt, kürzlich klang oder geregelt wird.
|
||||||
|
// Erst zusammenfassen, dann filtern: sonst verschwindet der
|
||||||
|
// Hauptprozess von Chrome, weil nur sein Helfer Ton ausgibt.
|
||||||
|
let candidates = AudioProcessGroup.group(all)
|
||||||
|
for group in candidates where group.isPlaying { lastHeard[group.bundleID] = now }
|
||||||
|
|
||||||
|
groups = candidates.filter { group in
|
||||||
|
ProcessVisibility.shouldShow(
|
||||||
|
isPlaying: group.isPlaying,
|
||||||
|
secondsSinceHeard: lastHeard[group.bundleID]
|
||||||
|
.map { now.timeIntervalSince($0) },
|
||||||
|
isManaged: managed.contains(group.bundleID))
|
||||||
|
}
|
||||||
|
processes = groups.flatMap(\.processes)
|
||||||
|
syncTaps()
|
||||||
|
startLevelTimerIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt Taps an und ab, sodass sie zu `managed` und den laufenden
|
||||||
|
/// Programmen passen.
|
||||||
|
private func syncTaps() {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
guard let outputUID = Self.defaultOutputUID() else {
|
||||||
|
lastError = AudioMixerError.noOutputDevice.errorDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgegriffen wird je **Prozess**, geregelt je **Programm**: Chromes
|
||||||
|
// Ton kommt aus mehreren Helfern, die alle denselben Regler bekommen.
|
||||||
|
// Der Schlüssel muss deshalb den Prozess mitführen — mehrere Helfer
|
||||||
|
// teilen sich eine Kennung und überschrieben sonst einander.
|
||||||
|
var wanted: [String: (process: AudioProcess, group: AudioProcessGroup)] = [:]
|
||||||
|
for group in groups where managed.contains(group.bundleID) {
|
||||||
|
for process in group.processes {
|
||||||
|
wanted[Self.tapKey(process)] = (process, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beendete oder abgewählte Programme freigeben — sonst bliebe der Tap
|
||||||
|
// stehen und schaltete ein Programm stumm, das gar nicht mehr gemeint ist.
|
||||||
|
for (key, tap) in taps where wanted[key] == nil {
|
||||||
|
tap.stop()
|
||||||
|
taps[key] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, entry) in wanted {
|
||||||
|
let gain = store.effectiveGain(for: entry.group.bundleID)
|
||||||
|
if let existing = taps[key] {
|
||||||
|
existing.setGain(gain)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let tap = ProcessTap(process: entry.process)
|
||||||
|
tap.setGain(gain)
|
||||||
|
do {
|
||||||
|
try tap.start(outputDeviceUID: outputUID)
|
||||||
|
taps[key] = tap
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
// Bei einem Fehlschlag nicht in einem halben Zustand
|
||||||
|
// verharren: das Programm bleibt sonst stumm zurück.
|
||||||
|
tap.stop()
|
||||||
|
managed.remove(entry.group.bundleID)
|
||||||
|
persist()
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
log.error("Tap für \(entry.process.name, privacy: .public) fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Tap je Prozess, nicht je Programm — mehrere Helfer eines Programms
|
||||||
|
/// tragen dieselbe Kennung und überschrieben sich sonst gegenseitig.
|
||||||
|
private static func tapKey(_ process: AudioProcess) -> String {
|
||||||
|
"\(process.bundleID)#\(process.pid)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut Taps neu auf, die nur noch Stille liefern.
|
||||||
|
private func checkForStalledTaps() {
|
||||||
|
for (key, tap) in taps {
|
||||||
|
guard tap.isRunning,
|
||||||
|
tap.secondsSinceAudio > 30,
|
||||||
|
tap.process.isPlaying
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
log.error("Tap für \(key, privacy: .public) liefert nur Stille — Neuaufbau")
|
||||||
|
tap.stop()
|
||||||
|
taps[key] = nil
|
||||||
|
}
|
||||||
|
syncTaps()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wechselt das Ausgabegerät, müssen alle Taps neu aufgebaut werden: sie
|
||||||
|
/// hängen am alten Gerät und gäben ins Leere aus.
|
||||||
|
private func observeDeviceChanges() {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
|
||||||
|
AudioObjectAddPropertyListenerBlock(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, .main) { [weak self] _, _ in
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self, self.isEnabled else { return }
|
||||||
|
log.notice("Ausgabegerät gewechselt — Taps werden neu aufgebaut")
|
||||||
|
for tap in self.taps.values { tap.stop() }
|
||||||
|
self.taps.removeAll()
|
||||||
|
self.syncTaps()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Steuerung
|
||||||
|
|
||||||
|
public func isManaged(_ bundleID: String) -> Bool { managed.contains(bundleID) }
|
||||||
|
|
||||||
|
/// Die Prozesse, deren Originalton gerade stummgeschaltet ist.
|
||||||
|
///
|
||||||
|
/// Der Ausgangspegel muss sie auslassen: ein Tap sieht das Signal **vor**
|
||||||
|
/// dem Stummschalten, sie zählten sonst doppelt — einmal als Original, das
|
||||||
|
/// niemand hört, und einmal als das, was Onyx daraus macht.
|
||||||
|
public var tappedProcessIDs: [AudioObjectID] {
|
||||||
|
taps.values.map(\.process.objectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
// Der Pegel gilt je **Programm**: Chromes Ton verteilt sich auf mehrere
|
||||||
|
// Helfer, und die Zeile zeigt den lautesten davon.
|
||||||
|
var peaks: [String: Double] = [:]
|
||||||
|
for (_, tap) in taps {
|
||||||
|
let app = ProcessNaming.parentBundleID(for: tap.process.bundleID)
|
||||||
|
peaks[app] = max(peaks[app] ?? 0, Double(tap.takePeak()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var next: [String: Double] = [:]
|
||||||
|
for (app, peak) in peaks {
|
||||||
|
let previous = levels[app] ?? 0
|
||||||
|
// Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt,
|
||||||
|
// flackert; einer, der nur fällt, verpasst die Spitzen.
|
||||||
|
next[app] = 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) {
|
||||||
|
guard !ProcessVisibility.isExcluded(bundleID: bundleID,
|
||||||
|
ownBundleID: Self.ownBundleID) else { return }
|
||||||
|
if isManaged { managed.insert(bundleID) } else { managed.remove(bundleID) }
|
||||||
|
persist()
|
||||||
|
syncTaps()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func volume(for bundleID: String) -> Double { store.volume(for: bundleID) }
|
||||||
|
public func isMuted(_ bundleID: String) -> Bool { store.isMuted(for: bundleID) }
|
||||||
|
public func isOverdriven(_ bundleID: String) -> Bool { store.isOverdriven(bundleID) }
|
||||||
|
|
||||||
|
/// Gibt den Bereich über 100 % frei — je Programm, nicht für alle.
|
||||||
|
///
|
||||||
|
/// Ein Browser braucht ihn für eine leise Konferenz, die Musik daneben
|
||||||
|
/// nicht. Und ein Regler, der überall bis 400 % geht, führt genau dorthin,
|
||||||
|
/// wo man ihn nicht haben will.
|
||||||
|
public func setOverdrive(_ isOn: Bool, for bundleID: String) {
|
||||||
|
store.setOverdrive(isOn, for: bundleID)
|
||||||
|
applyGain(for: bundleID)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setVolume(_ volume: Double, for bundleID: String) {
|
||||||
|
store.set(volume, for: bundleID)
|
||||||
|
applyGain(for: bundleID)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setMuted(_ isMuted: Bool, for bundleID: String) {
|
||||||
|
store.setMuted(isMuted, for: bundleID)
|
||||||
|
applyGain(for: bundleID)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gibt die neue Verstärkung an **alle** Taps des Programms weiter.
|
||||||
|
///
|
||||||
|
/// Vorher stand hier `taps[bundleID]` — die Taps liegen aber unter
|
||||||
|
/// `Kennung#Prozessnummer`, weil ein Programm mehrere Tonquellen haben kann.
|
||||||
|
/// Der Zugriff ging deshalb immer ins Leere, und der Regler wirkte erst,
|
||||||
|
/// wenn die Auffrischung drei Sekunden später von selbst nachzog.
|
||||||
|
private func applyGain(for bundleID: String) {
|
||||||
|
let gain = store.effectiveGain(for: bundleID)
|
||||||
|
for tap in taps.values
|
||||||
|
where ProcessNaming.parentBundleID(for: tap.process.bundleID) == bundleID {
|
||||||
|
tap.setGain(gain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
defaults.set(store.snapshot, forKey: Keys.volumes)
|
||||||
|
defaults.set(store.mutedIdentifiers, forKey: Keys.muted)
|
||||||
|
defaults.set(store.overdrivenIdentifiers, forKey: Keys.overdrive)
|
||||||
|
defaults.set(Array(managed), forKey: Keys.managed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Audio
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func currentProcesses() -> [AudioProcess] {
|
||||||
|
let ownBundleID = Self.ownBundleID
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyProcessObjectList,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size) == noErr, size > 0
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
let count = Int(size) / MemoryLayout<AudioObjectID>.stride
|
||||||
|
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
|
||||||
|
alignment: MemoryLayout<AudioObjectID>.alignment)
|
||||||
|
defer { raw.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size, raw) == noErr else { return [] }
|
||||||
|
|
||||||
|
let objects = Array(UnsafeBufferPointer(
|
||||||
|
start: raw.assumingMemoryBound(to: AudioObjectID.self), count: count))
|
||||||
|
|
||||||
|
return objects.compactMap { object -> AudioProcess? in
|
||||||
|
guard let bundleID = string(object, kAudioProcessPropertyBundleID),
|
||||||
|
!bundleID.isEmpty else { return nil }
|
||||||
|
let pid = value(object, kAudioProcessPropertyPID, pid_t(0)) ?? -1
|
||||||
|
|
||||||
|
guard !ProcessVisibility.isExcluded(bundleID: bundleID,
|
||||||
|
ownBundleID: ownBundleID) else { return nil }
|
||||||
|
|
||||||
|
let name = ProcessNaming.displayName(bundleID: bundleID, pid: pid)
|
||||||
|
let playing = (value(object, kAudioProcessPropertyIsRunningOutput, UInt32(0)) ?? 0) != 0
|
||||||
|
return AudioProcess(objectID: object, pid: pid, bundleID: bundleID,
|
||||||
|
name: name, isPlaying: playing)
|
||||||
|
}
|
||||||
|
// Was gerade klingt, gehört nach oben.
|
||||||
|
.sorted { ($0.isPlaying ? 0 : 1, $0.name.lowercased())
|
||||||
|
< ($1.isPlaying ? 0 : 1, $1.name.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultOutputUID() -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
guard let device = value(AudioObjectID(kAudioObjectSystemObject), nil,
|
||||||
|
AudioObjectID(kAudioObjectUnknown), address: &address)
|
||||||
|
else { return nil }
|
||||||
|
return string(device, kAudioDevicePropertyDeviceUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func value<T>(_ object: AudioObjectID, _ selector: AudioObjectPropertySelector?,
|
||||||
|
_ initial: T,
|
||||||
|
address custom: UnsafeMutablePointer<AudioObjectPropertyAddress>? = nil)
|
||||||
|
-> T? {
|
||||||
|
var address = custom?.pointee ?? AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector ?? 0, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var result = initial
|
||||||
|
var size = UInt32(MemoryLayout<T>.size)
|
||||||
|
guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, &result) == noErr
|
||||||
|
else { return nil }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func string(_ object: AudioObjectID,
|
||||||
|
_ selector: AudioObjectPropertySelector) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var result: CFString?
|
||||||
|
var size = UInt32(MemoryLayout<CFString?>.size)
|
||||||
|
let status = withUnsafeMutablePointer(to: &result) {
|
||||||
|
AudioObjectGetPropertyData(object, &address, 0, nil, &size, $0)
|
||||||
|
}
|
||||||
|
guard status == noErr, let result else { return nil }
|
||||||
|
return result as String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Alle Tonquellen **eines** Programms in einer Zeile.
|
||||||
|
///
|
||||||
|
/// Chrome gibt seinen Ton über Hilfsprozesse aus — einen je Tab. In der Liste
|
||||||
|
/// standen deshalb „Google Chrome" und „Google Chrome Helper" nebeneinander,
|
||||||
|
/// und zwar mit getrennten Reglern für dasselbe Programm: zieht man den einen,
|
||||||
|
/// hört man den anderen. Fünfzehn Regler für einen Browser sind keine Auskunft,
|
||||||
|
/// sondern eine Zumutung.
|
||||||
|
public struct AudioProcessGroup: Identifiable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Die Kennung des **Programms**, nicht die des Prozesses. Sie ist der
|
||||||
|
/// Schlüssel für Lautstärke, Stummschaltung und Auswahl.
|
||||||
|
public let bundleID: String
|
||||||
|
public let name: String
|
||||||
|
public let processes: [AudioProcess]
|
||||||
|
|
||||||
|
public var id: String { bundleID }
|
||||||
|
|
||||||
|
/// Spielt einer, spielt die Gruppe.
|
||||||
|
///
|
||||||
|
/// Der Ton kommt aus dem Helfer, während der Hauptprozess schweigt — die
|
||||||
|
/// Zeile muss trotzdem als spielend gelten, sonst verschwindet sie.
|
||||||
|
public var isPlaying: Bool { processes.contains { $0.isPlaying } }
|
||||||
|
|
||||||
|
public static func group(_ processes: [AudioProcess]) -> [AudioProcessGroup] {
|
||||||
|
let byApp = Dictionary(grouping: processes) {
|
||||||
|
ProcessNaming.parentBundleID(for: $0.bundleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return byApp.map { bundleID, members in
|
||||||
|
// Der Name kommt vom Programm selbst, wenn es dabei ist. Sonst vom
|
||||||
|
// Helfer — dann ist er alles, was da ist.
|
||||||
|
let name = members.first { $0.bundleID == bundleID }?.name
|
||||||
|
?? ProcessNaming.fallbackName(for: bundleID)
|
||||||
|
return AudioProcessGroup(bundleID: bundleID, name: name,
|
||||||
|
processes: members.sorted { $0.pid < $1.pid })
|
||||||
|
}
|
||||||
|
// Nach Namen sortiert: Core Audio sagt keine Reihenfolge zu, und ohne
|
||||||
|
// feste Sortierung springen die Zeilen bei jeder Messung umher.
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
148
Packages/OnyxKit/Sources/AudioProvider/Gain.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Rechnet Reglerstellung in Verstärkung um.
|
||||||
|
public enum Gain {
|
||||||
|
|
||||||
|
/// Ohne Overdrive ist bei 100 % Schluss.
|
||||||
|
///
|
||||||
|
/// Das ist die Stellung, in der das Programm klingt, wie es gemeint war.
|
||||||
|
/// Alles darüber ist ein Eingriff und soll auch einer sein.
|
||||||
|
public static let maximumVolume: Double = 1.0
|
||||||
|
|
||||||
|
/// Mit Overdrive: das Vierfache.
|
||||||
|
///
|
||||||
|
/// Für leise Videokonferenzen und schlecht ausgesteuerte Videos. Darüber
|
||||||
|
/// hinaus bringt es nichts mehr — der Begrenzer drückt dann alles zusammen,
|
||||||
|
/// und was lauter wird, ist vor allem das Grundrauschen der Quelle.
|
||||||
|
public static let overdriveMaximum: Double = 4.0
|
||||||
|
|
||||||
|
public static func maximum(overdrive: Bool) -> Double {
|
||||||
|
overdrive ? overdriveMaximum : maximumVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verstärkungsfaktor zu einer Reglerstellung von 0 bis 2.
|
||||||
|
///
|
||||||
|
/// Bewusst nicht linear. Lautstärke wird ungefähr logarithmisch
|
||||||
|
/// wahrgenommen; ein linearer Regler wäre in der unteren Hälfte praktisch
|
||||||
|
/// wirkungslos und in der oberen viel zu empfindlich.
|
||||||
|
///
|
||||||
|
/// Der Exponent ist hergeleitet, nicht geraten: halbe empfundene Lautstärke
|
||||||
|
/// entspricht rund −10 dB, also Faktor 0,316. Aus 0,5^k = 0,316 folgt
|
||||||
|
/// k ≈ 1,66. Damit klingt der halbe Reglerweg auch halb so laut.
|
||||||
|
///
|
||||||
|
/// Eine Potenz statt einer Dezibel-Rechnung, weil hier kein Logarithmus je
|
||||||
|
/// Abtastwert anfallen soll — der Faktor wird einmal je Puffer bestimmt,
|
||||||
|
/// aber die Formel steht in einem Echtzeitpfad.
|
||||||
|
public static let taper: Double = 1.66
|
||||||
|
|
||||||
|
/// Oberhalb von 100 % gilt die Kennlinie **nicht**.
|
||||||
|
///
|
||||||
|
/// Dort steht die Zahl für den Faktor selbst: 400 % sind das Vierfache der
|
||||||
|
/// Amplitude. Mit der Kennlinie wären daraus 4^1,66 ≈ 10,4 geworden — eine
|
||||||
|
/// Beschriftung, die etwas anderes verspricht, als der Regler tut. Bei
|
||||||
|
/// genau 100 % stimmen beide Hälften überein, die Kurve hat also keinen
|
||||||
|
/// Sprung, nur einen Knick.
|
||||||
|
public static func factor(forVolume volume: Double) -> Double {
|
||||||
|
let clamped = min(max(volume, 0), overdriveMaximum)
|
||||||
|
guard clamped > 0 else { return 0 }
|
||||||
|
return clamped <= 1 ? pow(clamped, taper) : clamped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Weiche Begrenzung.
|
||||||
|
///
|
||||||
|
/// Bei zweifacher Verstärkung entstehen Abtastwerte weit über dem gültigen
|
||||||
|
/// Bereich. Ungebremst schneidet die Hardware sie hart ab, und das knackt
|
||||||
|
/// hörbar. Der Tangens hyperbolicus biegt sie stattdessen sanft um: leise
|
||||||
|
/// Signale bleiben unangetastet, laute werden zunehmend gestaucht.
|
||||||
|
public enum SoftClip {
|
||||||
|
|
||||||
|
/// Ab hier greift die Kennlinie. Darunter bleibt das Signal unverändert —
|
||||||
|
/// eine Begrenzung, die schon bei Zimmerlautstärke arbeitet, klingt matt.
|
||||||
|
public static let threshold: Float = 0.7
|
||||||
|
|
||||||
|
public static func apply(_ sample: Float) -> Float {
|
||||||
|
let magnitude = abs(sample)
|
||||||
|
guard magnitude > threshold else { return sample }
|
||||||
|
|
||||||
|
let excess = magnitude - threshold
|
||||||
|
let headroom = 1 - threshold
|
||||||
|
let compressed = threshold + headroom * tanh(excess / headroom)
|
||||||
|
return sample < 0 ? -compressed : compressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merkt sich Lautstärke und Stummschaltung je Programm.
|
||||||
|
///
|
||||||
|
/// Nach der Bundle-Kennung, nicht nach Prozessnummer: die ändert sich bei
|
||||||
|
/// jedem Start, und eine Einstellung, die einen Neustart des Programms nicht
|
||||||
|
/// überlebt, ist keine.
|
||||||
|
public struct VolumeStore: Sendable {
|
||||||
|
|
||||||
|
private var volumes: [String: Double] = [:]
|
||||||
|
private var muted: Set<String> = []
|
||||||
|
private var overdrive: Set<String> = []
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Unbekannte Programme starten auf voller Lautstärke — nicht auf null.
|
||||||
|
/// Ein Programm, das nach dem Start stumm ist, wirkt kaputt, und niemand
|
||||||
|
/// sucht die Ursache im Mixer.
|
||||||
|
public func volume(for bundleID: String) -> Double {
|
||||||
|
volumes[bundleID] ?? 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isMuted(for bundleID: String) -> Bool { muted.contains(bundleID) }
|
||||||
|
|
||||||
|
public mutating func set(_ volume: Double, for bundleID: String) {
|
||||||
|
volumes[bundleID] = min(max(volume, 0), Gain.maximum(overdrive: isOverdriven(bundleID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isOverdriven(_ bundleID: String) -> Bool { overdrive.contains(bundleID) }
|
||||||
|
|
||||||
|
/// Schaltet den erweiterten Bereich frei.
|
||||||
|
///
|
||||||
|
/// Beim Abschalten wird die Lautstärke zurückgeholt: ein Wert von 340 %
|
||||||
|
/// bliebe sonst wirksam, obwohl der Regler ihn gar nicht mehr darstellen
|
||||||
|
/// kann — der Knopf säße am rechten Anschlag und das Programm wäre trotzdem
|
||||||
|
/// dreifach verstärkt.
|
||||||
|
public mutating func setOverdrive(_ isOn: Bool, for bundleID: String) {
|
||||||
|
if isOn {
|
||||||
|
overdrive.insert(bundleID)
|
||||||
|
} else {
|
||||||
|
overdrive.remove(bundleID)
|
||||||
|
if let volume = volumes[bundleID], volume > Gain.maximumVolume {
|
||||||
|
volumes[bundleID] = Gain.maximumVolume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func setMuted(_ isMuted: Bool, for bundleID: String) {
|
||||||
|
// Getrennt von der Lautstärke: sonst verliert man beim Stummschalten
|
||||||
|
// den eingestellten Wert und muss ihn danach neu suchen.
|
||||||
|
if isMuted { muted.insert(bundleID) } else { muted.remove(bundleID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was der Audiopfad tatsächlich anwenden soll.
|
||||||
|
public func effectiveGain(for bundleID: String) -> Double {
|
||||||
|
isMuted(for: bundleID) ? 0 : Gain.factor(forVolume: volume(for: bundleID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sicherung
|
||||||
|
|
||||||
|
public var snapshot: [String: Double] { volumes }
|
||||||
|
public var mutedIdentifiers: [String] { Array(muted) }
|
||||||
|
public var overdrivenIdentifiers: [String] { Array(overdrive) }
|
||||||
|
|
||||||
|
public init(volumes: [String: Double], muted: [String], overdrive: [String] = []) {
|
||||||
|
// Der Overdrive zuerst: sonst schneidet das Einsetzen der Lautstärken
|
||||||
|
// alles über 100 % ab, was gerade wiederhergestellt werden soll.
|
||||||
|
self.overdrive = Set(overdrive)
|
||||||
|
let allowed = Set(overdrive)
|
||||||
|
self.volumes = volumes.reduce(into: [:]) { result, entry in
|
||||||
|
let maximum = Gain.maximum(overdrive: allowed.contains(entry.key))
|
||||||
|
result[entry.key] = min(max(entry.value, 0), maximum)
|
||||||
|
}
|
||||||
|
self.muted = Set(muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
285
Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"": {},
|
||||||
|
"%lld %%": {
|
||||||
|
"comment": "A row of controls for adjusting the volume and muting a process.",
|
||||||
|
"isCommentAutoGenerated": true,
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld %%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld %%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.autostart": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Beim Start von Onyx einschalten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Turn on when Onyx starts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.input": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Eingabe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Input"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.name": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No app is playing audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.notAdjustable": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Hier nicht regelbar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Not adjustable here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.off": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Mixer ausschalten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Turn mixer off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.off.explain": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lautstärke einzelner Programme regeln, auch über 100 %."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Set the volume of individual apps, including above 100 %."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Turn mixer on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.output": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ausgabe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Output"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.overdrive": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Overdrive — bis 400 %, kann hörbar verzerren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Overdrive — up to 400 %, may distort audibly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.recordingHint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Solange der Mixer läuft, zeigt macOS das orange Aufnahmesymbol: Ton abgreifen heißt für das System aufnehmen, auch wenn nur die Lautstärke geregelt wird."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "While the mixer runs, macOS shows the orange recording dot: tapping audio counts as recording to the system, even when only the volume is adjusted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.release": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nicht mehr regeln"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Stop controlling"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.reset": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zurück auf 100 %"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Back to 100 %"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.section.apps": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Programme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Programs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.section.system": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "System"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "System"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"–": {
|
||||||
|
"comment": "A placeholder for the volume of a process.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.1"
|
||||||
|
}
|
||||||
664
Packages/OnyxKit/Sources/AudioProvider/MixerView.swift
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
import CoreAudio
|
||||||
|
|
||||||
|
/// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover.
|
||||||
|
///
|
||||||
|
/// Die Anordnung folgt SoundSource, weil sie dort gut gelöst ist: eine Zeile je
|
||||||
|
/// Programm, links das Symbol als Kachel, dann ein Stummschalter, dann der
|
||||||
|
/// Regler, rechts der Wert. Nichts steht übereinander, nichts muss man suchen.
|
||||||
|
///
|
||||||
|
/// Die Farben folgen ihr **nicht**. SoundSource färbt jeden Regler grün; in
|
||||||
|
/// Onyx ist Farbe ein Signal — die Verstärkung über 100 % ist eines, eine
|
||||||
|
/// gewöhnliche Lautstärke nicht.
|
||||||
|
public struct MixerView: View {
|
||||||
|
let mixer: AudioMixer
|
||||||
|
let system: SystemAudio
|
||||||
|
/// Im Panel ist es eng, im Popover nicht.
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
public init(mixer: AudioMixer, system: SystemAudio, compact: Bool = false) {
|
||||||
|
self.mixer = mixer
|
||||||
|
self.system = system
|
||||||
|
self.compact = compact
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: compact ? 10 : 12) {
|
||||||
|
// Die Systemregler stehen **vor** den Programmen und außerhalb des
|
||||||
|
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
|
||||||
|
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
|
||||||
|
// nichts einschalten müssen.
|
||||||
|
SectionTitle("mixer.section.system", compact: compact)
|
||||||
|
SystemSection(system: system, compact: compact)
|
||||||
|
|
||||||
|
SectionTitle("mixer.section.apps", compact: compact)
|
||||||
|
|
||||||
|
if mixer.isEnabled {
|
||||||
|
if mixer.groups.isEmpty {
|
||||||
|
Text("mixer.noProcesses", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} else {
|
||||||
|
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
||||||
|
// zusammengehört, und trennt es vom Ausschalter darunter.
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
||||||
|
id: \.element.id) { index, group in
|
||||||
|
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
|
||||||
|
ProcessRow(mixer: mixer, group: group, compact: compact)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, compact ? 6 : 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Onyx.Color.elevated.opacity(0.55),
|
||||||
|
in: .rect(cornerRadius: 10, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = mixer.lastError {
|
||||||
|
Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
autostartToggle
|
||||||
|
|
||||||
|
// 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: {
|
||||||
|
Text("mixer.off", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 2)
|
||||||
|
} else {
|
||||||
|
OffState(mixer: mixer, compact: compact)
|
||||||
|
autostartToggle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.animation(Onyx.Motion.value, value: mixer.groups.count)
|
||||||
|
// Kein `onAppear` für die Pegel: die Messung hängt am Popover, nicht
|
||||||
|
// an dieser View. `onDisappear` bleibt bei einem NSPopover aus, und
|
||||||
|
// ein Mikrofon, das nur beim Öffnen an- und nie wieder ausgeht, ist
|
||||||
|
// schlimmer als eines, das gar nicht misst.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MixerView {
|
||||||
|
/// Das Häkchen steht in **beiden** Zuständen: eingeschaltet, um es wieder
|
||||||
|
/// abzuwählen, ausgeschaltet, um es gleich mit zu setzen.
|
||||||
|
@ViewBuilder
|
||||||
|
var autostartToggle: some View {
|
||||||
|
Toggle(isOn: Binding(get: { mixer.startsAutomatically },
|
||||||
|
set: { mixer.startsAutomatically = $0 })) {
|
||||||
|
Text("mixer.autostart", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
// Der Hinweis gehört hierher, weil hier die Entscheidung fällt.
|
||||||
|
//
|
||||||
|
// Ein Tap ist für macOS eine Tonaufnahme — auch wenn nur die
|
||||||
|
// Lautstärke geregelt wird. Solange der Mixer läuft, steht deshalb das
|
||||||
|
// orange Aufnahmesymbol in der Leiste. Wer das ohne Erklärung sieht,
|
||||||
|
// sucht den Fehler bei sich oder hält die App für unsauber.
|
||||||
|
Text("mixer.recordingHint", bundle: .module)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: 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)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { mixer.enable() } label: {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ProcessRow: View {
|
||||||
|
let mixer: AudioMixer
|
||||||
|
let group: AudioProcessGroup
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
private var managed: Bool { mixer.isManaged(group.bundleID) }
|
||||||
|
private var muted: Bool { mixer.isMuted(group.bundleID) }
|
||||||
|
private var volume: Double { mixer.volume(for: group.bundleID) }
|
||||||
|
private var overdriven: Bool { mixer.isOverdriven(group.bundleID) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name
|
||||||
|
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
|
||||||
|
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
|
||||||
|
// hält den Zeiger auf das Symbol.
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
icon
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||||||
|
mixer.setMuted(!muted, for: group.bundleID)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.frame(width: 15, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(muted ? Onyx.Color.critical
|
||||||
|
: (managed ? Onyx.Color.textSecondary
|
||||||
|
: Onyx.Color.textTertiary))
|
||||||
|
|
||||||
|
VolumeBar(
|
||||||
|
volume: volume,
|
||||||
|
maximum: Gain.maximum(overdrive: overdriven),
|
||||||
|
level: mixer.level(for: group.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: group.bundleID) }
|
||||||
|
mixer.setVolume(newValue, for: group.bundleID)
|
||||||
|
})
|
||||||
|
|
||||||
|
Text(managed ? "\(Int(volume * 100)) %" : "–")
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(volume > 1 && managed ? Onyx.Color.warning
|
||||||
|
: Onyx.Color.textTertiary)
|
||||||
|
// Feste Breite, sonst wandert der Regler beim Ziehen.
|
||||||
|
.frame(width: 36, alignment: .trailing)
|
||||||
|
|
||||||
|
overdriveButton
|
||||||
|
resetButton
|
||||||
|
}
|
||||||
|
.contentShape(.rect)
|
||||||
|
.contextMenu {
|
||||||
|
if managed {
|
||||||
|
Button { mixer.setManaged(false, for: group.bundleID) } label: {
|
||||||
|
Text("mixer.release", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(Onyx.Motion.value, value: managed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Overdrive-Schalter.
|
||||||
|
///
|
||||||
|
/// Er steht bei jedem Programm, auch bei einem ungeregelten — sonst müsste
|
||||||
|
/// man erst den Regler anfassen, um an den Schalter zu kommen, und hätte
|
||||||
|
/// damit schon geregelt. Eingeschaltet reicht der Regler bis 400 %.
|
||||||
|
@ViewBuilder
|
||||||
|
private var overdriveButton: some View {
|
||||||
|
Button {
|
||||||
|
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||||||
|
mixer.setOverdrive(!overdriven, for: group.bundleID)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "bolt.fill")
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
|
||||||
|
.help(Text("mixer.overdrive", bundle: .module))
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück auf 100 %.
|
||||||
|
///
|
||||||
|
/// Der Platz dafür ist **immer** reserviert, auch wenn der Knopf nicht zu
|
||||||
|
/// sehen ist. Ein Knopf, der auftaucht und dabei den Regler schmaler macht,
|
||||||
|
/// verschiebt genau das, was man gerade bedient — und beim Loslassen springt
|
||||||
|
/// es zurück.
|
||||||
|
@ViewBuilder
|
||||||
|
private var resetButton: some View {
|
||||||
|
Group {
|
||||||
|
if managed, abs(volume - 1) > 0.001 || overdriven {
|
||||||
|
Button {
|
||||||
|
// Der Overdrive geht mit zurück: er ist keine eigene
|
||||||
|
// Einstellung, sondern die Freigabe für einen Wert, den es
|
||||||
|
// danach nicht mehr gibt. Bliebe er stehen, zeigte die Zeile
|
||||||
|
// 100 % und der Regler stünde trotzdem im Boost-Bereich.
|
||||||
|
mixer.setOverdrive(false, for: group.bundleID)
|
||||||
|
mixer.setVolume(1, for: group.bundleID)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.help(Text("mixer.reset", bundle: .module))
|
||||||
|
} else {
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: Self.resetSlotWidth, height: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die reservierte Breite des Rücksetzknopfes.
|
||||||
|
private static let resetSlotWidth: CGFloat = 14
|
||||||
|
|
||||||
|
/// Wie viele Wellen am Lautsprecher — folgt der Lautstärke, wie überall
|
||||||
|
/// sonst in macOS auch.
|
||||||
|
private var speakerSymbol: String {
|
||||||
|
switch volume {
|
||||||
|
case ..<0.01: "speaker.fill"
|
||||||
|
case ..<0.5: "speaker.wave.1.fill"
|
||||||
|
case ..<1.2: "speaker.wave.2.fill"
|
||||||
|
default: "speaker.wave.3.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ohne Namen daneben trägt das Symbol die Erkennung allein — also größer
|
||||||
|
/// und ohne Kachel, damit es tatsächlich zu erkennen ist.
|
||||||
|
@ViewBuilder
|
||||||
|
private var icon: some View {
|
||||||
|
// Auch Hilfsprozesse bekommen das Symbol ihres Programms.
|
||||||
|
Group {
|
||||||
|
if let image = ProcessNaming.icon(bundleID: group.bundleID,
|
||||||
|
pid: group.processes.first?.pid ?? 0) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.opacity(managed ? 1 : 0.65)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "app.dashed")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
// Der Name hängt am Symbol, nicht an der Zeile: die Zeile besteht zum
|
||||||
|
// größten Teil aus Regler und Knöpfen, und die verschlucken den
|
||||||
|
// Hinweis. Am Symbol kommt er an — und dort sucht man ihn auch, wenn
|
||||||
|
// man das Programm nicht erkennt.
|
||||||
|
.help(tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name des Programms, bei mehreren Tonquellen mit ihrer Anzahl.
|
||||||
|
///
|
||||||
|
/// Chrome erscheint als eine Zeile, gibt seinen Ton aber über mehrere
|
||||||
|
/// Hilfsprozesse aus. Die Zahl erklärt, warum ein Regler mehr als einen
|
||||||
|
/// Prozess bewegt.
|
||||||
|
private var tooltip: String {
|
||||||
|
guard group.processes.count > 1 else { return group.name }
|
||||||
|
return "\(group.name) (\(group.processes.count))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
|
||||||
|
let maximum: Double
|
||||||
|
let level: Double
|
||||||
|
let isActive: Bool
|
||||||
|
let onChange: (Double) -> Void
|
||||||
|
|
||||||
|
@State private var isDragging = false
|
||||||
|
|
||||||
|
private let trackHeight: CGFloat = 6
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let width = geometry.size.width
|
||||||
|
let fraction = min(max(volume / maximum, 0), 1)
|
||||||
|
let knob: CGFloat = isDragging ? 13 : 11
|
||||||
|
// Der Knopf soll an den Enden nicht überstehen.
|
||||||
|
let travel = max(width - knob, 1)
|
||||||
|
let fillWidth = travel * fraction + knob / 2
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Onyx.Color.surface)
|
||||||
|
.frame(height: trackHeight)
|
||||||
|
|
||||||
|
// Markierung bei 100 %: darüber wird verstärkt, und man soll
|
||||||
|
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
|
||||||
|
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
|
||||||
|
if maximum > 1 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Onyx.Color.hairline)
|
||||||
|
.frame(width: 1, height: trackHeight + 4)
|
||||||
|
.offset(x: knob / 2 + travel / maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
Capsule()
|
||||||
|
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
|
||||||
|
.frame(width: fillWidth, height: trackHeight)
|
||||||
|
.opacity(isActive ? 1 : 0.3)
|
||||||
|
|
||||||
|
// 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.4))
|
||||||
|
.frame(width: fillWidth * meterFraction, height: trackHeight)
|
||||||
|
.blendMode(.plusLighter)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: knob, height: knob)
|
||||||
|
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
||||||
|
.offset(x: travel * fraction)
|
||||||
|
.opacity(isActive ? 1 : 0.55)
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
.contentShape(.rect)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { value in
|
||||||
|
isDragging = true
|
||||||
|
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
|
||||||
|
onChange(position * maximum)
|
||||||
|
}
|
||||||
|
.onEnded { _ in isDragging = false })
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
.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 {
|
||||||
|
public let id = "audio"
|
||||||
|
public var displayName: String { String(localized: "mixer.name", bundle: .module) }
|
||||||
|
public let symbolName = "slider.horizontal.3"
|
||||||
|
|
||||||
|
private let mixer: AudioMixer
|
||||||
|
private let system: SystemAudio
|
||||||
|
|
||||||
|
public init(mixer: AudioMixer, system: SystemAudio) {
|
||||||
|
self.mixer = mixer
|
||||||
|
self.system = system
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeView() -> AnyView {
|
||||||
|
AnyView(
|
||||||
|
ScrollView {
|
||||||
|
MixerView(mixer: mixer, system: system, compact: true)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.never)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Systemzeilen
|
||||||
|
|
||||||
|
private struct SectionTitle: View {
|
||||||
|
let key: LocalizedStringKey
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
init(_ key: LocalizedStringKey, compact: Bool) {
|
||||||
|
self.key = key
|
||||||
|
self.compact = compact
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(key, bundle: .module)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.padding(.top, compact ? 0 : 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ausgabe, Eingabe und Gerätewahl.
|
||||||
|
///
|
||||||
|
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
|
||||||
|
/// Regler, Wert. Zwei verschiedene Regler in einem Fenster wären eine
|
||||||
|
/// Behauptung, dass es zwei verschiedene Dinge sind — es ist aber beides
|
||||||
|
/// Lautstärke.
|
||||||
|
private struct SystemSection: View {
|
||||||
|
let system: SystemAudio
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
SystemRow(
|
||||||
|
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
|
title: outputName,
|
||||||
|
devices: system.outputDevices,
|
||||||
|
selected: system.outputDeviceID,
|
||||||
|
volume: system.outputVolume,
|
||||||
|
level: system.levels.output,
|
||||||
|
muted: system.outputMuted,
|
||||||
|
adjustable: system.outputIsAdjustable,
|
||||||
|
compact: compact,
|
||||||
|
onVolume: { system.setOutputVolume($0) },
|
||||||
|
onMute: { system.setOutputMuted(!system.outputMuted) },
|
||||||
|
onSelect: { system.selectOutput($0) })
|
||||||
|
|
||||||
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
|
|
||||||
|
SystemRow(
|
||||||
|
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
|
||||||
|
title: inputName,
|
||||||
|
devices: system.inputDevices,
|
||||||
|
selected: system.inputDeviceID,
|
||||||
|
volume: system.inputVolume,
|
||||||
|
level: system.levels.input,
|
||||||
|
muted: system.inputMuted,
|
||||||
|
adjustable: system.inputIsAdjustable,
|
||||||
|
compact: compact,
|
||||||
|
onVolume: { system.setInputVolume($0) },
|
||||||
|
onMute: { system.setInputMuted(!system.inputMuted) },
|
||||||
|
onSelect: { system.selectInput($0) })
|
||||||
|
}
|
||||||
|
.background(Onyx.Color.elevated.opacity(0.55),
|
||||||
|
in: .rect(cornerRadius: 10, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outputName: String {
|
||||||
|
system.outputDevices.first { $0.id == system.outputDeviceID }?.name
|
||||||
|
?? String(localized: "mixer.output", bundle: .module)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputName: String {
|
||||||
|
system.inputDevices.first { $0.id == system.inputDeviceID }?.name
|
||||||
|
?? String(localized: "mixer.input", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SystemRow: View {
|
||||||
|
let symbol: String
|
||||||
|
let title: String
|
||||||
|
let devices: [AudioDevice]
|
||||||
|
let selected: AudioObjectID
|
||||||
|
let volume: Double
|
||||||
|
let level: Double
|
||||||
|
let muted: Bool
|
||||||
|
let adjustable: Bool
|
||||||
|
let compact: Bool
|
||||||
|
let onVolume: (Double) -> Void
|
||||||
|
let onMute: () -> Void
|
||||||
|
let onSelect: (AudioDevice) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: compact ? 5 : 6) {
|
||||||
|
// Der Gerätename **ist** die Auswahl: ein eigenes Auswahlfeld
|
||||||
|
// daneben wäre eine zweite Stelle für dieselbe Sache.
|
||||||
|
Menu {
|
||||||
|
ForEach(devices) { device in
|
||||||
|
Button { onSelect(device) } label: {
|
||||||
|
if device.id == selected {
|
||||||
|
Label(device.name, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(device.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.menuIndicator(.hidden)
|
||||||
|
.fixedSize()
|
||||||
|
|
||||||
|
HStack(spacing: 9) {
|
||||||
|
Button(action: onMute) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.frame(width: 15, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
||||||
|
|
||||||
|
if adjustable {
|
||||||
|
SystemVolumeBar(volume: volume, level: level,
|
||||||
|
isActive: !muted, onChange: onVolume)
|
||||||
|
Text("\(Int(volume * 100)) %")
|
||||||
|
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 36, alignment: .trailing)
|
||||||
|
} else {
|
||||||
|
// Manche Geräte bringen keinen eigenen Regler mit — HDMI,
|
||||||
|
// viele USB-Interfaces. Ein Regler, der nichts tut, wäre
|
||||||
|
// eine Pseudobedienung.
|
||||||
|
Text("mixer.notAdjustable", bundle: .module)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, compact ? 7 : 9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
|
||||||
|
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
|
||||||
|
private struct SystemVolumeBar: View {
|
||||||
|
let volume: Double
|
||||||
|
let level: Double
|
||||||
|
let isActive: Bool
|
||||||
|
let onChange: (Double) -> Void
|
||||||
|
|
||||||
|
@State private var isDragging = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let knob: CGFloat = isDragging ? 13 : 11
|
||||||
|
let travel = max(geometry.size.width - knob, 1)
|
||||||
|
let fraction = min(max(volume, 0), 1)
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Onyx.Color.surface).frame(height: 6)
|
||||||
|
let fillWidth = travel * fraction + knob / 2
|
||||||
|
|
||||||
|
Capsule()
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(width: fillWidth, height: 6)
|
||||||
|
.opacity(isActive ? 1 : 0.3)
|
||||||
|
|
||||||
|
// Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
|
||||||
|
// sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
|
||||||
|
// so viel Grafik für dieselbe Auskunft wäre.
|
||||||
|
if isActive, level > 0.001 {
|
||||||
|
Capsule()
|
||||||
|
.fill(.white.opacity(0.4))
|
||||||
|
.frame(width: fillWidth * meterFraction, height: 6)
|
||||||
|
.blendMode(.plusLighter)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: knob, height: knob)
|
||||||
|
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
||||||
|
.offset(x: travel * fraction)
|
||||||
|
.opacity(isActive ? 1 : 0.55)
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
.contentShape(.rect)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { value in
|
||||||
|
isDragging = true
|
||||||
|
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
|
||||||
|
}
|
||||||
|
.onEnded { _ in isDragging = false })
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
.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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
131
Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Macht aus Core-Audio-Prozessen etwas, das man wiedererkennt.
|
||||||
|
///
|
||||||
|
/// Core Audio meldet **Prozesse**, nicht Programme. Was dort ankommt, heißt
|
||||||
|
/// `com.google.Chrome.helper` oder `com.apple.WebKit.GPU`. Technisch richtig,
|
||||||
|
/// im Mischpult unbrauchbar: wer „Spotify leiser" sucht, sucht nach „Spotify".
|
||||||
|
public enum ProcessNaming {
|
||||||
|
|
||||||
|
/// Endungen, mit denen Programme ihre Hilfsprozesse benennen.
|
||||||
|
///
|
||||||
|
/// Chromes Ton kommt aus einem solchen Prozess — abgegriffen wird also
|
||||||
|
/// zurecht der Helfer, angezeigt aber das Programm dahinter.
|
||||||
|
private static let helperSuffixes = [
|
||||||
|
".helper", ".Helper", ".ServiceExtension", ".Renderer", ".GPU",
|
||||||
|
".plugin", ".xpc", ".Service",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Die Kennung des Programms, zu dem ein Prozess gehört.
|
||||||
|
public static func parentBundleID(for bundleID: String) -> String {
|
||||||
|
var result = bundleID
|
||||||
|
// Mehrfach kürzen: `com.google.Chrome.helper.Renderer` hat zwei.
|
||||||
|
var changed = true
|
||||||
|
while changed {
|
||||||
|
changed = false
|
||||||
|
for suffix in helperSuffixes where result.hasSuffix(suffix) {
|
||||||
|
result.removeLast(suffix.count)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.isEmpty ? bundleID : result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der anzuzeigende Name, wenn das Programm selbst nicht gefragt werden kann.
|
||||||
|
public static func fallbackName(for bundleID: String) -> String {
|
||||||
|
guard !bundleID.isEmpty else { return "Unbekannt" }
|
||||||
|
let parent = parentBundleID(for: bundleID)
|
||||||
|
// Der letzte Bestandteil ist bei umgekehrter Domainschreibweise der
|
||||||
|
// Programmname — „com.spotify.client" ist die Ausnahme, die auf einen
|
||||||
|
// generischen Begriff endet.
|
||||||
|
let components = parent.split(separator: ".")
|
||||||
|
// Ein einzelnes Wort ist keine umgekehrte Domain, sondern ein
|
||||||
|
// Prozessname wie `systemsoundserverd`. Den groß zu schreiben macht ihn
|
||||||
|
// nicht lesbarer, nur falsch.
|
||||||
|
guard components.count > 1 else { return parent }
|
||||||
|
guard let last = components.last else { return bundleID }
|
||||||
|
|
||||||
|
let generic = ["client", "app", "mac", "macos", "desktop"]
|
||||||
|
let chosen = generic.contains(last.lowercased()) && components.count > 1
|
||||||
|
? components[components.count - 2]
|
||||||
|
: last
|
||||||
|
|
||||||
|
return chosen.prefix(1).uppercased() + chosen.dropFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der beste verfügbare Name: erst das laufende Programm fragen, dann den
|
||||||
|
/// übergeordneten Prozess, dann die Kennung zerlegen.
|
||||||
|
@MainActor
|
||||||
|
public static func displayName(bundleID: String, pid: pid_t) -> String {
|
||||||
|
if let name = NSRunningApplication(processIdentifier: pid)?.localizedName {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
let parent = parentBundleID(for: bundleID)
|
||||||
|
if parent != bundleID,
|
||||||
|
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) {
|
||||||
|
return FileManager.default.displayName(atPath: url.path)
|
||||||
|
.replacingOccurrences(of: ".app", with: "")
|
||||||
|
}
|
||||||
|
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
|
||||||
|
return FileManager.default.displayName(atPath: url.path)
|
||||||
|
.replacingOccurrences(of: ".app", with: "")
|
||||||
|
}
|
||||||
|
return fallbackName(for: bundleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Symbol des Programms — auch für Hilfsprozesse.
|
||||||
|
@MainActor
|
||||||
|
public static func icon(bundleID: String, pid: pid_t) -> NSImage? {
|
||||||
|
if let icon = NSRunningApplication(processIdentifier: pid)?.icon { return icon }
|
||||||
|
let parent = parentBundleID(for: bundleID)
|
||||||
|
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) {
|
||||||
|
return NSWorkspace.shared.icon(forFile: url.path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wer im Mischpult auftaucht.
|
||||||
|
public enum ProcessVisibility {
|
||||||
|
|
||||||
|
/// Wie lange ein verstummtes Programm noch sichtbar bleibt.
|
||||||
|
///
|
||||||
|
/// Ohne diese Nachlaufzeit verschwindet Spotify beim Pausieren aus der
|
||||||
|
/// Liste — und man kann die Lautstärke genau dann nicht mehr einstellen,
|
||||||
|
/// wenn man es will.
|
||||||
|
public static let gracePeriod: TimeInterval = 120
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - isPlaying: gibt der Prozess gerade Ton aus?
|
||||||
|
/// - secondsSinceHeard: wie lange ist das letzte Mal her? `nil` = noch nie.
|
||||||
|
/// - isManaged: wird seine Lautstärke von Onyx geregelt?
|
||||||
|
public static func shouldShow(isPlaying: Bool,
|
||||||
|
secondsSinceHeard: TimeInterval?,
|
||||||
|
isManaged: Bool) -> Bool {
|
||||||
|
// Geregelte Programme immer: wer eine Lautstärke eingestellt hat, muss
|
||||||
|
// sie auch zurücknehmen können.
|
||||||
|
if isManaged || isPlaying { return true }
|
||||||
|
guard let seconds = secondsSinceHeard else { return false }
|
||||||
|
return seconds <= gracePeriod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ProcessVisibility {
|
||||||
|
|
||||||
|
/// Programme, die gar nicht erst in der Liste auftauchen.
|
||||||
|
///
|
||||||
|
/// **Onyx selbst gehört dazu**, und nicht nur der Ordnung halber: sobald der
|
||||||
|
/// Mixer ein Programm bearbeitet, gibt *er* dessen Ton aus — und steht damit
|
||||||
|
/// als Tonquelle in seiner eigenen Liste. Greift er sich dort ab, hört er
|
||||||
|
/// seine eigene Ausgabe wieder mit und gibt sie erneut aus. Gemessen klettert
|
||||||
|
/// der Pegel dabei binnen Sekunden von 0,37 auf 2,83: eine Rückkopplung, die
|
||||||
|
/// man nicht bauen, sondern ausschließen muss.
|
||||||
|
///
|
||||||
|
/// Dazu die Systemdienste: `coreaudiod` und die Sprachausgabe sind in einer
|
||||||
|
/// Liste, in der man „Spotify leiser" sucht, nur Rauschen.
|
||||||
|
static func isExcluded(bundleID: String, ownBundleID: String?) -> Bool {
|
||||||
|
if let ownBundleID, !ownBundleID.isEmpty, bundleID == ownBundleID { return true }
|
||||||
|
return bundleID.hasPrefix("com.apple.audio") || bundleID.hasPrefix("com.apple.core")
|
||||||
|
}
|
||||||
|
}
|
||||||
251
Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import Foundation
|
||||||
|
import Synchronization
|
||||||
|
import AudioToolbox
|
||||||
|
import CoreAudio
|
||||||
|
import AVFoundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
|
||||||
|
|
||||||
|
/// Ein Programm, das Ton ausgibt.
|
||||||
|
public struct AudioProcess: Identifiable, Equatable, Sendable {
|
||||||
|
public let objectID: AudioObjectID
|
||||||
|
public let pid: pid_t
|
||||||
|
public let bundleID: String
|
||||||
|
public let name: String
|
||||||
|
public let isPlaying: Bool
|
||||||
|
|
||||||
|
public var id: AudioObjectID { objectID }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fängt den Ton **eines** Programms ab, verstärkt ihn und gibt ihn wieder aus.
|
||||||
|
///
|
||||||
|
/// Der Weg ist der einzige, den macOS ohne Treiber anbietet: ein Process Tap
|
||||||
|
/// mit `mutedWhenTapped` schaltet die ursprüngliche Ausgabe stumm und liefert
|
||||||
|
/// das Signal stattdessen hier ab. Ein privates Aggregate-Device aus Tap und
|
||||||
|
/// echtem Ausgabegerät gibt es nach der Bearbeitung wieder aus.
|
||||||
|
///
|
||||||
|
/// **Fällt dieser Aufbau auseinander, ist das Programm still.** Deshalb wird
|
||||||
|
/// jeder Fehler zum Abbau des Taps führen, nicht zu einem halben Zustand — und
|
||||||
|
/// deshalb gibt es einen Wachhund gegen die bekannten Null-Puffer.
|
||||||
|
final class ProcessTap {
|
||||||
|
|
||||||
|
/// Vorsatz der Gerätekennungen, die Onyx selbst anlegt.
|
||||||
|
static let aggregateUIDPrefix = "com.scarriffleservices.onyx.tap."
|
||||||
|
|
||||||
|
|
||||||
|
let process: AudioProcess
|
||||||
|
/// Wird im Echtzeitpfad gelesen. `atomic` genügt hier nicht als Sprachmittel,
|
||||||
|
/// 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<UInt32>(0)
|
||||||
|
|
||||||
|
/// Wann zuletzt Ton kam — in Sekunden seit dem Programmstart, damit der
|
||||||
|
/// Echtzeitpfad keine `Date` anfassen muss.
|
||||||
|
private let lastAudioUptime = Atomic<UInt64>(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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { stop() }
|
||||||
|
|
||||||
|
func setGain(_ value: Double) {
|
||||||
|
gain = Float(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aufbau
|
||||||
|
|
||||||
|
func start(outputDeviceUID: String) throws {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
|
||||||
|
let description = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
|
||||||
|
description.uuid = tapUUID
|
||||||
|
description.name = "Onyx – \(process.name)"
|
||||||
|
description.isPrivate = true
|
||||||
|
// Der Kern des Ganzen: das Original wird stumm geschaltet, das Signal
|
||||||
|
// kommt nur noch hier an. Ohne das hörte man beides gleichzeitig.
|
||||||
|
description.muteBehavior = .mutedWhenTapped
|
||||||
|
|
||||||
|
var tap = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
let tapStatus = AudioHardwareCreateProcessTap(description, &tap)
|
||||||
|
guard tapStatus == noErr else { throw AudioMixerError.tapFailed(tapStatus) }
|
||||||
|
tapID = tap
|
||||||
|
|
||||||
|
let aggregate: [String: Any] = [
|
||||||
|
kAudioAggregateDeviceNameKey: "Onyx Mixer – \(process.name)",
|
||||||
|
// Kennung mit erkennbarem Vorsatz: die Geräte sind zwar privat,
|
||||||
|
// das verbirgt sie aber nur vor fremden Prozessen. Onyx selbst
|
||||||
|
// sieht sie in der Geräteliste und muss sie dort aussortieren
|
||||||
|
// können — über den Namen ginge das nur unzuverlässig.
|
||||||
|
kAudioAggregateDeviceUIDKey: Self.aggregateUIDPrefix + UUID().uuidString,
|
||||||
|
// Privat: das Gerät soll nicht in der Tonauswahl des Systems
|
||||||
|
// auftauchen und auch nicht von anderen Programmen gewählt werden.
|
||||||
|
kAudioAggregateDeviceIsPrivateKey: true,
|
||||||
|
kAudioAggregateDeviceIsStackedKey: false,
|
||||||
|
kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID,
|
||||||
|
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]],
|
||||||
|
kAudioAggregateDeviceTapListKey: [[
|
||||||
|
kAudioSubTapUIDKey: tapUUID.uuidString,
|
||||||
|
kAudioSubTapDriftCompensationKey: true,
|
||||||
|
]],
|
||||||
|
]
|
||||||
|
|
||||||
|
var device = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &device)
|
||||||
|
guard aggregateStatus == noErr else {
|
||||||
|
cleanUp()
|
||||||
|
throw AudioMixerError.aggregateFailed(aggregateStatus)
|
||||||
|
}
|
||||||
|
aggregateID = device
|
||||||
|
|
||||||
|
var proc: AudioDeviceIOProcID?
|
||||||
|
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
|
||||||
|
[weak self] _, inputData, _, outputData, _ in
|
||||||
|
self?.render(input: inputData, output: outputData)
|
||||||
|
}
|
||||||
|
guard ioStatus == noErr, let proc else {
|
||||||
|
cleanUp()
|
||||||
|
throw AudioMixerError.ioProcFailed(ioStatus)
|
||||||
|
}
|
||||||
|
procID = proc
|
||||||
|
|
||||||
|
guard AudioDeviceStart(aggregateID, proc) == noErr else {
|
||||||
|
cleanUp()
|
||||||
|
throw AudioMixerError.startFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning = true
|
||||||
|
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
|
||||||
|
log.notice("Tap aktiv für \(self.process.name, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Echtzeitpfad
|
||||||
|
|
||||||
|
/// Läuft auf dem Audiothread. Hier darf nichts geschehen, was blockieren
|
||||||
|
/// kann: keine Speicheranforderung, keine Sperre, kein Protokollieren.
|
||||||
|
private func render(input: UnsafePointer<AudioBufferList>,
|
||||||
|
output: UnsafeMutablePointer<AudioBufferList>) {
|
||||||
|
let inputList = UnsafeMutableAudioBufferListPointer(
|
||||||
|
UnsafeMutablePointer(mutating: input))
|
||||||
|
let outputList = UnsafeMutableAudioBufferListPointer(output)
|
||||||
|
|
||||||
|
let factor = gain
|
||||||
|
var peak: Float = 0
|
||||||
|
|
||||||
|
for (index, outputBuffer) in outputList.enumerated() {
|
||||||
|
guard index < inputList.count,
|
||||||
|
let source = inputList[index].mData,
|
||||||
|
let destination = outputBuffer.mData else { continue }
|
||||||
|
|
||||||
|
let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize))
|
||||||
|
/ MemoryLayout<Float>.size
|
||||||
|
let inputSamples = source.assumingMemoryBound(to: Float.self)
|
||||||
|
let outputSamples = destination.assumingMemoryBound(to: Float.self)
|
||||||
|
|
||||||
|
for sample in 0..<count {
|
||||||
|
let value = inputSamples[sample] * factor
|
||||||
|
// 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.
|
||||||
|
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 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
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard tapID != kAudioObjectUnknown || aggregateID != kAudioObjectUnknown else { return }
|
||||||
|
cleanUp()
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immer in dieser Reihenfolge: erst anhalten, dann IOProc, dann Gerät,
|
||||||
|
/// dann Tap. Umgekehrt bleibt das Programm stumm zurück, weil der Tap noch
|
||||||
|
/// stummschaltet, aber nichts mehr ausgibt.
|
||||||
|
private func cleanUp() {
|
||||||
|
if let procID {
|
||||||
|
AudioDeviceStop(aggregateID, procID)
|
||||||
|
AudioDeviceDestroyIOProcID(aggregateID, procID)
|
||||||
|
self.procID = nil
|
||||||
|
}
|
||||||
|
if aggregateID != kAudioObjectUnknown {
|
||||||
|
AudioHardwareDestroyAggregateDevice(aggregateID)
|
||||||
|
aggregateID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
}
|
||||||
|
if tapID != kAudioObjectUnknown {
|
||||||
|
AudioHardwareDestroyProcessTap(tapID)
|
||||||
|
tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AudioMixerError: Error, LocalizedError {
|
||||||
|
case tapFailed(OSStatus)
|
||||||
|
case aggregateFailed(OSStatus)
|
||||||
|
case ioProcFailed(OSStatus)
|
||||||
|
case startFailed
|
||||||
|
case noOutputDevice
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .tapFailed(let status):
|
||||||
|
"Der Ton dieses Programms lässt sich nicht abgreifen (\(status))."
|
||||||
|
case .aggregateFailed(let status):
|
||||||
|
"Das Mischgerät ließ sich nicht anlegen (\(status))."
|
||||||
|
case .ioProcFailed(let status):
|
||||||
|
"Die Tonverarbeitung ließ sich nicht starten (\(status))."
|
||||||
|
case .startFailed:
|
||||||
|
"Das Mischgerät startet nicht."
|
||||||
|
case .noOutputDevice:
|
||||||
|
"Kein Ausgabegerät gefunden."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
345
Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreAudio
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
|
||||||
|
|
||||||
|
/// Ein Ein- oder Ausgabegerät.
|
||||||
|
public struct AudioDevice: Identifiable, Equatable, Sendable {
|
||||||
|
public let id: AudioObjectID
|
||||||
|
public let name: String
|
||||||
|
public let isOutput: Bool
|
||||||
|
public let isInput: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Systemlautstärken und die Gerätewahl.
|
||||||
|
///
|
||||||
|
/// Getrennt vom Mischpult der einzelnen Programme, weil es technisch etwas
|
||||||
|
/// ganz anderes ist: hier wird kein Ton abgegriffen, hier werden die Regler
|
||||||
|
/// des Systems bedient. Das geht ohne Tap, ohne Aggregate-Gerät und ohne
|
||||||
|
/// Berechtigung — und funktioniert deshalb auch, wenn der Mixer aus ist.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class SystemAudio {
|
||||||
|
|
||||||
|
public private(set) var outputDevices: [AudioDevice] = []
|
||||||
|
public private(set) var inputDevices: [AudioDevice] = []
|
||||||
|
|
||||||
|
public private(set) var outputDeviceID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
public private(set) var inputDeviceID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
|
||||||
|
public private(set) var outputVolume: Double = 0
|
||||||
|
public private(set) var outputMuted = false
|
||||||
|
public private(set) var inputVolume: Double = 0
|
||||||
|
public private(set) var inputMuted = false
|
||||||
|
/// Warnton-Lautstärke. `nil`, wenn das Gerät sie nicht getrennt führt.
|
||||||
|
public private(set) var alertVolume: Double?
|
||||||
|
|
||||||
|
/// Ob sich die Lautstärke überhaupt setzen lässt.
|
||||||
|
///
|
||||||
|
/// Manche Geräte — HDMI, viele USB-Interfaces — bringen keinen eigenen
|
||||||
|
/// Regler mit; dort regelt man am Gerät. Ein Regler, der nichts tut, wäre
|
||||||
|
/// eine Pseudobedienung.
|
||||||
|
public private(set) var outputIsAdjustable = false
|
||||||
|
public private(set) var inputIsAdjustable = false
|
||||||
|
|
||||||
|
private var listeners: [PropertyListener] = []
|
||||||
|
|
||||||
|
/// Die Pegel von Ausgabe und Eingabe.
|
||||||
|
///
|
||||||
|
/// Hier und nicht im Mixer: sie gehören zu den Systemreglern, und die
|
||||||
|
/// stehen bewusst außerhalb des Mixer-Schalters.
|
||||||
|
public let levels = SystemLevels()
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
refresh()
|
||||||
|
observeSystemChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lesen
|
||||||
|
|
||||||
|
public func refresh() {
|
||||||
|
outputDevices = Self.devices().filter(\.isOutput)
|
||||||
|
inputDevices = Self.devices().filter(\.isInput)
|
||||||
|
|
||||||
|
outputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultOutputDevice)
|
||||||
|
inputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice)
|
||||||
|
|
||||||
|
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
|
||||||
|
outputIsAdjustable = Self.volume(outputDeviceID,
|
||||||
|
scope: kAudioDevicePropertyScopeOutput) != nil
|
||||||
|
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
|
||||||
|
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
|
||||||
|
inputIsAdjustable = Self.volume(inputDeviceID,
|
||||||
|
scope: kAudioDevicePropertyScopeInput) != nil
|
||||||
|
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||||
|
|
||||||
|
let alertDevice = Self.defaultDevice(kAudioHardwarePropertyDefaultSystemOutputDevice)
|
||||||
|
alertVolume = alertDevice == outputDeviceID
|
||||||
|
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
|
||||||
|
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setzen
|
||||||
|
|
||||||
|
public func setOutputVolume(_ value: Double) {
|
||||||
|
Self.setVolume(value, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
outputVolume = min(max(value, 0), 1)
|
||||||
|
// Lautstärke ziehen hebt die Stummschaltung auf — sonst zieht man am
|
||||||
|
// Regler und hört weiter nichts.
|
||||||
|
if outputMuted, value > 0 { setOutputMuted(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setOutputMuted(_ muted: Bool) {
|
||||||
|
Self.setMuted(muted, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
outputMuted = muted
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setInputVolume(_ value: Double) {
|
||||||
|
Self.setVolume(value, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||||
|
inputVolume = min(max(value, 0), 1)
|
||||||
|
if inputMuted, value > 0 { setInputMuted(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setInputMuted(_ muted: Bool) {
|
||||||
|
Self.setMuted(muted, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||||
|
inputMuted = muted
|
||||||
|
}
|
||||||
|
|
||||||
|
public func selectOutput(_ device: AudioDevice) {
|
||||||
|
Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultOutputDevice)
|
||||||
|
// Warntöne folgen dem Ausgabegerät: zwei getrennte Wahlen sind eine
|
||||||
|
// Quelle für „warum kommt der Systemton aus dem Notebook".
|
||||||
|
Self.setDefaultDevice(device.id,
|
||||||
|
selector: kAudioHardwarePropertyDefaultSystemOutputDevice)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func selectInput(_ device: AudioDevice) {
|
||||||
|
Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultInputDevice)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Audio
|
||||||
|
|
||||||
|
/// Gerätewechsel kommen von außen: Kopfhörer einstecken, Bluetooth
|
||||||
|
/// verbinden, ein anderes Programm stellt das Standardgerät um.
|
||||||
|
private func observeSystemChanges() {
|
||||||
|
listeners = [kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
kAudioHardwarePropertyDevices].compactMap { selector in
|
||||||
|
PropertyListener(selector: selector) { [weak self] in
|
||||||
|
Task { @MainActor in self?.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func devices() -> [AudioDevice] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size) == noErr else { return [] }
|
||||||
|
|
||||||
|
var ids = [AudioObjectID](repeating: 0,
|
||||||
|
count: Int(size) / MemoryLayout<AudioObjectID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size, &ids) == noErr else { return [] }
|
||||||
|
|
||||||
|
return ids.compactMap { id in
|
||||||
|
// Die eigenen Aggregate-Geräte gehören nicht in die Auswahl: sie
|
||||||
|
// sind Werkzeug, kein Ziel. Wer eines davon als Ausgabe wählt,
|
||||||
|
// hängt seinen Ton in eine Schleife.
|
||||||
|
guard !isOwnDevice(uid: uid(of: id)) else { return nil }
|
||||||
|
guard let name = name(of: id) else { return nil }
|
||||||
|
let output = channels(id, scope: kAudioDevicePropertyScopeOutput) > 0
|
||||||
|
let input = channels(id, scope: kAudioDevicePropertyScopeInput) > 0
|
||||||
|
// Geräte ohne Kanäle sind Aggregate-Hüllen und Ähnliches — sie in
|
||||||
|
// die Auswahl zu stellen hilft niemandem.
|
||||||
|
guard output || input else { return nil }
|
||||||
|
return AudioDevice(id: id, name: name, isOutput: output, isInput: input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob Onyx dieses Gerät selbst angelegt hat.
|
||||||
|
///
|
||||||
|
/// `nonisolated`, weil es eine reine Zeichenkettenprüfung ist und keinen
|
||||||
|
/// Zustand anfasst — so lässt es sich auch prüfen, ohne den MainActor zu
|
||||||
|
/// bemühen.
|
||||||
|
nonisolated static func isOwnDevice(uid: String?) -> Bool {
|
||||||
|
guard let uid, !uid.isEmpty else { return false }
|
||||||
|
return uid.hasPrefix(ProcessTap.aggregateUIDPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func uid(of device: AudioObjectID) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyDeviceUID,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size = UInt32(MemoryLayout<CFString?>.size)
|
||||||
|
var uid: CFString?
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &uid) == noErr
|
||||||
|
else { return nil }
|
||||||
|
return uid as String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func name(of device: AudioObjectID) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioObjectPropertyName,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size = UInt32(MemoryLayout<CFString?>.size)
|
||||||
|
var name: CFString?
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &name) == noErr
|
||||||
|
else { return nil }
|
||||||
|
return name as String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func channels(_ device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) -> Int {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr,
|
||||||
|
size > 0 else { return 0 }
|
||||||
|
|
||||||
|
let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
|
||||||
|
alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { buffer.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, buffer) == noErr
|
||||||
|
else { return 0 }
|
||||||
|
|
||||||
|
let list = UnsafeMutableAudioBufferListPointer(
|
||||||
|
buffer.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return list.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var device = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||||
|
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size, &device)
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setDefaultDevice(_ device: AudioObjectID,
|
||||||
|
selector: AudioObjectPropertySelector) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var value = device
|
||||||
|
let status = AudioObjectSetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<AudioObjectID>.size), &value)
|
||||||
|
if status != noErr {
|
||||||
|
log.error("Ausgabegerät nicht gesetzt: \(status, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Lautstärke eines Geräts.
|
||||||
|
///
|
||||||
|
/// **Zuerst der Hauptregler, dann die Kanäle.** Viele Geräte führen keinen
|
||||||
|
/// Regler für Element 0, sondern nur je Kanal — fragt man nur den
|
||||||
|
/// Hauptregler, hält man ein regelbares Gerät für unregelbar.
|
||||||
|
private static func volume(_ device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) -> Double? {
|
||||||
|
guard device != AudioObjectID(kAudioObjectUnknown) else { return nil }
|
||||||
|
|
||||||
|
for element in [kAudioObjectPropertyElementMain, 1, 2] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyVolumeScalar,
|
||||||
|
mScope: scope, mElement: AudioObjectPropertyElement(element))
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { continue }
|
||||||
|
var value: Float32 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float32>.size)
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr
|
||||||
|
else { continue }
|
||||||
|
return Double(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setVolume(_ value: Double, on device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) {
|
||||||
|
guard device != AudioObjectID(kAudioObjectUnknown) else { return }
|
||||||
|
var scalar = Float32(min(max(value, 0), 1))
|
||||||
|
|
||||||
|
for element in [kAudioObjectPropertyElementMain, 1, 2] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyVolumeScalar,
|
||||||
|
mScope: scope, mElement: AudioObjectPropertyElement(element))
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { continue }
|
||||||
|
var settable: DarwinBoolean = false
|
||||||
|
guard AudioObjectIsPropertySettable(device, &address, &settable) == noErr,
|
||||||
|
settable.boolValue else { continue }
|
||||||
|
AudioObjectSetPropertyData(device, &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<Float32>.size), &scalar)
|
||||||
|
// Beim Hauptregler ist man fertig; bei Kanälen müssen beide gesetzt
|
||||||
|
// werden, sonst wandert das Stereobild.
|
||||||
|
if element == kAudioObjectPropertyElementMain { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func muted(_ device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) -> Bool {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyMute,
|
||||||
|
mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { return false }
|
||||||
|
var value: UInt32 = 0
|
||||||
|
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr
|
||||||
|
else { return false }
|
||||||
|
return value != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setMuted(_ muted: Bool, on device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyMute,
|
||||||
|
mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { return }
|
||||||
|
var value: UInt32 = muted ? 1 : 0
|
||||||
|
AudioObjectSetPropertyData(device, &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<UInt32>.size), &value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Hält eine Core-Audio-Anmeldung und meldet sie beim Abräumen wieder ab.
|
||||||
|
///
|
||||||
|
/// Wie beim Kalender ein eigenes Objekt: `SystemAudio` liegt auf dem
|
||||||
|
/// MainActor, sein `deinit` aber nicht — es dürfte die Anmeldung dort gar
|
||||||
|
/// nicht mehr anfassen. Und `AudioObjectRemovePropertyListenerBlock` will
|
||||||
|
/// **denselben** Block zurück, den man angemeldet hat; der muss also
|
||||||
|
/// irgendwo aufgehoben werden.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable` ist belegbar: nach `init` wird hier nichts mehr
|
||||||
|
/// geschrieben.
|
||||||
|
private final class PropertyListener: @unchecked Sendable {
|
||||||
|
|
||||||
|
private let object = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
private var address: AudioObjectPropertyAddress
|
||||||
|
private let block: AudioObjectPropertyListenerBlock
|
||||||
|
|
||||||
|
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) {
|
||||||
|
address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
block = { _, _ in handler() }
|
||||||
|
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
|
||||||
|
else { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
AudioObjectRemovePropertyListenerBlock(object, &address, .main, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
329
Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import Foundation
|
||||||
|
import Synchronization
|
||||||
|
import AudioToolbox
|
||||||
|
import CoreAudio
|
||||||
|
import AVFoundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
|
||||||
|
|
||||||
|
/// Der höchste Ausschlag seit dem letzten Abholen — als Bitmuster, weil
|
||||||
|
/// `Atomic` keine Fließkommazahlen führt.
|
||||||
|
///
|
||||||
|
/// Sperrenfrei, weil geschrieben wird, während der Audiothread läuft. Eine
|
||||||
|
/// gewöhnliche Eigenschaft wäre dort ein Wettlauf, eine Sperre ein Aussetzer.
|
||||||
|
final class PeakMeter: Sendable {
|
||||||
|
private let bits = Atomic<UInt32>(0)
|
||||||
|
|
||||||
|
func record(_ magnitude: Float) {
|
||||||
|
let previous = Float(bitPattern: bits.load(ordering: .relaxed))
|
||||||
|
if magnitude > previous { bits.store(magnitude.bitPattern, ordering: .relaxed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt den Höchstwert ab und setzt zurück, damit die Anzeige abfällt,
|
||||||
|
/// statt auf der Spitze stehen zu bleiben.
|
||||||
|
func take() -> Float { Float(bitPattern: bits.exchange(0, ordering: .relaxed)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Misst, was gerade aus dem Lautsprecher kommt.
|
||||||
|
///
|
||||||
|
/// Über einen globalen Tap, also denselben Weg wie der Mixer — nur ohne
|
||||||
|
/// Stummschalten und ohne Ausgabe. Ausgenommen werden die Programme, die der
|
||||||
|
/// Mixer bereits abgreift: deren Originalton ist stummgeschaltet, aber ein Tap
|
||||||
|
/// sieht ihn trotzdem. Sie zählten sonst doppelt — einmal als Original, das
|
||||||
|
/// niemand hört, und einmal als das, was Onyx daraus macht.
|
||||||
|
final class OutputMeter {
|
||||||
|
|
||||||
|
let peak = PeakMeter()
|
||||||
|
private(set) var excluded: [AudioObjectID] = []
|
||||||
|
private var tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
private var aggregateID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
private var procID: AudioDeviceIOProcID?
|
||||||
|
|
||||||
|
var isRunning: Bool { procID != nil }
|
||||||
|
|
||||||
|
deinit { stop() }
|
||||||
|
|
||||||
|
func start(excluding processes: [AudioObjectID], outputDeviceUID: String) {
|
||||||
|
stop()
|
||||||
|
excluded = processes
|
||||||
|
|
||||||
|
let tapUUID = UUID()
|
||||||
|
let description = CATapDescription(stereoGlobalTapButExcludeProcesses: processes)
|
||||||
|
description.uuid = tapUUID
|
||||||
|
description.name = "Onyx – Ausgangspegel"
|
||||||
|
description.isPrivate = true
|
||||||
|
// Nur zuhören. Ein Pegelmesser, der die Wiedergabe verändert, wäre
|
||||||
|
// kein Messgerät.
|
||||||
|
description.muteBehavior = .unmuted
|
||||||
|
|
||||||
|
guard AudioHardwareCreateProcessTap(description, &tapID) == noErr else {
|
||||||
|
tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let aggregate: [String: Any] = [
|
||||||
|
kAudioAggregateDeviceNameKey: "Onyx Pegel",
|
||||||
|
kAudioAggregateDeviceUIDKey: ProcessTap.aggregateUIDPrefix + UUID().uuidString,
|
||||||
|
kAudioAggregateDeviceIsPrivateKey: true,
|
||||||
|
kAudioAggregateDeviceIsStackedKey: false,
|
||||||
|
kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID,
|
||||||
|
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]],
|
||||||
|
kAudioAggregateDeviceTapListKey: [[
|
||||||
|
kAudioSubTapUIDKey: tapUUID.uuidString,
|
||||||
|
kAudioSubTapDriftCompensationKey: true,
|
||||||
|
]],
|
||||||
|
]
|
||||||
|
guard AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID) == noErr
|
||||||
|
else {
|
||||||
|
aggregateID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var proc: AudioDeviceIOProcID?
|
||||||
|
let status = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
|
||||||
|
[peak] _, inputData, _, outputData, _ in
|
||||||
|
let input = UnsafeMutableAudioBufferListPointer(
|
||||||
|
UnsafeMutablePointer(mutating: inputData))
|
||||||
|
for buffer in input {
|
||||||
|
guard let data = buffer.mData else { continue }
|
||||||
|
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
|
||||||
|
let samples = data.assumingMemoryBound(to: Float.self)
|
||||||
|
var high: Float = 0
|
||||||
|
for index in 0..<count {
|
||||||
|
let magnitude = abs(samples[index])
|
||||||
|
if magnitude > high { high = magnitude }
|
||||||
|
}
|
||||||
|
peak.record(high)
|
||||||
|
}
|
||||||
|
// Die eigene Ausgabe bleibt still: das Gerät gibt auf denselben
|
||||||
|
// Lautsprecher aus, und ungeschriebene Puffer enthalten, was zuletzt
|
||||||
|
// darin stand.
|
||||||
|
AudioBuffers.silence(outputData)
|
||||||
|
}
|
||||||
|
guard status == noErr, let proc else { stop(); return }
|
||||||
|
procID = proc
|
||||||
|
|
||||||
|
guard AudioDeviceStart(aggregateID, proc) == noErr else { stop(); return }
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
if let procID {
|
||||||
|
AudioDeviceStop(aggregateID, procID)
|
||||||
|
AudioDeviceDestroyIOProcID(aggregateID, procID)
|
||||||
|
self.procID = nil
|
||||||
|
}
|
||||||
|
if aggregateID != kAudioObjectUnknown {
|
||||||
|
AudioHardwareDestroyAggregateDevice(aggregateID)
|
||||||
|
aggregateID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
}
|
||||||
|
if tapID != kAudioObjectUnknown {
|
||||||
|
AudioHardwareDestroyProcessTap(tapID)
|
||||||
|
tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Misst, was das Mikrofon hört.
|
||||||
|
///
|
||||||
|
/// **Dafür wird tatsächlich aufgenommen** — anders geht es nicht. Es gibt keine
|
||||||
|
/// Eigenschaft am Gerät, die den Ausschlag verrät; einen Pegel bekommt nur, wer
|
||||||
|
/// das Signal liest. Genau das meldet macOS mit dem orangen Punkt, und das ist
|
||||||
|
/// richtig so. Deshalb läuft diese Messung nur, solange jemand hinschaut, und
|
||||||
|
/// hört mit dem Schließen des Popovers sofort wieder auf.
|
||||||
|
final class InputMeter {
|
||||||
|
|
||||||
|
let peak = PeakMeter()
|
||||||
|
private var deviceID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
private var procID: AudioDeviceIOProcID?
|
||||||
|
|
||||||
|
var isRunning: Bool { procID != nil }
|
||||||
|
|
||||||
|
deinit { stop() }
|
||||||
|
|
||||||
|
func start(device: AudioObjectID) {
|
||||||
|
stop()
|
||||||
|
guard device != kAudioObjectUnknown, Self.deliversFloat(device) else { return }
|
||||||
|
deviceID = device
|
||||||
|
|
||||||
|
var proc: AudioDeviceIOProcID?
|
||||||
|
let status = AudioDeviceCreateIOProcIDWithBlock(&proc, device, nil) {
|
||||||
|
[peak] _, inputData, _, outputData, _ in
|
||||||
|
let input = UnsafeMutableAudioBufferListPointer(
|
||||||
|
UnsafeMutablePointer(mutating: inputData))
|
||||||
|
for buffer in input {
|
||||||
|
guard let data = buffer.mData else { continue }
|
||||||
|
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
|
||||||
|
let samples = data.assumingMemoryBound(to: Float.self)
|
||||||
|
var high: Float = 0
|
||||||
|
for index in 0..<count {
|
||||||
|
let magnitude = abs(samples[index])
|
||||||
|
if magnitude > high { high = magnitude }
|
||||||
|
}
|
||||||
|
peak.record(high)
|
||||||
|
}
|
||||||
|
// Manche Geräte haben Ein- und Ausgang. Der eigene Ausgabepuffer
|
||||||
|
// bleibt still — er trägt zum Gerät nur bei, was hier hineingeht.
|
||||||
|
AudioBuffers.silence(outputData)
|
||||||
|
}
|
||||||
|
guard status == noErr, let proc else { stop(); return }
|
||||||
|
procID = proc
|
||||||
|
|
||||||
|
if AudioDeviceStart(device, proc) != noErr {
|
||||||
|
log.error("Mikrofonpegel: Gerät startet nicht")
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
if let procID {
|
||||||
|
AudioDeviceStop(deviceID, procID)
|
||||||
|
AudioDeviceDestroyIOProcID(deviceID, procID)
|
||||||
|
self.procID = nil
|
||||||
|
}
|
||||||
|
deviceID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liefert das Gerät Fließkommawerte?
|
||||||
|
///
|
||||||
|
/// Ohne diese Prüfung würden Ganzzahlwerte als Fließkomma gelesen — das
|
||||||
|
/// ergibt keinen zu hohen oder zu niedrigen Pegel, sondern Unsinn.
|
||||||
|
private static func deliversFloat(_ device: AudioObjectID) -> Bool {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioStreamPropertyVirtualFormat,
|
||||||
|
mScope: kAudioObjectPropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var format = AudioStreamBasicDescription()
|
||||||
|
var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &format) == noErr
|
||||||
|
else { return false }
|
||||||
|
return format.mFormatFlags & kAudioFormatFlagIsFloat != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AudioBuffers {
|
||||||
|
/// Füllt alle Puffer mit Nullen.
|
||||||
|
static func silence(_ list: UnsafeMutablePointer<AudioBufferList>) {
|
||||||
|
for buffer in UnsafeMutableAudioBufferListPointer(list) {
|
||||||
|
guard let data = buffer.mData else { continue }
|
||||||
|
memset(data, 0, Int(buffer.mDataByteSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Pegel von Ausgabe und Eingabe.
|
||||||
|
///
|
||||||
|
/// Sie laufen nur, solange jemand hinschaut: beide brauchen einen offenen
|
||||||
|
/// Audioweg, der Mikrofonpegel obendrein den orangen Punkt. Ein Messgerät, das
|
||||||
|
/// im Hintergrund weiterläuft, wäre bei beidem falsch.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class SystemLevels {
|
||||||
|
|
||||||
|
public private(set) var output: Double = 0
|
||||||
|
public private(set) var input: Double = 0
|
||||||
|
|
||||||
|
/// Ob gerade tatsächlich das Mikrofon gelesen wird — für den Hinweis in der
|
||||||
|
/// Oberfläche. Wer den orangen Punkt sieht, soll in Onyx nachlesen können,
|
||||||
|
/// warum er da ist.
|
||||||
|
public private(set) var isListeningToInput = false
|
||||||
|
|
||||||
|
/// Prozesse, deren Originalton der Mixer bereits stummgeschaltet hat.
|
||||||
|
///
|
||||||
|
/// Wird von außen gesetzt, weil nur der Mixer sie kennt. Ohne die Angabe
|
||||||
|
/// zählte jedes geregelte Programm doppelt.
|
||||||
|
public var mutedProcesses: @MainActor () -> [AudioObjectID] = { [] }
|
||||||
|
|
||||||
|
@ObservationIgnored private let outputMeter = OutputMeter()
|
||||||
|
@ObservationIgnored private let inputMeter = InputMeter()
|
||||||
|
@ObservationIgnored private var consumers = 0
|
||||||
|
@ObservationIgnored private var timer: Timer?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Ein Zähler statt eines Schalters: Popover und Panel können gleichzeitig
|
||||||
|
/// offen sein, und das zweite Schließen darf nicht abschalten, was das erste
|
||||||
|
/// noch braucht.
|
||||||
|
public func addConsumer() {
|
||||||
|
consumers += 1
|
||||||
|
guard consumers == 1 else { return }
|
||||||
|
rebuild()
|
||||||
|
let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.sample() }
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
self.timer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeConsumer() {
|
||||||
|
consumers = max(0, consumers - 1)
|
||||||
|
guard consumers == 0 else { return }
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
outputMeter.stop()
|
||||||
|
inputMeter.stop()
|
||||||
|
isListeningToInput = false
|
||||||
|
output = 0
|
||||||
|
input = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuild() {
|
||||||
|
if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
|
||||||
|
outputMeter.start(excluding: mutedProcesses(), outputDeviceUID: uid)
|
||||||
|
}
|
||||||
|
inputMeter.start(device: Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice))
|
||||||
|
isListeningToInput = inputMeter.isRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sample() {
|
||||||
|
// Ändert sich, welche Programme der Mixer abgreift, muss der Tap neu
|
||||||
|
// aufgebaut werden — sonst zählte ein gerade erst geregeltes Programm
|
||||||
|
// ab dem nächsten Puffer doppelt.
|
||||||
|
let muted = mutedProcesses()
|
||||||
|
if outputMeter.isRunning, muted.sorted() != outputMeter.excluded.sorted() {
|
||||||
|
if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
|
||||||
|
outputMeter.start(excluding: muted, outputDeviceUID: uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schnell hoch, langsam runter — wie bei den Programmzeilen, damit
|
||||||
|
// beide Balken dieselbe Sprache sprechen.
|
||||||
|
output = decay(output, toward: Double(outputMeter.peak.take()))
|
||||||
|
input = decay(input, toward: Double(inputMeter.peak.take()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decay(_ current: Double, toward peak: Double) -> Double {
|
||||||
|
peak > current ? peak : current * 0.72
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Audio
|
||||||
|
|
||||||
|
private static func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var device = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size, &device) == noErr
|
||||||
|
else { return AudioObjectID(kAudioObjectUnknown) }
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultDeviceUID(_ selector: AudioObjectPropertySelector) -> String? {
|
||||||
|
let device = defaultDevice(selector)
|
||||||
|
guard device != kAudioObjectUnknown else { return nil }
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyDeviceUID,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var result: CFString?
|
||||||
|
var size = UInt32(MemoryLayout<CFString?>.size)
|
||||||
|
let status = withUnsafeMutablePointer(to: &result) {
|
||||||
|
AudioObjectGetPropertyData(device, &address, 0, nil, &size, $0)
|
||||||
|
}
|
||||||
|
guard status == noErr, let result else { return nil }
|
||||||
|
return result as String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Teilt die Terminliste in Tage.
|
||||||
|
///
|
||||||
|
/// **Ohne Tagesüberschrift ist die Liste unlesbar.** Zwei Zeilen „Nina
|
||||||
|
/// arbeiten, 8:00" untereinander sind zwei verschiedene Tage, und nichts
|
||||||
|
/// verrät welche. Die Uhrzeit allein beantwortet nur die halbe Frage.
|
||||||
|
public enum AgendaGrouping {
|
||||||
|
|
||||||
|
public struct Day: Identifiable, Equatable, Sendable {
|
||||||
|
/// Der Tagesanfang — er ist zugleich die Kennung.
|
||||||
|
public let date: Date
|
||||||
|
public let events: [OnyxEvent]
|
||||||
|
public var id: Date { date }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppiert nach Anfangstag, in der Reihenfolge, in der die Tage kommen.
|
||||||
|
public static func group(_ events: [OnyxEvent],
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> [Day] {
|
||||||
|
var order: [Date] = []
|
||||||
|
var byDay: [Date: [OnyxEvent]] = [:]
|
||||||
|
|
||||||
|
for event in events.sorted(by: Self.isEarlier) {
|
||||||
|
let day = calendar.startOfDay(for: event.start)
|
||||||
|
if byDay[day] == nil { order.append(day) }
|
||||||
|
byDay[day, default: []].append(event)
|
||||||
|
}
|
||||||
|
return order.map { Day(date: $0, events: byDay[$0] ?? []) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ganztägige zuerst, dann nach Uhrzeit.
|
||||||
|
///
|
||||||
|
/// Ein ganztägiger Termin beginnt um Mitternacht und stünde sonst zwischen
|
||||||
|
/// den frühen Terminen — dabei gilt er für den ganzen Tag und gehört über
|
||||||
|
/// sie.
|
||||||
|
private static func isEarlier(_ left: OnyxEvent, _ right: OnyxEvent) -> Bool {
|
||||||
|
if left.isAllDay != right.isAllDay { return left.isAllDay }
|
||||||
|
return left.start < right.start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Art von Tag — die Entscheidung getrennt von ihrer Beschriftung,
|
||||||
|
/// damit sie ohne Sprachkatalog prüfbar bleibt.
|
||||||
|
public enum DayKind: Equatable, Sendable {
|
||||||
|
case today
|
||||||
|
case tomorrow
|
||||||
|
case later
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func kind(for day: Date, today: Date = Date(),
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> DayKind {
|
||||||
|
let start = calendar.startOfDay(for: day)
|
||||||
|
let reference = calendar.startOfDay(for: today)
|
||||||
|
switch calendar.dateComponents([.day], from: reference, to: start).day ?? 0 {
|
||||||
|
case 0: return .today
|
||||||
|
case 1: return .tomorrow
|
||||||
|
default: return .later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Heute", „Morgen" oder das Datum.
|
||||||
|
///
|
||||||
|
/// Für die nächsten beiden Tage die Wörter: sie sind kürzer als jedes
|
||||||
|
/// Datum und beantworten die Frage sofort. Danach Wochentag und Datum —
|
||||||
|
/// der Wochentag allein wäre in drei Wochen mehrdeutig.
|
||||||
|
public static func label(for day: Date, today: Date = Date(),
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> String {
|
||||||
|
switch kind(for: day, today: today, calendar: calendar) {
|
||||||
|
case .today: return String(localized: "widget.calendar.today", bundle: .module)
|
||||||
|
case .tomorrow: return String(localized: "widget.calendar.tomorrow", bundle: .module)
|
||||||
|
case .later:
|
||||||
|
return calendar.startOfDay(for: day)
|
||||||
|
.formatted(.dateTime.weekday(.abbreviated).day().month(.defaultDigits))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,12 +80,25 @@ public enum CalendarSourceState: Equatable, Sendable {
|
|||||||
case unreadable(reason: String)
|
case unreadable(reason: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Berechtigungszustand einer Quelle.
|
||||||
|
///
|
||||||
|
/// `undetermined` und `denied` müssen getrennt bleiben: im ersten Fall lohnt
|
||||||
|
/// eine Anfrage, im zweiten zeigt macOS nie wieder einen Dialog und es hilft
|
||||||
|
/// nur der Weg über die Systemeinstellungen.
|
||||||
|
public enum CalendarAuthorization: Equatable, Sendable {
|
||||||
|
case granted
|
||||||
|
case denied
|
||||||
|
case undetermined
|
||||||
|
/// Die Quelle braucht gar keine Berechtigung (z. B. Calendarr).
|
||||||
|
case notRequired
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies.
|
/// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies.
|
||||||
public protocol CalendarSource: Sendable {
|
public protocol CalendarSource: Sendable {
|
||||||
var id: String { get }
|
var id: String { get }
|
||||||
/// Ob bereits gelesen werden darf — **ohne** dabei nachzufragen.
|
/// Der Zustand — **ohne** dabei nachzufragen. Die Einstellungen brauchen
|
||||||
/// Die Einstellungen brauchen das, um den Zustand anzuzeigen, ohne
|
/// das, um ihn anzuzeigen, ohne nebenbei einen Systemdialog auszulösen.
|
||||||
/// nebenbei einen Systemdialog auszulösen.
|
var authorization: CalendarAuthorization { get }
|
||||||
var isAuthorized: Bool { get }
|
var isAuthorized: Bool { get }
|
||||||
func requestAccess() async -> Bool
|
func requestAccess() async -> Bool
|
||||||
func calendars() async -> [OnyxCalendar]
|
func calendars() async -> [OnyxCalendar]
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Woher die Termine kommen sollen.
|
||||||
|
public enum CalendarSourceKind: String, CaseIterable, Sendable {
|
||||||
|
case appleCalendar
|
||||||
|
case calendarr
|
||||||
|
case both
|
||||||
|
|
||||||
|
public var localizedName: String {
|
||||||
|
switch self {
|
||||||
|
case .appleCalendar: String(localized: "calendar.source.apple", bundle: .module)
|
||||||
|
case .calendarr: String(localized: "calendar.source.calendarr", bundle: .module)
|
||||||
|
case .both: String(localized: "calendar.source.both", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fasst die Zustände mehrerer Quellen zu einem zusammen.
|
||||||
|
///
|
||||||
|
/// Solange **eine** Quelle liefert, wird geliefert. Calendarr nie geöffnet
|
||||||
|
/// zu haben darf den Apple-Kalender nicht mit ausknipsen — sonst wäre
|
||||||
|
/// „beide" schlechter als jede einzelne Quelle für sich.
|
||||||
|
public static func merge(_ states: [CalendarSourceState]) -> CalendarSourceState {
|
||||||
|
guard !states.isEmpty else { return .ready(.empty) }
|
||||||
|
let windows = states.compactMap { state -> EventWindow? in
|
||||||
|
if case .ready(let window) = state { return window }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Keine einzige Quelle liefert: dann ist der erste Grund die Antwort.
|
||||||
|
// Irgendeine Begründung ist besser als eine leere Liste, die aussieht,
|
||||||
|
// als wäre nichts los.
|
||||||
|
guard !windows.isEmpty else { return states[0] }
|
||||||
|
return .ready(.merged(windows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension EventWindow {
|
||||||
|
|
||||||
|
/// Führt mehrere Fenster zu einem zusammen.
|
||||||
|
static func merged(_ windows: [EventWindow]) -> EventWindow {
|
||||||
|
guard !windows.isEmpty else { return .empty }
|
||||||
|
guard windows.count > 1 else { return windows[0] }
|
||||||
|
|
||||||
|
let events = windows.flatMap(\.events).sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
// Die Abdeckung ist der **Schnitt**, nicht die Vereinigung: ein Tag
|
||||||
|
// gilt nur als bekannt, wenn ihn jede beteiligte Quelle abdeckt. Deckt
|
||||||
|
// Calendarr den März nicht ab, ist der März unbekannt — auch wenn der
|
||||||
|
// Apple-Kalender dafür Auskunft geben könnte. Sonst zeigte das Raster
|
||||||
|
// einen halben März als „frei", der in Wahrheit nur halb bekannt ist.
|
||||||
|
var coverage: DateInterval?
|
||||||
|
for window in windows {
|
||||||
|
// `nil` heißt unbegrenzt und schränkt daher nichts ein.
|
||||||
|
guard let next = window.coverage else { continue }
|
||||||
|
coverage = coverage.map { current in
|
||||||
|
let start = max(current.start, next.start)
|
||||||
|
let end = min(current.end, next.end)
|
||||||
|
// Ohne Überschneidung bleibt nichts übrig — und das ist die
|
||||||
|
// richtige Antwort, nicht ein beliebiger der beiden Zeiträume.
|
||||||
|
return DateInterval(start: start, end: max(start, end))
|
||||||
|
} ?? next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der älteste Stand begrenzt die Verlässlichkeit des Ganzen. Den
|
||||||
|
// neueren zu zeigen behauptete eine Aktualität, die nicht alle haben.
|
||||||
|
let asOf = windows.compactMap(\.asOf).min()
|
||||||
|
|
||||||
|
return EventWindow(events: events, coverage: coverage, asOf: asOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension CalendarSourceState {
|
||||||
|
/// Blendet die abgewählten Kalender aus.
|
||||||
|
///
|
||||||
|
/// Die Abdeckung bleibt unangetastet: welche Kalender man sehen will, sagt
|
||||||
|
/// nichts darüber aus, für welchen Zeitraum Daten vorliegen. Ein Tag ohne
|
||||||
|
/// sichtbare Termine bleibt ein bekannter Tag.
|
||||||
|
func filteringHidden(_ hidden: Set<String>) -> CalendarSourceState {
|
||||||
|
guard case .ready(let window) = self, !hidden.isEmpty else { return self }
|
||||||
|
return .ready(EventWindow(
|
||||||
|
events: window.events.filter { !hidden.contains($0.calendarID) },
|
||||||
|
coverage: window.coverage,
|
||||||
|
asOf: window.asOf))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
|
||||||
|
/// Die Wahl der Terminquelle — samt ehrlicher Auskunft darüber, was Calendarr
|
||||||
|
/// gerade liefert.
|
||||||
|
///
|
||||||
|
/// Der Zustandstext ist kein Beiwerk: bleibt das Widget leer, ist genau hier
|
||||||
|
/// die Antwort auf das Warum. Ohne ihn sieht „Calendarr nie geöffnet" genauso
|
||||||
|
/// aus wie „keine Termine".
|
||||||
|
public struct CalendarSourceSettings: View {
|
||||||
|
|
||||||
|
@Bindable private var model: CalendarModel
|
||||||
|
@State private var calendarrState: CalendarSourceState?
|
||||||
|
|
||||||
|
public init(model: CalendarModel) {
|
||||||
|
self.model = model
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Picker(selection: $model.kind) {
|
||||||
|
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
||||||
|
Text(kind.localizedName).tag(kind)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("calendar.source.title", bundle: .module)
|
||||||
|
}
|
||||||
|
.pickerStyle(.radioGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.kind != .appleCalendar {
|
||||||
|
Section {
|
||||||
|
LabeledContent {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
statusText
|
||||||
|
Text("calendar.source.readOnly", bundle: .module)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(verbatim: "Calendarr")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.task(id: model.kind) { await loadCalendarrState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusText: some View {
|
||||||
|
switch calendarrState {
|
||||||
|
case .ready(let window):
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
|
||||||
|
bundle: .module)
|
||||||
|
if let coverage = window.coverage {
|
||||||
|
// Die Grenze der Abdeckung gehört sichtbar hin: dahinter
|
||||||
|
// zeigt Onyx bewusst „unbekannt" statt „frei", und das
|
||||||
|
// soll niemand für einen Fehler halten.
|
||||||
|
Text("calendar.source.status.coverage \(coverage.end.formatted(date: .abbreviated, time: .omitted))",
|
||||||
|
bundle: .module)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
case .neverWritten:
|
||||||
|
notice("calendar.source.status.neverWritten")
|
||||||
|
case .loggedOut:
|
||||||
|
notice("calendar.source.status.loggedOut")
|
||||||
|
case .incompatible:
|
||||||
|
notice("calendar.source.status.incompatible")
|
||||||
|
case .unreadable(let reason):
|
||||||
|
Text("calendar.source.status.unreadable \(reason)", bundle: .module)
|
||||||
|
.font(.callout).foregroundStyle(Onyx.Color.warning)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
case .denied, .none:
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notice(_ key: String.LocalizationValue) -> some View {
|
||||||
|
Text(String(localized: key, bundle: .module))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func asOfText(_ date: Date?) -> String {
|
||||||
|
date?.formatted(date: .abbreviated, time: .shortened) ?? "–"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCalendarrState() async {
|
||||||
|
guard model.kind != .appleCalendar else { return }
|
||||||
|
// Bewusst über die Quelle selbst und nicht über das Modell: hier soll
|
||||||
|
// der Zustand von Calendarr allein stehen, nicht die Mischung aus
|
||||||
|
// beiden Quellen. Sonst verdeckt ein funktionierender Apple-Kalender,
|
||||||
|
// dass Calendarr gar nichts liefert.
|
||||||
|
let source = CalendarrSource()
|
||||||
|
let now = Date()
|
||||||
|
calendarrState = await source.load(
|
||||||
|
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
|
||||||
|
end: now.addingTimeInterval(60 * 86_400)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,17 +14,139 @@ public final class CalendarModel {
|
|||||||
public private(set) var state: CalendarSourceState = .ready(.empty)
|
public private(set) var state: CalendarSourceState = .ready(.empty)
|
||||||
public private(set) var isLoading = false
|
public private(set) var isLoading = false
|
||||||
|
|
||||||
private let source: CalendarSource
|
/// Welche Quellen gefragt werden. Umschalten lädt sofort neu.
|
||||||
private var calendar: Calendar
|
public var kind: CalendarSourceKind {
|
||||||
private var refreshTask: Task<Void, Never>?
|
didSet {
|
||||||
|
guard kind != oldValue else { return }
|
||||||
public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) {
|
defaults.set(kind.rawValue, forKey: Self.kindKey)
|
||||||
self.source = source
|
observeCalendarrIfNeeded()
|
||||||
self.calendar = calendar
|
refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Monatsraster statt Terminliste.
|
||||||
|
public var showsMonthGrid: Bool {
|
||||||
|
didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Kalender Daten liefern. `nil` heißt: alle.
|
||||||
|
///
|
||||||
|
/// Bewusst eine Liste der **abgewählten**, nicht der gewählten: kommt ein
|
||||||
|
/// neuer Kalender dazu, ist er dann automatisch dabei. Andersherum müsste
|
||||||
|
/// man ihn suchen und anhaken, ohne zu wissen, dass es ihn gibt.
|
||||||
|
public var hiddenCalendarIDs: Set<String> {
|
||||||
|
didSet {
|
||||||
|
guard hiddenCalendarIDs != oldValue else { return }
|
||||||
|
defaults.set(Array(hiddenCalendarIDs), forKey: Self.hiddenKey)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kalender, die zur Auswahl stehen.
|
||||||
|
public private(set) var availableCalendars: [OnyxCalendar] = []
|
||||||
|
|
||||||
|
private static let kindKey = "calendar.source"
|
||||||
|
private static let hiddenKey = "calendar.hiddenCalendars"
|
||||||
|
private static let gridKey = "calendar.showsMonthGrid"
|
||||||
|
|
||||||
|
private let appleSource: CalendarSource
|
||||||
|
private let calendarrSource: CalendarSource
|
||||||
|
/// Der Weg über den Server. Wird benutzt, sobald Zugangsdaten hinterlegt
|
||||||
|
/// sind — dann hängt die Aktualität nicht mehr daran, ob jemand die
|
||||||
|
/// Calendarr-App geöffnet hat.
|
||||||
|
private let calendarrLive: CalendarrLiveSource
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private var calendar: Calendar
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
/// `nil` = nicht auf Calendarr-Änderungen angemeldet.
|
||||||
|
private var calendarrObserver: DarwinObserver?
|
||||||
|
/// Das Sicherheitsnetz unter der Benachrichtigung.
|
||||||
|
private var calendarrTimer: Timer?
|
||||||
|
|
||||||
|
public init(appleSource: CalendarSource = EventKitSource(),
|
||||||
|
calendarrSource: CalendarSource = CalendarrSource(),
|
||||||
|
calendarrLive: CalendarrLiveSource = CalendarrLiveSource(),
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) {
|
||||||
|
self.appleSource = appleSource
|
||||||
|
self.calendarrSource = calendarrSource
|
||||||
|
self.calendarrLive = calendarrLive
|
||||||
|
self.defaults = defaults
|
||||||
|
self.calendar = calendar
|
||||||
|
self.kind = defaults.string(forKey: Self.kindKey)
|
||||||
|
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
|
||||||
|
self.showsMonthGrid = defaults.bool(forKey: Self.gridKey)
|
||||||
|
self.hiddenCalendarIDs = Set(defaults.stringArray(forKey: Self.hiddenKey) ?? [])
|
||||||
|
observeCalendarrIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die gerade befragten Quellen.
|
||||||
|
private var activeSources: [CalendarSource] {
|
||||||
|
switch kind {
|
||||||
|
case .appleCalendar: [appleSource]
|
||||||
|
case .calendarr: [calendarrChoice]
|
||||||
|
case .both: [appleSource, calendarrChoice]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server oder Schnappschuss.
|
||||||
|
///
|
||||||
|
/// Sind Zugangsdaten hinterlegt, gilt der Server — er ist die genauere
|
||||||
|
/// Auskunft. Der Schnappschuss bleibt das Netz darunter: er braucht kein
|
||||||
|
/// Netzwerk und hält, was Calendarr zuletzt geschrieben hat.
|
||||||
|
private var calendarrChoice: CalendarSource {
|
||||||
|
calendarrLive.isAuthorized ? calendarrLive : calendarrSource
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob gerade direkt vom Server gelesen wird.
|
||||||
|
public var usesCalendarrServer: Bool { calendarrLive.isAuthorized }
|
||||||
|
|
||||||
/// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen.
|
/// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen.
|
||||||
public var hasAccess: Bool { source.isAuthorized }
|
///
|
||||||
|
/// **Eine** berechtigte Quelle genügt: bei „beide" darf ein gesperrter
|
||||||
|
/// Apple-Kalender nicht dazu führen, dass auch Calendarr stumm bleibt.
|
||||||
|
public var hasAccess: Bool { activeSources.contains(where: \.isAuthorized) }
|
||||||
|
|
||||||
|
/// Der Berechtigungszustand, der die Oberfläche interessiert — also der des
|
||||||
|
/// Apple-Kalenders, denn nur der kann überhaupt einen Dialog auslösen.
|
||||||
|
public var authorization: CalendarAuthorization {
|
||||||
|
kind == .calendarr ? .notRequired : appleSource.authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Calendarr-Benachrichtigung
|
||||||
|
|
||||||
|
/// Calendarr meldet neue Schnappschüsse per Darwin-Nachricht — deshalb muss
|
||||||
|
/// Onyx die Datei nicht abfragen, sondern erfährt Änderungen.
|
||||||
|
///
|
||||||
|
/// Nur angemeldet, solange Calendarr überhaupt gefragt wird. Wer nur den
|
||||||
|
/// Apple-Kalender nutzt, soll von Calendarr auch nicht geweckt werden.
|
||||||
|
private func observeCalendarrIfNeeded() {
|
||||||
|
let wanted = kind != .appleCalendar
|
||||||
|
guard wanted != (calendarrObserver != nil) else { return }
|
||||||
|
calendarrObserver = wanted
|
||||||
|
? DarwinObserver(name: CalendarrSource.changeNotification) { @Sendable [weak self] in
|
||||||
|
Task { @MainActor in self?.refresh() }
|
||||||
|
}
|
||||||
|
: nil
|
||||||
|
|
||||||
|
// Dazu ein Blick alle zwei Minuten.
|
||||||
|
//
|
||||||
|
// Die Benachrichtigung ist der schnelle Weg, aber sie ist flüchtig:
|
||||||
|
// schreibt Calendarr, während Onyx nicht läuft oder gerade startet,
|
||||||
|
// kommt sie nie an — und dann steht der Schnappschuss so lange still,
|
||||||
|
// bis Calendarr das nächste Mal schreibt. Das Nachsehen kostet das
|
||||||
|
// Einlesen einer Datei von wenigen Kilobyte; das ist billiger als eine
|
||||||
|
// Terminliste, die aus unerfindlichen Gründen von gestern ist.
|
||||||
|
// Am Server seltener als an der Datei: ein Dateizugriff kostet nichts,
|
||||||
|
// eine Anfrage über das Netz schon.
|
||||||
|
calendarrTimer?.invalidate()
|
||||||
|
calendarrTimer = wanted
|
||||||
|
? Timer.scheduledTimer(withTimeInterval: usesCalendarrServer ? 300 : 120,
|
||||||
|
repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.refresh() }
|
||||||
|
}
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
|
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
|
||||||
///
|
///
|
||||||
@@ -35,9 +157,11 @@ public final class CalendarModel {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
public func requestAccessNow() async -> Bool {
|
public func requestAccessNow() async -> Bool {
|
||||||
log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
||||||
let granted = await source.requestAccess()
|
let granted = await appleSource.requestAccess()
|
||||||
log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
||||||
if granted { refresh() } else { state = .denied }
|
// Auch bei Ablehnung neu laden statt hart auf `denied` zu setzen: läuft
|
||||||
|
// Calendarr mit, hat Onyx weiterhin Termine zu zeigen.
|
||||||
|
refresh()
|
||||||
return granted
|
return granted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +172,15 @@ public final class CalendarModel {
|
|||||||
|
|
||||||
/// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit
|
/// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit
|
||||||
/// die Randtage der Nachbarmonate im Raster nicht fälschlich leer wirken.
|
/// die Randtage der Nachbarmonate im Raster nicht fälschlich leer wirken.
|
||||||
|
/// Nach dem Verbinden oder Trennen: Quelle und Takt neu bestimmen.
|
||||||
|
public func calendarrAccountChanged() {
|
||||||
|
calendarrObserver = nil
|
||||||
|
calendarrTimer?.invalidate()
|
||||||
|
calendarrTimer = nil
|
||||||
|
observeCalendarrIfNeeded()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
public func refresh(around date: Date = Date()) {
|
public func refresh(around date: Date = Date()) {
|
||||||
refreshTask?.cancel()
|
refreshTask?.cancel()
|
||||||
refreshTask = Task { [weak self] in
|
refreshTask = Task { [weak self] in
|
||||||
@@ -55,20 +188,32 @@ public final class CalendarModel {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
let start = calendar.date(byAdding: .day, value: -14,
|
||||||
|
to: calendar.startOfDay(for: date))!
|
||||||
|
let end = calendar.date(byAdding: .day, value: 60, to: start)!
|
||||||
|
let interval = DateInterval(start: start, end: end)
|
||||||
|
|
||||||
|
var states: [CalendarSourceState] = []
|
||||||
|
for source in activeSources {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
||||||
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
||||||
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
||||||
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
||||||
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
||||||
guard source.isAuthorized else {
|
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
||||||
state = .denied
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
let start = calendar.date(byAdding: .day, value: -14,
|
|
||||||
to: calendar.startOfDay(for: date))!
|
|
||||||
let end = calendar.date(byAdding: .day, value: 60, to: start)!
|
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
state = await source.load(DateInterval(start: start, end: end))
|
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
|
||||||
|
|
||||||
|
// Die Liste der wählbaren Kalender nebenbei mitführen — sie ändert
|
||||||
|
// sich, wenn jemand in Kalender einen anlegt oder die Quelle
|
||||||
|
// wechselt.
|
||||||
|
var found: [OnyxCalendar] = []
|
||||||
|
for source in activeSources where source.isAuthorized {
|
||||||
|
found.append(contentsOf: await source.calendars())
|
||||||
|
}
|
||||||
|
availableCalendars = found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +238,6 @@ public struct CalendarWidget: OnyxWidget {
|
|||||||
public let id = "calendar"
|
public let id = "calendar"
|
||||||
public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) }
|
public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) }
|
||||||
public let symbolName = "calendar"
|
public let symbolName = "calendar"
|
||||||
public let supportedSizes: [WidgetSize] = [.medium, .large]
|
|
||||||
|
|
||||||
private let model: CalendarModel
|
private let model: CalendarModel
|
||||||
|
|
||||||
@@ -101,21 +245,23 @@ public struct CalendarWidget: OnyxWidget {
|
|||||||
self.model = model
|
self.model = model
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeView(size: WidgetSize) -> AnyView {
|
public func makeView() -> AnyView {
|
||||||
AnyView(CalendarWidgetView(model: model, size: size))
|
AnyView(CalendarWidgetView(model: model))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CalendarWidgetView: View {
|
private struct CalendarWidgetView: View {
|
||||||
let model: CalendarModel
|
let model: CalendarModel
|
||||||
let size: WidgetSize
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
switch model.state {
|
switch model.state {
|
||||||
case .ready(let window):
|
case .ready(let window):
|
||||||
if size == .large {
|
// Monatsraster oder Terminliste — das ist keine Frage der
|
||||||
LargeCalendar(window: window, upcoming: model.upcoming)
|
// Größe, sondern was man sehen will. Beides zugleich passt in
|
||||||
|
// eine Kachel nicht, ohne dass eines davon unlesbar wird.
|
||||||
|
if model.showsMonthGrid {
|
||||||
|
LargeCalendar(window: window, upcoming: [])
|
||||||
} else {
|
} else {
|
||||||
AgendaList(events: model.upcoming, coverage: window.coverage)
|
AgendaList(events: model.upcoming, coverage: window.coverage)
|
||||||
}
|
}
|
||||||
@@ -265,10 +411,20 @@ private struct AgendaList: View {
|
|||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
|
||||||
ForEach(events) { event in
|
let days = AgendaGrouping.group(events)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
|
||||||
|
Text(AgendaGrouping.label(for: day.date))
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
// Luft nur zwischen den Tagen, nicht über dem ersten.
|
||||||
|
.padding(.top, index == 0 ? 0 : 3)
|
||||||
|
ForEach(day.events) { event in
|
||||||
EventRow(event: event)
|
EventRow(event: event)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
481
Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
|
||||||
|
|
||||||
|
/// Liest die Termine **direkt vom Calendarr-Server**.
|
||||||
|
///
|
||||||
|
/// Der andere Weg — der Schnappschuss im gemeinsamen Ordner — hängt daran, dass
|
||||||
|
/// Calendarr läuft und schreibt. Wer die Mac-App wochenlang nicht öffnet, sieht
|
||||||
|
/// wochenalte Termine, und Onyx kann nichts dafür und nichts dagegen.
|
||||||
|
///
|
||||||
|
/// Hier holt Onyx selbst. Ein einziger Aufruf genügt: `/api/caldav/events` fasst
|
||||||
|
/// CalDAV-Konten, lokale Kalender, Geburtstage, iCal-Abos und Google bereits
|
||||||
|
/// zusammen — nachgebaut wird nichts.
|
||||||
|
public final class CalendarrLiveSource: CalendarSource {
|
||||||
|
|
||||||
|
public let id = "calendarr.live"
|
||||||
|
/// Keine Systemberechtigung im Spiel: hier entscheidet allein, ob
|
||||||
|
/// Zugangsdaten hinterlegt sind.
|
||||||
|
public var authorization: CalendarAuthorization { .notRequired }
|
||||||
|
public var isAuthorized: Bool { store.account != nil }
|
||||||
|
|
||||||
|
private let store: CalendarrCredentials
|
||||||
|
private let session: URLSession
|
||||||
|
private let tokens: TokenCache
|
||||||
|
|
||||||
|
public init(store: CalendarrCredentials = CalendarrCredentials(),
|
||||||
|
session: URLSession = .shared) {
|
||||||
|
self.store = store
|
||||||
|
self.session = session
|
||||||
|
self.tokens = TokenCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func requestAccess() async -> Bool { store.account != nil }
|
||||||
|
|
||||||
|
/// Die Kalender kommen aus den Terminen selbst.
|
||||||
|
///
|
||||||
|
/// Der Server hat keinen Endpunkt, der alle Quellen zusammen aufzählt — die
|
||||||
|
/// gibt es je Art einzeln. Aus den Terminen abgeleitet stimmt die Liste
|
||||||
|
/// dagegen immer mit dem überein, was tatsächlich zu sehen ist. Der Preis:
|
||||||
|
/// ein Kalender ohne Termine im Fenster taucht nicht auf. Das ist der
|
||||||
|
/// bessere von zwei Fehlern — man kann nichts ausblenden, was ohnehin
|
||||||
|
/// nichts zeigt.
|
||||||
|
public func calendars() async -> [OnyxCalendar] {
|
||||||
|
guard case .ready(let window) = await load(Self.defaultWindow()) else { return [] }
|
||||||
|
var seen: [String: OnyxCalendar] = [:]
|
||||||
|
for event in window.events where seen[event.calendarID] == nil {
|
||||||
|
seen[event.calendarID] = OnyxCalendar(id: event.calendarID,
|
||||||
|
title: event.calendarTitle,
|
||||||
|
colorHex: event.colorHex)
|
||||||
|
}
|
||||||
|
return seen.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultWindow() -> DateInterval {
|
||||||
|
let now = Date()
|
||||||
|
return DateInterval(start: now.addingTimeInterval(-14 * 86400),
|
||||||
|
end: now.addingTimeInterval(46 * 86400))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(_ interval: DateInterval) async -> CalendarSourceState {
|
||||||
|
guard let account = store.account else { return .neverWritten }
|
||||||
|
let password = store.password()
|
||||||
|
await tokens.adopt(store.token())
|
||||||
|
|
||||||
|
do {
|
||||||
|
let token = try await tokens.token(for: account, password: password,
|
||||||
|
session: session)
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try await fetchEvents(interval, account: account, token: token)
|
||||||
|
} catch CalendarrLiveError.unauthorized {
|
||||||
|
// Abgelaufene Token sind der Normalfall, nicht die Ausnahme:
|
||||||
|
// sie halten sieben Tage. Einmal neu anmelden und noch einmal
|
||||||
|
// versuchen — scheitert auch das, stimmt etwas anderes nicht.
|
||||||
|
await tokens.discard()
|
||||||
|
let fresh = try await tokens.token(for: account, password: password,
|
||||||
|
session: session)
|
||||||
|
data = try await fetchEvents(interval, account: account, token: fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = try CalendarrLiveDecoder.events(from: data)
|
||||||
|
// Die Abdeckung ist genau das erfragte Fenster: mehr wurde nicht
|
||||||
|
// gefragt, weniger hat der Server nicht geliefert.
|
||||||
|
return .ready(EventWindow(events: events, coverage: interval, asOf: Date()))
|
||||||
|
} catch CalendarrLiveError.unauthorized {
|
||||||
|
return .loggedOut(asOf: nil)
|
||||||
|
} catch {
|
||||||
|
log.error("Calendarr-Server: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return .unreadable(reason: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchEvents(_ interval: DateInterval, account: CalendarrAccount,
|
||||||
|
token: String) async throws -> Data {
|
||||||
|
var request = URLRequest(url: try CalendarrLiveDecoder.eventsURL(server: account.server,
|
||||||
|
interval: interval))
|
||||||
|
request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse }
|
||||||
|
if http.statusCode == 401 || http.statusCode == 403 { throw CalendarrLiveError.unauthorized }
|
||||||
|
guard http.statusCode == 200 else { throw CalendarrLiveError.status(http.statusCode) }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft die Zugangsdaten, ohne etwas zu speichern — für den Knopf in den
|
||||||
|
/// Einstellungen. Ein Feld, das man ausfüllt und bei dem man erst am
|
||||||
|
/// nächsten Tag merkt, dass ein Tippfehler drin war, ist keins.
|
||||||
|
public static func check(server: URL, username: String, password: String,
|
||||||
|
totpCode: String?,
|
||||||
|
session: URLSession = .shared) async -> Result<String, Error> {
|
||||||
|
do {
|
||||||
|
let token = try await TokenCache.fetchToken(server: server, username: username,
|
||||||
|
password: password, totpCode: totpCode,
|
||||||
|
session: session)
|
||||||
|
return .success(token)
|
||||||
|
} catch {
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token
|
||||||
|
|
||||||
|
/// Hält das Token, solange es gilt.
|
||||||
|
///
|
||||||
|
/// Ein Actor, weil mehrere Widgets gleichzeitig laden können und sich sonst
|
||||||
|
/// zwei Anmeldungen überholen.
|
||||||
|
private actor TokenCache {
|
||||||
|
private var token: String?
|
||||||
|
private var expiry: Date?
|
||||||
|
|
||||||
|
func discard() { token = nil; expiry = nil }
|
||||||
|
|
||||||
|
func adopt(_ stored: String?) {
|
||||||
|
guard token == nil, let stored else { return }
|
||||||
|
token = stored
|
||||||
|
expiry = CalendarrLiveDecoder.expiry(ofToken: stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func token(for account: CalendarrAccount, password: String?,
|
||||||
|
session: URLSession) async throws -> String {
|
||||||
|
// Fünf Minuten Sicherheitsabstand: ein Token, das während des Abrufs
|
||||||
|
// abläuft, kostet einen zweiten Durchgang.
|
||||||
|
if let token, let expiry, expiry.timeIntervalSinceNow > 300 { return token }
|
||||||
|
|
||||||
|
// Ohne Passwort — oder mit zweitem Faktor — kann sich niemand von
|
||||||
|
// selbst neu anmelden. Dann muss der Mensch ran, und das sagt der
|
||||||
|
// Fehler auch.
|
||||||
|
guard let password, !password.isEmpty else { throw CalendarrLiveError.twoFactorRequired }
|
||||||
|
|
||||||
|
let fresh = try await Self.fetchToken(server: account.server,
|
||||||
|
username: account.username,
|
||||||
|
password: password, totpCode: nil,
|
||||||
|
session: session)
|
||||||
|
token = fresh
|
||||||
|
expiry = CalendarrLiveDecoder.expiry(ofToken: fresh)
|
||||||
|
?? Date().addingTimeInterval(6 * 24 * 3600)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet an und gibt ein Langzeit-Token zurück.
|
||||||
|
///
|
||||||
|
/// Über `/api/auth/login` statt `/api/auth/token`: das Formular-Endstück
|
||||||
|
/// kennt keinen zweiten Faktor und antwortet Konten mit aktivem 2FA
|
||||||
|
/// **immer** mit „falsches Passwort" — auch wenn es stimmt. Der JSON-Weg
|
||||||
|
/// nimmt den Code entgegen und kann obendrein `remember_me`.
|
||||||
|
///
|
||||||
|
/// Und das ist hier keine Bequemlichkeit, sondern die Bedingung: mit 2FA
|
||||||
|
/// kann Onyx sich nicht selbst neu anmelden, wenn ein Token abläuft — den
|
||||||
|
/// Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage,
|
||||||
|
/// `remember_me` hält hundertachtzig.
|
||||||
|
static func fetchToken(server: URL, username: String, password: String,
|
||||||
|
totpCode: String?, session: URLSession) async throws -> String {
|
||||||
|
var request = URLRequest(url: server.appendingPathComponent("api/auth/login"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var body: [String: Any] = ["username": username, "password": password,
|
||||||
|
"remember_me": true]
|
||||||
|
if let totpCode, !totpCode.isEmpty { body["totp_code"] = totpCode }
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse }
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
|
||||||
|
if let failure = CalendarrLiveDecoder.loginFailure(status: http.statusCode,
|
||||||
|
detail: object?["detail"] as? String) {
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = object?["access_token"] as? String, !token.isEmpty
|
||||||
|
else { throw CalendarrLiveError.badResponse }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CalendarrLiveError: LocalizedError, Equatable {
|
||||||
|
case unauthorized
|
||||||
|
/// Das Konto verlangt einen zweiten Faktor — ohne Code geht hier nichts.
|
||||||
|
case twoFactorRequired
|
||||||
|
case twoFactorWrong
|
||||||
|
case badResponse
|
||||||
|
case status(Int)
|
||||||
|
case badServer
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unauthorized: String(localized: "calendarr.live.error.credentials", bundle: .module)
|
||||||
|
case .twoFactorRequired: String(localized: "calendarr.live.error.twoFactor", bundle: .module)
|
||||||
|
case .twoFactorWrong: String(localized: "calendarr.live.error.twoFactorWrong", bundle: .module)
|
||||||
|
case .badResponse: String(localized: "calendarr.live.error.response", bundle: .module)
|
||||||
|
case .status(let code): String(localized: "calendarr.live.error.status \(code)", bundle: .module)
|
||||||
|
case .badServer: String(localized: "calendarr.live.error.server", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auswertung
|
||||||
|
|
||||||
|
/// Alles, was ohne Netz prüfbar ist — und deshalb auch ohne Netz geprüft wird.
|
||||||
|
public enum CalendarrLiveDecoder {
|
||||||
|
|
||||||
|
public static func eventsURL(server: URL, interval: DateInterval) throws -> URL {
|
||||||
|
guard var components = URLComponents(url: server.appendingPathComponent("api/caldav/events"),
|
||||||
|
resolvingAgainstBaseURL: false)
|
||||||
|
else { throw CalendarrLiveError.badServer }
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "start", value: formatter.string(from: interval.start)),
|
||||||
|
URLQueryItem(name: "end", value: formatter.string(from: interval.end)),
|
||||||
|
]
|
||||||
|
guard let url = components.url else { throw CalendarrLiveError.badServer }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Anmeldekörper. Prozentkodiert von Hand, weil `+` in einem Passwort
|
||||||
|
/// sonst als Leerzeichen ankommt — ein Fehler, den man erst bei genau
|
||||||
|
/// diesem einen Passwort bemerkt.
|
||||||
|
public static func formBody(username: String, password: String) -> Data {
|
||||||
|
var allowed = CharacterSet.alphanumerics
|
||||||
|
allowed.insert(charactersIn: "-._~")
|
||||||
|
func escape(_ text: String) -> String {
|
||||||
|
text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
|
||||||
|
}
|
||||||
|
return Data("username=\(escape(username))&password=\(escape(password))".utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func events(from data: Data) throws -> [OnyxEvent] {
|
||||||
|
guard let object = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let list = (object as? [String: Any])?["events"] as? [[String: Any]]
|
||||||
|
?? object as? [[String: Any]]
|
||||||
|
else { throw CalendarrLiveError.badResponse }
|
||||||
|
|
||||||
|
return list.compactMap(event(from:))
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func event(from item: [String: Any]) -> OnyxEvent? {
|
||||||
|
guard let title = item["title"] as? String,
|
||||||
|
let startText = item["start"] as? String,
|
||||||
|
let start = date(from: startText)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let isAllDay = item["allDay"] as? Bool ?? (item["all_day"] as? Bool ?? false)
|
||||||
|
let end = (item["end"] as? String).flatMap(date(from:))
|
||||||
|
// Ohne Ende ist ein Termin einen Tag lang, wenn er ganztägig ist,
|
||||||
|
// sonst eine Stunde. Beides ist geraten — aber ein Termin ohne
|
||||||
|
// Länge wäre unsichtbar.
|
||||||
|
?? start.addingTimeInterval(isAllDay ? 86400 : 3600)
|
||||||
|
|
||||||
|
let identifier = item["id"] as? String
|
||||||
|
?? item["uid"] as? String
|
||||||
|
?? "\(startText)#\(title)"
|
||||||
|
|
||||||
|
return OnyxEvent(
|
||||||
|
id: identifier,
|
||||||
|
title: title,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
isAllDay: isAllDay,
|
||||||
|
// Die Farbe des Termins schlägt die des Kalenders — so macht es
|
||||||
|
// Calendarr selbst auch.
|
||||||
|
colorHex: item["color"] as? String ?? item["calendarColor"] as? String,
|
||||||
|
location: (item["location"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||||
|
calendarID: item["calendar_id"] as? String ?? "calendarr",
|
||||||
|
calendarTitle: item["calendar_name"] as? String ?? "Calendarr")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ganztägige Termine kommen als `2026-08-14`, alle anderen mit Uhrzeit.
|
||||||
|
public static func date(from text: String) -> Date? {
|
||||||
|
if text.count == 10 {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = .current
|
||||||
|
let parts = text.split(separator: "-").compactMap { Int($0) }
|
||||||
|
guard parts.count == 3 else { return nil }
|
||||||
|
return calendar.date(from: DateComponents(year: parts[0], month: parts[1],
|
||||||
|
day: parts[2]))
|
||||||
|
}
|
||||||
|
let withFraction = ISO8601DateFormatter()
|
||||||
|
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = withFraction.date(from: text) { return date }
|
||||||
|
|
||||||
|
let plain = ISO8601DateFormatter()
|
||||||
|
if let date = plain.date(from: text) { return date }
|
||||||
|
|
||||||
|
// Ohne Zeitzone im Text: dann gilt die des Rechners. Calendarr liefert
|
||||||
|
// lokale Zeiten für Termine ohne feste Zone.
|
||||||
|
let local = DateFormatter()
|
||||||
|
local.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
local.timeZone = .current
|
||||||
|
for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm:ss"] {
|
||||||
|
local.dateFormat = format
|
||||||
|
if let date = local.date(from: text) { return date }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was eine abgelehnte Anmeldung bedeutet.
|
||||||
|
///
|
||||||
|
/// Der Server unterscheidet die Fälle nur im Begleittext, und der
|
||||||
|
/// Unterschied ist der zwischen „Passwort neu tippen" und „App aufmachen".
|
||||||
|
/// Ohne diese Unterscheidung stand bei einem Konto mit zweitem Faktor
|
||||||
|
/// „Benutzername oder Passwort stimmen nicht" — obwohl beides stimmte.
|
||||||
|
public static func loginFailure(status: Int, detail: String?) -> CalendarrLiveError? {
|
||||||
|
switch status {
|
||||||
|
case 200: return nil
|
||||||
|
case 401:
|
||||||
|
let detail = detail ?? ""
|
||||||
|
if detail == "2fa_required" { return .twoFactorRequired }
|
||||||
|
if detail.localizedCaseInsensitiveContains("2fa") { return .twoFactorWrong }
|
||||||
|
return .unauthorized
|
||||||
|
default: return .status(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wann das Token abläuft — steht in ihm selbst.
|
||||||
|
///
|
||||||
|
/// Ein JWT trägt seine Gültigkeit im Mittelteil, base64-kodiert. Die
|
||||||
|
/// Alternative wäre, sieben Tage anzunehmen; das steht heute so im Server
|
||||||
|
/// und morgen vielleicht nicht mehr.
|
||||||
|
public static func expiry(ofToken token: String) -> Date? {
|
||||||
|
let parts = token.split(separator: ".")
|
||||||
|
guard parts.count == 3 else { return nil }
|
||||||
|
|
||||||
|
var payload = String(parts[1])
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
// Base64 ohne Auffüllzeichen, wie in JWT üblich.
|
||||||
|
while payload.count % 4 != 0 { payload += "=" }
|
||||||
|
|
||||||
|
guard let data = Data(base64Encoded: payload),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let seconds = object["exp"] as? Double
|
||||||
|
else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt entgegen, was Menschen eintippen: „calendarr.example.com",
|
||||||
|
/// „https://calendarr.example.com/" oder mit Pfad dahinter.
|
||||||
|
public static func server(from text: String) -> URL? {
|
||||||
|
var trimmed = text.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if !trimmed.contains("://") { trimmed = "https://" + trimmed }
|
||||||
|
while trimmed.hasSuffix("/") { trimmed.removeLast() }
|
||||||
|
guard let url = URL(string: trimmed), url.host != nil else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Zugangsdaten
|
||||||
|
|
||||||
|
/// Adresse und Benutzername in den Einstellungen, das Passwort im Schlüsselbund.
|
||||||
|
///
|
||||||
|
/// Getrennt, weil es zwei verschiedene Dinge sind: das eine will man sehen und
|
||||||
|
/// nachlesen können, das andere darf nirgends stehen, wo man es versehentlich
|
||||||
|
/// mitliest — und `defaults read` ist genau so eine Stelle.
|
||||||
|
public struct CalendarrCredentials: @unchecked Sendable {
|
||||||
|
|
||||||
|
/// `UserDefaults` gilt Swift nicht als übertragbar, ist aber seit jeher
|
||||||
|
/// threadsicher. Die Ausnahme steht hier und nicht als Sperre um jeden
|
||||||
|
/// Zugriff.
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private static let service = "com.scarriffleservices.onyx.calendarr"
|
||||||
|
private enum Keys {
|
||||||
|
static let server = "calendarr.live.server"
|
||||||
|
static let username = "calendarr.live.username"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
public var account: CalendarrAccount? {
|
||||||
|
guard let server = defaults.string(forKey: Keys.server).flatMap(URL.init(string:)),
|
||||||
|
let username = defaults.string(forKey: Keys.username), !username.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return CalendarrAccount(server: server, username: username)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Token gehört mit in den Schlüsselbund, nicht nur ins Gedächtnis des
|
||||||
|
/// laufenden Programms.
|
||||||
|
///
|
||||||
|
/// Bei einem Konto mit zweitem Faktor ist es das einzige, womit Onyx
|
||||||
|
/// überhaupt noch hineinkommt: neu anmelden ginge nur mit einem Code, und
|
||||||
|
/// den hat nur der Mensch. Ginge es beim Beenden verloren, müsste man sich
|
||||||
|
/// bei jedem Start neu anmelden.
|
||||||
|
public func save(server: URL, username: String, password: String, token: String?) {
|
||||||
|
defaults.set(server.absoluteString, forKey: Keys.server)
|
||||||
|
defaults.set(username, forKey: Keys.username)
|
||||||
|
Self.setSecret(password, for: username, kind: .password)
|
||||||
|
Self.setSecret(token, for: username, kind: .token)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clear() {
|
||||||
|
if let username = defaults.string(forKey: Keys.username) {
|
||||||
|
Self.setSecret(nil, for: username, kind: .password)
|
||||||
|
Self.setSecret(nil, for: username, kind: .token)
|
||||||
|
}
|
||||||
|
defaults.removeObject(forKey: Keys.server)
|
||||||
|
defaults.removeObject(forKey: Keys.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func password() -> String? { secret(.password) }
|
||||||
|
public func token() -> String? { secret(.token) }
|
||||||
|
|
||||||
|
private func secret(_ kind: Secret) -> String? {
|
||||||
|
guard let username = defaults.string(forKey: Keys.username) else { return nil }
|
||||||
|
var query = Self.query(for: username, kind: kind)
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
|
||||||
|
var item: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||||
|
let data = item as? Data
|
||||||
|
else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Secret: String {
|
||||||
|
case password
|
||||||
|
case token
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func query(for username: String, kind: Secret) -> [String: Any] {
|
||||||
|
[kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service + "." + kind.rawValue,
|
||||||
|
kSecAttrAccount as String: username]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setSecret(_ value: String?, for username: String, kind: Secret) {
|
||||||
|
let query = query(for: username, kind: kind)
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
guard let value, !value.isEmpty else { return }
|
||||||
|
|
||||||
|
var item = query
|
||||||
|
item[kSecValueData as String] = Data(value.utf8)
|
||||||
|
// Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein
|
||||||
|
// Kalenderwidget braucht kein Passwort, solange niemand angemeldet ist.
|
||||||
|
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
let status = SecItemAdd(item as CFDictionary, nil)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
log.error("Passwort ließ sich nicht sichern (\(status, privacy: .public))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CalendarrAccount: Equatable, Sendable {
|
||||||
|
public let server: URL
|
||||||
|
public let username: String
|
||||||
|
|
||||||
|
public init(server: URL, username: String) {
|
||||||
|
self.server = server
|
||||||
|
self.username = username
|
||||||
|
}
|
||||||
|
}
|
||||||
222
Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
|
||||||
|
|
||||||
|
/// Liest den Schnappschuss, den Calendarr in den Gruppencontainer legt.
|
||||||
|
///
|
||||||
|
/// Streng lesend. Onyx meldet sich nie selbst an, holt nichts aus dem Netz und
|
||||||
|
/// schreibt nie in den Container zurück — der Schnappschuss gehört Calendarr,
|
||||||
|
/// und zwei Schreiber auf einer Datei wären ein Fehler, den man erst bemerkt,
|
||||||
|
/// wenn Termine verschwinden.
|
||||||
|
public enum CalendarrSnapshotReader {
|
||||||
|
|
||||||
|
/// Wie weit nach vorne der Schnappschuss reicht.
|
||||||
|
///
|
||||||
|
/// Steht nicht in der Datei, sondern ist zugesagtes Verhalten des
|
||||||
|
/// Schreibers. Läge der Wert zu niedrig, würde Onyx zu viel ausgrauen —
|
||||||
|
/// unschön, aber ehrlich. Läge er zu hoch, würde es leere Tage als „frei"
|
||||||
|
/// behaupten, für die nie jemand Daten geschrieben hat. Von beiden Fehlern
|
||||||
|
/// ist nur der erste vertretbar, deshalb wird hier nicht großzügig gerundet.
|
||||||
|
public static let coverageDays = 49
|
||||||
|
|
||||||
|
/// Höchste Formatfassung, deren Bedeutung Onyx kennt.
|
||||||
|
public static let maxSupportedVersion = 1
|
||||||
|
|
||||||
|
/// Der Zeitraum, über den der Schnappschuss etwas aussagt.
|
||||||
|
///
|
||||||
|
/// Beginnt am Tag des Schreibens, nicht früher: Calendarr schreibt nach
|
||||||
|
/// vorne. Ein vergangener Tag ohne Termine kann „war nichts" heißen oder
|
||||||
|
/// „wurde nie geschrieben" — das ist von außen nicht unterscheidbar, also
|
||||||
|
/// wird es auch nicht behauptet.
|
||||||
|
public static func coverage(writtenAt: Date, calendar: Calendar) -> DateInterval {
|
||||||
|
let start = calendar.startOfDay(for: writtenAt)
|
||||||
|
let end = calendar.date(byAdding: .day, value: coverageDays, to: start) ?? start
|
||||||
|
return DateInterval(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wertet Schnappschuss und Kalenderliste aus. Rein — ohne Dateisystem,
|
||||||
|
/// damit jeder Zustand ohne installierte Calendarr-App prüfbar bleibt.
|
||||||
|
public static func state(fromSnapshot snapshot: Data?,
|
||||||
|
calendars: Data?,
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> CalendarSourceState {
|
||||||
|
// Keine Datei heißt nicht „kaputt", sondern „Calendarr war noch nie
|
||||||
|
// offen". Dafür gibt es einen eigenen, hilfreicheren Hinweis.
|
||||||
|
guard let snapshot else { return .neverWritten }
|
||||||
|
|
||||||
|
let raw: RawSnapshot
|
||||||
|
do {
|
||||||
|
raw = try JSONDecoder().decode(RawSnapshot.self, from: snapshot)
|
||||||
|
} catch {
|
||||||
|
log.error("Schnappschuss unlesbar: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return .unreadable(reason: error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = raw.version ?? 1
|
||||||
|
guard version <= maxSupportedVersion else {
|
||||||
|
return .incompatible(found: version, maxSupported: maxSupportedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
let parser = DateParser()
|
||||||
|
let writtenAt = raw.writtenAtDate(parser)
|
||||||
|
|
||||||
|
// Abgemeldet: die vorhandenen Termine sind alt und sehen trotzdem
|
||||||
|
// aktuell aus. Genau deshalb werden sie verworfen statt angezeigt.
|
||||||
|
if raw.loggedOut == true { return .loggedOut(asOf: writtenAt) }
|
||||||
|
|
||||||
|
let titles = calendarTitles(from: calendars)
|
||||||
|
let events = (raw.events ?? [])
|
||||||
|
.compactMap { $0.asOnyxEvent(titles: titles, parser: parser) }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
return .ready(EventWindow(
|
||||||
|
events: events,
|
||||||
|
coverage: writtenAt.map { coverage(writtenAt: $0, calendar: calendar) },
|
||||||
|
asOf: writtenAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kalenderliste als eigene Datei — sie trägt die Namen, die im
|
||||||
|
/// Schnappschuss selbst nur als Kennung stehen.
|
||||||
|
public static func calendars(from data: Data?) -> [OnyxCalendar] {
|
||||||
|
guard let data,
|
||||||
|
let raw = try? JSONDecoder().decode([RawCalendar].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return raw.map { OnyxCalendar(id: $0.id, title: $0.name, colorHex: $0.colorHex) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func calendarTitles(from data: Data?) -> [String: String] {
|
||||||
|
Dictionary(calendars(from: data).map { ($0.id, $0.title) },
|
||||||
|
uniquingKeysWith: { first, _ in first })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rohformat
|
||||||
|
|
||||||
|
private struct RawSnapshot: Decodable {
|
||||||
|
let version: Int?
|
||||||
|
let loggedOut: Bool?
|
||||||
|
let writtenAt: String?
|
||||||
|
let events: [RawEvent]?
|
||||||
|
|
||||||
|
func writtenAtDate(_ parser: DateParser) -> Date? {
|
||||||
|
writtenAt.flatMap(parser.date(from:))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RawCalendar: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let colorHex: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RawEvent: Decodable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let start: String
|
||||||
|
let end: String
|
||||||
|
let isAllDay: Bool?
|
||||||
|
let location: String?
|
||||||
|
let calendarKey: String?
|
||||||
|
let colorHex: String?
|
||||||
|
|
||||||
|
func asOnyxEvent(titles: [String: String], parser: DateParser) -> OnyxEvent? {
|
||||||
|
guard let start = parser.date(from: start),
|
||||||
|
let end = parser.date(from: end) else { return nil }
|
||||||
|
let key = calendarKey ?? ""
|
||||||
|
return OnyxEvent(
|
||||||
|
id: id, title: title, start: start, end: end,
|
||||||
|
isAllDay: isAllDay ?? false,
|
||||||
|
colorHex: colorHex,
|
||||||
|
// Ein leerer Ort ist kein Ort — sonst steht unter jedem Termin
|
||||||
|
// eine leere Zeile mit Stecknadel.
|
||||||
|
location: (location?.isEmpty ?? true) ? nil : location,
|
||||||
|
calendarID: key,
|
||||||
|
calendarTitle: titles[key] ?? key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendarr schreibt ISO 8601 in UTC, mal mit und mal ohne Sekundenbruch.
|
||||||
|
///
|
||||||
|
/// Bewusst kein gemeinsamer statischer Formatierer: `ISO8601DateFormatter`
|
||||||
|
/// ist nicht `Sendable`, und ein `nonisolated(unsafe)` wäre hier eine
|
||||||
|
/// Behauptung, die niemand prüft. Einmal je Durchlauf gebaut und
|
||||||
|
/// durchgereicht kostet nichts und ist nachweislich richtig.
|
||||||
|
struct DateParser {
|
||||||
|
private let plain: ISO8601DateFormatter
|
||||||
|
private let withFraction: ISO8601DateFormatter
|
||||||
|
|
||||||
|
init() {
|
||||||
|
plain = ISO8601DateFormatter()
|
||||||
|
plain.formatOptions = [.withInternetDateTime]
|
||||||
|
withFraction = ISO8601DateFormatter()
|
||||||
|
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
}
|
||||||
|
|
||||||
|
func date(from string: String) -> Date? {
|
||||||
|
withFraction.date(from: string) ?? plain.date(from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quelle
|
||||||
|
|
||||||
|
/// Calendarr als Terminquelle.
|
||||||
|
public final class CalendarrSource: CalendarSource, @unchecked Sendable {
|
||||||
|
|
||||||
|
public let id = "calendarr"
|
||||||
|
|
||||||
|
/// Der Gruppencontainer, den Calendarr und Onyx teilen.
|
||||||
|
///
|
||||||
|
/// Onyx läuft ohne Sandbox — der Container ist trotzdem erreichbar, weil
|
||||||
|
/// die Gruppe im Entitlement steht und beide Apps im selben Team signiert
|
||||||
|
/// sind. Siehe docs/spikes/D-appgroup.md.
|
||||||
|
public static let appGroup = "PP34X97WS3.group.com.scarriffleservices.calendarr"
|
||||||
|
|
||||||
|
/// Die Nachricht, mit der Calendarr auf einen neuen Schnappschuss hinweist.
|
||||||
|
public static let changeNotification = "com.scarriffleservices.calendarr.snapshot-changed"
|
||||||
|
|
||||||
|
private let containerURL: URL?
|
||||||
|
private let calendar: Calendar
|
||||||
|
|
||||||
|
public init(calendar: Calendar = .autoupdatingCurrent) {
|
||||||
|
self.containerURL = FileManager.default
|
||||||
|
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)
|
||||||
|
self.calendar = calendar
|
||||||
|
if containerURL == nil {
|
||||||
|
log.error("Gruppencontainer \(Self.appGroup, privacy: .public) nicht erreichbar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendarr braucht keinen Systemdialog — der Container ist entweder da
|
||||||
|
/// oder die App ist nicht installiert.
|
||||||
|
public var authorization: CalendarAuthorization { .notRequired }
|
||||||
|
public var isAuthorized: Bool { true }
|
||||||
|
public func requestAccess() async -> Bool { true }
|
||||||
|
|
||||||
|
public func calendars() async -> [OnyxCalendar] {
|
||||||
|
CalendarrSnapshotReader.calendars(from: read("widget-calendars.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das angefragte Intervall wird zum Filtern benutzt, nicht zum Abfragen:
|
||||||
|
/// der Schnappschuss liegt fertig da, seine Reichweite bestimmt Calendarr.
|
||||||
|
public func load(_ interval: DateInterval) async -> CalendarSourceState {
|
||||||
|
guard containerURL != nil else {
|
||||||
|
return .unreadable(reason: "Gruppencontainer nicht erreichbar")
|
||||||
|
}
|
||||||
|
let state = CalendarrSnapshotReader.state(fromSnapshot: read("widget-cache.json"),
|
||||||
|
calendars: read("widget-calendars.json"),
|
||||||
|
calendar: calendar)
|
||||||
|
guard case .ready(let window) = state else { return state }
|
||||||
|
// Die Abdeckung bleibt die des Schnappschusses und wird nicht auf das
|
||||||
|
// angefragte Fenster eingeengt — sonst würde ein schmaler Ausschnitt
|
||||||
|
// wie eine schmale Datenlage aussehen.
|
||||||
|
return .ready(EventWindow(
|
||||||
|
events: window.events.filter { $0.end >= interval.start && $0.start < interval.end },
|
||||||
|
coverage: window.coverage,
|
||||||
|
asOf: window.asOf))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func read(_ name: String) -> Data? {
|
||||||
|
guard let url = containerURL?.appendingPathComponent(name) else { return nil }
|
||||||
|
return try? Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Hört auf eine Darwin-Nachricht und meldet sich beim Abräumen wieder ab.
|
||||||
|
///
|
||||||
|
/// Warum ein eigenes Objekt und nicht einfach ein Feld im Modell: das Modell
|
||||||
|
/// liegt auf dem MainActor, sein `deinit` aber nicht — es dürfte den Beobachter
|
||||||
|
/// dort gar nicht mehr anfassen. Ein Objekt, dessen einzige Aufgabe die
|
||||||
|
/// Anmeldung ist, bindet das Abmelden an genau die richtige Lebensdauer.
|
||||||
|
/// Auf `nil` setzen genügt zum Abmelden.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable` ist hier belegbar und keine Behauptung ins Blaue:
|
||||||
|
/// `handler` wird nach `init` nie mehr geschrieben, und der Rückruf liest ihn
|
||||||
|
/// nur. Andere veränderliche Zustände gibt es nicht.
|
||||||
|
final class DarwinObserver: @unchecked Sendable {
|
||||||
|
|
||||||
|
private let name: CFString
|
||||||
|
private let handler: @Sendable () -> Void
|
||||||
|
|
||||||
|
/// - Parameter handler: läuft auf dem Thread, der die Nachricht zustellt —
|
||||||
|
/// nicht zwingend dem Hauptthread. Wer UI anfasst, hüpft selbst hinüber.
|
||||||
|
init(name: String, handler: @escaping @Sendable () -> Void) {
|
||||||
|
self.name = name as CFString
|
||||||
|
self.handler = handler
|
||||||
|
|
||||||
|
CFNotificationCenterAddObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
{ _, observer, _, _, _ in
|
||||||
|
guard let observer else { return }
|
||||||
|
// Unretained: der Beobachter wird beim Abmelden entfernt, also
|
||||||
|
// kann hier kein toter Zeiger ankommen — und ein starker
|
||||||
|
// Verweis hielte das Objekt ewig am Leben.
|
||||||
|
Unmanaged<DarwinObserver>.fromOpaque(observer)
|
||||||
|
.takeUnretainedValue().handler()
|
||||||
|
},
|
||||||
|
self.name, nil, .deliverImmediately)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
CFNotificationCenterRemoveObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
CFNotificationName(name), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,17 @@ public final class EventKitSource: CalendarSource, @unchecked Sendable {
|
|||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var isAuthorized: Bool {
|
public var authorization: CalendarAuthorization {
|
||||||
EKEventStore.authorizationStatus(for: .event) == .fullAccess
|
switch EKEventStore.authorizationStatus(for: .event) {
|
||||||
|
case .fullAccess: .granted
|
||||||
|
case .denied, .restricted: .denied
|
||||||
|
// `writeOnly` ist für uns so gut wie nichts — Onyx zeigt nur an.
|
||||||
|
case .notDetermined, .writeOnly: .undetermined
|
||||||
|
@unknown default: .denied
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isAuthorized: Bool { authorization == .granted }
|
||||||
|
|
||||||
public func requestAccess() async -> Bool {
|
public func requestAccess() async -> Bool {
|
||||||
switch EKEventStore.authorizationStatus(for: .event) {
|
switch EKEventStore.authorizationStatus(for: .event) {
|
||||||
|
|||||||
@@ -1,12 +1,353 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"strings": {
|
||||||
"" : {
|
"": {},
|
||||||
|
|
||||||
},
|
|
||||||
"%lld": {
|
"%lld": {
|
||||||
"comment": "A day of the month. The argument is the day of the month.",
|
"comment": "A day of the month. The argument is the day of the month.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated": true,
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.apple": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Apple Kalender"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Apple Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.both": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Beide"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Both"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.calendarr": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Onyx only reads. It never signs in and never writes to Calendarr."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.status.coverage": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bekannt bis %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Known through %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.status.coverage %@": {
|
||||||
|
"comment": "A footnote in the Calendarr settings, indicating the end of the coverage window.",
|
||||||
|
"isCommentAutoGenerated": true,
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bekannt bis %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Open Calendarr once so it can write a snapshot."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.status.ready": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%@ Termine, Stand %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%@ events, as of %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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$@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%@ Termine, Stand %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.status.unreadable": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schnappschuss unlesbar: %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Snapshot unreadable: %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schnappschuss unlesbar: %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Snapshot unreadable: %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar.source.title": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Terminquelle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Calendar source"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarr.live.error.credentials": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Benutzername oder Passwort stimmen nicht."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wrong username or password."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarr.live.error.response": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server hat geantwortet, aber nicht verständlich."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server replied, but not intelligibly."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarr.live.error.server": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Diese Adresse ergibt keinen Server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "That address doesn’t resolve to a server."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarr.live.error.status %lld": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server meldet Fehler %lld."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server reports error %lld."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarr.live.error.twoFactor": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dieses Konto verlangt einen Zwei-Faktor-Code."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This account requires a two-factor code."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarr.live.error.twoFactorWrong": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Zwei-Faktor-Code stimmt nicht — er gilt nur eine halbe Minute."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wrong two-factor code — it’s only valid for half a minute."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.allDay": {
|
"widget.calendar.allDay": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -138,6 +479,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"widget.calendar.today": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Heute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Today"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.tomorrow": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Morgen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Tomorrow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"widget.calendar.unreadable": {
|
"widget.calendar.unreadable": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
80
Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Übersetzt die JSON-Zeilen des mediaremote-adapter.
|
||||||
|
///
|
||||||
|
/// Der Adapter sendet im `stream`-Modus Zeilen der Form
|
||||||
|
/// `{"type":"data","diff":false,"payload":{…}}`. Bei `diff = true` enthält
|
||||||
|
/// `payload` **nur die geänderten Felder** — ein vollständiges Ersetzen würde
|
||||||
|
/// dann Titel und Cover löschen, sobald sich nur die Position ändert.
|
||||||
|
public enum AdapterPayload {
|
||||||
|
|
||||||
|
public struct Envelope: Decodable {
|
||||||
|
public let type: String
|
||||||
|
public let diff: Bool?
|
||||||
|
public let payload: [String: AnyDecodable]?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wendet eine Zeile auf den bisherigen Stand an.
|
||||||
|
///
|
||||||
|
/// - Returns: der neue Stand, oder `nil` wenn die Zeile nichts aussagt.
|
||||||
|
public static func apply(line: String, to previous: NowPlaying?) -> NowPlaying? {
|
||||||
|
guard let data = line.data(using: .utf8),
|
||||||
|
let envelope = try? JSONDecoder().decode(Envelope.self, from: data),
|
||||||
|
envelope.type == "data" else { return nil }
|
||||||
|
|
||||||
|
guard let payload = envelope.payload, !payload.isEmpty else {
|
||||||
|
// Ein leerer Payload bei einer Vollmeldung heißt: es läuft nichts.
|
||||||
|
// Bei einer Teilmeldung heißt er gar nichts und wird verworfen.
|
||||||
|
return envelope.diff == true ? previous : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei einer Teilmeldung auf dem alten Stand aufbauen, sonst neu beginnen.
|
||||||
|
var state = (envelope.diff == true ? previous : nil) ?? NowPlaying()
|
||||||
|
state.origin = .adapter
|
||||||
|
|
||||||
|
if let value = payload["title"]?.string { state.title = value }
|
||||||
|
if let value = payload["artist"]?.string { state.artist = value }
|
||||||
|
if let value = payload["album"]?.string { state.album = value }
|
||||||
|
if let value = payload["playing"]?.bool { state.isPlaying = value }
|
||||||
|
if let value = payload["duration"]?.double { state.duration = value > 0 ? value : nil }
|
||||||
|
if let value = payload["elapsedTime"]?.double { state.elapsed = value }
|
||||||
|
if let value = payload["playbackRate"]?.double { state.playbackRate = value }
|
||||||
|
if let value = payload["bundleIdentifier"]?.string { state.bundleIdentifier = value }
|
||||||
|
if let value = payload["artworkData"]?.string {
|
||||||
|
state.artwork = Data(base64Encoded: value, options: .ignoreUnknownCharacters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `timestamp` ist der Zeitpunkt, zu dem `elapsedTime` galt — nicht der
|
||||||
|
// Empfangszeitpunkt. Ohne ihn springt der Scrubber bei jedem Ereignis.
|
||||||
|
if let raw = payload["timestamp"]?.string,
|
||||||
|
let parsed = ISO8601DateFormatter().date(from: raw) {
|
||||||
|
state.asOf = parsed
|
||||||
|
} else {
|
||||||
|
state.asOf = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimaler dynamischer Wert — der Payload hat kein festes Schema, und ein
|
||||||
|
/// vollständiger `Decodable`-Typ müsste bei jedem neuen Feld nachgezogen werden.
|
||||||
|
public struct AnyDecodable: Decodable, Sendable {
|
||||||
|
private enum Storage: Sendable {
|
||||||
|
case string(String), double(Double), bool(Bool), other
|
||||||
|
}
|
||||||
|
private let storage: Storage
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
// Reihenfolge zählt: `Bool` vor `Double`, sonst wird `true` zu 1.
|
||||||
|
if let value = try? container.decode(Bool.self) { storage = .bool(value) }
|
||||||
|
else if let value = try? container.decode(Double.self) { storage = .double(value) }
|
||||||
|
else if let value = try? container.decode(String.self) { storage = .string(value) }
|
||||||
|
else { storage = .other }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var string: String? { if case .string(let v) = storage { v } else { nil } }
|
||||||
|
public var double: Double? { if case .double(let v) = storage { v } else { nil } }
|
||||||
|
public var bool: Bool? { if case .bool(let v) = storage { v } else { nil } }
|
||||||
|
}
|
||||||
166
Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
||||||
|
|
||||||
|
/// Hält den mediaremote-adapter am Leben und liest seinen Datenstrom.
|
||||||
|
///
|
||||||
|
/// Der Umweg über `/usr/bin/perl` ist kein Trick um seiner selbst willen:
|
||||||
|
/// seit macOS 15.4 prüft der `mediaremoted`-Daemon Entitlements, und
|
||||||
|
/// Fremdprogramme bekommen keine Now-Playing-Daten mehr. Perl ist ein
|
||||||
|
/// mitgeliefertes, berechtigtes Systemprogramm; es lädt das beigelegte
|
||||||
|
/// Framework und gibt die Daten zeilenweise aus.
|
||||||
|
@MainActor
|
||||||
|
public final class AdapterProcess {
|
||||||
|
|
||||||
|
/// Befehlskennungen aus `MediaRemote.h` des Adapters.
|
||||||
|
private enum Command: Int {
|
||||||
|
case play = 0, pause = 1, togglePlayPause = 2, next = 4, previous = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var isRunning = false
|
||||||
|
/// Wird bei jeder Änderung gerufen. `nil` heißt: es läuft nichts.
|
||||||
|
public var onUpdate: ((NowPlaying?) -> Void)?
|
||||||
|
|
||||||
|
private var process: Process?
|
||||||
|
private var buffer = Data()
|
||||||
|
private var current: NowPlaying?
|
||||||
|
private var restartAttempts = 0
|
||||||
|
private var restartTimer: Timer?
|
||||||
|
|
||||||
|
private let perl = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Ob die beigelegten Dateien vorhanden sind. Fehlen sie, greift die
|
||||||
|
/// nächste Stufe der Kaskade — ohne dass irgendwo ein Prozess scheitert.
|
||||||
|
public static var isAvailable: Bool { resources != nil }
|
||||||
|
|
||||||
|
private static var resources: (script: URL, framework: URL)? {
|
||||||
|
guard let vendor = Bundle.main.resourceURL?.appendingPathComponent("Vendor") else { return nil }
|
||||||
|
let script = vendor.appendingPathComponent("mediaremote-adapter.pl")
|
||||||
|
let framework = vendor.appendingPathComponent("MediaRemoteAdapter.framework")
|
||||||
|
guard FileManager.default.fileExists(atPath: script.path),
|
||||||
|
FileManager.default.fileExists(atPath: framework.path) else { return nil }
|
||||||
|
return (script, framework)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lebenszyklus
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
guard !isRunning, let resources = Self.resources else { return }
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = perl
|
||||||
|
process.arguments = [resources.script.path, resources.framework.path, "stream"]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
|
||||||
|
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||||
|
let chunk = handle.availableData
|
||||||
|
guard !chunk.isEmpty else { return }
|
||||||
|
Task { @MainActor in self?.consume(chunk) }
|
||||||
|
}
|
||||||
|
|
||||||
|
process.terminationHandler = { @Sendable [weak self] _ in
|
||||||
|
Task { @MainActor in self?.handleTermination() }
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
self.process = process
|
||||||
|
isRunning = true
|
||||||
|
log.notice("MediaRemote-Adapter gestartet")
|
||||||
|
} catch {
|
||||||
|
log.error("Adapter konnte nicht starten: \(error.localizedDescription, privacy: .public)")
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
restartTimer?.invalidate()
|
||||||
|
restartTimer = nil
|
||||||
|
(process?.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil
|
||||||
|
process?.terminationHandler = nil
|
||||||
|
process?.terminate()
|
||||||
|
process = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTermination() {
|
||||||
|
isRunning = false
|
||||||
|
process = nil
|
||||||
|
// Der Adapter beendet sich auch im Normalbetrieb gelegentlich. Ein
|
||||||
|
// Neustart mit wachsendem Abstand hält ihn am Leben, ohne bei einem
|
||||||
|
// echten Defekt in einer Schleife zu landen.
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRestart() {
|
||||||
|
guard restartAttempts < 5 else {
|
||||||
|
log.error("Adapter startet nicht mehr — Kaskade fällt auf AppleScript zurück")
|
||||||
|
onUpdate?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let delay = pow(2.0, Double(restartAttempts))
|
||||||
|
restartAttempts += 1
|
||||||
|
restartTimer?.invalidate()
|
||||||
|
restartTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.start() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Datenstrom
|
||||||
|
|
||||||
|
private func consume(_ chunk: Data) {
|
||||||
|
buffer.append(chunk)
|
||||||
|
|
||||||
|
// Zeilenweise: ein Lesevorgang kann mehrere Zeilen oder eine halbe
|
||||||
|
// enthalten. Cover kommen base64-kodiert und sprengen einen Puffer
|
||||||
|
// schnell, deshalb wird der Rest aufgehoben statt verworfen.
|
||||||
|
while let newline = buffer.firstIndex(of: 0x0A) {
|
||||||
|
let lineData = buffer[buffer.startIndex..<newline]
|
||||||
|
buffer.removeSubrange(buffer.startIndex...newline)
|
||||||
|
|
||||||
|
guard let line = String(data: lineData, encoding: .utf8), !line.isEmpty else { continue }
|
||||||
|
restartAttempts = 0
|
||||||
|
|
||||||
|
if let updated = AdapterPayload.apply(line: line, to: current) {
|
||||||
|
current = updated
|
||||||
|
onUpdate?(updated)
|
||||||
|
} else if line.contains("\"payload\":{}") {
|
||||||
|
current = nil
|
||||||
|
onUpdate?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Steuerung
|
||||||
|
|
||||||
|
public func send(_ command: MediaCommand) {
|
||||||
|
switch command {
|
||||||
|
case .playPause: run(["send", String(Command.togglePlayPause.rawValue)])
|
||||||
|
case .next: run(["send", String(Command.next.rawValue)])
|
||||||
|
case .previous: run(["send", String(Command.previous.rawValue)])
|
||||||
|
case .seek(let position):
|
||||||
|
// Der Adapter erwartet Mikrosekunden.
|
||||||
|
run(["seek", String(Int(position * 1_000_000))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Befehle laufen als eigener, kurzlebiger Prozess.
|
||||||
|
///
|
||||||
|
/// Den Datenstrom dafür zu unterbrechen wäre der falsche Weg: er würde bei
|
||||||
|
/// jedem Tastendruck neu aufgebaut, und in der Lücke fehlen Ereignisse.
|
||||||
|
private func run(_ arguments: [String]) {
|
||||||
|
guard let resources = Self.resources else { return }
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = perl
|
||||||
|
process.arguments = [resources.script.path, resources.framework.path] + arguments
|
||||||
|
process.standardOutput = FileHandle.nullDevice
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
try? process.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Packages/OnyxKit/Sources/MediaProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"widget.media.name": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Medien"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Media"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.media.nothing": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nichts läuft gerade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nothing playing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.media.viaAppleScript": {
|
||||||
|
"comment": "Hinweis, dass die schwächere Quelle greift",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Über AppleScript — ohne Cover, nur Spotify und Musik"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Via AppleScript — no artwork, Spotify and Music only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
138
Packages/OnyxKit/Sources/MediaProvider/MediaFallbacks.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
||||||
|
|
||||||
|
/// Zweite Stufe: AppleScript für Spotify und Musik.
|
||||||
|
///
|
||||||
|
/// Deckt weit weniger ab als der Adapter — Browser und alle anderen Programme
|
||||||
|
/// fallen heraus. Dafür ist es offiziell unterstützt und überlebt
|
||||||
|
/// macOS-Aktualisierungen, die den Adapter brechen könnten.
|
||||||
|
public enum AppleScriptSource {
|
||||||
|
|
||||||
|
private struct Player {
|
||||||
|
let bundleID: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let players = [
|
||||||
|
Player(bundleID: "com.spotify.client", name: "Spotify"),
|
||||||
|
Player(bundleID: "com.apple.Music", name: "Music"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Fragt die laufenden Programme ab. Nicht laufende werden übersprungen —
|
||||||
|
/// ein AppleScript an ein beendetes Programm würde es sonst **starten**.
|
||||||
|
public static func poll() -> NowPlaying? {
|
||||||
|
for player in players {
|
||||||
|
guard NSRunningApplication
|
||||||
|
.runningApplications(withBundleIdentifier: player.bundleID)
|
||||||
|
.isEmpty == false else { continue }
|
||||||
|
if let state = query(player) { return state }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func query(_ player: Player) -> NowPlaying? {
|
||||||
|
let script = """
|
||||||
|
tell application id "\(player.bundleID)"
|
||||||
|
if player state is playing or player state is paused then
|
||||||
|
set t to name of current track
|
||||||
|
set a to artist of current track
|
||||||
|
set al to album of current track
|
||||||
|
set dur to duration of current track
|
||||||
|
set pos to player position
|
||||||
|
set st to (player state as text)
|
||||||
|
return t & "\\n" & a & "\\n" & al & "\\n" & dur & "\\n" & pos & "\\n" & st
|
||||||
|
end if
|
||||||
|
end tell
|
||||||
|
"""
|
||||||
|
|
||||||
|
var error: NSDictionary?
|
||||||
|
guard let result = NSAppleScript(source: script)?
|
||||||
|
.executeAndReturnError(&error).stringValue else {
|
||||||
|
if let error { log.debug("AppleScript \(player.name): \(String(describing: error))") }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = result.components(separatedBy: "\n")
|
||||||
|
guard parts.count >= 6 else { return nil }
|
||||||
|
|
||||||
|
// Spotify gibt die Länge in Millisekunden, Musik in Sekunden.
|
||||||
|
let rawDuration = Double(parts[3]) ?? 0
|
||||||
|
let duration = player.bundleID == "com.spotify.client" ? rawDuration / 1000 : rawDuration
|
||||||
|
|
||||||
|
return NowPlaying(title: parts[0], artist: parts[1], album: parts[2],
|
||||||
|
isPlaying: parts[5].hasPrefix("playing"),
|
||||||
|
duration: duration > 0 ? duration : nil,
|
||||||
|
elapsed: Double(parts[4]) ?? 0,
|
||||||
|
playbackRate: 1, asOf: Date(), artwork: nil,
|
||||||
|
bundleIdentifier: player.bundleID, origin: .appleScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func send(_ command: MediaCommand) -> Bool {
|
||||||
|
for player in players {
|
||||||
|
guard !NSRunningApplication
|
||||||
|
.runningApplications(withBundleIdentifier: player.bundleID).isEmpty else { continue }
|
||||||
|
|
||||||
|
let action = switch command {
|
||||||
|
case .playPause: "playpause"
|
||||||
|
case .next: "next track"
|
||||||
|
case .previous: "previous track"
|
||||||
|
case .seek(let position): "set player position to \(position)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var error: NSDictionary?
|
||||||
|
NSAppleScript(source: "tell application id \"\(player.bundleID)\" to \(action)")?
|
||||||
|
.executeAndReturnError(&error)
|
||||||
|
if error == nil { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letzte Stufe: die Medientasten der Tastatur.
|
||||||
|
///
|
||||||
|
/// Sie kennt keine Titel und keine Position — aber sie steuert **jedes**
|
||||||
|
/// Programm, auch solche, die weder der Adapter noch AppleScript erreichen.
|
||||||
|
/// Damit bleibt das Widget selbst dann bedienbar, wenn Apple die
|
||||||
|
/// Now-Playing-Schnittstelle weiter zumacht.
|
||||||
|
public enum MediaKeys {
|
||||||
|
|
||||||
|
private enum Key: Int32 {
|
||||||
|
case playPause = 16 // NX_KEYTYPE_PLAY
|
||||||
|
case next = 17 // NX_KEYTYPE_NEXT
|
||||||
|
case previous = 18 // NX_KEYTYPE_PREVIOUS
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func send(_ command: MediaCommand) {
|
||||||
|
let key: Key? = switch command {
|
||||||
|
case .playPause: .playPause
|
||||||
|
case .next: .next
|
||||||
|
case .previous: .previous
|
||||||
|
case .seek: nil // Über Tasten nicht möglich.
|
||||||
|
}
|
||||||
|
guard let key else { return }
|
||||||
|
post(key, down: true)
|
||||||
|
post(key, down: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(_ key: Key, down: Bool) {
|
||||||
|
// Medientasten sind keine gewöhnlichen Tastendrücke, sondern
|
||||||
|
// NSSystemDefined-Ereignisse mit Subtyp 8. Ein normaler CGEvent käme
|
||||||
|
// nirgends an.
|
||||||
|
let flags = NSEvent.ModifierFlags(rawValue: UInt(down ? 0xA00 : 0xB00))
|
||||||
|
let data1 = Int((key.rawValue << 16) | ((down ? 0xA : 0xB) << 8))
|
||||||
|
|
||||||
|
guard let event = NSEvent.otherEvent(with: .systemDefined,
|
||||||
|
location: .zero,
|
||||||
|
modifierFlags: flags,
|
||||||
|
timestamp: 0,
|
||||||
|
windowNumber: 0,
|
||||||
|
context: nil,
|
||||||
|
subtype: 8,
|
||||||
|
data1: data1,
|
||||||
|
data2: -1)?.cgEvent else { return }
|
||||||
|
event.post(tap: .cghidEventTap)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
||||||
|
|
||||||
|
/// Führt die drei Stufen zusammen.
|
||||||
|
///
|
||||||
|
/// **Adapter** liefert alles inklusive Browser. **AppleScript** springt für
|
||||||
|
/// Spotify und Musik ein, wenn der Adapter schweigt. **Medientasten** steuern
|
||||||
|
/// immer, auch ohne jede Angabe.
|
||||||
|
///
|
||||||
|
/// Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die
|
||||||
|
/// Now-Playing-Schnittstelle in macOS 15.4 bereits einmal zugemacht. Fällt der
|
||||||
|
/// Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class MediaModel {
|
||||||
|
|
||||||
|
public private(set) var nowPlaying: NowPlaying?
|
||||||
|
/// Für die laufende Fortschrittsanzeige. Wird sekündlich gesetzt, aber nur
|
||||||
|
/// solange etwas abspielt und das Panel offen ist.
|
||||||
|
public private(set) var tick = Date()
|
||||||
|
|
||||||
|
private let adapter = AdapterProcess()
|
||||||
|
private var fallbackTimer: Timer?
|
||||||
|
private var tickTimer: Timer?
|
||||||
|
private var consumers = 0
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
adapter.onUpdate = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
// Der Adapter hat Vorrang. Nur wenn er nichts weiß, wird gefragt.
|
||||||
|
if let state {
|
||||||
|
nowPlaying = state
|
||||||
|
} else {
|
||||||
|
nowPlaying = AppleScriptSource.poll()
|
||||||
|
}
|
||||||
|
updateTicking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An- und Abmelden statt Start und Stopp: das Widget im Panel und ein
|
||||||
|
/// künftiges Menüleisten-Modul teilen sich dieselbe Quelle, und der
|
||||||
|
/// Adapter soll genau einmal laufen.
|
||||||
|
public func addConsumer() {
|
||||||
|
consumers += 1
|
||||||
|
guard consumers == 1 else { return }
|
||||||
|
|
||||||
|
if AdapterProcess.isAvailable {
|
||||||
|
adapter.start()
|
||||||
|
} else {
|
||||||
|
log.notice("Adapter nicht im Bundle — nur AppleScript und Medientasten")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppleScript wird abgefragt, nicht gemeldet. Zwei Sekunden reichen für
|
||||||
|
// eine Anzeige, die ohnehin nur beim geöffneten Panel sichtbar ist.
|
||||||
|
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.pollFallbackIfNeeded() }
|
||||||
|
}
|
||||||
|
pollFallbackIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeConsumer() {
|
||||||
|
consumers = max(consumers - 1, 0)
|
||||||
|
guard consumers == 0 else { return }
|
||||||
|
adapter.stop()
|
||||||
|
fallbackTimer?.invalidate()
|
||||||
|
fallbackTimer = nil
|
||||||
|
tickTimer?.invalidate()
|
||||||
|
tickTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollFallbackIfNeeded() {
|
||||||
|
// Nur einspringen, wenn der Adapter nichts liefert — sonst würde die
|
||||||
|
// schwächere Quelle die bessere überschreiben.
|
||||||
|
guard nowPlaying == nil || nowPlaying?.origin == .appleScript else { return }
|
||||||
|
nowPlaying = AppleScriptSource.poll()
|
||||||
|
updateTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Uhr läuft nur, wenn wirklich etwas abspielt. Ein Timer, der bei
|
||||||
|
/// pausierter Wiedergabe weiterläuft, kostet ohne jeden Nutzen.
|
||||||
|
private func updateTicking() {
|
||||||
|
let shouldTick = nowPlaying?.isPlaying == true
|
||||||
|
if shouldTick, tickTimer == nil {
|
||||||
|
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.tick = Date() }
|
||||||
|
}
|
||||||
|
} else if !shouldTick {
|
||||||
|
tickTimer?.invalidate()
|
||||||
|
tickTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Steuerung
|
||||||
|
|
||||||
|
public func send(_ command: MediaCommand) {
|
||||||
|
switch nowPlaying?.origin {
|
||||||
|
case .adapter:
|
||||||
|
adapter.send(command)
|
||||||
|
case .appleScript:
|
||||||
|
if !AppleScriptSource.send(command) { MediaKeys.send(command) }
|
||||||
|
case .mediaKeys, .none:
|
||||||
|
// Ohne bekannte Quelle bleibt die Taste — sie erreicht auch
|
||||||
|
// Programme, die keine der beiden anderen Stufen kennt.
|
||||||
|
MediaKeys.send(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort umschalten statt auf das nächste Ereignis zu warten. Ein
|
||||||
|
// Play-Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt
|
||||||
|
// an; kommt das echte Ereignis, korrigiert es den Stand ohnehin.
|
||||||
|
if case .playPause = command, var state = nowPlaying {
|
||||||
|
state.elapsed = state.elapsed(at: Date())
|
||||||
|
state.asOf = Date()
|
||||||
|
state.isPlaying.toggle()
|
||||||
|
nowPlaying = state
|
||||||
|
updateTicking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet das Programm, das gerade abspielt.
|
||||||
|
public func activateSource() {
|
||||||
|
guard let bundleID = nowPlaying?.bundleIdentifier,
|
||||||
|
let app = NSRunningApplication
|
||||||
|
.runningApplications(withBundleIdentifier: bundleID).first else { return }
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
205
Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
|
||||||
|
public struct MediaWidget: OnyxWidget {
|
||||||
|
public let id = "media"
|
||||||
|
public var displayName: String { String(localized: "widget.media.name", bundle: .module) }
|
||||||
|
public let symbolName = "play.circle"
|
||||||
|
|
||||||
|
private let model: MediaModel
|
||||||
|
|
||||||
|
public init(model: MediaModel) { self.model = model }
|
||||||
|
|
||||||
|
public func makeView() -> AnyView {
|
||||||
|
AnyView(MediaWidgetView(model: model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MediaWidgetView: View {
|
||||||
|
let model: MediaModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let state = model.nowPlaying, state.hasMetadata {
|
||||||
|
Playing(model: model, state: state)
|
||||||
|
} else {
|
||||||
|
// Auch ohne Angaben bleibt das Widget bedienbar: die
|
||||||
|
// Medientasten erreichen jedes Programm. Ein Widget, das bei
|
||||||
|
// fehlenden Metadaten gar nichts mehr kann, wäre die schlechtere
|
||||||
|
// Antwort auf ein Problem, das nur die Anzeige betrifft.
|
||||||
|
Idle(model: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { model.addConsumer() }
|
||||||
|
.onDisappear { model.removeConsumer() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Idle: View {
|
||||||
|
let model: MediaModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("widget.media.nothing", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
TransportControls(model: model, size: 13)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Playing: View {
|
||||||
|
let model: MediaModel
|
||||||
|
let state: NowPlaying
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
|
||||||
|
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann
|
||||||
|
// steht dort „Bernhard H…" statt eines Titels.
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
Artwork(data: state.artwork)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 88)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(state.title)
|
||||||
|
.font(Onyx.Font.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(state.artist)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Scrubber(model: model, state: state)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
TransportControls(model: model, size: 15)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if state.origin == .appleScript {
|
||||||
|
// Sichtbar machen, dass gerade die schwächere Quelle
|
||||||
|
// greift: dann fehlen Cover und Browser-Wiedergaben,
|
||||||
|
// und das soll erklärbar sein statt rätselhaft.
|
||||||
|
Image(systemName: "applescript")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.help(Text("widget.media.viaAppleScript", bundle: .module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(count: 2) { model.activateSource() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Artwork: View {
|
||||||
|
let data: Data?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
|
||||||
|
// Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum
|
||||||
|
// beschneidet man das Bild auf seine eigene, viel größere Größe — und es
|
||||||
|
// ragt anschließend über die Kachel hinaus, mitten in den Nachbarn.
|
||||||
|
Group {
|
||||||
|
if let data, let image = NSImage(data: data) {
|
||||||
|
Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
|
||||||
|
} else {
|
||||||
|
// Kein Cover ist der Normalfall bei Browser-Wiedergaben und
|
||||||
|
// beim AppleScript-Rückfall — deshalb ein ruhiger Platzhalter
|
||||||
|
// statt eines Fehlerzeichens.
|
||||||
|
shape.fill(Onyx.Color.elevated)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.clipShape(shape)
|
||||||
|
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Scrubber: View {
|
||||||
|
let model: MediaModel
|
||||||
|
let state: NowPlaying
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// `model.tick` wird sekündlich gesetzt; das Lesen hier sorgt dafür,
|
||||||
|
// dass die Ansicht zwischen den Adapter-Ereignissen mitläuft.
|
||||||
|
let now = model.tick
|
||||||
|
let progress = state.progress(at: now)
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Onyx.Color.hairline)
|
||||||
|
Capsule()
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(width: geometry.size.width * (progress ?? 0))
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { location in
|
||||||
|
guard let duration = state.duration, duration > 0 else { return }
|
||||||
|
let ratio = min(max(location.x / geometry.size.width, 0), 1)
|
||||||
|
model.send(.seek(to: duration * ratio))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
|
||||||
|
if state.duration != nil {
|
||||||
|
HStack {
|
||||||
|
Text(format(state.elapsed(at: now)))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(format(state.duration ?? 0))
|
||||||
|
}
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.linear(duration: 0.9), value: progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func format(_ seconds: TimeInterval) -> String {
|
||||||
|
let total = Int(seconds.rounded())
|
||||||
|
return String(format: "%d:%02d", total / 60, total % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TransportControls: View {
|
||||||
|
let model: MediaModel
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
button("backward.fill") { model.send(.previous) }
|
||||||
|
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
|
||||||
|
model.send(.playPause)
|
||||||
|
}
|
||||||
|
button("forward.fill") { model.send(.next) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func button(_ symbol: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: size))
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Woher die Angaben stammen. Bestimmt, was steuerbar ist.
|
||||||
|
public enum MediaOrigin: String, Equatable, Sendable {
|
||||||
|
/// mediaremote-adapter — kennt alle Quellen inkl. Browser.
|
||||||
|
case adapter
|
||||||
|
/// AppleScript für Spotify oder Musik.
|
||||||
|
case appleScript
|
||||||
|
/// Nur Medientasten: Steuerung ohne Angaben.
|
||||||
|
case mediaKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was gerade läuft.
|
||||||
|
public struct NowPlaying: Equatable, Sendable {
|
||||||
|
public var title: String
|
||||||
|
public var artist: String
|
||||||
|
public var album: String
|
||||||
|
public var isPlaying: Bool
|
||||||
|
/// Gesamtlänge in Sekunden. `nil` bei Livestreams.
|
||||||
|
public var duration: TimeInterval?
|
||||||
|
/// Position **zum Zeitpunkt `asOf`** — nicht jetzt. Siehe `elapsed(at:)`.
|
||||||
|
public var elapsed: TimeInterval
|
||||||
|
public var playbackRate: Double
|
||||||
|
/// Wann die Angaben gültig waren.
|
||||||
|
public var asOf: Date
|
||||||
|
public var artwork: Data?
|
||||||
|
public var bundleIdentifier: String?
|
||||||
|
public var origin: MediaOrigin
|
||||||
|
|
||||||
|
public init(title: String = "", artist: String = "", album: String = "",
|
||||||
|
isPlaying: Bool = false, duration: TimeInterval? = nil,
|
||||||
|
elapsed: TimeInterval = 0, playbackRate: Double = 1,
|
||||||
|
asOf: Date = Date(), artwork: Data? = nil,
|
||||||
|
bundleIdentifier: String? = nil, origin: MediaOrigin = .adapter) {
|
||||||
|
self.title = title
|
||||||
|
self.artist = artist
|
||||||
|
self.album = album
|
||||||
|
self.isPlaying = isPlaying
|
||||||
|
self.duration = duration
|
||||||
|
self.elapsed = elapsed
|
||||||
|
self.playbackRate = playbackRate
|
||||||
|
self.asOf = asOf
|
||||||
|
self.artwork = artwork
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.origin = origin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Position zum angegebenen Zeitpunkt.
|
||||||
|
///
|
||||||
|
/// Der Adapter liefert `elapsedTime` **nicht** laufend nach — bei
|
||||||
|
/// unveränderter Wiedergabe kommt minutenlang kein Ereignis. Ein Scrubber,
|
||||||
|
/// der stur `elapsed` anzeigt, steht deshalb still, obwohl der Titel läuft.
|
||||||
|
/// Er muss zwischen den Ereignissen selbst hochzählen, ausgehend von
|
||||||
|
/// `asOf` und `playbackRate`.
|
||||||
|
public func elapsed(at date: Date) -> TimeInterval {
|
||||||
|
guard isPlaying else { return clamp(elapsed) }
|
||||||
|
let advanced = elapsed + date.timeIntervalSince(asOf) * playbackRate
|
||||||
|
return clamp(advanced)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fortschritt von 0 bis 1, oder `nil` ohne bekannte Länge.
|
||||||
|
public func progress(at date: Date) -> Double? {
|
||||||
|
guard let duration, duration > 0 else { return nil }
|
||||||
|
return min(max(elapsed(at: date) / duration, 0), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hasMetadata: Bool { !title.isEmpty || !artist.isEmpty }
|
||||||
|
|
||||||
|
private func clamp(_ value: TimeInterval) -> TimeInterval {
|
||||||
|
// Nie über die Länge hinaus: sonst zeigt die Anzeige nach dem Ende
|
||||||
|
// eines Titels weiter steigende Zahlen, solange kein Ereignis kommt.
|
||||||
|
guard let duration, duration > 0 else { return max(value, 0) }
|
||||||
|
return min(max(value, 0), duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was das Widget auslösen kann.
|
||||||
|
public enum MediaCommand: Equatable, Sendable {
|
||||||
|
case playPause
|
||||||
|
case next
|
||||||
|
case previous
|
||||||
|
case seek(to: TimeInterval)
|
||||||
|
}
|
||||||
326
Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import AppKit
|
||||||
|
import OnyxMenuBar
|
||||||
|
|
||||||
|
/// Der Akku, gezeichnet wie der von macOS.
|
||||||
|
///
|
||||||
|
/// Warum nicht als SF-Symbol: das kennt fünf Füllstufen und — außer bei
|
||||||
|
/// hundert Prozent — keine Blitz-Variante. Der Blitz musste deshalb **neben**
|
||||||
|
/// den Akku, und das sieht nach zwei Dingen aus statt nach einem. macOS
|
||||||
|
/// zeichnet ihn mitten hinein, und der Ladestand läuft stufenlos.
|
||||||
|
///
|
||||||
|
/// Selbst zu zeichnen ist hier die kleinere Lösung als die Symbolakrobatik:
|
||||||
|
/// ein Rahmen, ein Knubbel, eine Füllung und ein ausgestanzter Blitz.
|
||||||
|
public enum BatteryGlyph {
|
||||||
|
|
||||||
|
/// Maße wie in der Menüleiste von macOS.
|
||||||
|
public static let size = CGSize(width: 25, height: 12)
|
||||||
|
/// Damit zwei Prozent nicht wie null aussehen.
|
||||||
|
public static let minimumFill: CGFloat = 2
|
||||||
|
/// Ab hier wird die Farbe zum Signal — dieselbe Schwelle wie bei macOS.
|
||||||
|
public static let lowThreshold = 0.2
|
||||||
|
|
||||||
|
public static func fillWidth(charge: Double, inner: CGFloat) -> CGFloat {
|
||||||
|
let clamped = min(max(charge, 0), 1)
|
||||||
|
guard clamped > 0 else { return 0 }
|
||||||
|
return min(max(inner * clamped, minimumFill), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was mitten im Akku steht.
|
||||||
|
public enum Indicator: CaseIterable, Sendable {
|
||||||
|
case none
|
||||||
|
/// Lädt.
|
||||||
|
case bolt
|
||||||
|
/// Am Netz, aber es fließt nichts — meist, weil das Ladelimit erreicht
|
||||||
|
/// ist. Ohne eigenes Zeichen sieht das aus wie Akkubetrieb, und man
|
||||||
|
/// fragt sich, ob das Kabel wirklich steckt.
|
||||||
|
///
|
||||||
|
/// Als Pausenzeichen und nicht als Stecker: „Laden angehalten" ist die
|
||||||
|
/// Aussage, und ein Stecker sagt nur „Kabel steckt" — das sieht man
|
||||||
|
/// ohnehin am Kabel.
|
||||||
|
case paused
|
||||||
|
|
||||||
|
public var symbolName: String? {
|
||||||
|
switch self {
|
||||||
|
case .none: nil
|
||||||
|
case .bolt: "bolt.fill"
|
||||||
|
case .paused: "pause.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func indicator(isCharging: Bool, isPluggedIn: Bool) -> Indicator {
|
||||||
|
if isCharging { return .bolt }
|
||||||
|
return isPluggedIn ? .paused : .none
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wo der Blitz auf der Füllung liegt und wo im Leerraum.
|
||||||
|
///
|
||||||
|
/// Er wird über der Füllung **ausgestanzt** und daneben **ausgemalt**. Nur
|
||||||
|
/// auszustanzen reicht nicht: über dem leeren Teil ist das Transparenz auf
|
||||||
|
/// Transparenz, und bei wenig Ladung sitzt der Blitz fast ganz dort.
|
||||||
|
public static func boltSplit(charge: Double,
|
||||||
|
inner: CGRect) -> (filled: CGRect, empty: CGRect) {
|
||||||
|
let width = fillWidth(charge: charge, inner: inner.width)
|
||||||
|
let filled = CGRect(x: inner.minX, y: inner.minY,
|
||||||
|
width: width, height: inner.height)
|
||||||
|
let empty = CGRect(x: inner.minX + width, y: inner.minY,
|
||||||
|
width: inner.width - width, height: inner.height)
|
||||||
|
return (filled, empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Blitz in zwei Fassungen, hell und dunkel.
|
||||||
|
///
|
||||||
|
/// **Warum zwei Dateien statt einer eingefärbten:** eine helle Fläche mit
|
||||||
|
/// dunklem Rand ließ sich aus einem einzelnen Symbol nur nachbauen, indem
|
||||||
|
/// dasselbe Zeichen zweimal in verschiedener Größe übereinander gezeichnet
|
||||||
|
/// wurde — und die Größenverhältnisse sahen nie stimmig aus. Zwei fertige
|
||||||
|
/// Fassungen ineinander zu blenden ist der geradere Weg: gezeichnet wird
|
||||||
|
/// genau das, was in der Datei steht.
|
||||||
|
public static let boltLight: NSImage? = load("bolt-light")
|
||||||
|
public static let boltDark: NSImage? = load("bolt-dark")
|
||||||
|
|
||||||
|
private static func load(_ name: String) -> NSImage? {
|
||||||
|
guard let url = Bundle.main.url(forResource: name, withExtension: "svg")
|
||||||
|
else { return nil }
|
||||||
|
return NSImage(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie dick der Rand um das Zeichen ist.
|
||||||
|
public static let outlineWidth: CGFloat = 1
|
||||||
|
|
||||||
|
/// Der Rahmen für den Rand — dasselbe Zeichen, etwas größer.
|
||||||
|
public static func outlineRect(_ glyph: CGRect) -> CGRect {
|
||||||
|
glyph.insetBy(dx: -outlineWidth, dy: -outlineWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie hoch das Zeichen im Akku ist.
|
||||||
|
///
|
||||||
|
/// Bezugsgröße ist der **Innenraum**, nicht die Außenhöhe: der Rahmen
|
||||||
|
/// nimmt oben und unten je einen Punkt, die Füllung sitzt zwei Punkte
|
||||||
|
/// eingerückt, und um das Zeichen kommt noch sein eigener Rand. Wer von
|
||||||
|
/// der Außenhöhe ausgeht, landet bei einem Blitz, der den ganzen Akku
|
||||||
|
/// ausfüllt und an den Rahmen stößt.
|
||||||
|
public static func glyphHeight(for indicator: Indicator, in height: CGFloat) -> CGFloat {
|
||||||
|
let inner = max(height - 2 * fillInset, 1)
|
||||||
|
switch indicator {
|
||||||
|
// Das Pausenzeichen ist von Haus aus höher gebaut als der Blitz.
|
||||||
|
case .paused: return inner * 0.8
|
||||||
|
default: return inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie weit die Füllung vom Rahmen abrückt.
|
||||||
|
public static let fillInset: CGFloat = 2
|
||||||
|
|
||||||
|
/// Wie lange ein voller Hin- und Rückweg des Blitzes dauert.
|
||||||
|
public static let fadeDuration: TimeInterval = 2.2
|
||||||
|
|
||||||
|
/// Der Ping-Pong von 0 nach 1 und zurück.
|
||||||
|
///
|
||||||
|
/// Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den
|
||||||
|
/// Umkehrpunkten sichtbar, der Kosinus wird dort flach. In der Menüleiste
|
||||||
|
/// fällt so ein Knick auf.
|
||||||
|
public static func fadeAmount(phase: Double) -> Double {
|
||||||
|
(1 - cos(phase.truncatingRemainder(dividingBy: 1) * 2 * .pi)) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie weiß der Blitz gerade ist, 1 = weiß, 0 = schwarz.
|
||||||
|
///
|
||||||
|
/// Darum geht es: bei wenig Ladung sitzt der Blitz im leeren Teil des
|
||||||
|
/// Akkus und braucht Weiß, über der Füllung braucht er Schwarz. Ein Faden
|
||||||
|
/// dazwischen ist in beiden Fällen irgendwann gut lesbar — und man sieht
|
||||||
|
/// am Wandern zusätzlich, dass geladen wird.
|
||||||
|
public static func boltWhiteness(phase: Double) -> Double {
|
||||||
|
1 - fadeAmount(phase: phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isLow(charge: Double, isCharging: Bool = false) -> Bool {
|
||||||
|
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
|
||||||
|
// sich schon erledigt.
|
||||||
|
!isCharging && charge < lowThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Symbol in der gewünschten Farbe. `color.set()` genügt nicht:
|
||||||
|
/// Template-Bilder tragen ihre Farbe nicht aus dem Grafikzustand.
|
||||||
|
private static func tint(_ image: NSImage, with color: NSColor) -> NSImage? {
|
||||||
|
let configuration = NSImage.SymbolConfiguration(paletteColors: [color])
|
||||||
|
return image.withSymbolConfiguration(configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeichnet den Akku in `rect`.
|
||||||
|
///
|
||||||
|
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
|
||||||
|
/// kritischem Ladestand weicht die Füllung davon ab — Farbe ist in der
|
||||||
|
/// Menüleiste ein Signal, keine Dekoration.
|
||||||
|
public static func draw(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
|
||||||
|
fadePhase: Double = 0,
|
||||||
|
color: NSColor, in rect: CGRect) {
|
||||||
|
let capWidth: CGFloat = 2
|
||||||
|
let body = CGRect(x: rect.minX, y: rect.minY,
|
||||||
|
width: rect.width - capWidth - 1, height: rect.height)
|
||||||
|
|
||||||
|
// Rahmen — zurückgenommen, damit die Füllung die Aussage trägt.
|
||||||
|
let outline = NSBezierPath(roundedRect: body.insetBy(dx: 0.5, dy: 0.5),
|
||||||
|
xRadius: 3, yRadius: 3)
|
||||||
|
outline.lineWidth = 1
|
||||||
|
color.withAlphaComponent(0.45).setStroke()
|
||||||
|
outline.stroke()
|
||||||
|
|
||||||
|
// Der Knubbel am Pluspol.
|
||||||
|
let cap = CGRect(x: body.maxX + 1, y: rect.midY - 2.5, width: capWidth, height: 5)
|
||||||
|
color.withAlphaComponent(0.45).setFill()
|
||||||
|
NSBezierPath(roundedRect: cap, xRadius: 1, yRadius: 1).fill()
|
||||||
|
|
||||||
|
// Die Füllung.
|
||||||
|
let inner = body.insetBy(dx: 2, dy: 2)
|
||||||
|
let width = fillWidth(charge: charge, inner: inner.width)
|
||||||
|
if width > 0 {
|
||||||
|
let fill = CGRect(x: inner.minX, y: inner.minY,
|
||||||
|
width: width, height: inner.height)
|
||||||
|
(isLow(charge: charge, isCharging: isCharging)
|
||||||
|
? NSColor.systemRed : color).setFill()
|
||||||
|
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Zeichen wird **ausgestanzt**, nicht daraufgelegt.
|
||||||
|
//
|
||||||
|
// `destinationOut` nimmt weg, was vorher gezeichnet wurde — Füllung und
|
||||||
|
// Rahmen. Übrig bleibt ein Loch in Blitz- bzw. Steckerform, durch das
|
||||||
|
// die Menüleiste scheint. Genau so sieht der von macOS aus, und es
|
||||||
|
// funktioniert auf hellem wie dunklem Grund, ohne die Hintergrundfarbe
|
||||||
|
// zu kennen.
|
||||||
|
guard let name = indicator(isCharging: isCharging,
|
||||||
|
isPluggedIn: isPluggedIn).symbolName,
|
||||||
|
let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
||||||
|
else { return }
|
||||||
|
let indicator = self.indicator(isCharging: isCharging, isPluggedIn: isPluggedIn)
|
||||||
|
let glyphHeight = glyphHeight(for: indicator, in: rect.height)
|
||||||
|
let glyphWidth = (glyph.size.width / glyph.size.height) * glyphHeight
|
||||||
|
let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
|
||||||
|
y: rect.midY - glyphHeight / 2,
|
||||||
|
width: glyphWidth, height: glyphHeight)
|
||||||
|
guard isCharging else {
|
||||||
|
// Das Pausenzeichen wird **ausgestanzt**: ein Loch in der Füllung.
|
||||||
|
//
|
||||||
|
// Weiß mit Rand funktioniert hier nicht — bei fast vollem Akku
|
||||||
|
// liegt es auf weißer Füllung, das Weiß verschwindet darin und
|
||||||
|
// übrig bleiben die dunklen Ränder als zwei Striche.
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Blitz blendet zwischen den beiden Fassungen über.
|
||||||
|
//
|
||||||
|
// Erst die dunkle voll deckend, dann die helle mit steigender
|
||||||
|
// Deckkraft darüber. So ist das Zeichen in jedem Moment vollständig
|
||||||
|
// gedeckt — zeichnete man beide anteilig, schiene dazwischen der
|
||||||
|
// Hintergrund durch und der Blitz wirkte blass.
|
||||||
|
let whiteness = boltWhiteness(phase: fadePhase)
|
||||||
|
if let dark = boltDark, let light = boltLight {
|
||||||
|
dark.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
|
||||||
|
light.draw(in: glyphRect, from: .zero, operation: .sourceOver,
|
||||||
|
fraction: whiteness)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let filled = tint(glyph, with: NSColor(white: whiteness, alpha: 1)) {
|
||||||
|
filled.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - SwiftUI
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Derselbe Akku für die Kachel.
|
||||||
|
///
|
||||||
|
/// Dieselbe Form wie in der Menüleiste, damit nicht zwei verschiedene Akkus
|
||||||
|
/// in derselben App stehen. Der Blitz ist hier kein Ausstanzen, sondern eine
|
||||||
|
/// Maske — in SwiftUI ist das der geradere Weg zum selben Bild.
|
||||||
|
public struct BatteryGlyphView: View {
|
||||||
|
private let charge: Double
|
||||||
|
private let isCharging: Bool
|
||||||
|
private let height: CGFloat
|
||||||
|
|
||||||
|
private let isPluggedIn: Bool
|
||||||
|
|
||||||
|
public init(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
|
||||||
|
height: CGFloat = 14) {
|
||||||
|
self.charge = charge
|
||||||
|
self.isCharging = isCharging
|
||||||
|
self.isPluggedIn = isPluggedIn
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scale: CGFloat { height / BatteryGlyph.size.height }
|
||||||
|
private var width: CGFloat { BatteryGlyph.size.width * scale }
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 1 * scale) {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous)
|
||||||
|
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
|
||||||
|
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let inner = geometry.size.width - 4 * scale
|
||||||
|
RoundedRectangle(cornerRadius: 1.5 * scale, style: .continuous)
|
||||||
|
.fill(fillColor)
|
||||||
|
.frame(width: BatteryGlyph.fillWidth(charge: charge, inner: inner))
|
||||||
|
.padding(2 * scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
|
||||||
|
.overlay {
|
||||||
|
let indicator = BatteryGlyph.indicator(isCharging: isCharging,
|
||||||
|
isPluggedIn: isPluggedIn)
|
||||||
|
if let name = indicator.symbolName {
|
||||||
|
// `TimelineView` gibt die Zeit her, ohne dass hier ein
|
||||||
|
// Zeitgeber laufen muss.
|
||||||
|
let size = BatteryGlyph.glyphHeight(for: indicator, in: height)
|
||||||
|
if isCharging {
|
||||||
|
TimelineView(.animation) { context in
|
||||||
|
let phase = context.date.timeIntervalSinceReferenceDate
|
||||||
|
/ BatteryGlyph.fadeDuration
|
||||||
|
let whiteness = BatteryGlyph.boltWhiteness(phase: phase)
|
||||||
|
bolt(side: size, whiteness: whiteness)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: name)
|
||||||
|
.font(.system(size: size))
|
||||||
|
.blendMode(.destinationOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.compositingGroup()
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous)
|
||||||
|
.fill(.primary.opacity(0.45))
|
||||||
|
.frame(width: 2 * scale, height: 5 * scale)
|
||||||
|
}
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dieselben zwei Fassungen wie in der Menüleiste, ineinander geblendet.
|
||||||
|
@ViewBuilder
|
||||||
|
private func bolt(side: CGFloat, whiteness: Double) -> some View {
|
||||||
|
if let dark = BatteryGlyph.boltDark, let light = BatteryGlyph.boltLight {
|
||||||
|
ZStack {
|
||||||
|
Image(nsImage: dark).resizable().aspectRatio(contentMode: .fit)
|
||||||
|
Image(nsImage: light).resizable().aspectRatio(contentMode: .fit)
|
||||||
|
.opacity(whiteness)
|
||||||
|
}
|
||||||
|
.frame(width: side, height: side)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "bolt.fill")
|
||||||
|
.font(.system(size: side))
|
||||||
|
.foregroundStyle(Color(white: whiteness))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fillColor: Color {
|
||||||
|
BatteryGlyph.isLow(charge: charge, isCharging: isCharging) ? .red : .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Packages/OnyxKit/Sources/MetricsProvider/BatterySymbol.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Das Akkusymbol zum jeweiligen Zustand.
|
||||||
|
///
|
||||||
|
/// Ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist Dekoration und
|
||||||
|
/// keine Anzeige — es beantwortet die eine Frage nicht, für die es da ist. Wer
|
||||||
|
/// in der Menüleiste nur das Symbol stehen hat, will es auf einen Blick lesen
|
||||||
|
/// können, ohne die Zahl daneben.
|
||||||
|
public enum BatterySymbol {
|
||||||
|
|
||||||
|
/// Kein Akku da — oder keiner zu lesen.
|
||||||
|
public static let unavailable = "batteryblock.slash"
|
||||||
|
|
||||||
|
/// Der Blitz beim Laden — **getrennt** vom Füllstand.
|
||||||
|
///
|
||||||
|
/// Nachgemessen, nicht angenommen: von den kombinierten Symbolen existiert
|
||||||
|
/// allein `battery.100percent.bolt`. Für 0, 25, 50 und 75 Prozent gibt es
|
||||||
|
/// keine Blitz-Variante, und `battery.slash` gibt es auch nicht.
|
||||||
|
///
|
||||||
|
/// Den Füllstand beim Laden gegen einen Blitz einzutauschen wäre die
|
||||||
|
/// falsche Wahl: dass geladen wird, sieht man am Netzstecker; **wie weit**
|
||||||
|
/// es ist, sieht man nirgends sonst. Also bleibt der Füllstand, und der
|
||||||
|
/// Blitz kommt als kleines Zeichen darüber.
|
||||||
|
public static let chargingBolt = "bolt.fill"
|
||||||
|
|
||||||
|
/// SF Symbols kennt fünf Füllstufen. Gerundet wird zur nächsten: 60 % soll
|
||||||
|
/// nicht halbleer aussehen und 40 % nicht dreiviertel voll.
|
||||||
|
private static let steps = [0, 25, 50, 75, 100]
|
||||||
|
|
||||||
|
public static func name(charge: Double) -> String {
|
||||||
|
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
|
||||||
|
let step = steps.min { abs($0 - percent) < abs($1 - percent) } ?? 0
|
||||||
|
return "battery.\(step)percent"
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Die CPU-Zeitzähler eines Kerns, wie `host_processor_info` sie liefert.
|
||||||
|
///
|
||||||
|
/// Es sind **Summen seit dem Start**, keine Momentanwerte. Die Auslastung
|
||||||
|
/// ergibt sich erst aus der Differenz zweier Abtastungen.
|
||||||
|
public struct CPUTicks: Equatable, Sendable {
|
||||||
|
public let user: UInt32
|
||||||
|
public let system: UInt32
|
||||||
|
public let idle: UInt32
|
||||||
|
public let nice: UInt32
|
||||||
|
|
||||||
|
public init(user: UInt32, system: UInt32, idle: UInt32, nice: UInt32) {
|
||||||
|
self.user = user
|
||||||
|
self.system = system
|
||||||
|
self.idle = idle
|
||||||
|
self.nice = nice
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auslastung zwischen zwei Abtastungen, 0 bis 1.
|
||||||
|
public static func usage(from previous: CPUTicks, to current: CPUTicks) -> Double {
|
||||||
|
// `&-` statt `-`: die Zähler sind 32 Bit breit und laufen nach gut
|
||||||
|
// 400 Tagen über. Ein gewöhnlicher Abzug stürzt dabei ab oder liefert
|
||||||
|
// einen absurden Sprung; die überlaufende Subtraktion ergibt genau die
|
||||||
|
// richtige Differenz.
|
||||||
|
let user = UInt64(current.user &- previous.user)
|
||||||
|
let system = UInt64(current.system &- previous.system)
|
||||||
|
let nice = UInt64(current.nice &- previous.nice)
|
||||||
|
let idle = UInt64(current.idle &- previous.idle)
|
||||||
|
|
||||||
|
let busy = user + system + nice
|
||||||
|
let total = busy + idle
|
||||||
|
guard total > 0 else { return 0 }
|
||||||
|
|
||||||
|
return min(max(Double(busy) / Double(total), 0), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie angespannt die Speicherlage ist.
|
||||||
|
public enum MemoryPressure: Equatable, Sendable {
|
||||||
|
case normal
|
||||||
|
case warning
|
||||||
|
case critical
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - free: Anteil wirklich freien Speichers, 0 bis 1.
|
||||||
|
/// - compressed: Anteil komprimierten Speichers, 0 bis 1.
|
||||||
|
/// - swapUsed: belegter Auslagerungsspeicher in Byte.
|
||||||
|
public static func classify(free: Double, compressed: Double, swapUsed: UInt64) -> MemoryPressure {
|
||||||
|
// Auslagerung schlägt alles andere: sobald sie läuft, bremst der
|
||||||
|
// Rechner spürbar — auch wenn die Freispeicheranzeige harmlos aussieht.
|
||||||
|
// Ein bisschen Swap hat macOS fast immer angelegt, deshalb erst ab
|
||||||
|
// einem halben Gigabyte.
|
||||||
|
if swapUsed > 512 * 1024 * 1024 { return .critical }
|
||||||
|
if free < 0.10 || compressed > 0.20 { return .warning }
|
||||||
|
return .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Netzwerkdurchsatz aus zwei Zählerständen.
|
||||||
|
public enum Throughput {
|
||||||
|
|
||||||
|
/// Byte pro Sekunde zwischen zwei Abtastungen.
|
||||||
|
///
|
||||||
|
/// Die Normierung auf die tatsächlich verstrichene Zeit ist der Kern: die
|
||||||
|
/// Menüleisten-Module tasten je nach Einstellung alle 1 bis 10 Sekunden ab.
|
||||||
|
/// Ohne sie zeigte ein Modul mit 5 Sekunden Intervall das Fünffache an.
|
||||||
|
public static func rate(previous: UInt64, current: UInt64, elapsed: TimeInterval) -> Double {
|
||||||
|
guard elapsed > 0 else { return 0 }
|
||||||
|
|
||||||
|
// Zähler kleiner als vorher heißt: er wurde zurückgesetzt. Das passiert
|
||||||
|
// bei jedem Interface-Wechsel — WLAN aus, VPN an — und beim Neustart
|
||||||
|
// des Dienstes.
|
||||||
|
//
|
||||||
|
// Ein Überlauf wäre die andere denkbare Erklärung, kommt hier aber
|
||||||
|
// nicht vor: `if_data64` liefert 64-Bit-Zähler, die bei 10 Gbit/s
|
||||||
|
// erst nach rund 470 Jahren umlaufen. Der Versuch, beides zu
|
||||||
|
// unterscheiden, produziert nur den Ausreißer, den er verhindern soll —
|
||||||
|
// deshalb null.
|
||||||
|
guard current >= previous else { return 0 }
|
||||||
|
|
||||||
|
return Double(current - previous) / elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „2,4 MB/s" statt „2400000".
|
||||||
|
public static func formatted(_ bytesPerSecond: Double) -> String {
|
||||||
|
let formatter = ByteCountFormatter()
|
||||||
|
formatter.countStyle = .binary
|
||||||
|
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||||
|
formatter.zeroPadsFractionDigits = false
|
||||||
|
// Ohne das steht bei null „Zero KB/s" statt „0 KB/s" — in einer Spalte
|
||||||
|
// aus Zahlen fällt ein Wort unangenehm auf und ändert die Breite.
|
||||||
|
formatter.allowsNonnumericFormatting = false
|
||||||
|
return formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) + "/s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheitliche Zahlendarstellung für alle Messwerte.
|
||||||
|
public enum MetricFormat {
|
||||||
|
|
||||||
|
/// Ganzzahlig. Nachkommastellen bei der Auslastung täuschen eine
|
||||||
|
/// Genauigkeit vor, die die Messung nicht hat — und sie zappeln im
|
||||||
|
/// Sekundentakt, was die Anzeige unruhig macht.
|
||||||
|
public static func percent(_ fraction: Double) -> String {
|
||||||
|
"\(Int((min(max(fraction, 0), 1) * 100).rounded())) %"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ohne Leerzeichen — nur für die Menüleiste.
|
||||||
|
///
|
||||||
|
/// Dort ist die Breite fest für „100 %" reserviert, damit nichts springt,
|
||||||
|
/// wenn der Wert eine Stelle mehr bekommt. Das Leerzeichen kostet in dieser
|
||||||
|
/// Reservierung drei Punkte je Element, und in der Menüleiste ist Platz
|
||||||
|
/// das knappste Gut. Im Panel und in den Popovern bleibt es stehen.
|
||||||
|
public static func percentCompact(_ fraction: Double) -> String {
|
||||||
|
"\(Int((min(max(fraction, 0), 1) * 100).rounded()))%"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func temperature(_ celsius: Double) -> String {
|
||||||
|
"\(Int(celsius.rounded()))°"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func watts(_ value: Double) -> String {
|
||||||
|
String(format: "%.1f W", max(value, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Was mit dem Akku gerade geschieht — in Worten statt in Symbolen.
|
||||||
|
///
|
||||||
|
/// „Am Netz" allein beantwortet die Frage nicht, die man hat, wenn der
|
||||||
|
/// Ladestand stehen bleibt: lädt es gerade nicht, weil das Limit erreicht ist,
|
||||||
|
/// oder stimmt etwas nicht?
|
||||||
|
public enum ChargeActivity: Equatable, Sendable {
|
||||||
|
case charging
|
||||||
|
/// Angehalten, weil das eingestellte Ladelimit erreicht ist.
|
||||||
|
case pausedAtLimit(Int)
|
||||||
|
/// Am Netz, lädt aber nicht — Grund unbekannt.
|
||||||
|
case pluggedNotCharging
|
||||||
|
case onBattery
|
||||||
|
|
||||||
|
/// Wie weit unter dem Limit noch als „am Limit" gilt.
|
||||||
|
///
|
||||||
|
/// Der Ladestand pendelt um die Grenze. Auf das Prozent genau zu
|
||||||
|
/// vergleichen hieße, den Grund die halbe Zeit zu verschweigen.
|
||||||
|
public static let limitTolerance = 2
|
||||||
|
|
||||||
|
public static func resolve(isCharging: Bool, isPluggedIn: Bool,
|
||||||
|
charge: Double, limit: Int?) -> ChargeActivity {
|
||||||
|
if isCharging { return .charging }
|
||||||
|
guard isPluggedIn else { return .onBattery }
|
||||||
|
|
||||||
|
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
|
||||||
|
// Ein Limit von hundert ist keines.
|
||||||
|
if let limit, limit < 100, percent >= limit - limitTolerance {
|
||||||
|
return .pausedAtLimit(limit)
|
||||||
|
}
|
||||||
|
// Steht das Laden aus einem anderen Grund — etwa weil macOS den Akku
|
||||||
|
// schont —, ist das der ehrliche Befund. Einen falschen Grund zu
|
||||||
|
// nennen wäre schlimmer als keinen.
|
||||||
|
return .pluggedNotCharging
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxHelperProtocol
|
||||||
|
|
||||||
|
/// Wer das Ladelimit **lesen** kann.
|
||||||
|
///
|
||||||
|
/// Nur lesen, und das ist eine gemessene Grenze, keine Bequemlichkeit: der
|
||||||
|
/// SMC lehnt Schreibzugriffe auf `CHLT` ab. Der Helfer hat es versucht und
|
||||||
|
/// `false` gemeldet, der Wert blieb unverändert. macOS setzt das Limit
|
||||||
|
/// offenbar auf einem anderen Weg als über den SMC-Userclient.
|
||||||
|
///
|
||||||
|
/// Ein Regler, der sich ziehen lässt und dann zurückspringt, ist schlimmer als
|
||||||
|
/// gar keiner — er behauptet eine Fähigkeit, die nicht existiert.
|
||||||
|
@MainActor
|
||||||
|
public protocol ChargeLimitReading: AnyObject {
|
||||||
|
/// Das eingestellte Limit in Prozent, `nil` wenn keines gilt.
|
||||||
|
var chargeLimit: Int? { get }
|
||||||
|
/// Ob der Wert überhaupt gelesen werden kann — ohne Helfer nicht.
|
||||||
|
var canReadCharge: Bool { get }
|
||||||
|
func refreshChargeLimit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeigt das Ladelimit und führt dorthin, wo man es ändern kann.
|
||||||
|
public struct ChargeLimitControl: View {
|
||||||
|
|
||||||
|
private let control: any ChargeLimitReading
|
||||||
|
/// Im Popover ist Platz für den Hinweis, in der Kachel nicht.
|
||||||
|
private let roomy: Bool
|
||||||
|
|
||||||
|
public init(control: any ChargeLimitReading, roomy: Bool) {
|
||||||
|
self.control = control
|
||||||
|
self.roomy = roomy
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("charge.limit", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
|
if let limit = control.chargeLimit, limit < ChargeSafety.noLimit {
|
||||||
|
Text(verbatim: "\(limit) %")
|
||||||
|
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
|
} else if control.canReadCharge {
|
||||||
|
Text("charge.limit.off", bundle: .module)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
} else {
|
||||||
|
Text(verbatim: "–")
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if roomy {
|
||||||
|
// Kein Regler, sondern der Weg dorthin, wo es wirklich geht.
|
||||||
|
Button {
|
||||||
|
NSWorkspace.shared.open(
|
||||||
|
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.forward.app")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(Text("charge.limit.inSystemSettings", bundle: .module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { control.refreshChargeLimit() }
|
||||||
|
}
|
||||||
|
}
|
||||||
1064
Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxMenuBar
|
||||||
|
|
||||||
|
/// Ein Menüleisten-Modul je Hardwaregröße.
|
||||||
|
///
|
||||||
|
/// Teilt sich das Modell — und damit die Messschleife — mit dem Panel-Widget
|
||||||
|
/// derselben Größe. Wer CPU im Panel **und** in der Menüleiste zeigt, bekommt
|
||||||
|
/// trotzdem nur eine Messung.
|
||||||
|
@MainActor
|
||||||
|
public final class MetricMenuBarModule: MenuBarModule {
|
||||||
|
|
||||||
|
public let id: String
|
||||||
|
public var displayName: String {
|
||||||
|
String(localized: .init(metric.localizationKey), bundle: .module)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let metric: MetricKind
|
||||||
|
private let model: MetricsModel
|
||||||
|
private var token: UUID?
|
||||||
|
private var view: MetricStatusView?
|
||||||
|
private var observation: (any NSObjectProtocol)?
|
||||||
|
|
||||||
|
public init(metric: MetricKind, model: MetricsModel) {
|
||||||
|
self.metric = metric
|
||||||
|
self.model = model
|
||||||
|
self.id = "metric.\(metric.rawValue)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurz genug für die Menüleiste. „Arbeitsspeicher" wäre dort ein Balken.
|
||||||
|
public var shortLabel: String {
|
||||||
|
switch metric {
|
||||||
|
case .cpu: "CPU"
|
||||||
|
case .gpu: "GPU"
|
||||||
|
case .memory: "RAM"
|
||||||
|
case .battery: String(localized: "menubar.short.battery", bundle: .module)
|
||||||
|
// Der gewählte Sensor gibt sein eigenes Kürzel — „Temp" neben einer
|
||||||
|
// Zahl sagt nicht, welcher Punkt gemeint ist.
|
||||||
|
case .temperature: model.selectedSensorName
|
||||||
|
?? String(localized: "menubar.short.temperature", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||||
|
let view = MetricStatusView(metric: metric, presentation: presentation,
|
||||||
|
label: shortLabel)
|
||||||
|
view.sensorCelsius = model.selectedSensorCelsius
|
||||||
|
view.sensorCelsius = model.selectedSensorCelsius
|
||||||
|
view.update(snapshot: model.snapshot, history: model.series(metric),
|
||||||
|
mode: model.displayMode(for: metric))
|
||||||
|
self.view = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makePopoverView() -> AnyView {
|
||||||
|
AnyView(MetricPopoverContent(model: model, metric: metric))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func activate() {
|
||||||
|
guard token == nil else { return }
|
||||||
|
token = model.addConsumer(interval: 2)
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deactivate() {
|
||||||
|
if let token { model.removeConsumer(token) }
|
||||||
|
token = nil
|
||||||
|
observation = nil
|
||||||
|
view = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `withObservationTracking` meldet sich **einmal** und muss danach neu
|
||||||
|
/// eingerichtet werden — sonst friert die Anzeige nach der ersten Messung ein.
|
||||||
|
private func startObserving() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = model.snapshot
|
||||||
|
_ = model.displayModes // auch auf den Moduswechsel reagieren
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, token != nil else { return }
|
||||||
|
view?.update(snapshot: model.snapshot, history: model.series(metric),
|
||||||
|
mode: model.displayMode(for: metric))
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeichnet den Messwert in die Menüleiste.
|
||||||
|
///
|
||||||
|
/// Eigene `NSView` statt SwiftUI: die Menüleiste verlangt eine feste Breite je
|
||||||
|
/// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit
|
||||||
|
/// SwiftUI müsste man diese Breite doppelt führen — einmal für das Layout und
|
||||||
|
/// einmal für `NSStatusItem.length`.
|
||||||
|
final class MetricStatusView: NSView, WidthReporting {
|
||||||
|
|
||||||
|
private let metric: MetricKind
|
||||||
|
private let presentation: MenuBarPresentation
|
||||||
|
private var snapshot = MetricsSnapshot()
|
||||||
|
private var history: [Double] = []
|
||||||
|
private var mode: MetricDisplayMode = .usage
|
||||||
|
|
||||||
|
private let label: String
|
||||||
|
|
||||||
|
init(metric: MetricKind, presentation: MenuBarPresentation, label: String) {
|
||||||
|
self.metric = metric
|
||||||
|
self.presentation = presentation
|
||||||
|
self.label = label
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// Läuft nur, solange geladen wird — nur dann bewegt sich der Blitz.
|
||||||
|
///
|
||||||
|
/// 30 Bilder je Sekunde reichen für ein weiches Faden und kosten deutlich
|
||||||
|
/// weniger als die 60, die man reflexhaft nähme. Steht das Ladekabel nicht
|
||||||
|
/// drin, läuft gar nichts.
|
||||||
|
private var fadeTimer: Timer?
|
||||||
|
private var fadePhase: Double = 0
|
||||||
|
|
||||||
|
private var shownWidth: CGFloat = MenuBarText.minimumWidth
|
||||||
|
|
||||||
|
/// Der breiteste Wert, der vorkommen kann.
|
||||||
|
///
|
||||||
|
/// **Fest**, nicht am aktuellen Wert bemessen: ein Element, das bei
|
||||||
|
/// „431" breiter wird als bei „14", schiebt alle Symbole rechts davon.
|
||||||
|
///
|
||||||
|
/// Der frühere Fehler war nicht die feste Breite, sondern der falsche
|
||||||
|
/// Bezugswert — gezeichnet wird „63°", bemessen wurde „100 °C".
|
||||||
|
private var reference: String {
|
||||||
|
switch (metric, mode) {
|
||||||
|
case (.temperature, _), (_, .temperature): "100°"
|
||||||
|
default: "100%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
NSSize(width: shownWidth, height: 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Platz für das, was **gerade** dasteht.
|
||||||
|
///
|
||||||
|
/// Auf die breiteste denkbare Zeichenfolge zu dimensionieren war zu teuer:
|
||||||
|
/// „Temp 53°" stand in einer Box für „Temp 100 °C", und in der Menüleiste
|
||||||
|
/// ist Platz das knappste Gut. `AdaptiveWidth` nimmt dem die Unruhe —
|
||||||
|
/// sofort wachsen, zögernd schrumpfen.
|
||||||
|
private var width: CGFloat {
|
||||||
|
switch presentation {
|
||||||
|
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
|
||||||
|
case .graph: 28
|
||||||
|
case .bars: barsWidth
|
||||||
|
case .value: MenuBarText.width(for: reference)
|
||||||
|
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
|
||||||
|
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
|
||||||
|
case .symbolAndValue:
|
||||||
|
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Balken je Kern braucht Platz für jeden Kern. Bei fünfzehn ist eine
|
||||||
|
/// feste Breite entweder zu eng oder überall sonst zu weit.
|
||||||
|
private var barsWidth: CGFloat {
|
||||||
|
let count = (metric == .cpu && mode == .usage) ? max(snapshot.cpu.perCore.count, 1) : 1
|
||||||
|
guard count > 1 else { return MenuBarText.minimumWidth }
|
||||||
|
return min(CGFloat(count) * 2 + CGFloat(count - 1) * 1 + 4, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was tatsächlich dasteht. Bei den Sensoren der gewählte Punkt statt des
|
||||||
|
/// stillen Maximums über alle.
|
||||||
|
private var displayValue: String {
|
||||||
|
if metric == .temperature, let celsius = sensorCelsius {
|
||||||
|
return MetricFormat.temperature(celsius)
|
||||||
|
}
|
||||||
|
// In der Menüleiste ohne Leerzeichen vor dem Prozentzeichen — siehe
|
||||||
|
// `MetricFormat.percentCompact`.
|
||||||
|
let value = MetricSummary.value(metric, snapshot, mode: mode)
|
||||||
|
return value.replacingOccurrences(of: " %", with: "%")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Meldet, wenn sich die nötige Breite geändert hat — der Controller stellt
|
||||||
|
/// das Element danach neu ein.
|
||||||
|
var onWidthChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
|
/// Der gewählte Sensor, falls einer gewählt ist.
|
||||||
|
var sensorCelsius: Double?
|
||||||
|
|
||||||
|
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.history = history
|
||||||
|
self.mode = mode
|
||||||
|
needsDisplay = true
|
||||||
|
|
||||||
|
updateFadeTimer()
|
||||||
|
|
||||||
|
let next = max(width, MenuBarText.minimumWidth)
|
||||||
|
guard next != shownWidth else { return }
|
||||||
|
shownWidth = next
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
onWidthChange?(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
// `labelColor` statt der Onyx-Farben: die Menüleiste ist mal hell, mal
|
||||||
|
// dunkel, und nur die Systemfarbe passt sich beidem an.
|
||||||
|
let color = NSColor.labelColor
|
||||||
|
|
||||||
|
switch presentation {
|
||||||
|
case .value:
|
||||||
|
drawText(displayValue, color: color)
|
||||||
|
case .symbol:
|
||||||
|
drawSymbol(color: color)
|
||||||
|
case .graph:
|
||||||
|
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
|
||||||
|
case .bars:
|
||||||
|
drawBars(color: color)
|
||||||
|
case .labelAndValue:
|
||||||
|
drawText("\(label) \(displayValue)", color: color)
|
||||||
|
|
||||||
|
case .symbolAndValue:
|
||||||
|
let glyph = symbolSlot
|
||||||
|
if metric == .battery {
|
||||||
|
drawBattery(color: color,
|
||||||
|
at: NSPoint(x: 0, y: bounds.midY - BatteryGlyph.size.height / 2))
|
||||||
|
} else if let image = Self.customGlyphs[metric] {
|
||||||
|
MenuBarText.drawTemplate(image, color: color,
|
||||||
|
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
||||||
|
width: glyph, height: 13))
|
||||||
|
} else {
|
||||||
|
MenuBarText.drawSymbol(currentSymbol, color: color,
|
||||||
|
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
||||||
|
width: glyph, height: 13))
|
||||||
|
}
|
||||||
|
drawText(displayValue, color: color,
|
||||||
|
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
|
||||||
|
width: bounds.width - glyph - MenuBarText.innerGap,
|
||||||
|
height: bounds.height))
|
||||||
|
|
||||||
|
case .valueAndGraph:
|
||||||
|
let split = bounds.width * 0.55
|
||||||
|
drawText(displayValue, color: color,
|
||||||
|
in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
|
||||||
|
drawGraph(color: color,
|
||||||
|
in: NSRect(x: split + 2, y: 5,
|
||||||
|
width: bounds.width - split - 4, height: bounds.height - 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) {
|
||||||
|
MenuBarText.draw(text, color: color, in: rect ?? bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigene Vorlagen für CPU und GPU — SF Symbols hat für beides nur den
|
||||||
|
/// gleichen Chip, und „cpu" neben „cpu.fill" ist kein Unterschied, den man
|
||||||
|
/// in der Menüleiste erkennt.
|
||||||
|
private static let customGlyphs: [MetricKind: NSImage] = {
|
||||||
|
var glyphs: [MetricKind: NSImage] = [:]
|
||||||
|
for (metric, name) in [(MetricKind.cpu, "chip"), (.gpu, "graphic-card")] {
|
||||||
|
guard let url = Bundle.main.url(forResource: name, withExtension: "svg"),
|
||||||
|
let image = NSImage(contentsOf: url) else { continue }
|
||||||
|
glyphs[metric] = image
|
||||||
|
}
|
||||||
|
return glyphs
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func drawSymbol(color: NSColor) {
|
||||||
|
if metric == .battery {
|
||||||
|
drawBattery(color: color,
|
||||||
|
at: NSPoint(x: bounds.midX - BatteryGlyph.size.width / 2,
|
||||||
|
y: bounds.midY - BatteryGlyph.size.height / 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let side: CGFloat = 15
|
||||||
|
let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
|
||||||
|
width: side, height: side)
|
||||||
|
if let glyph = Self.customGlyphs[metric] {
|
||||||
|
MenuBarText.drawTemplate(glyph, color: color, in: rect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
|
||||||
|
MenuBarText.drawSymbol(currentSymbol, color: color,
|
||||||
|
in: NSRect(x: bounds.midX - width / 2,
|
||||||
|
y: bounds.midY - side / 2,
|
||||||
|
width: width, height: side))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Akku wird selbst gezeichnet — mit stufenloser Füllung und dem Blitz
|
||||||
|
/// mitten drin, so wie macOS es zeigt.
|
||||||
|
private func drawBattery(color: NSColor, at origin: NSPoint) {
|
||||||
|
guard let battery = snapshot.battery else {
|
||||||
|
MenuBarText.drawSymbol(BatterySymbol.unavailable, color: color,
|
||||||
|
in: NSRect(x: origin.x, y: origin.y,
|
||||||
|
width: 15, height: 15))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging,
|
||||||
|
isPluggedIn: battery.isPluggedIn, fadePhase: fadePhase,
|
||||||
|
color: color,
|
||||||
|
in: CGRect(origin: origin, size: BatteryGlyph.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Zeitgeber fürs Faden — an und aus mit dem Ladevorgang.
|
||||||
|
private func updateFadeTimer() {
|
||||||
|
let shouldFade = metric == .battery && snapshot.battery?.isCharging == true
|
||||||
|
if shouldFade, fadeTimer == nil {
|
||||||
|
let timer = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self else { return }
|
||||||
|
self.fadePhase += (1.0 / 30) / BatteryGlyph.fadeDuration
|
||||||
|
self.needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
fadeTimer = timer
|
||||||
|
} else if !shouldFade, fadeTimer != nil {
|
||||||
|
fadeTimer?.invalidate()
|
||||||
|
fadeTimer = nil
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene
|
||||||
|
/// Maße; alles andere ist ein SF-Symbol.
|
||||||
|
private var symbolSlot: CGFloat {
|
||||||
|
if metric == .battery { return BatteryGlyph.size.width }
|
||||||
|
// Eigene Vorlagen sind quadratisch; ihre Breite folgt der Höhe.
|
||||||
|
if Self.customGlyphs[metric] != nil { return 13 }
|
||||||
|
return MenuBarText.symbolWidth(currentSymbol, height: 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Symbol zum **Zustand**, nicht zur Metrik.
|
||||||
|
///
|
||||||
|
/// Beim Akku ändert es sich mit dem Ladestand und zeigt beim Laden einen
|
||||||
|
/// Blitz — ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist
|
||||||
|
/// Dekoration. Die übrigen Messgrößen haben nichts, was sich sinnvoll im
|
||||||
|
/// Glyph abbilden ließe.
|
||||||
|
private var currentSymbol: String {
|
||||||
|
guard metric == .battery else { return metric.symbolName }
|
||||||
|
guard let battery = snapshot.battery else { return BatterySymbol.unavailable }
|
||||||
|
return BatterySymbol.name(charge: battery.charge)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawGraph(color: NSColor, in rect: NSRect) {
|
||||||
|
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||||
|
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
|
||||||
|
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
|
||||||
|
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
|
||||||
|
// Breite.
|
||||||
|
let xs = GraphLayout.positions(count: history.count,
|
||||||
|
capacity: MetricsModel.historyLength, in: rect)
|
||||||
|
guard !xs.isEmpty else { return }
|
||||||
|
|
||||||
|
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
|
||||||
|
context.setLineWidth(1)
|
||||||
|
context.setLineJoin(.round)
|
||||||
|
for (index, x) in xs.enumerated() {
|
||||||
|
let value = history[history.count - xs.count + index]
|
||||||
|
let point = CGPoint(x: x,
|
||||||
|
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
|
||||||
|
index == 0 ? context.move(to: point) : context.addLine(to: point)
|
||||||
|
}
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Balken je Kern. Bei fünfzehn Kernen ist das kein Diagramm mehr,
|
||||||
|
/// sondern ein Muster — aber genau daran erkennt man auf einen Blick, ob
|
||||||
|
/// eine einzelne Last läuft oder alles ausgelastet ist.
|
||||||
|
private func drawBars(color: NSColor) {
|
||||||
|
// Bei der CPU-Auslastung ein Balken je Kern; sonst — auch bei der
|
||||||
|
// CPU-Temperatur — ein einzelner Balken.
|
||||||
|
let values = (metric == .cpu && mode == .usage)
|
||||||
|
? snapshot.cpu.perCore
|
||||||
|
: [MetricSummary.fraction(metric, snapshot, mode: mode)]
|
||||||
|
guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
|
||||||
|
|
||||||
|
let area = bounds.insetBy(dx: 2, dy: 5)
|
||||||
|
let gap: CGFloat = values.count > 4 ? 1 : 2
|
||||||
|
let width = (area.width - gap * CGFloat(values.count - 1)) / CGFloat(values.count)
|
||||||
|
|
||||||
|
for (index, value) in values.enumerated() {
|
||||||
|
let height = max(area.height * CGFloat(min(max(value, 0), 1)), 1)
|
||||||
|
let rect = NSRect(x: area.minX + CGFloat(index) * (width + gap),
|
||||||
|
y: area.minY, width: width, height: height)
|
||||||
|
context.setFillColor(color.withAlphaComponent(0.85).cgColor)
|
||||||
|
context.fill(rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MenuBarPresentation {
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension MetricSummary {
|
||||||
|
/// Der Messwert als Anteil von 0 bis 1 — für Balken und Graphen.
|
||||||
|
static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Double {
|
||||||
|
switch metric {
|
||||||
|
case .cpu: snapshot.cpu.total
|
||||||
|
case .gpu: snapshot.gpu ?? 0
|
||||||
|
case .memory: snapshot.memory.usedFraction
|
||||||
|
case .battery: snapshot.battery?.charge ?? 0
|
||||||
|
case .temperature: (snapshot.sensors.map(\.celsius).max() ?? 0) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
443
Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
|
||||||
|
/// Die Detailansichten hinter den Menüleisten-Symbolen.
|
||||||
|
///
|
||||||
|
/// Hier ist ausdrücklich Platz — anders als in der Menüleiste, wo jede Zahl um
|
||||||
|
/// Breite kämpft. Ein Popover, das nur den Wert größer wiederholt, den man
|
||||||
|
/// gerade angeklickt hat, ist verschenkt.
|
||||||
|
@MainActor
|
||||||
|
struct MetricPopoverContent: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
|
||||||
|
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
|
||||||
|
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
switch metric {
|
||||||
|
case .cpu, .gpu: ProcessorDetail(model: model, metric: metric)
|
||||||
|
case .memory: MemoryDetail(snapshot: model.snapshot)
|
||||||
|
case .battery: BatteryDetail(snapshot: model.snapshot, control: model.chargeControl)
|
||||||
|
case .temperature: SensorDetail(snapshot: model.snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(width: 280)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bausteine
|
||||||
|
|
||||||
|
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
|
||||||
|
/// lange Werteliste.
|
||||||
|
private struct SectionTitle: View {
|
||||||
|
let text: LocalizedStringKey
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text, bundle: .module)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Row: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
var emphasis: Color?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label).font(.callout)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text(value)
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(emphasis ?? .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Mitte des **Rings** — nicht die des Rings samt Beschriftung.
|
||||||
|
///
|
||||||
|
/// Ein `HStack` zentriert seine Inhalte über die volle Höhe. Die Beschriftung
|
||||||
|
/// unter dem Ring gehört aber dazu, und dadurch rutscht alles daneben um deren
|
||||||
|
/// halbe Höhe nach unten. Bei einer einzelnen Zeile fällt das auf: sie steht
|
||||||
|
/// dann sichtbar tiefer als die Mitte, auf die man schaut.
|
||||||
|
private extension VerticalAlignment {
|
||||||
|
enum DialCenterID: AlignmentID {
|
||||||
|
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||||
|
context[VerticalAlignment.center]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static let dialCenter = VerticalAlignment(DialCenterID.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Ring mit Zahl in der Mitte.
|
||||||
|
private struct Dial: View {
|
||||||
|
let value: Double
|
||||||
|
let caption: String
|
||||||
|
let text: String
|
||||||
|
/// Ohne Angabe die Akzentfarbe. Als Standardwert ginge sie nicht: der
|
||||||
|
/// wird außerhalb des Hauptthreads erzeugt, die Farbe liegt aber dort.
|
||||||
|
var tint: Color?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
|
||||||
|
// sah aus, als gehörte sie noch hinein.
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(.quaternary, lineWidth: 5)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: min(max(value, 0), 1))
|
||||||
|
.stroke(tint ?? Onyx.Color.accent,
|
||||||
|
style: .init(lineWidth: 5, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit())
|
||||||
|
}
|
||||||
|
.frame(width: 62, height: 62)
|
||||||
|
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
||||||
|
Text(caption).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prozessor
|
||||||
|
|
||||||
|
private struct ProcessorDetail: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let snapshot = model.snapshot
|
||||||
|
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
|
||||||
|
caption: metric == .cpu ? "CPU" : "GPU",
|
||||||
|
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
|
||||||
|
: (snapshot.gpu ?? 0)))
|
||||||
|
if let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature {
|
||||||
|
Dial(value: celsius / 100,
|
||||||
|
caption: String(localized: "popover.temperature", bundle: .module),
|
||||||
|
text: MetricFormat.temperature(celsius),
|
||||||
|
tint: MetricSummary.temperatureColor(celsius))
|
||||||
|
}
|
||||||
|
if let fan = snapshot.fans.first {
|
||||||
|
Dial(value: fan.fraction,
|
||||||
|
caption: String(localized: "popover.fans", bundle: .module),
|
||||||
|
text: MetricFormat.percent(fan.fraction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
|
||||||
|
SectionTitle(text: "popover.perCore")
|
||||||
|
CoreGrid(values: model.snapshot.cpu.perCore)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle(text: "popover.power")
|
||||||
|
if let cpu = snapshot.power.cpu {
|
||||||
|
Row(label: String(localized: "popover.power.cpu", bundle: .module),
|
||||||
|
value: MetricFormat.watts(cpu))
|
||||||
|
}
|
||||||
|
if let display = snapshot.power.display {
|
||||||
|
Row(label: String(localized: "popover.power.display", bundle: .module),
|
||||||
|
value: MetricFormat.watts(display))
|
||||||
|
}
|
||||||
|
if let total = snapshot.power.total {
|
||||||
|
Row(label: String(localized: "popover.power.total", bundle: .module),
|
||||||
|
value: MetricFormat.watts(total), emphasis: .primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !snapshot.fans.isEmpty {
|
||||||
|
SectionTitle(text: "popover.fans")
|
||||||
|
ForEach(snapshot.fans) { fan in
|
||||||
|
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { model.displayMode(for: metric) },
|
||||||
|
set: { model.setDisplayMode($0, for: metric) })) {
|
||||||
|
Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage)
|
||||||
|
Text("metric.mode.temperature", bundle: .module).tag(MetricDisplayMode.temperature)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Kästchen je Kern statt einer Balkenreihe.
|
||||||
|
///
|
||||||
|
/// Bei fünfzehn Kernen wird eine Reihe unleserlich schmal. Als Raster bleibt
|
||||||
|
/// jeder Kern erkennbar, und man sieht auf einen Blick, ob eine einzelne Last
|
||||||
|
/// läuft oder alles arbeitet.
|
||||||
|
private struct CoreGrid: View {
|
||||||
|
let values: [Double]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8),
|
||||||
|
spacing: 3) {
|
||||||
|
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
||||||
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||||
|
.fill(Onyx.Color.accent.opacity(0.18))
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(height: max(14 * value, 1.5))
|
||||||
|
}
|
||||||
|
.frame(height: 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Speicher
|
||||||
|
|
||||||
|
private struct MemoryDetail: View {
|
||||||
|
let snapshot: MetricsSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let memory = snapshot.memory
|
||||||
|
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Dial(value: memory.usedFraction,
|
||||||
|
caption: String(localized: "popover.memory.used", bundle: .module),
|
||||||
|
text: MetricFormat.percent(memory.usedFraction),
|
||||||
|
tint: pressureColor)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
legend(Onyx.Color.accent, "popover.memory.app", memory.app)
|
||||||
|
legend(.purple, "popover.memory.wired", memory.wired)
|
||||||
|
legend(.teal, "popover.memory.compressed", memory.compressed)
|
||||||
|
legend(.gray, "popover.memory.free", memory.free)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle(text: "popover.memory.swap")
|
||||||
|
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
|
||||||
|
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
|
||||||
|
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
|
||||||
|
value: "\(bytes(memory.swapUsed)) / \(bytes(memory.swapTotal))",
|
||||||
|
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil)
|
||||||
|
|
||||||
|
if !snapshot.topMemoryProcesses.isEmpty {
|
||||||
|
SectionTitle(text: "popover.processes")
|
||||||
|
ForEach(snapshot.topMemoryProcesses) { process in
|
||||||
|
Row(label: process.name, value: bytes(process.bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pressureColor: Color {
|
||||||
|
switch snapshot.memory.pressure {
|
||||||
|
case .normal: Onyx.Color.positive
|
||||||
|
case .warning: Onyx.Color.warning
|
||||||
|
case .critical: Onyx.Color.critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle().fill(color).frame(width: 7, height: 7)
|
||||||
|
Text(key, bundle: .module).font(.caption)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(bytes(value)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bytes(_ value: UInt64) -> String {
|
||||||
|
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Akku
|
||||||
|
|
||||||
|
private struct BatteryDetail: View {
|
||||||
|
let snapshot: MetricsSnapshot
|
||||||
|
let control: (any ChargeLimitReading)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let battery = snapshot.battery {
|
||||||
|
HStack(alignment: .dialCenter, spacing: 14) {
|
||||||
|
Dial(value: battery.charge,
|
||||||
|
caption: String(localized: "metric.battery", bundle: .module),
|
||||||
|
text: MetricFormat.percent(battery.charge),
|
||||||
|
tint: battery.isCharging ? Onyx.Color.positive
|
||||||
|
: (battery.charge < 0.2 ? Onyx.Color.critical
|
||||||
|
: Onyx.Color.accent))
|
||||||
|
// Beides gehört neben den Ring: die Schätzung und der Grund
|
||||||
|
// dafür. „Am Netzteil" über dem Ring und „Am Netz, lädt nicht"
|
||||||
|
// darunter waren zweimal dieselbe Auskunft an zwei Stellen —
|
||||||
|
// und die zweite stand allein unter dem Diagramm, wo sie
|
||||||
|
// aussah, als gehörte sie zu dessen Beschriftung.
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let remaining = battery.timeRemaining {
|
||||||
|
Text(duration(remaining)).font(.title3.monospacedDigit())
|
||||||
|
Text(battery.isCharging ? "popover.battery.untilFull"
|
||||||
|
: "popover.battery.remaining",
|
||||||
|
bundle: .module)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else if !battery.isPluggedIn {
|
||||||
|
// Am Netz braucht es keine Entschuldigung für die
|
||||||
|
// fehlende Schätzung — die Zeile darunter sagt ohnehin,
|
||||||
|
// was los ist.
|
||||||
|
Text("popover.battery.calculating", bundle: .module)
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was gerade geschieht, in Worten. „Am Netz" allein
|
||||||
|
// beantwortet die Frage nicht, die man hat, wenn der
|
||||||
|
// Ladestand stehen bleibt.
|
||||||
|
activityLine(battery)
|
||||||
|
}
|
||||||
|
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Ladelimit steht hier und nicht in den Einstellungen: es ist
|
||||||
|
// eine Akkusache, und man greift danach, während man auf den
|
||||||
|
// Ladestand schaut.
|
||||||
|
if let control {
|
||||||
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
|
ChargeLimitControl(control: control, roomy: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle(text: "popover.battery.condition")
|
||||||
|
if let health = battery.health {
|
||||||
|
Row(label: String(localized: "popover.battery.health", bundle: .module),
|
||||||
|
value: MetricFormat.percent(health))
|
||||||
|
}
|
||||||
|
if let cycles = battery.cycleCount {
|
||||||
|
Row(label: String(localized: "popover.battery.cycles", bundle: .module),
|
||||||
|
value: "\(cycles)")
|
||||||
|
}
|
||||||
|
if let sensor = snapshot.sensors.first(where: { $0.key == "TB0T" }) {
|
||||||
|
Row(label: String(localized: "popover.temperature", bundle: .module),
|
||||||
|
value: MetricFormat.temperature(sensor.celsius))
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle(text: "popover.power")
|
||||||
|
if let watts = battery.watts {
|
||||||
|
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
|
||||||
|
: "popover.battery.draining",
|
||||||
|
bundle: .module),
|
||||||
|
value: MetricFormat.watts(abs(watts)), emphasis: .primary)
|
||||||
|
}
|
||||||
|
if let adapter = snapshot.power.adapter, adapter > 0 {
|
||||||
|
Row(label: String(localized: "popover.power.adapter", bundle: .module),
|
||||||
|
value: MetricFormat.watts(adapter))
|
||||||
|
}
|
||||||
|
if let voltage = snapshot.power.busVoltage {
|
||||||
|
Row(label: String(localized: "popover.battery.voltage", bundle: .module),
|
||||||
|
value: String(format: "%.2f V", voltage))
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
// Ehrlich benennen, was fehlt: das Ladelimit braucht Schreibzugriff
|
||||||
|
// auf den SMC und damit den privilegierten Helfer — und der Key ist
|
||||||
|
// auf diesem Modell noch nicht identifiziert.
|
||||||
|
Text("popover.battery.limitComing", bundle: .module)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else {
|
||||||
|
Text("popover.battery.none", bundle: .module).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func activityLine(_ battery: BatteryReading) -> some View {
|
||||||
|
let activity = ChargeActivity.resolve(isCharging: battery.isCharging,
|
||||||
|
isPluggedIn: battery.isPluggedIn,
|
||||||
|
charge: battery.charge,
|
||||||
|
limit: control?.chargeLimit)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: symbol(for: activity))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(tint(for: activity))
|
||||||
|
switch activity {
|
||||||
|
case .charging:
|
||||||
|
Text("popover.battery.charging", bundle: .module)
|
||||||
|
case .pausedAtLimit(let limit):
|
||||||
|
Text("popover.battery.pausedAtLimit \(limit)", bundle: .module)
|
||||||
|
case .pluggedNotCharging:
|
||||||
|
Text("popover.battery.notCharging", bundle: .module)
|
||||||
|
case .onBattery:
|
||||||
|
Text("popover.battery.onBattery", bundle: .module)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func symbol(for activity: ChargeActivity) -> String {
|
||||||
|
switch activity {
|
||||||
|
case .charging: "bolt.fill"
|
||||||
|
case .pausedAtLimit: "pause.fill"
|
||||||
|
case .pluggedNotCharging: "powerplug.fill"
|
||||||
|
case .onBattery: "battery.50percent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tint(for activity: ChargeActivity) -> Color {
|
||||||
|
// Farbe nur, wo sie etwas meldet: Laden ist ein Zustand, den man
|
||||||
|
// sucht. Alles andere bleibt zurückhaltend.
|
||||||
|
activity == .charging ? Onyx.Color.positive : .secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func duration(_ seconds: TimeInterval) -> String {
|
||||||
|
let total = Int(seconds / 60)
|
||||||
|
return total >= 60 ? "\(total / 60) h \(total % 60) min" : "\(total) min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sensoren
|
||||||
|
|
||||||
|
private struct SensorDetail: View {
|
||||||
|
let snapshot: MetricsSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SectionTitle(text: "popover.temperature")
|
||||||
|
ForEach(snapshot.sensors) { sensor in
|
||||||
|
HStack {
|
||||||
|
Text(sensor.name).font(.callout)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text(MetricFormat.temperature(sensor.celsius))
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
|
||||||
|
// Der Ring macht aus einer Zahl einen Vergleich: 45° neben 79°
|
||||||
|
// sieht man schneller als man es liest.
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: min(sensor.celsius / 100, 1))
|
||||||
|
.stroke(MetricSummary.temperatureColor(sensor.celsius),
|
||||||
|
style: .init(lineWidth: 2, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !snapshot.fans.isEmpty {
|
||||||
|
SectionTitle(text: "popover.fans")
|
||||||
|
ForEach(snapshot.fans) { fan in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(fan.name).font(.callout)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text("\(Int(fan.rpm)) U/min").font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
// Ohne Bezug sagt eine Drehzahl nichts: der Balken zeigt sie
|
||||||
|
// zwischen dem Minimum und Maximum der Firmware.
|
||||||
|
ProgressView(value: fan.fraction).controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("metric.fan.controlComing", bundle: .module)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
440
Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
import OnyxMenuBar
|
||||||
|
|
||||||
|
/// Ein Widget je Hardwaregröße. Alle teilen sich dasselbe Modell und damit
|
||||||
|
/// dieselbe Messschleife.
|
||||||
|
public struct MetricWidget: OnyxWidget {
|
||||||
|
public let id: String
|
||||||
|
public let metric: MetricKind
|
||||||
|
public var displayName: String {
|
||||||
|
String(localized: .init(metric.localizationKey), bundle: .module)
|
||||||
|
}
|
||||||
|
public var symbolName: String { metric.symbolName }
|
||||||
|
|
||||||
|
private let model: MetricsModel
|
||||||
|
|
||||||
|
public init(metric: MetricKind, model: MetricsModel) {
|
||||||
|
self.metric = metric
|
||||||
|
self.model = model
|
||||||
|
self.id = switch metric {
|
||||||
|
case .cpu: "cpu"
|
||||||
|
case .gpu: "gpu"
|
||||||
|
case .memory: "memory"
|
||||||
|
case .battery: "battery"
|
||||||
|
case .temperature: "sensors"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeView() -> AnyView {
|
||||||
|
AnyView(MetricWidgetView(model: model, metric: metric))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MetricKind {
|
||||||
|
var localizationKey: String {
|
||||||
|
switch self {
|
||||||
|
case .cpu: "metric.cpu"
|
||||||
|
case .gpu: "metric.gpu"
|
||||||
|
case .memory: "metric.memory"
|
||||||
|
case .battery: "metric.battery"
|
||||||
|
case .temperature: "metric.sensors"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MetricWidgetView: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
@State private var token: UUID?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
// Die Kachel ist hochkant: die Sensorliste hat Platz für Details,
|
||||||
|
// alles andere zeigt Zahl und Verlauf.
|
||||||
|
if metric == .temperature {
|
||||||
|
SensorList(snapshot: model.snapshot, detailed: true)
|
||||||
|
} else if metric == .battery {
|
||||||
|
BatteryTile(model: model)
|
||||||
|
} else {
|
||||||
|
WideMetric(model: model, metric: metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Bei offenem Panel schneller messen: dort sieht man die Zahl,
|
||||||
|
// und ein Wert, der nur alle zwei Sekunden springt, wirkt träge.
|
||||||
|
token = model.addConsumer(interval: 1)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if let token { model.removeConsumer(token) }
|
||||||
|
token = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bausteine
|
||||||
|
|
||||||
|
private struct CompactMetric: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
|
||||||
|
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: metric.symbolName)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Text(MetricSummary.value(metric, model.snapshot, mode: mode))
|
||||||
|
.font(Onyx.Font.metric)
|
||||||
|
.foregroundStyle(MetricSummary.color(metric, model.snapshot, mode: mode))
|
||||||
|
.animation(Onyx.Motion.value,
|
||||||
|
value: MetricSummary.value(metric, model.snapshot, mode: mode))
|
||||||
|
|
||||||
|
if let detail = MetricSummary.detail(metric, model.snapshot) {
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WideMetric: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein
|
||||||
|
// Verlauf über die volle Breite liest sich besser als einer, der sich
|
||||||
|
// die Zeile mit der Zahl teilt.
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
CompactMetric(model: model, metric: metric)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Sparkline(values: model.series(metric),
|
||||||
|
tint: MetricSummary.color(metric, model.snapshot,
|
||||||
|
mode: model.displayMode(for: metric)))
|
||||||
|
.frame(height: 46)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verlaufskurve. Bewusst ohne Achsen und Beschriftung: auf 90 × 40 Punkten
|
||||||
|
/// ist die Form die Information, nicht der Zahlenwert.
|
||||||
|
public struct Sparkline: View {
|
||||||
|
let values: [Double]
|
||||||
|
let tint: Color
|
||||||
|
/// Wie viele Werte der Verlauf fasst — die Schrittweite hängt daran, nicht
|
||||||
|
/// an der Anzahl der bisher vorhandenen.
|
||||||
|
let capacity: Int
|
||||||
|
|
||||||
|
public init(values: [Double], tint: Color, capacity: Int = MetricsModel.historyLength) {
|
||||||
|
self.values = values
|
||||||
|
self.tint = tint
|
||||||
|
self.capacity = capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let points = Array(values.suffix(capacity))
|
||||||
|
if points.count > 1 {
|
||||||
|
let size = geometry.size
|
||||||
|
// Die Schrittweite kommt aus der **vollen** Länge des Verlaufs,
|
||||||
|
// nicht aus der Anzahl vorhandener Werte. Frisch gestartet sind
|
||||||
|
// es zwei statt sechzig; daraus gerechnet spannte sich der Weg
|
||||||
|
// zwischen ihnen über die ganze Breite — ein riesiger Zickzack,
|
||||||
|
// der nach viel Bewegung aussah und nichts bedeutete.
|
||||||
|
let xs = GraphLayout.positions(count: points.count,
|
||||||
|
capacity: capacity,
|
||||||
|
in: CGRect(origin: .zero, size: size))
|
||||||
|
let coordinates = zip(xs, points).map { x, value in
|
||||||
|
CGPoint(x: x, y: size.height * (1 - CGFloat(min(max(value, 0), 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Path { path in
|
||||||
|
for (index, point) in coordinates.enumerated() {
|
||||||
|
index == 0 ? path.move(to: point) : path.addLine(to: point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dieselbe Kurve, unten geschlossen — die Fläche darunter gibt
|
||||||
|
// dem Verlauf Gewicht, ohne dass eine zweite Linie nötig wäre.
|
||||||
|
// Die Fläche beginnt am ersten Messwert, nicht am linken Rand:
|
||||||
|
// sonst zieht sich bei wenigen Werten ein Keil über die ganze
|
||||||
|
// Breite, obwohl dort noch nichts gemessen wurde.
|
||||||
|
let area = Path { path in
|
||||||
|
guard let first = coordinates.first, let last = coordinates.last
|
||||||
|
else { return }
|
||||||
|
path.move(to: CGPoint(x: first.x, y: size.height))
|
||||||
|
coordinates.forEach { path.addLine(to: $0) }
|
||||||
|
path.addLine(to: CGPoint(x: last.x, y: size.height))
|
||||||
|
path.closeSubpath()
|
||||||
|
}
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
area.fill(LinearGradient(colors: [tint.opacity(0.22), .clear],
|
||||||
|
startPoint: .top, endPoint: .bottom))
|
||||||
|
line.stroke(tint, style: .init(lineWidth: 1.5, lineJoin: .round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SensorList: View {
|
||||||
|
let snapshot: MetricsSnapshot
|
||||||
|
let detailed: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(sensor.name)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(MetricFormat.temperature(sensor.celsius))
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if detailed, !snapshot.fans.isEmpty {
|
||||||
|
Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2)
|
||||||
|
ForEach(snapshot.fans) { fan in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "fan")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Text("\(Int(fan.rpm))")
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
Text(verbatim: "U/min")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.sensors.isEmpty {
|
||||||
|
Text("metric.sensors.none", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie eine Größe zu Text und Farbe wird — an einer Stelle, damit Panel und
|
||||||
|
/// Menüleiste nicht auseinanderlaufen.
|
||||||
|
public enum MetricSummary {
|
||||||
|
|
||||||
|
/// Der anzuzeigende Wert, abhängig vom gewählten Modus.
|
||||||
|
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
|
||||||
|
mode: MetricDisplayMode) -> String {
|
||||||
|
guard mode == .temperature, metric.supportsDisplayModes else {
|
||||||
|
return value(metric, snapshot)
|
||||||
|
}
|
||||||
|
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||||
|
// Solange die Fühlersuche im Hintergrund läuft, gibt es keinen Wert.
|
||||||
|
// „—" ist dann die richtige Auskunft, nicht eine 0.
|
||||||
|
return celsius.map(MetricFormat.temperature) ?? "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
|
||||||
|
mode: MetricDisplayMode) -> Color {
|
||||||
|
guard mode == .temperature, metric.supportsDisplayModes else {
|
||||||
|
return color(metric, snapshot)
|
||||||
|
}
|
||||||
|
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||||
|
return temperatureColor(celsius ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
|
||||||
|
mode: MetricDisplayMode) -> Double {
|
||||||
|
guard mode == .temperature, metric.supportsDisplayModes else {
|
||||||
|
return fraction(metric, snapshot)
|
||||||
|
}
|
||||||
|
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||||
|
// Auf 100 °C normiert: darüber wird ohnehin gedrosselt.
|
||||||
|
return min(max((celsius ?? 0) / 100, 0), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String {
|
||||||
|
switch metric {
|
||||||
|
case .cpu: MetricFormat.percent(snapshot.cpu.total)
|
||||||
|
case .gpu: snapshot.gpu.map(MetricFormat.percent) ?? "—"
|
||||||
|
case .memory: MetricFormat.percent(snapshot.memory.usedFraction)
|
||||||
|
case .battery: snapshot.battery.map { MetricFormat.percent($0.charge) } ?? "—"
|
||||||
|
case .temperature: snapshot.sensors.map(\.celsius).max()
|
||||||
|
.map(MetricFormat.temperature) ?? "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func detail(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String? {
|
||||||
|
switch metric {
|
||||||
|
case .cpu:
|
||||||
|
return snapshot.cpu.watts.map(MetricFormat.watts)
|
||||||
|
case .memory:
|
||||||
|
return ByteCountFormatter.string(fromByteCount: Int64(snapshot.memory.used),
|
||||||
|
countStyle: .binary)
|
||||||
|
case .battery:
|
||||||
|
guard let battery = snapshot.battery else { return nil }
|
||||||
|
if battery.isCharging {
|
||||||
|
return String(localized: "metric.battery.charging", bundle: .module)
|
||||||
|
}
|
||||||
|
// Beim Entladen ist die Leistung negativ; der Betrag ist gemeint.
|
||||||
|
return battery.watts.map { MetricFormat.watts(abs($0)) }
|
||||||
|
case .gpu, .temperature:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Color {
|
||||||
|
switch metric {
|
||||||
|
case .memory:
|
||||||
|
switch snapshot.memory.pressure {
|
||||||
|
case .normal: return Onyx.Color.textPrimary
|
||||||
|
case .warning: return Onyx.Color.warning
|
||||||
|
case .critical: return Onyx.Color.critical
|
||||||
|
}
|
||||||
|
case .battery:
|
||||||
|
guard let battery = snapshot.battery else { return Onyx.Color.textPrimary }
|
||||||
|
if battery.isCharging { return Onyx.Color.positive }
|
||||||
|
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
|
||||||
|
case .temperature:
|
||||||
|
return temperatureColor(snapshot.sensors.map(\.celsius).max() ?? 0)
|
||||||
|
case .cpu, .gpu:
|
||||||
|
return Onyx.Color.textPrimary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func temperatureColor(_ celsius: Double) -> Color {
|
||||||
|
// Grenzwerte für Apple Silicon: unter 70 unauffällig, ab 90 wird
|
||||||
|
// gedrosselt. Dazwischen ist es warm, aber unproblematisch.
|
||||||
|
switch celsius {
|
||||||
|
case ..<70: Onyx.Color.textPrimary
|
||||||
|
case ..<90: Onyx.Color.warning
|
||||||
|
default: Onyx.Color.critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Die Akku-Kachel.
|
||||||
|
///
|
||||||
|
/// „40 %" und ein Verlauf über die letzten Sekunden ist keine Auskunft — der
|
||||||
|
/// Ladestand ändert sich in Minuten, nicht in Sekunden, und ein Graph darüber
|
||||||
|
/// ist eine waagerechte Linie. Was man wissen will: wie lange noch, zieht oder
|
||||||
|
/// gibt der Akku gerade, läuft der Energiesparmodus, wo endet das Laden.
|
||||||
|
private struct BatteryTile: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let battery = model.snapshot.battery
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 7) {
|
||||||
|
// Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 %
|
||||||
|
// dasselbe wie bei 95 % und trägt nichts bei.
|
||||||
|
if let battery {
|
||||||
|
BatteryGlyphView(charge: battery.charge,
|
||||||
|
isCharging: battery.isCharging,
|
||||||
|
isPluggedIn: battery.isPluggedIn, height: 15)
|
||||||
|
.foregroundStyle(tint(battery))
|
||||||
|
} else {
|
||||||
|
Image(systemName: BatterySymbol.unavailable)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
Text(MetricFormat.percent(battery?.charge ?? 0))
|
||||||
|
.font(Onyx.Font.metric).monospacedDigit()
|
||||||
|
.foregroundStyle(tint(battery))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let battery {
|
||||||
|
if let remaining = battery.timeRemaining {
|
||||||
|
Text(battery.isCharging ? "battery.untilFull \(duration(remaining))"
|
||||||
|
: "battery.remaining \(duration(remaining))",
|
||||||
|
bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
} else if battery.isPluggedIn {
|
||||||
|
Text("battery.pluggedIn", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
if let watts = battery.watts, abs(watts) > 0.1 {
|
||||||
|
row(watts > 0 ? "battery.charging" : "battery.draining",
|
||||||
|
value: String(format: "%.1f W", abs(watts)))
|
||||||
|
}
|
||||||
|
if let limit = model.chargeControl?.chargeLimit, limit < 100 {
|
||||||
|
row("charge.limit", value: "\(limit) %")
|
||||||
|
}
|
||||||
|
if let health = battery.health {
|
||||||
|
row("battery.health", value: MetricFormat.percent(health))
|
||||||
|
}
|
||||||
|
if let cycles = battery.cycleCount {
|
||||||
|
row("battery.cycles", value: "\(cycles)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Energiesparmodus erklärt, warum die Maschine langsamer
|
||||||
|
// wirkt. Ohne diesen Hinweis sucht man den Grund woanders.
|
||||||
|
if battery.isLowPower {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "leaf.fill").font(.system(size: 9))
|
||||||
|
Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Onyx.Color.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ key: String.LocalizationValue, value: String) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(String(localized: key, bundle: .module))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
Text(value).monospacedDigit().foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tint(_ battery: BatteryReading?) -> Color {
|
||||||
|
guard let battery else { return Onyx.Color.textPrimary }
|
||||||
|
if battery.isCharging { return Onyx.Color.positive }
|
||||||
|
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func duration(_ seconds: TimeInterval) -> String {
|
||||||
|
let total = Int(seconds)
|
||||||
|
return total >= 3600 ? "\(total / 3600) h \(total % 3600 / 60) min"
|
||||||
|
: "\(total / 60) min"
|
||||||
|
}
|
||||||
|
}
|
||||||
187
Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Ein Messwertstrom mit Referenzzählung.
|
||||||
|
///
|
||||||
|
/// Panel-Widget und Menüleisten-Modul greifen auf **dieselbe** Messschleife zu.
|
||||||
|
/// Ohne das liefe für jede Anzeige ein eigener Timer, der dieselben IOKit- und
|
||||||
|
/// SMC-Abfragen doppelt macht — bei sechs Modulen und ebenso vielen Widgets
|
||||||
|
/// wäre das ein Vielfaches der nötigen Arbeit.
|
||||||
|
///
|
||||||
|
/// Meldet sich der letzte Konsument ab, hört die Schleife auf. Im Ruhezustand
|
||||||
|
/// misst Onyx nichts.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class MetricsModel {
|
||||||
|
|
||||||
|
/// Wer das Ladelimit setzen kann — die App reicht den Helfer herein.
|
||||||
|
/// `nil` heißt: kein Regler, weil es nichts zu regeln gibt.
|
||||||
|
public weak var chargeControl: (any ChargeLimitReading)?
|
||||||
|
|
||||||
|
/// Welcher Sensor in der Menüleiste steht. `nil` = der wärmste.
|
||||||
|
///
|
||||||
|
/// Ohne diese Wahl zeigte das Modul stumm das Maximum über alle Sensoren —
|
||||||
|
/// eine Zahl, von der niemand weiß, woher sie kommt. „Der wärmste Punkt"
|
||||||
|
/// bleibt die Voreinstellung, weil das die Frage ist, die man meistens
|
||||||
|
/// hat; aber wer den Akku oder die GPU im Blick behalten will, soll das
|
||||||
|
/// sagen können.
|
||||||
|
public var selectedSensorKey: String? {
|
||||||
|
didSet {
|
||||||
|
guard selectedSensorKey != oldValue else { return }
|
||||||
|
defaults.set(selectedSensorKey, forKey: "onyx.metric.sensorKey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Wert des gewählten Sensors — oder der wärmste, wenn keiner gewählt
|
||||||
|
/// ist oder der gewählte gerade nicht meldet.
|
||||||
|
public var selectedSensorCelsius: Double? {
|
||||||
|
if let key = selectedSensorKey,
|
||||||
|
let sensor = snapshot.sensors.first(where: { $0.key == key }) {
|
||||||
|
return sensor.celsius
|
||||||
|
}
|
||||||
|
return snapshot.sensors.map(\.celsius).max()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie der gewählte Sensor heißt — fürs Kürzel in der Menüleiste.
|
||||||
|
public var selectedSensorName: String? {
|
||||||
|
guard let key = selectedSensorKey else { return nil }
|
||||||
|
return snapshot.sensors.first(where: { $0.key == key })?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var snapshot = MetricsSnapshot()
|
||||||
|
/// Verlauf für die Graphen, jüngster Wert zuletzt.
|
||||||
|
public private(set) var history: [MetricsSnapshot] = []
|
||||||
|
|
||||||
|
/// So viele Punkte zeigt ein Menüleisten-Graph. Mehr aufzuheben kostet
|
||||||
|
/// Speicher für etwas, das niemand sieht.
|
||||||
|
public static let historyLength = 60
|
||||||
|
|
||||||
|
/// Was CPU und GPU zeigen. Beobachtbar, damit Panel und Menüleiste sofort
|
||||||
|
/// umschalten.
|
||||||
|
public private(set) var displayModes: [MetricKind: MetricDisplayMode] = [:]
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let source = SystemMetrics()
|
||||||
|
private var timer: Timer?
|
||||||
|
/// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten.
|
||||||
|
private var demands: [UUID: TimeInterval] = [:]
|
||||||
|
|
||||||
|
public init(defaults: UserDefaults = .standard) {
|
||||||
|
self.selectedSensorKey = defaults.string(forKey: "onyx.metric.sensorKey")
|
||||||
|
self.defaults = defaults
|
||||||
|
for metric in MetricKind.allCases where metric.supportsDisplayModes {
|
||||||
|
let stored = defaults.string(forKey: Self.key(metric))
|
||||||
|
displayModes[metric] = stored.flatMap(MetricDisplayMode.init(rawValue:)) ?? .usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func displayMode(for metric: MetricKind) -> MetricDisplayMode {
|
||||||
|
displayModes[metric] ?? .usage
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setDisplayMode(_ mode: MetricDisplayMode, for metric: MetricKind) {
|
||||||
|
guard metric.supportsDisplayModes else { return }
|
||||||
|
displayModes[metric] = mode
|
||||||
|
defaults.set(mode.rawValue, forKey: Self.key(metric))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func key(_ metric: MetricKind) -> String {
|
||||||
|
"onyx.metric.\(metric.rawValue).displayMode"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht.
|
||||||
|
@discardableResult
|
||||||
|
public func addConsumer(interval: TimeInterval = 2) -> UUID {
|
||||||
|
let token = UUID()
|
||||||
|
demands[token] = min(max(interval, 1), 10)
|
||||||
|
restartTimer()
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeConsumer(_ token: UUID) {
|
||||||
|
demands[token] = nil
|
||||||
|
restartTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ändert das Intervall eines bestehenden Konsumenten, ohne die Schleife
|
||||||
|
/// anzuhalten — etwa wenn das Panel aufgeht und schneller messen will.
|
||||||
|
public func updateConsumer(_ token: UUID, interval: TimeInterval) {
|
||||||
|
guard demands[token] != nil else { return }
|
||||||
|
demands[token] = min(max(interval, 1), 10)
|
||||||
|
restartTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
|
||||||
|
guard let interval = demands.values.min() else {
|
||||||
|
history.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sample()
|
||||||
|
let timer = Timer(timeInterval: interval, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.sample() }
|
||||||
|
}
|
||||||
|
// `.common`, sonst steht die Messung, während ein Menü offen ist.
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
self.timer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sample() {
|
||||||
|
let value = source.sample()
|
||||||
|
snapshot = value
|
||||||
|
history.append(value)
|
||||||
|
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension MetricsModel {
|
||||||
|
/// Verlauf einer einzelnen Größe, für die Mini-Graphen.
|
||||||
|
func series(_ metric: MetricKind) -> [Double] {
|
||||||
|
let mode = displayMode(for: metric)
|
||||||
|
return history.compactMap { snapshot in
|
||||||
|
if mode == .temperature, metric.supportsDisplayModes {
|
||||||
|
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||||
|
return celsius.map { $0 / 100 }
|
||||||
|
}
|
||||||
|
switch metric {
|
||||||
|
case .cpu: return snapshot.cpu.total
|
||||||
|
case .gpu: return snapshot.gpu
|
||||||
|
case .memory: return snapshot.memory.usedFraction
|
||||||
|
case .battery: return snapshot.battery?.charge
|
||||||
|
case .temperature: return snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was ein CPU- oder GPU-Element zeigt.
|
||||||
|
///
|
||||||
|
/// Beide Werte kommen aus derselben Messung — die Umschaltung kostet nichts und
|
||||||
|
/// erspart ein zweites Element in einer ohnehin knappen Menüleiste.
|
||||||
|
public enum MetricDisplayMode: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||||
|
case usage
|
||||||
|
case temperature
|
||||||
|
public var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
|
||||||
|
case cpu, gpu, memory, battery, temperature
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
public var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .cpu: "cpu"
|
||||||
|
case .gpu: "cpu.fill"
|
||||||
|
case .memory: "memorychip"
|
||||||
|
case .battery: "battery.100"
|
||||||
|
case .temperature: "thermometer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur CPU und GPU haben beides. Speicher und Akku haben keine eigene
|
||||||
|
/// Temperatur, die Sensorkarte zeigt ohnehin nur Temperaturen.
|
||||||
|
public var supportsDisplayModes: Bool { self == .cpu || self == .gpu }
|
||||||
|
}
|
||||||
199
Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "SMC")
|
||||||
|
|
||||||
|
/// Liest Werte aus dem System Management Controller. **Ausschließlich lesend.**
|
||||||
|
///
|
||||||
|
/// Es gibt hier bewusst keinen Schreibpfad. Lüfterdrehzahl und Ladegrenze
|
||||||
|
/// verlangen root und gehören in den privilegierten Helfer aus Phase 6 — nicht
|
||||||
|
/// in einen Typ, der im normalen App-Prozess läuft und von jedem Widget
|
||||||
|
/// erreichbar ist.
|
||||||
|
///
|
||||||
|
/// Die Struktur und die Erkenntnisse stammen aus `docs/spikes/A-smc.md`, gemessen
|
||||||
|
/// auf Mac17,9. Wichtigster Punkt von dort: **alle Mehrbyte-Werte sind
|
||||||
|
/// little-endian.** Der verbreitete Beispielcode nimmt big-endian an — er
|
||||||
|
/// stammt aus der Intel-Ära und liefert auf Apple Silicon Unsinn.
|
||||||
|
public final class SMCReader: @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: - Aufbau des AppleSMC-UserClients
|
||||||
|
|
||||||
|
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 getKeyFromIndex: UInt8 = 8
|
||||||
|
private static let getKeyInfo: UInt8 = 9
|
||||||
|
|
||||||
|
private var connection: io_connect_t = 0
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
/// Typ und Größe je Key. Beides steht in der Firmware fest und ändert sich
|
||||||
|
/// zur Laufzeit nicht — es bei jedem Lesen erneut zu erfragen verdoppelt
|
||||||
|
/// die Zahl der IOKit-Aufrufe. Bei fünfzig Temperaturfühlern im Sekundentakt
|
||||||
|
/// ist das der Unterschied zwischen spürbar und unmerklich.
|
||||||
|
private var keyInfoCache: [String: KeyInfo] = [:]
|
||||||
|
|
||||||
|
public init?() {
|
||||||
|
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
||||||
|
IOServiceMatching("AppleSMC"))
|
||||||
|
guard service != 0 else {
|
||||||
|
log.error("AppleSMC nicht gefunden — Sensoren bleiben leer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer { IOObjectRelease(service) }
|
||||||
|
guard IOServiceOpen(service, mach_task_self_, 0, &connection) == kIOReturnSuccess else {
|
||||||
|
log.error("AppleSMC nicht zu öffnen")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { if connection != 0 { IOServiceClose(connection) } }
|
||||||
|
|
||||||
|
// MARK: - Lesen
|
||||||
|
|
||||||
|
/// Ein `flt`-Wert (Lüfterdrehzahl, Temperatur, Leistung).
|
||||||
|
public func float(_ key: String) -> Double? {
|
||||||
|
guard let value = read(key), value.type == "flt ", value.bytes.count >= 4 else { return nil }
|
||||||
|
// Little-endian, siehe Klassenkommentar.
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein vorzeichenloser Ganzzahlwert beliebiger Breite.
|
||||||
|
public func integer(_ key: String) -> UInt64? {
|
||||||
|
guard let value = read(key) else { return nil }
|
||||||
|
var result: UInt64 = 0
|
||||||
|
for (index, byte) in value.bytes.prefix(8).enumerated() {
|
||||||
|
result |= UInt64(byte) << (8 * index) // little-endian
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exists(_ key: String) -> Bool { read(key) != nil }
|
||||||
|
|
||||||
|
/// Alle Keys, deren Name mit einem der Präfixe beginnt.
|
||||||
|
///
|
||||||
|
/// Wird einmal beim Start aufgerufen. Fest verdrahtete Listen wären hier
|
||||||
|
/// falsch: welche Fühler es gibt, hängt am Modell — auf dem M5 sind es
|
||||||
|
/// 285 Temperaturfühler, auf einem anderen Mac ganz andere.
|
||||||
|
public func keys(matching prefixes: [String]) -> [String] {
|
||||||
|
guard let total = keyCount() else { return [] }
|
||||||
|
|
||||||
|
var found: [String] = []
|
||||||
|
for index in 0..<UInt32(total) {
|
||||||
|
guard let name = key(atIndex: index) else { continue }
|
||||||
|
if prefixes.contains(where: name.hasPrefix) { found.append(name) }
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Anzahl der Keys — **big-endian**.
|
||||||
|
///
|
||||||
|
/// Die Byte-Reihenfolge ist im SMC nicht durchgängig, und das ist keine
|
||||||
|
/// Nachlässigkeit dieser Klasse, sondern der Hardware: Messwerte wie `B0AV`
|
||||||
|
/// kommen little-endian aus dem Akku-Baustein, die Metadaten des SMC selbst
|
||||||
|
/// dagegen big-endian. `#KEY` little-endian gelesen ergibt 2 651 652 096
|
||||||
|
/// statt 3486 — die Suche brach damit still ab und alle Temperaturen
|
||||||
|
/// blieben leer.
|
||||||
|
private func keyCount() -> UInt32? {
|
||||||
|
guard let value = read("#KEY"), value.bytes.count >= 4 else { return nil }
|
||||||
|
let count = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16
|
||||||
|
| UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3])
|
||||||
|
return (0..<100_000).contains(count) ? count : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func key(atIndex index: UInt32) -> String? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
var command = Param()
|
||||||
|
command.data8 = Self.getKeyFromIndex
|
||||||
|
command.data32 = index
|
||||||
|
guard let output = call(command) else { return nil }
|
||||||
|
return Self.fourCCString(output.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Value { let type: String; let bytes: [UInt8] }
|
||||||
|
|
||||||
|
private func read(_ key: String) -> Value? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
let described: KeyInfo
|
||||||
|
if let cached = keyInfoCache[key] {
|
||||||
|
described = cached
|
||||||
|
} else {
|
||||||
|
var info = Param()
|
||||||
|
info.key = Self.fourCC(key)
|
||||||
|
info.data8 = Self.getKeyInfo
|
||||||
|
guard let result = call(info) else { return nil }
|
||||||
|
described = result.keyInfo
|
||||||
|
keyInfoCache[key] = described
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = Param()
|
||||||
|
command.key = Self.fourCC(key)
|
||||||
|
command.keyInfo = described
|
||||||
|
command.data8 = Self.readKey
|
||||||
|
guard let output = call(command) else { return nil }
|
||||||
|
|
||||||
|
let size = Int(min(described.dataSize, 32))
|
||||||
|
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
|
||||||
|
return Value(type: Self.fourCCString(described.dataType),
|
||||||
|
bytes: Array(all.prefix(size)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func call(_ input: Param) -> Param? {
|
||||||
|
var input = input
|
||||||
|
var output = Param()
|
||||||
|
var size = MemoryLayout<Param>.stride
|
||||||
|
|
||||||
|
let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
|
||||||
|
&input, MemoryLayout<Param>.stride,
|
||||||
|
&output, &size)
|
||||||
|
// Ein nicht vorhandener Key meldet sich über `result` und ist kein
|
||||||
|
// Fehler: welche Keys es gibt, unterscheidet sich je nach Modell.
|
||||||
|
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) ?? "????"
|
||||||
|
}
|
||||||
|
}
|
||||||
483
Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
import IOKit.ps
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
/// Ein vollständiger Messwertsatz.
|
||||||
|
public struct MetricsSnapshot: Equatable, Sendable {
|
||||||
|
public var cpu = CPUReading()
|
||||||
|
public var gpu: Double?
|
||||||
|
/// Wärmster Punkt der GPU in Grad Celsius.
|
||||||
|
public var gpuTemperature: Double?
|
||||||
|
public var memory = MemoryReading()
|
||||||
|
public var battery: BatteryReading?
|
||||||
|
public var sensors: [SensorReading] = []
|
||||||
|
public var fans: [FanReading] = []
|
||||||
|
public var power = PowerReading()
|
||||||
|
public var topMemoryProcesses: [ProcessMemory] = []
|
||||||
|
public var asOf = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wohin die Leistung geht.
|
||||||
|
///
|
||||||
|
/// Die Schlüssel sind gegen den Aktivitätsmonitor und gegen iStat Menus
|
||||||
|
/// abgeglichen: `PHPC` deckt sich mit deren CPU-Wert, `PSTR` mit der
|
||||||
|
/// Gesamtleistung.
|
||||||
|
public struct PowerReading: Equatable, Sendable {
|
||||||
|
public var total: Double?
|
||||||
|
public var cpu: Double?
|
||||||
|
public var display: Double?
|
||||||
|
public var adapter: Double?
|
||||||
|
public var busVoltage: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CPUReading: Equatable, Sendable {
|
||||||
|
/// Gesamtauslastung, 0 bis 1.
|
||||||
|
public var total: Double = 0
|
||||||
|
/// Auslastung je Kern, in der Reihenfolge des Systems.
|
||||||
|
public var perCore: [Double] = []
|
||||||
|
/// Leistungsaufnahme in Watt, falls messbar.
|
||||||
|
public var watts: Double?
|
||||||
|
/// Der wärmste Kern in Grad Celsius.
|
||||||
|
///
|
||||||
|
/// Apple Silicon hat pro Kern einen Fühler — auf dem M5 Pro sind es
|
||||||
|
/// über zwanzig. Der Höchstwert ist der aussagekräftige: gedrosselt wird
|
||||||
|
/// nach dem heißesten Punkt, nicht nach dem Durchschnitt.
|
||||||
|
public var temperature: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MemoryReading: Equatable, Sendable {
|
||||||
|
public var used: UInt64 = 0
|
||||||
|
public var total: UInt64 = 0
|
||||||
|
public var compressed: UInt64 = 0
|
||||||
|
public var swapUsed: UInt64 = 0
|
||||||
|
public var swapTotal: UInt64 = 0
|
||||||
|
/// Von Programmen belegt (aktiv + inaktiv).
|
||||||
|
public var app: UInt64 = 0
|
||||||
|
/// Vom Kernel festgehalten, nicht auslagerbar.
|
||||||
|
public var wired: UInt64 = 0
|
||||||
|
public var free: UInt64 = 0
|
||||||
|
public var pressure: MemoryPressure = .normal
|
||||||
|
|
||||||
|
public var usedFraction: Double {
|
||||||
|
total > 0 ? Double(used) / Double(total) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BatteryReading: Equatable, Sendable {
|
||||||
|
public var charge: Double = 0 // 0 bis 1
|
||||||
|
public var isCharging = false
|
||||||
|
public var isPluggedIn = false
|
||||||
|
public var cycleCount: Int?
|
||||||
|
public var health: Double? // 0 bis 1
|
||||||
|
public var watts: Double? // negativ = Entladung
|
||||||
|
public var timeRemaining: TimeInterval?
|
||||||
|
/// Energiesparmodus. Erklärt, warum die Maschine langsamer wirkt — und
|
||||||
|
/// gehört deshalb sichtbar dorthin, wo man nach dem Grund sucht.
|
||||||
|
public var isLowPower = false
|
||||||
|
/// Temperatur der Zelle, falls der SMC sie hergibt.
|
||||||
|
public var celsius: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Programm mit seinem Speicherverbrauch.
|
||||||
|
public struct ProcessMemory: Equatable, Sendable, Identifiable {
|
||||||
|
public let pid: pid_t
|
||||||
|
public let name: String
|
||||||
|
public let bytes: UInt64
|
||||||
|
public var id: pid_t { pid }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SensorReading: Equatable, Sendable, Identifiable {
|
||||||
|
public let key: String
|
||||||
|
public let name: String
|
||||||
|
public let celsius: Double
|
||||||
|
public var id: String { key }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FanReading: Equatable, Sendable, Identifiable {
|
||||||
|
public let index: Int
|
||||||
|
public let name: String
|
||||||
|
public let rpm: Double
|
||||||
|
public let minimum: Double
|
||||||
|
public let maximum: Double
|
||||||
|
public var id: Int { index }
|
||||||
|
|
||||||
|
/// Anteil zwischen Minimum und Maximum, 0 bis 1.
|
||||||
|
public var fraction: Double {
|
||||||
|
guard maximum > minimum else { return 0 }
|
||||||
|
return min(max((rpm - minimum) / (maximum - minimum), 0), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest die Hardwarewerte des Systems.
|
||||||
|
///
|
||||||
|
/// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil
|
||||||
|
/// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell
|
||||||
|
/// sie es brauchen.
|
||||||
|
public final class SystemMetrics: @unchecked Sendable {
|
||||||
|
|
||||||
|
private let smc = SMCReader()
|
||||||
|
private var previousCPUTicks: [CPUTicks] = []
|
||||||
|
|
||||||
|
/// Welche Kern- und GPU-Fühler dieses Modell hat. `Tp*` sind die
|
||||||
|
/// CPU-Kerne, `Tg*` die GPU-Cluster — die Konvention gilt für die ganze
|
||||||
|
/// M-Serie.
|
||||||
|
///
|
||||||
|
/// Die Suche läuft im Hintergrund: sie geht alle 3486 Keys durch und
|
||||||
|
/// braucht dafür knapp eine Sekunde. Auf dem Hauptthread wäre das eine
|
||||||
|
/// spürbare Startverzögerung für etwas, das nach dem ersten Messwert
|
||||||
|
/// niemandem auffällt.
|
||||||
|
private var temperatureKeys: (cpu: [String], gpu: [String]) = ([], [])
|
||||||
|
private let keysLock = NSLock()
|
||||||
|
|
||||||
|
/// Zwischenspeicher für die Temperaturen.
|
||||||
|
///
|
||||||
|
/// Fünfzig Fühler zu lesen kostet gut 15 ms. Bei 1 Hz wären das anderthalb
|
||||||
|
/// Prozent CPU für Werte, die sich in einer Sekunde kaum bewegen — deshalb
|
||||||
|
/// höchstens alle zwei Sekunden, unabhängig von der Abtastrate ringsum.
|
||||||
|
private var cachedTemperatures: (cpu: Double?, gpu: Double?) = (nil, nil)
|
||||||
|
private var temperaturesReadAt = Date.distantPast
|
||||||
|
private static let temperatureInterval: TimeInterval = 2
|
||||||
|
|
||||||
|
/// Seit dem Start unveränderlich — einmal ermitteln reicht.
|
||||||
|
private static let pageSize: UInt64 = {
|
||||||
|
var size: UInt64 = 0
|
||||||
|
var length = MemoryLayout<UInt64>.size
|
||||||
|
guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else {
|
||||||
|
return 16384 // Apple Silicon
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}()
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
// Nicht im Initialisierer blockieren: der läuft beim App-Start.
|
||||||
|
// `DispatchQueue` statt `Task.detached`: die Suche ist blockierende
|
||||||
|
// IOKit-Arbeit ohne Suspendierungspunkt, sie gehört nicht in den
|
||||||
|
// Nebenläufigkeitspool von Swift — und `NSLock` ist aus einem
|
||||||
|
// asynchronen Kontext ohnehin nicht erlaubt.
|
||||||
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||||
|
guard let self, let smc else { return }
|
||||||
|
let cpu = smc.keys(matching: ["Tp"])
|
||||||
|
let gpu = smc.keys(matching: ["Tg"])
|
||||||
|
keysLock.lock()
|
||||||
|
temperatureKeys = (cpu, gpu)
|
||||||
|
keysLock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sample() -> MetricsSnapshot {
|
||||||
|
var snapshot = MetricsSnapshot()
|
||||||
|
snapshot.cpu = readCPU()
|
||||||
|
snapshot.gpu = readGPU()
|
||||||
|
snapshot.memory = readMemory()
|
||||||
|
snapshot.battery = readBattery()
|
||||||
|
snapshot.sensors = readSensors()
|
||||||
|
snapshot.fans = readFans()
|
||||||
|
snapshot.topMemoryProcesses = readTopMemoryProcesses()
|
||||||
|
snapshot.power = readPower()
|
||||||
|
snapshot.cpu.watts = snapshot.power.cpu ?? snapshot.power.total
|
||||||
|
let temperatures = readTemperatures()
|
||||||
|
snapshot.cpu.temperature = temperatures.cpu
|
||||||
|
snapshot.gpuTemperature = temperatures.gpu
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CPU
|
||||||
|
|
||||||
|
private func readCPU() -> CPUReading {
|
||||||
|
var count: natural_t = 0
|
||||||
|
var info: processor_info_array_t?
|
||||||
|
var infoCount: mach_msg_type_number_t = 0
|
||||||
|
|
||||||
|
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
|
||||||
|
&count, &info, &infoCount) == KERN_SUCCESS,
|
||||||
|
let info else { return CPUReading() }
|
||||||
|
|
||||||
|
defer {
|
||||||
|
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
|
||||||
|
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: [CPUTicks] = []
|
||||||
|
current.reserveCapacity(Int(count))
|
||||||
|
for core in 0..<Int(count) {
|
||||||
|
let base = core * Int(CPU_STATE_MAX)
|
||||||
|
current.append(CPUTicks(
|
||||||
|
user: UInt32(bitPattern: info[base + Int(CPU_STATE_USER)]),
|
||||||
|
system: UInt32(bitPattern: info[base + Int(CPU_STATE_SYSTEM)]),
|
||||||
|
idle: UInt32(bitPattern: info[base + Int(CPU_STATE_IDLE)]),
|
||||||
|
nice: UInt32(bitPattern: info[base + Int(CPU_STATE_NICE)])))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die erste Abtastung hat keinen Vorgänger und kann deshalb keine
|
||||||
|
// Auslastung ergeben. Null zu zeigen ist ehrlicher als der Anteil seit
|
||||||
|
// dem Systemstart, den man sonst bekäme — der liegt immer bei ein paar
|
||||||
|
// Prozent und sieht aus wie ein Messwert.
|
||||||
|
defer { previousCPUTicks = current }
|
||||||
|
guard previousCPUTicks.count == current.count else { return CPUReading() }
|
||||||
|
|
||||||
|
let perCore = zip(previousCPUTicks, current).map(CPUTicks.usage(from:to:))
|
||||||
|
return CPUReading(total: perCore.isEmpty ? 0 : perCore.reduce(0, +) / Double(perCore.count),
|
||||||
|
perCore: perCore,
|
||||||
|
watts: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GPU
|
||||||
|
|
||||||
|
private func readGPU() -> Double? {
|
||||||
|
var iterator: io_iterator_t = 0
|
||||||
|
guard IOServiceGetMatchingServices(kIOMainPortDefault,
|
||||||
|
IOServiceMatching("IOAccelerator"),
|
||||||
|
&iterator) == kIOReturnSuccess else { return nil }
|
||||||
|
defer { IOObjectRelease(iterator) }
|
||||||
|
|
||||||
|
while case let entry = IOIteratorNext(iterator), entry != 0 {
|
||||||
|
defer { IOObjectRelease(entry) }
|
||||||
|
|
||||||
|
var unmanaged: Unmanaged<CFMutableDictionary>?
|
||||||
|
guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0)
|
||||||
|
== kIOReturnSuccess,
|
||||||
|
let properties = unmanaged?.takeRetainedValue() as? [String: Any],
|
||||||
|
let statistics = properties["PerformanceStatistics"] as? [String: Any]
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
// Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple
|
||||||
|
// Silicon ist es „Device Utilization %".
|
||||||
|
for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] {
|
||||||
|
if let value = statistics[name] as? Int { return Double(value) / 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Arbeitsspeicher
|
||||||
|
|
||||||
|
private func readMemory() -> MemoryReading {
|
||||||
|
var statistics = vm_statistics64_data_t()
|
||||||
|
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size
|
||||||
|
/ MemoryLayout<integer_t>.size)
|
||||||
|
let result = withUnsafeMutablePointer(to: &statistics) {
|
||||||
|
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||||
|
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard result == KERN_SUCCESS else { return MemoryReading() }
|
||||||
|
|
||||||
|
// `vm_kernel_page_size` ist eine globale Variable und damit unter
|
||||||
|
// strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start
|
||||||
|
// fest, also einmal über sysctl holen.
|
||||||
|
let pageSize = Self.pageSize
|
||||||
|
let total = ProcessInfo.processInfo.physicalMemory
|
||||||
|
let compressed = UInt64(statistics.compressor_page_count) * pageSize
|
||||||
|
|
||||||
|
// „Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert.
|
||||||
|
// Der Dateicache (`external_page_count`) zählt nicht mit — er wird bei
|
||||||
|
// Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll
|
||||||
|
// aussehen.
|
||||||
|
let used = UInt64(statistics.active_count) * pageSize
|
||||||
|
+ UInt64(statistics.wire_count) * pageSize
|
||||||
|
+ compressed
|
||||||
|
|
||||||
|
var swap = xsw_usage()
|
||||||
|
var swapSize = MemoryLayout<xsw_usage>.size
|
||||||
|
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
|
||||||
|
|
||||||
|
let app = UInt64(statistics.active_count + statistics.inactive_count) * pageSize
|
||||||
|
let wired = UInt64(statistics.wire_count) * pageSize
|
||||||
|
let freeBytes = UInt64(statistics.free_count) * pageSize
|
||||||
|
|
||||||
|
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
|
||||||
|
return MemoryReading(used: used, total: total, compressed: compressed,
|
||||||
|
swapUsed: swap.xsu_used, swapTotal: swap.xsu_total,
|
||||||
|
app: app, wired: wired, free: freeBytes,
|
||||||
|
pressure: MemoryPressure.classify(
|
||||||
|
free: free,
|
||||||
|
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
|
||||||
|
swapUsed: swap.xsu_used))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Akku
|
||||||
|
|
||||||
|
private func readBattery() -> BatteryReading? {
|
||||||
|
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
|
||||||
|
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef],
|
||||||
|
let source = sources.first,
|
||||||
|
let description = IOPSGetPowerSourceDescription(blob, source)?
|
||||||
|
.takeUnretainedValue() as? [String: Any] else { return nil }
|
||||||
|
|
||||||
|
var reading = BatteryReading()
|
||||||
|
if let current = description[kIOPSCurrentCapacityKey] as? Int,
|
||||||
|
let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 {
|
||||||
|
reading.charge = Double(current) / Double(maximum)
|
||||||
|
}
|
||||||
|
reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
|
||||||
|
reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String)
|
||||||
|
== kIOPSACPowerValue
|
||||||
|
|
||||||
|
if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 {
|
||||||
|
reading.timeRemaining = TimeInterval(seconds * 60)
|
||||||
|
}
|
||||||
|
if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 {
|
||||||
|
reading.timeRemaining = TimeInterval(seconds * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung,
|
||||||
|
// sondern in der Batterie selbst.
|
||||||
|
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
||||||
|
IOServiceMatching("AppleSmartBattery"))
|
||||||
|
if service != 0 {
|
||||||
|
defer { IOObjectRelease(service) }
|
||||||
|
var unmanaged: Unmanaged<CFMutableDictionary>?
|
||||||
|
if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0)
|
||||||
|
== kIOReturnSuccess,
|
||||||
|
let properties = unmanaged?.takeRetainedValue() as? [String: Any] {
|
||||||
|
reading.cycleCount = properties["CycleCount"] as? Int
|
||||||
|
if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 {
|
||||||
|
reading.health = Double(maximum) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen.
|
||||||
|
reading.isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
|
||||||
|
// TB0T ist die Akkutemperatur; nicht jede Maschine führt sie.
|
||||||
|
reading.celsius = smc?.float("TB0T").map { Double($0) }
|
||||||
|
if let watts = smc?.float("PPBR") {
|
||||||
|
reading.watts = reading.isCharging ? watts : -watts
|
||||||
|
}
|
||||||
|
return reading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bei zwei Lüftern sind es links und rechts — so beschriftet Apple sie
|
||||||
|
/// selbst. Bei einer anderen Anzahl bleibt die Nummer, weil die Anordnung
|
||||||
|
/// dann nicht bekannt ist.
|
||||||
|
private static func fanName(index: Int, of count: Int) -> String {
|
||||||
|
guard count == 2 else { return "Lüfter \(index + 1)" }
|
||||||
|
return index == 0 ? "Lüfter links" : "Lüfter rechts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Leistung
|
||||||
|
|
||||||
|
private func readPower() -> PowerReading {
|
||||||
|
guard let smc else { return PowerReading() }
|
||||||
|
return PowerReading(total: smc.float("PSTR"),
|
||||||
|
cpu: smc.float("PHPC"),
|
||||||
|
display: smc.float("PDBR"),
|
||||||
|
adapter: smc.float("PDTR"),
|
||||||
|
busVoltage: smc.float("VP0R"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Speicher je Programm
|
||||||
|
|
||||||
|
/// Die größten Speicherverbraucher.
|
||||||
|
///
|
||||||
|
/// `phys_footprint` ist derselbe Wert, den der Aktivitätsmonitor unter
|
||||||
|
/// „Speicher" zeigt — nicht der virtuelle Adressraum, der bei modernen
|
||||||
|
/// Programmen sinnlos groß ist. Fremde Nutzer bleiben außen vor: dafür
|
||||||
|
/// bräuchte es root, und für einen Blick aufs eigene System reicht das hier.
|
||||||
|
private func readTopMemoryProcesses(limit: Int = 5) -> [ProcessMemory] {
|
||||||
|
var pids = [pid_t](repeating: 0, count: 4096)
|
||||||
|
let size = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids,
|
||||||
|
Int32(pids.count * MemoryLayout<pid_t>.size))
|
||||||
|
guard size > 0 else { return [] }
|
||||||
|
|
||||||
|
let count = Int(size) / MemoryLayout<pid_t>.size
|
||||||
|
var results: [ProcessMemory] = []
|
||||||
|
|
||||||
|
for pid in pids.prefix(count) where pid > 0 {
|
||||||
|
var usage = rusage_info_v4()
|
||||||
|
let result = withUnsafeMutablePointer(to: &usage) {
|
||||||
|
$0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
|
||||||
|
proc_pid_rusage(pid, RUSAGE_INFO_V4, $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard result == 0, usage.ri_phys_footprint > 64 * 1024 * 1024 else { continue }
|
||||||
|
|
||||||
|
var nameBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN))
|
||||||
|
proc_name(pid, &nameBuffer, UInt32(nameBuffer.count))
|
||||||
|
let name = String(cString: nameBuffer)
|
||||||
|
guard !name.isEmpty else { continue }
|
||||||
|
|
||||||
|
results.append(ProcessMemory(pid: pid, name: name, bytes: usage.ri_phys_footprint))
|
||||||
|
}
|
||||||
|
return Array(results.sorted { $0.bytes > $1.bytes }.prefix(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sensoren und Lüfter
|
||||||
|
|
||||||
|
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
|
||||||
|
///
|
||||||
|
/// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind
|
||||||
|
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
|
||||||
|
/// fällt beim Lesen einfach weg.
|
||||||
|
private static let temperatureKeys: [(String, String)] = [
|
||||||
|
("TB0T", "Akku"),
|
||||||
|
("Ts0P", "Handballenauflage"),
|
||||||
|
("Ts1P", "Gehäuse hinten"),
|
||||||
|
("TW0P", "WLAN"),
|
||||||
|
("TH0x", "SSD"),
|
||||||
|
("TCMb", "Luftstrom"),
|
||||||
|
("TDeL", "Thunderbolt links"),
|
||||||
|
("TDeR", "Thunderbolt rechts"),
|
||||||
|
]
|
||||||
|
|
||||||
|
private func readTemperatures() -> (cpu: Double?, gpu: Double?) {
|
||||||
|
guard Date().timeIntervalSince(temperaturesReadAt) >= Self.temperatureInterval else {
|
||||||
|
return cachedTemperatures
|
||||||
|
}
|
||||||
|
keysLock.lock()
|
||||||
|
let keys = temperatureKeys
|
||||||
|
keysLock.unlock()
|
||||||
|
|
||||||
|
// Solange die Suche im Hintergrund läuft, bleiben die Werte leer — die
|
||||||
|
// Oberfläche zeigt dann „—" statt einer erfundenen Zahl.
|
||||||
|
guard !keys.cpu.isEmpty || !keys.gpu.isEmpty else { return (nil, nil) }
|
||||||
|
|
||||||
|
cachedTemperatures = (hottest(of: keys.cpu), hottest(of: keys.gpu))
|
||||||
|
temperaturesReadAt = Date()
|
||||||
|
return cachedTemperatures
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der höchste plausible Messwert einer Fühlergruppe.
|
||||||
|
///
|
||||||
|
/// Die Plausibilitätsgrenze ist nötig: einzelne Fühler liefern gelegentlich
|
||||||
|
/// 0 oder Werte jenseits von 200 °C, und ein einziger Ausreißer würde die
|
||||||
|
/// Anzeige übernehmen, weil hier das Maximum gebildet wird.
|
||||||
|
private func hottest(of keys: [String]) -> Double? {
|
||||||
|
guard let smc, !keys.isEmpty else { return nil }
|
||||||
|
return keys.compactMap { key -> Double? in
|
||||||
|
guard let value = smc.float(key), value > 5, value < 150 else { return nil }
|
||||||
|
return value
|
||||||
|
}.max()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readSensors() -> [SensorReading] {
|
||||||
|
guard let smc else { return [] }
|
||||||
|
let named = Self.temperatureKeys.compactMap { key, name -> SensorReading? in
|
||||||
|
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
|
||||||
|
return SensorReading(key: key, name: name, celsius: celsius)
|
||||||
|
}
|
||||||
|
// CPU und GPU nach vorn: das sind die Werte, wegen derer man hinschaut.
|
||||||
|
let temperatures = readTemperatures()
|
||||||
|
var result: [SensorReading] = []
|
||||||
|
if let cpu = temperatures.cpu {
|
||||||
|
result.append(SensorReading(key: "cpu", name: "CPU-Kerne", celsius: cpu))
|
||||||
|
}
|
||||||
|
if let gpu = temperatures.gpu {
|
||||||
|
result.append(SensorReading(key: "gpu", name: "Grafik", celsius: gpu))
|
||||||
|
}
|
||||||
|
return result + named
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readFans() -> [FanReading] {
|
||||||
|
guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] }
|
||||||
|
return (0..<Int(count)).compactMap { index in
|
||||||
|
guard let rpm = smc.float("F\(index)Ac") else { return nil }
|
||||||
|
return FanReading(index: index,
|
||||||
|
name: Self.fanName(index: index, of: Int(count)),
|
||||||
|
rpm: rpm,
|
||||||
|
minimum: smc.float("F\(index)Mn") ?? 0,
|
||||||
|
maximum: smc.float("F\(index)Mx") ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
376
Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"menubar.short.network": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Netz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Net"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.addresses": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Adressen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Addresses"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.download": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Download"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Download"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.field.interface": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schnittstelle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Interface"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.field.ipv4": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "IP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "IP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.field.ipv6": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "IPv6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "IPv6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.field.router": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Router"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Router"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.name": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Netzwerk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.offline": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nicht verbunden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Not connected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.peak.download": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Spitze ↓"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Peak ↓"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.peak.upload": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Spitze ↑"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Peak ↑"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.perProcess.unavailable": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Durchsatz je Programm zeigt Onyx nicht: dafür gibt es ohne root keine Schnittstelle."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Onyx doesn't show per-app throughput: there is no interface for it without root."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.processes": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Programme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Per app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.public": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Öffentliche Adresse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Public address"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.public.enable": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Öffentliche IP anzeigen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Show public IP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.public.failed": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nicht abrufbar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.public.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dafür fragt Onyx einen fremden Dienst — standardmäßig aus, weil ein Systemmonitor nicht ungefragt nach draußen telefonieren sollte."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This asks an external service — off by default, because a system monitor shouldn't phone home unasked."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.total.received": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Empfangen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Received"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.total.sent": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Gesendet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.transferred": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Übertragen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Transferred"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.upload": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Upload"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Upload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.vpn.active": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Verkehr läuft durch einen Tunnel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Traffic goes through a tunnel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.wifi.connected": {
|
||||||
|
"comment": "Wenn der Netzwerkname mangels Ortungsberechtigung fehlt",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "WLAN verbunden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wi-Fi connected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
153
Packages/OnyxKit/Sources/NetworkProvider/NetworkExtras.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Network")
|
||||||
|
|
||||||
|
/// Durchsatz je Programm.
|
||||||
|
public struct ProcessTraffic: Equatable, Sendable, Identifiable {
|
||||||
|
public let name: String
|
||||||
|
public let pid: pid_t
|
||||||
|
public let downloadRate: Double
|
||||||
|
public let uploadRate: Double
|
||||||
|
public var id: pid_t { pid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest den Durchsatz je Programm über `nettop`.
|
||||||
|
///
|
||||||
|
/// Es gibt dafür keine Bibliotheksschnittstelle — die zugrundeliegende
|
||||||
|
/// `NetworkStatistics`-API ist privat und verlangt eine Entitlement, die Apple
|
||||||
|
/// nicht vergibt. `nettop` ist Apples eigenes Werkzeug dafür, liegt im System
|
||||||
|
/// und läuft **ohne root** für die Prozesse des eigenen Nutzers.
|
||||||
|
///
|
||||||
|
/// Deshalb wird es nur abgefragt, solange jemand hinschaut: einen
|
||||||
|
/// Fremdprozess im Sekundentakt zu starten wäre teurer als der gesamte übrige
|
||||||
|
/// Monitor zusammen.
|
||||||
|
public final class ProcessTrafficSampler: @unchecked Sendable {
|
||||||
|
|
||||||
|
private var previous: [pid_t: (received: UInt64, sent: UInt64)] = [:]
|
||||||
|
private var previousTime: Date?
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func sample(limit: Int = 6) -> [ProcessTraffic] {
|
||||||
|
guard let output = runNettop() else { return [] }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
let elapsed = previousTime.map { now.timeIntervalSince($0) } ?? 0
|
||||||
|
var current: [pid_t: (received: UInt64, sent: UInt64)] = [:]
|
||||||
|
var results: [ProcessTraffic] = []
|
||||||
|
|
||||||
|
// Format: `name.pid,bytes_in,bytes_out,`
|
||||||
|
for line in output.split(separator: "\n").dropFirst() {
|
||||||
|
let fields = line.split(separator: ",", omittingEmptySubsequences: false)
|
||||||
|
guard fields.count >= 3 else { continue }
|
||||||
|
|
||||||
|
let identifier = String(fields[0])
|
||||||
|
guard let separator = identifier.lastIndex(of: "."),
|
||||||
|
let pid = pid_t(identifier[identifier.index(after: separator)...]),
|
||||||
|
let received = UInt64(fields[1]), let sent = UInt64(fields[2])
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let name = String(identifier[..<separator])
|
||||||
|
current[pid] = (received, sent)
|
||||||
|
|
||||||
|
guard elapsed > 0, let before = previous[pid] else { continue }
|
||||||
|
let down = Throughput.rate(previous: before.received, current: received, elapsed: elapsed)
|
||||||
|
let up = Throughput.rate(previous: before.sent, current: sent, elapsed: elapsed)
|
||||||
|
guard down + up > 0 else { continue }
|
||||||
|
|
||||||
|
results.append(ProcessTraffic(name: name, pid: pid,
|
||||||
|
downloadRate: down, uploadRate: up))
|
||||||
|
}
|
||||||
|
|
||||||
|
previous = current
|
||||||
|
previousTime = now
|
||||||
|
return Array(results.sorted { $0.downloadRate + $0.uploadRate
|
||||||
|
> $1.downloadRate + $1.uploadRate }.prefix(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runNettop() -> String? {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
|
||||||
|
// `-P` fasst je Prozess zusammen, `-x` unterdrückt Einheiten, `-L 1`
|
||||||
|
// nimmt genau eine Messung und beendet sich dann.
|
||||||
|
process.arguments = ["-P", "-x", "-L", "1", "-J", "bytes_in,bytes_out"]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
log.error("nettop nicht ausführbar: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt die öffentliche IP-Adresse.
|
||||||
|
///
|
||||||
|
/// **Standardmäßig aus.** Dafür geht eine Anfrage an einen fremden Dienst, und
|
||||||
|
/// wer einen Systemmonitor benutzt, rechnet nicht damit, dass der nach draußen
|
||||||
|
/// telefoniert. Wird sie eingeschaltet, steht der benutzte Dienst in der
|
||||||
|
/// Oberfläche — man soll nachlesen können, wem die Adresse bekannt wird.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class PublicAddressLookup {
|
||||||
|
|
||||||
|
public private(set) var address: String?
|
||||||
|
public private(set) var isLoading = false
|
||||||
|
public private(set) var failed = false
|
||||||
|
|
||||||
|
/// Der Dienst, der gefragt wird. Bewusst als sichtbare Angabe und nicht
|
||||||
|
/// versteckt im Code.
|
||||||
|
public static let serviceName = "api.ipify.org"
|
||||||
|
private static let endpoint = URL(string: "https://api.ipify.org?format=json")!
|
||||||
|
|
||||||
|
private var lastFetch: Date?
|
||||||
|
/// Mindestabstand zwischen zwei Abfragen. Die öffentliche Adresse ändert
|
||||||
|
/// sich selten; sie bei jedem Öffnen des Popovers neu zu holen wäre
|
||||||
|
/// unnötiger Verkehr nach draußen.
|
||||||
|
private static let minimumInterval: TimeInterval = 15 * 60
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func refresh(force: Bool = false) {
|
||||||
|
if !force, let last = lastFetch,
|
||||||
|
Date().timeIntervalSince(last) < Self.minimumInterval { return }
|
||||||
|
guard !isLoading else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
failed = false
|
||||||
|
Task { [weak self] in
|
||||||
|
defer { Task { @MainActor in self?.isLoading = false } }
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: Self.endpoint)
|
||||||
|
let decoded = try JSONDecoder().decode([String: String].self, from: data)
|
||||||
|
await MainActor.run {
|
||||||
|
self?.address = decoded["ip"]
|
||||||
|
self?.lastFetch = Date()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log.error("Öffentliche IP nicht abrufbar: \(error.localizedDescription, privacy: .public)")
|
||||||
|
await MainActor.run { self?.failed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vergisst die Adresse — beim Abschalten der Funktion.
|
||||||
|
public func clear() {
|
||||||
|
address = nil
|
||||||
|
lastFetch = nil
|
||||||
|
failed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import OnyxMenuBar
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
/// Das Netzwerk in der Menüleiste.
|
||||||
|
///
|
||||||
|
/// Anders als die Hardwaregrößen hat es **zwei** Werte, die beide interessieren.
|
||||||
|
/// Sie untereinander zu setzen ist der einzige Weg, beide in der Höhe einer
|
||||||
|
/// Menüleiste unterzubringen — nebeneinander bräuchte es die doppelte Breite,
|
||||||
|
/// und die hat man dort nicht.
|
||||||
|
@MainActor
|
||||||
|
public final class NetworkMenuBarModule: MenuBarModule {
|
||||||
|
|
||||||
|
public let id = "network"
|
||||||
|
public var displayName: String { String(localized: "network.name", bundle: .module) }
|
||||||
|
|
||||||
|
private let model: NetworkModel
|
||||||
|
private var token: UUID?
|
||||||
|
private var view: NetworkStatusView?
|
||||||
|
|
||||||
|
public init(model: NetworkModel) { self.model = model }
|
||||||
|
|
||||||
|
public var shortLabel: String { String(localized: "menubar.short.network", bundle: .module) }
|
||||||
|
|
||||||
|
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||||
|
let view = NetworkStatusView(presentation: presentation)
|
||||||
|
view.update(model.snapshot, history: model.series(download: true))
|
||||||
|
self.view = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makePopoverView() -> AnyView {
|
||||||
|
AnyView(NetworkPopoverContent(model: model))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func activate() {
|
||||||
|
guard token == nil else { return }
|
||||||
|
token = model.addConsumer(interval: 2)
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deactivate() {
|
||||||
|
if let token { model.removeConsumer(token) }
|
||||||
|
token = nil
|
||||||
|
view = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startObserving() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = model.snapshot
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, token != nil else { return }
|
||||||
|
view?.update(model.snapshot, history: model.series(download: true))
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class NetworkStatusView: NSView, WidthReporting {
|
||||||
|
|
||||||
|
private let presentation: MenuBarPresentation
|
||||||
|
private var snapshot = NetworkSnapshot()
|
||||||
|
private var history: [Double] = []
|
||||||
|
|
||||||
|
init(presentation: MenuBarPresentation) {
|
||||||
|
self.presentation = presentation
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22))
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// Breiter als die Hardwaremodule: „↓ 2,4 MB/s" braucht schlicht mehr Platz
|
||||||
|
/// als „46 %".
|
||||||
|
/// Der Platz für die **aktuellen** Raten.
|
||||||
|
///
|
||||||
|
/// Auf „↓ 999,9 MB/s" zu dimensionieren hieße, neben „↓ 13 KB/s" dauerhaft
|
||||||
|
/// die halbe Breite leer zu lassen — und genau so sah es aus. Gemessen wird
|
||||||
|
/// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die
|
||||||
|
/// Leiste dabei nicht zappelt.
|
||||||
|
/// Feste Breite, bemessen am breitesten Wert, der vorkommen kann.
|
||||||
|
///
|
||||||
|
/// Nicht am aktuellen: aus „14 KB/s" wird „431 KB/s", und mit jeder
|
||||||
|
/// zusätzlichen Stelle wanderte die halbe Menüleiste. „999 MB/s" kostet
|
||||||
|
/// gegenüber einem dreistelligen KB-Wert nur wenige Punkte — das ist der
|
||||||
|
/// Preis dafür, dass nie etwas springt.
|
||||||
|
private static var ratesWidth: CGFloat {
|
||||||
|
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
|
||||||
|
let widest = NSAttributedString(string: "↓ 999 MB/s", attributes: [.font: font])
|
||||||
|
return ceil(widest.size().width) + 2 * MenuBarText.sidePadding
|
||||||
|
}
|
||||||
|
|
||||||
|
private func neededWidth() -> CGFloat {
|
||||||
|
switch presentation {
|
||||||
|
case .symbol:
|
||||||
|
max(MenuBarText.minimumWidth,
|
||||||
|
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4)
|
||||||
|
case .graph, .bars: 28
|
||||||
|
case .value: Self.ratesWidth
|
||||||
|
case .valueAndGraph: Self.ratesWidth + 26
|
||||||
|
// Die Raten tragen mit ↓ und ↑ ihre Beschriftung schon in sich; ein
|
||||||
|
// Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich —
|
||||||
|
// es zeigt WLAN, Ethernet oder VPN.
|
||||||
|
case .labelAndValue, .symbolAndValue:
|
||||||
|
Self.ratesWidth + symbolSlot + MenuBarText.innerGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var symbolSlot: CGFloat {
|
||||||
|
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var width: CGFloat = MenuBarText.minimumWidth
|
||||||
|
var onWidthChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
NSSize(width: width, height: 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(_ snapshot: NetworkSnapshot, history: [Double]) {
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.history = history
|
||||||
|
needsDisplay = true
|
||||||
|
|
||||||
|
let next = max(neededWidth(), MenuBarText.minimumWidth)
|
||||||
|
guard next != width else { return }
|
||||||
|
width = next
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
onWidthChange?(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
let color = NSColor.labelColor
|
||||||
|
|
||||||
|
switch presentation {
|
||||||
|
case .symbol:
|
||||||
|
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
|
||||||
|
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
|
||||||
|
width: 15, height: 15))
|
||||||
|
|
||||||
|
case .graph, .bars:
|
||||||
|
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
|
||||||
|
|
||||||
|
case .labelAndValue, .symbolAndValue:
|
||||||
|
let glyph = symbolSlot
|
||||||
|
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
|
||||||
|
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
||||||
|
width: glyph, height: 13))
|
||||||
|
drawRates(color: color,
|
||||||
|
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
|
||||||
|
width: bounds.width - glyph - MenuBarText.innerGap,
|
||||||
|
height: bounds.height))
|
||||||
|
|
||||||
|
case .value, .valueAndGraph:
|
||||||
|
drawRates(color: color,
|
||||||
|
in: presentation == .value ? bounds
|
||||||
|
: NSRect(x: 0, y: 0, width: bounds.width - 26, height: bounds.height))
|
||||||
|
if presentation == .valueAndGraph {
|
||||||
|
drawGraph(color: color,
|
||||||
|
in: NSRect(x: bounds.width - 24, y: 5, width: 22, height: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Zeilen à 9 pt — die einzige Art, Hoch und Runter in 22 pt Höhe
|
||||||
|
/// unterzubringen.
|
||||||
|
private func drawRates(color: NSColor, in rect: NSRect) {
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
|
||||||
|
.foregroundColor: color,
|
||||||
|
]
|
||||||
|
let down = NSAttributedString(string: "↓ " + Throughput.formatted(snapshot.downloadRate),
|
||||||
|
attributes: attributes)
|
||||||
|
let up = NSAttributedString(string: "↑ " + Throughput.formatted(snapshot.uploadRate),
|
||||||
|
attributes: attributes)
|
||||||
|
// Mittig statt links: bleibt doch einmal Platz übrig — etwa weil die
|
||||||
|
// zweite Zeile schmaler ist —, verteilt er sich auf beide Seiten statt
|
||||||
|
// als Lücke rechts zu stehen.
|
||||||
|
down.draw(at: NSPoint(x: rect.midX - down.size().width / 2, y: rect.midY - 0.5))
|
||||||
|
up.draw(at: NSPoint(x: rect.midX - up.size().width / 2, y: rect.midY - 10.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawGraph(color: NSColor, in rect: NSRect) {
|
||||||
|
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||||
|
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
|
||||||
|
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
|
||||||
|
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
|
||||||
|
// Breite.
|
||||||
|
let xs = GraphLayout.positions(count: history.count,
|
||||||
|
capacity: NetworkModel.historyLength, in: rect)
|
||||||
|
guard !xs.isEmpty else { return }
|
||||||
|
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
|
||||||
|
context.setLineWidth(1)
|
||||||
|
for (index, x) in xs.enumerated() {
|
||||||
|
let value = history[history.count - xs.count + index]
|
||||||
|
let point = CGPoint(x: x,
|
||||||
|
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
|
||||||
|
index == 0 ? context.move(to: point) : context.addLine(to: point)
|
||||||
|
}
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
214
Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
import SystemConfiguration
|
||||||
|
import CoreWLAN
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
public struct NetworkSnapshot: Equatable, Sendable {
|
||||||
|
/// Byte pro Sekunde.
|
||||||
|
public var downloadRate: Double = 0
|
||||||
|
public var uploadRate: Double = 0
|
||||||
|
/// Summen seit dem Systemstart.
|
||||||
|
public var totalReceived: UInt64 = 0
|
||||||
|
public var totalSent: UInt64 = 0
|
||||||
|
|
||||||
|
public var interfaceName: String?
|
||||||
|
public var interfaceKind: InterfaceKind = .unknown
|
||||||
|
public var ipv4: String?
|
||||||
|
public var ipv6: String?
|
||||||
|
public var router: String?
|
||||||
|
|
||||||
|
/// `nil`, solange die Ortungsberechtigung fehlt — macOS gibt den Namen
|
||||||
|
/// sonst nicht heraus. Siehe `wifiName`.
|
||||||
|
public var ssid: String?
|
||||||
|
public var isWiFiConnected = false
|
||||||
|
|
||||||
|
public var asOf = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InterfaceKind: String, Equatable, Sendable {
|
||||||
|
case wifi, ethernet, vpn, cellular, loopback, unknown
|
||||||
|
|
||||||
|
public var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .wifi: "wifi"
|
||||||
|
case .ethernet: "cable.connector"
|
||||||
|
case .vpn: "lock.shield"
|
||||||
|
case .cellular: "antenna.radiowaves.left.and.right"
|
||||||
|
case .loopback, .unknown: "network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest Durchsatz, Adressen und Verbindungsart.
|
||||||
|
///
|
||||||
|
/// Alles ohne root und ohne private Schnittstellen. Der Durchsatz kommt aus den
|
||||||
|
/// Byte-Zählern des Kernels; sie sind Summen seit dem Start, die Rate ergibt
|
||||||
|
/// sich erst aus der Differenz — siehe `Throughput.rate`.
|
||||||
|
public final class NetworkMetrics: @unchecked Sendable {
|
||||||
|
|
||||||
|
private var previousReceived: UInt64 = 0
|
||||||
|
private var previousSent: UInt64 = 0
|
||||||
|
private var previousSampleTime: Date?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func sample() -> NetworkSnapshot {
|
||||||
|
var snapshot = NetworkSnapshot()
|
||||||
|
|
||||||
|
let counters = readCounters()
|
||||||
|
snapshot.totalReceived = counters.received
|
||||||
|
snapshot.totalSent = counters.sent
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
if let previous = previousSampleTime {
|
||||||
|
let elapsed = now.timeIntervalSince(previous)
|
||||||
|
snapshot.downloadRate = Throughput.rate(previous: previousReceived,
|
||||||
|
current: counters.received, elapsed: elapsed)
|
||||||
|
snapshot.uploadRate = Throughput.rate(previous: previousSent,
|
||||||
|
current: counters.sent, elapsed: elapsed)
|
||||||
|
}
|
||||||
|
previousReceived = counters.received
|
||||||
|
previousSent = counters.sent
|
||||||
|
previousSampleTime = now
|
||||||
|
|
||||||
|
let primary = primaryInterface()
|
||||||
|
snapshot.interfaceName = primary.name
|
||||||
|
snapshot.interfaceKind = primary.kind
|
||||||
|
snapshot.router = primary.router
|
||||||
|
|
||||||
|
let addresses = localAddresses(for: primary.name)
|
||||||
|
snapshot.ipv4 = addresses.ipv4
|
||||||
|
snapshot.ipv6 = addresses.ipv6
|
||||||
|
|
||||||
|
snapshot.isWiFiConnected = primary.kind == .wifi
|
||||||
|
snapshot.ssid = wifiName()
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Durchsatz
|
||||||
|
|
||||||
|
/// Summiert alle Interfaces außer Loopback.
|
||||||
|
///
|
||||||
|
/// Einzeln zu zählen wäre genauer, aber praktisch nutzlos: bei einem
|
||||||
|
/// Wechsel von WLAN auf VPN wandert der Verkehr auf ein anderes Interface,
|
||||||
|
/// und eine Anzeige, die dabei auf null fällt, ist falsch.
|
||||||
|
private func readCounters() -> (received: UInt64, sent: UInt64) {
|
||||||
|
var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0]
|
||||||
|
var length = 0
|
||||||
|
guard sysctl(&mib, 6, nil, &length, nil, 0) == 0, length > 0 else { return (0, 0) }
|
||||||
|
|
||||||
|
var buffer = [UInt8](repeating: 0, count: length)
|
||||||
|
guard sysctl(&mib, 6, &buffer, &length, nil, 0) == 0 else { return (0, 0) }
|
||||||
|
|
||||||
|
var received: UInt64 = 0
|
||||||
|
var sent: UInt64 = 0
|
||||||
|
|
||||||
|
buffer.withUnsafeBytes { raw in
|
||||||
|
var offset = 0
|
||||||
|
while offset < length {
|
||||||
|
let header = raw.baseAddress!.advanced(by: offset)
|
||||||
|
.assumingMemoryBound(to: if_msghdr.self).pointee
|
||||||
|
guard header.ifm_msglen > 0 else { break }
|
||||||
|
defer { offset += Int(header.ifm_msglen) }
|
||||||
|
|
||||||
|
guard header.ifm_type == RTM_IFINFO2 else { continue }
|
||||||
|
let message = raw.baseAddress!.advanced(by: offset)
|
||||||
|
.assumingMemoryBound(to: if_msghdr2.self).pointee
|
||||||
|
|
||||||
|
// Loopback ausschließen: der lokale Verkehr zwischen Programmen
|
||||||
|
// hat mit „was geht über die Leitung" nichts zu tun und kann
|
||||||
|
// ein Vielfaches davon sein.
|
||||||
|
guard message.ifm_data.ifi_type != UInt8(IFT_LOOP) else { continue }
|
||||||
|
|
||||||
|
received += message.ifm_data.ifi_ibytes
|
||||||
|
sent += message.ifm_data.ifi_obytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (received, sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interface und Adressen
|
||||||
|
|
||||||
|
private struct Primary {
|
||||||
|
var name: String?
|
||||||
|
var kind: InterfaceKind = .unknown
|
||||||
|
var router: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func primaryInterface() -> Primary {
|
||||||
|
var primary = Primary()
|
||||||
|
guard let store = SCDynamicStoreCreate(nil, "Onyx" as CFString, nil, nil),
|
||||||
|
let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString)
|
||||||
|
as? [String: Any] else { return primary }
|
||||||
|
|
||||||
|
primary.name = global["PrimaryInterface"] as? String
|
||||||
|
primary.router = global["Router"] as? String
|
||||||
|
primary.kind = kind(of: primary.name)
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kind(of name: String?) -> InterfaceKind {
|
||||||
|
guard let name else { return .unknown }
|
||||||
|
// Die Namensschemata sind stabil: en0/en1 sind Ethernet oder WLAN,
|
||||||
|
// utun/ipsec/ppp gehören zu VPNs, lo0 ist die Loopback-Schnittstelle.
|
||||||
|
if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn }
|
||||||
|
if name.hasPrefix("lo") { return .loopback }
|
||||||
|
if name.hasPrefix("pdp_ip") { return .cellular }
|
||||||
|
if name.hasPrefix("en") {
|
||||||
|
// en0 ist auf Notebooks das WLAN, aber nicht zwingend — CoreWLAN
|
||||||
|
// weiß es genau.
|
||||||
|
let wifiNames = CWWiFiClient.interfaceNames() ?? []
|
||||||
|
return wifiNames.contains(name) ? .wifi : .ethernet
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localAddresses(for interface: String?) -> (ipv4: String?, ipv6: String?) {
|
||||||
|
guard let interface else { return (nil, nil) }
|
||||||
|
|
||||||
|
var pointer: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&pointer) == 0, let first = pointer else { return (nil, nil) }
|
||||||
|
defer { freeifaddrs(pointer) }
|
||||||
|
|
||||||
|
var ipv4: String?
|
||||||
|
var ipv6: String?
|
||||||
|
|
||||||
|
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
guard String(cString: entry.pointee.ifa_name) == interface,
|
||||||
|
let address = entry.pointee.ifa_addr else { continue }
|
||||||
|
|
||||||
|
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
guard getnameinfo(address, socklen_t(address.pointee.sa_len),
|
||||||
|
&host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let text = String(cString: host)
|
||||||
|
switch Int32(address.pointee.sa_family) {
|
||||||
|
case AF_INET where ipv4 == nil:
|
||||||
|
ipv4 = text
|
||||||
|
case AF_INET6 where ipv6 == nil:
|
||||||
|
// Link-lokale Adressen (fe80::) tragen einen Zonen-Anhang und
|
||||||
|
// sagen dem Nutzer nichts.
|
||||||
|
if !text.hasPrefix("fe80") { ipv6 = text }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (ipv4, ipv6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WLAN
|
||||||
|
|
||||||
|
/// Der Netzwerkname — oder `nil`, wenn macOS ihn nicht herausgibt.
|
||||||
|
///
|
||||||
|
/// Seit macOS 14 liefert `ssid()` nur mit erteilter Ortungsberechtigung
|
||||||
|
/// einen Wert. Fehlt sie, kommt `nil` zurück, **ohne** dass ein Fehler
|
||||||
|
/// erscheint. Deshalb wird hier nicht geraten: die Oberfläche zeigt dann
|
||||||
|
/// „WLAN verbunden" statt eines leeren Feldes, das nach einem Defekt
|
||||||
|
/// aussieht.
|
||||||
|
private func wifiName() -> String? {
|
||||||
|
CWWiFiClient.shared().interface()?.ssid()
|
||||||
|
}
|
||||||
|
}
|
||||||
203
Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import MetricsProvider
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxMenuBar
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct NetworkPopoverContent: View {
|
||||||
|
let model: NetworkModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
|
||||||
|
Spacer()
|
||||||
|
rate(model.snapshot.uploadRate, "network.upload", Onyx.Color.upload)
|
||||||
|
}
|
||||||
|
|
||||||
|
TrafficChart(history: model.history)
|
||||||
|
.frame(height: 56)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download")
|
||||||
|
Spacer()
|
||||||
|
peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName)
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
if model.snapshot.interfaceKind == .vpn {
|
||||||
|
// Ein VPN verändert, was die Zahlen darüber bedeuten — der
|
||||||
|
// Verkehr läuft dann durch den Tunnel, nicht direkt.
|
||||||
|
// Über Text statt Label(_:systemImage:): letzteres kennt kein
|
||||||
|
// Bundle und zeigt sonst den rohen Schlüssel an.
|
||||||
|
Label {
|
||||||
|
Text("network.vpn.active", bundle: .module)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "lock.shield")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
section("network.addresses")
|
||||||
|
field("network.field.interface", model.snapshot.interfaceName)
|
||||||
|
field("network.field.ipv4", model.snapshot.ipv4)
|
||||||
|
field("network.field.ipv6", model.snapshot.ipv6)
|
||||||
|
field("network.field.router", model.snapshot.router)
|
||||||
|
|
||||||
|
section("network.transferred")
|
||||||
|
field("network.total.received", bytes(model.snapshot.totalReceived))
|
||||||
|
field("network.total.sent", bytes(model.snapshot.totalSent))
|
||||||
|
|
||||||
|
section("network.public")
|
||||||
|
if model.showsPublicAddress {
|
||||||
|
HStack {
|
||||||
|
if model.publicAddress.isLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if let address = model.publicAddress.address {
|
||||||
|
Text(address).font(.callout.monospacedDigit()).textSelection(.enabled)
|
||||||
|
} else if model.publicAddress.failed {
|
||||||
|
Text("network.public.failed", bundle: .module)
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button { model.publicAddress.refresh(force: true) } label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
// Wer die Adresse erfährt, gehört sichtbar dazu.
|
||||||
|
Text(verbatim: PublicAddressLookup.serviceName)
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
} else {
|
||||||
|
Toggle(isOn: Binding(get: { model.showsPublicAddress },
|
||||||
|
set: { model.showsPublicAddress = $0 })) {
|
||||||
|
Text("network.public.enable", bundle: .module).font(.callout)
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.controlSize(.small)
|
||||||
|
Text("network.public.hint", bundle: .module)
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.processes.isEmpty {
|
||||||
|
section("network.processes")
|
||||||
|
ForEach(model.processes) { process in
|
||||||
|
HStack {
|
||||||
|
Text(process.name).font(.callout).lineLimit(1)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(Throughput.formatted(process.downloadRate))
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(Onyx.Color.download)
|
||||||
|
Text(Throughput.formatted(process.uploadRate))
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(Onyx.Color.upload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(width: 300)
|
||||||
|
.onAppear { model.addDetailConsumer() }
|
||||||
|
.onDisappear { model.removeDetailConsumer() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionName: String {
|
||||||
|
if let ssid = model.snapshot.ssid { return ssid }
|
||||||
|
if model.snapshot.isWiFiConnected {
|
||||||
|
return String(localized: "network.wifi.connected", bundle: .module)
|
||||||
|
}
|
||||||
|
return model.snapshot.interfaceName
|
||||||
|
?? String(localized: "network.offline", bundle: .module)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(Throughput.formatted(value))
|
||||||
|
.font(.system(size: 18, weight: .medium).monospacedDigit())
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle().fill(tint).frame(width: 6, height: 6)
|
||||||
|
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func section(_ key: LocalizedStringKey) -> some View {
|
||||||
|
Text(key, bundle: .module)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
|
||||||
|
if let value {
|
||||||
|
HStack {
|
||||||
|
Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bytes(_ value: UInt64) -> String {
|
||||||
|
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verlauf in beide Richtungen: Download nach unten, Upload nach oben.
|
||||||
|
///
|
||||||
|
/// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu
|
||||||
|
/// lesen — hier sieht man sofort, ob gerade geladen oder gesendet wird, und in
|
||||||
|
/// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst
|
||||||
|
/// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s.
|
||||||
|
private struct TrafficChart: View {
|
||||||
|
let history: [NetworkSnapshot]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let points = history.suffix(NetworkModel.historyLength)
|
||||||
|
let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1)
|
||||||
|
// Die Balkenbreite kommt aus der **vollen** Länge des Verlaufs.
|
||||||
|
// Vorher war sie `Breite / Anzahl`: bei fünf Messwerten also ein
|
||||||
|
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
|
||||||
|
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
|
||||||
|
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
|
||||||
|
totalWidth: geometry.size.width, spacing: 1)
|
||||||
|
let half = geometry.size.height / 2
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: 1) {
|
||||||
|
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
|
||||||
|
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Onyx.Color.upload)
|
||||||
|
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
|
||||||
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Onyx.Color.download)
|
||||||
|
.frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5))
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(width: width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
238
Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
/// Wie `MetricsModel`, aber fürs Netz: eine Messschleife für Panel und
|
||||||
|
/// Menüleiste, mit Referenzzählung.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class NetworkModel {
|
||||||
|
|
||||||
|
public private(set) var snapshot = NetworkSnapshot()
|
||||||
|
public private(set) var history: [NetworkSnapshot] = []
|
||||||
|
|
||||||
|
public static let historyLength = 60
|
||||||
|
|
||||||
|
/// Durchsatz je Programm — nur gefüllt, solange ein Popover offen ist.
|
||||||
|
public private(set) var processes: [ProcessTraffic] = []
|
||||||
|
/// Die öffentliche Adresse, wenn eingeschaltet.
|
||||||
|
public let publicAddress = PublicAddressLookup()
|
||||||
|
|
||||||
|
/// Ob die öffentliche Adresse überhaupt abgefragt werden darf.
|
||||||
|
public var showsPublicAddress: Bool {
|
||||||
|
didSet {
|
||||||
|
guard showsPublicAddress != oldValue else { return }
|
||||||
|
defaults.set(showsPublicAddress, forKey: Self.publicAddressKey)
|
||||||
|
showsPublicAddress ? publicAddress.refresh() : publicAddress.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let publicAddressKey = "onyx.network.showsPublicAddress"
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let source = NetworkMetrics()
|
||||||
|
private let processSampler = ProcessTrafficSampler()
|
||||||
|
private var timer: Timer?
|
||||||
|
private var demands: [UUID: TimeInterval] = [:]
|
||||||
|
/// Zählt die offenen Popovers. Die Prozessliste kostet einen Fremdprozess
|
||||||
|
/// je Messung — sie läuft nur, während wirklich jemand hinsieht.
|
||||||
|
private var detailConsumers = 0
|
||||||
|
|
||||||
|
public init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.showsPublicAddress = defaults.bool(forKey: Self.publicAddressKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet ein offenes Popover an. Erst dann werden Prozesse abgefragt.
|
||||||
|
public func addDetailConsumer() {
|
||||||
|
detailConsumers += 1
|
||||||
|
if showsPublicAddress { publicAddress.refresh() }
|
||||||
|
sampleProcesses()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeDetailConsumer() {
|
||||||
|
detailConsumers = max(detailConsumers - 1, 0)
|
||||||
|
if detailConsumers == 0 { processes = [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sampleProcesses() {
|
||||||
|
guard detailConsumers > 0 else { return }
|
||||||
|
// `nettop` blockiert kurz — nicht auf dem Hauptthread.
|
||||||
|
let sampler = processSampler
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
let result = sampler.sample()
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self, detailConsumers > 0 else { return }
|
||||||
|
processes = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func addConsumer(interval: TimeInterval = 2) -> UUID {
|
||||||
|
let token = UUID()
|
||||||
|
demands[token] = min(max(interval, 1), 10)
|
||||||
|
restartTimer()
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeConsumer(_ token: UUID) {
|
||||||
|
demands[token] = nil
|
||||||
|
restartTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
guard let interval = demands.values.min() else {
|
||||||
|
history.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sample()
|
||||||
|
let timer = Timer(timeInterval: interval, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.sample() }
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
self.timer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sample() {
|
||||||
|
let value = source.sample()
|
||||||
|
snapshot = value
|
||||||
|
sampleProcesses()
|
||||||
|
history.append(value)
|
||||||
|
if history.count > Self.historyLength {
|
||||||
|
history.removeFirst(history.count - Self.historyLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verläufe, auf den bisherigen Höchstwert normiert.
|
||||||
|
///
|
||||||
|
/// Eine feste Obergrenze wäre unbrauchbar: bei 100 Mbit/s wäre eine
|
||||||
|
/// Video-Wiedergabe ein flacher Strich, und bei 1 Mbit/s ginge jeder
|
||||||
|
/// Download über den Rand. Die Kurve zeigt deshalb den Verlauf relativ zum
|
||||||
|
/// bisherigen Maximum — die absoluten Zahlen stehen daneben.
|
||||||
|
public func series(download: Bool) -> [Double] {
|
||||||
|
let values = history.map { download ? $0.downloadRate : $0.uploadRate }
|
||||||
|
guard let peak = values.max(), peak > 0 else { return values.map { _ in 0 } }
|
||||||
|
return values.map { $0 / peak }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
public struct NetworkWidget: OnyxWidget {
|
||||||
|
public let id = "network"
|
||||||
|
public var displayName: String { String(localized: "network.name", bundle: .module) }
|
||||||
|
public let symbolName = "network"
|
||||||
|
|
||||||
|
private let model: NetworkModel
|
||||||
|
|
||||||
|
public init(model: NetworkModel) { self.model = model }
|
||||||
|
|
||||||
|
public func makeView() -> AnyView {
|
||||||
|
AnyView(NetworkWidgetView(model: model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NetworkWidgetView: View {
|
||||||
|
let model: NetworkModel
|
||||||
|
@State private var token: UUID?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: model.snapshot.interfaceKind.symbolName)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Onyx.Color.download)
|
||||||
|
Text(connectionName)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rates(snapshot: model.snapshot, compact: false)
|
||||||
|
|
||||||
|
Sparkline(values: model.series(download: true), tint: Onyx.Color.download,
|
||||||
|
capacity: NetworkModel.historyLength)
|
||||||
|
.frame(height: 18)
|
||||||
|
|
||||||
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
|
Details(snapshot: model.snapshot)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.onAppear { token = model.addConsumer(interval: 1) }
|
||||||
|
.onDisappear {
|
||||||
|
if let token { model.removeConsumer(token) }
|
||||||
|
token = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Netzwerkname, wenn macOS ihn herausgibt — sonst die Verbindungsart.
|
||||||
|
///
|
||||||
|
/// Seit macOS 14 liefert die SSID nur mit Ortungsberechtigung einen Wert.
|
||||||
|
/// Fehlt sie, wäre ein leeres Feld eine Falschaussage: verbunden ist man ja.
|
||||||
|
private var connectionName: String {
|
||||||
|
if let ssid = model.snapshot.ssid { return ssid }
|
||||||
|
if model.snapshot.isWiFiConnected {
|
||||||
|
return String(localized: "network.wifi.connected", bundle: .module)
|
||||||
|
}
|
||||||
|
return model.snapshot.interfaceName ?? String(localized: "network.offline", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Rates: View {
|
||||||
|
let snapshot: NetworkSnapshot
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download)
|
||||||
|
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
Text(text)
|
||||||
|
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Details: View {
|
||||||
|
let snapshot: NetworkSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
field("network.field.interface", snapshot.interfaceName)
|
||||||
|
field("network.field.ipv4", snapshot.ipv4)
|
||||||
|
field("network.field.router", snapshot.router)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
|
||||||
|
if let value {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(label, bundle: .module)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"strings": {
|
||||||
"singleInstance.blocked.message" : {
|
"common.ok": {
|
||||||
"comment" : "Titel des Dialogs, wenn eine zweite Instanz zurücktritt",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value" : "Onyx läuft bereits"
|
"value": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value" : "Onyx is already running"
|
"value": "OK"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,18 +34,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common.ok" : {
|
"singleInstance.blocked.message": {
|
||||||
|
"comment": "Titel des Dialogs, wenn eine zweite Instanz zurücktritt",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value" : "OK"
|
"value": "Onyx läuft bereits"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value" : "OK"
|
"value": "Onyx is already running"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
Packages/OnyxKit/Sources/OnyxCore/LoginItemState.swift
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
30
Packages/OnyxKit/Sources/OnyxDesign/OnyxLabelStyle.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Färbt das Symbol eines `Label` in der eingestellten Akzentfarbe.
|
||||||
|
///
|
||||||
|
/// **Nötig, weil `.tint` hier nicht greift.** SwiftUI zeichnet Symbole in
|
||||||
|
/// Listen und Formularen auf macOS in der Akzentfarbe des *Systems* — nicht in
|
||||||
|
/// der des Fensters. Das Ergebnis war eine Widget-Liste in Systemgrün neben
|
||||||
|
/// einer Reiterleiste in Onyx-Blau, obwohl beides derselben Einstellung folgen
|
||||||
|
/// soll.
|
||||||
|
///
|
||||||
|
/// Ein Stil statt einer Farbe an jeder Fundstelle: er vererbt sich über die
|
||||||
|
/// Umgebung, also genügt eine Zeile an der Wurzel. Ein Symbol, das seine Farbe
|
||||||
|
/// selbst mitbringt — etwa der grüne Haken für „erteilt" —, behält sie: die
|
||||||
|
/// innere Angabe schlägt die äußere.
|
||||||
|
public struct OnyxAccentLabelStyle: LabelStyle {
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func makeBody(configuration: Configuration) -> some View {
|
||||||
|
Label {
|
||||||
|
configuration.title
|
||||||
|
} icon: {
|
||||||
|
configuration.icon.foregroundStyle(Onyx.Color.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension LabelStyle where Self == OnyxAccentLabelStyle {
|
||||||
|
static var onyx: OnyxAccentLabelStyle { OnyxAccentLabelStyle() }
|
||||||
|
}
|
||||||
106
Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Die einstellbaren Farben.
|
||||||
|
///
|
||||||
|
/// **Nicht alle Farben sind einstellbar, und das ist Absicht.** Gelb heißt
|
||||||
|
/// Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
|
||||||
|
/// Lüfter und Fehlermeldungen. Wer sie umstellen kann, kann ihre Bedeutung
|
||||||
|
/// zerstören; ein rotes „alles gut" liest niemand richtig. Einstellbar ist
|
||||||
|
/// deshalb die Akzentfarbe, mit der Onyx sich selbst zeichnet, und das Paar für
|
||||||
|
/// Herunter- und Hochladen — dort ist die Zuordnung willkürlich und nur eine
|
||||||
|
/// Gewohnheit.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class OnyxTheme {
|
||||||
|
|
||||||
|
/// Die Instanz, aus der `Onyx.Color` liest.
|
||||||
|
public static let shared = OnyxTheme()
|
||||||
|
|
||||||
|
/// Kühl und entsättigt — Farbe ist in Onyx ein Signal, keine Dekoration.
|
||||||
|
public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0)
|
||||||
|
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
|
||||||
|
/// aber eine verbreitete. Wer sie anders kennt, dreht sie um.
|
||||||
|
public static let defaultDownload = Color(red: 0.435, green: 0.812, blue: 0.592)
|
||||||
|
public static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612)
|
||||||
|
|
||||||
|
public var accent: Color {
|
||||||
|
didSet { store(accent, as: Keys.accent) }
|
||||||
|
}
|
||||||
|
public var download: Color {
|
||||||
|
didSet { store(download, as: Keys.download) }
|
||||||
|
}
|
||||||
|
public var upload: Color {
|
||||||
|
didSet { store(upload, as: Keys.upload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let accent = "onyx.theme.accent"
|
||||||
|
static let download = "onyx.theme.download"
|
||||||
|
static let upload = "onyx.theme.upload"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
|
||||||
|
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
|
||||||
|
upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob alles auf den Werkseinstellungen steht — für den Zurücksetzen-Knopf,
|
||||||
|
/// der sonst auch dann anböte, etwas zu tun, wenn es nichts zu tun gibt.
|
||||||
|
public var isDefault: Bool {
|
||||||
|
accent.onyxHex == Self.defaultAccent.onyxHex
|
||||||
|
&& download.onyxHex == Self.defaultDownload.onyxHex
|
||||||
|
&& upload.onyxHex == Self.defaultUpload.onyxHex
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reset() {
|
||||||
|
accent = Self.defaultAccent
|
||||||
|
download = Self.defaultDownload
|
||||||
|
upload = Self.defaultUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übernimmt die Akzentfarbe des Systems.
|
||||||
|
///
|
||||||
|
/// Genau die stand hier vorher an vielen Stellen — unfreiwillig, weil
|
||||||
|
/// `.tint` und `.accentColor` ohne eigene Angabe dorthin durchgereicht
|
||||||
|
/// werden. Wem das gefiel, soll es behalten können.
|
||||||
|
public func adoptSystemAccent() {
|
||||||
|
accent = Color(nsColor: .controlAccentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func store(_ color: Color, as key: String) {
|
||||||
|
defaults.set(color.onyxHex, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Color {
|
||||||
|
|
||||||
|
/// `#RRGGBB`, immer in sRGB.
|
||||||
|
///
|
||||||
|
/// Der Umweg über `NSColor` ist nötig, weil eine `Color` aus dem Farbwähler
|
||||||
|
/// in einem beliebigen Farbraum liegen kann — ohne Umrechnung ergäbe die
|
||||||
|
/// Zerlegung in Komponenten je nach Bildschirm etwas anderes.
|
||||||
|
var onyxHex: String {
|
||||||
|
guard let srgb = NSColor(self).usingColorSpace(.sRGB) else { return "#7FA8FF" }
|
||||||
|
let red = Int((srgb.redComponent * 255).rounded())
|
||||||
|
let green = Int((srgb.greenComponent * 255).rounded())
|
||||||
|
let blue = Int((srgb.blueComponent * 255).rounded())
|
||||||
|
return String(format: "#%02X%02X%02X", red, green, blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest `#RRGGBB` und `RRGGBB`.
|
||||||
|
init?(onyxHex: String?) {
|
||||||
|
guard let onyxHex else { return nil }
|
||||||
|
var text = onyxHex.trimmingCharacters(in: .whitespaces)
|
||||||
|
if text.hasPrefix("#") { text.removeFirst() }
|
||||||
|
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
|
||||||
|
self.init(.sRGB,
|
||||||
|
red: Double((value >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((value >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(value & 0xFF) / 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,22 @@ public enum Onyx {
|
|||||||
public static let textSecondary = SwiftUI.Color.white.opacity(0.58)
|
public static let textSecondary = SwiftUI.Color.white.opacity(0.58)
|
||||||
public static let textTertiary = SwiftUI.Color.white.opacity(0.34)
|
public static let textTertiary = SwiftUI.Color.white.opacity(0.34)
|
||||||
|
|
||||||
/// Kühl und entsättigt. Farbe ist in Onyx ein Signal, keine Dekoration.
|
/// Die Farbe, mit der Onyx sich selbst zeichnet — einstellbar.
|
||||||
public static let accent = SwiftUI.Color(red: 0.498, green: 0.659, blue: 1.0)
|
///
|
||||||
|
/// Gelesen statt gespeichert: die Tokens holen den Wert bei jedem
|
||||||
|
/// Zugriff aus `OnyxTheme`. Damit merkt SwiftUI die Abhängigkeit und
|
||||||
|
/// zeichnet neu, sobald sich die Farbe ändert — bei einer Konstanten
|
||||||
|
/// wären alle vierzig Fundstellen einzeln umzustellen gewesen.
|
||||||
|
@MainActor public static var accent: SwiftUI.Color { OnyxTheme.shared.accent }
|
||||||
|
|
||||||
|
/// Herunter- und Hochladen. Auch einstellbar: welche Richtung grün ist
|
||||||
|
/// und welche rosa, ist Gewohnheit, keine Bedeutung.
|
||||||
|
@MainActor public static var download: SwiftUI.Color { OnyxTheme.shared.download }
|
||||||
|
@MainActor public static var upload: SwiftUI.Color { OnyxTheme.shared.upload }
|
||||||
|
|
||||||
|
/// Die Signalfarben bleiben fest. Gelb heißt Warnung, Rot heißt
|
||||||
|
/// kritisch, Grün heißt in Ordnung — wer das umstellen kann, kann die
|
||||||
|
/// Bedeutung zerstören.
|
||||||
public static let positive = SwiftUI.Color(red: 0.435, green: 0.812, blue: 0.592)
|
public static let positive = SwiftUI.Color(red: 0.435, green: 0.812, blue: 0.592)
|
||||||
public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290)
|
public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290)
|
||||||
public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361)
|
public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Das Ladelimit — Kodierung und harte Grenzen.
|
||||||
|
///
|
||||||
|
/// Der SMC-Key heißt `CHLT`, drei Bytes. Gemessen am 11.08.2026 auf Mac17,9,
|
||||||
|
/// rein lesend, gegen macOS' eigene Ladebegrenzung:
|
||||||
|
///
|
||||||
|
/// | Einstellung | `CHLT` |
|
||||||
|
/// |---|---|
|
||||||
|
/// | 80 % | `50 01 00` |
|
||||||
|
/// | 85 % | `55 01 00` |
|
||||||
|
/// | 95 % | `5F 01 00` |
|
||||||
|
///
|
||||||
|
/// Erstes Byte ist die Prozentzahl, das zweite offenbar „Limit aktiv", das
|
||||||
|
/// dritte unbenutzt. Mehr ist nicht belegt, und mehr wird hier auch nicht
|
||||||
|
/// angenommen.
|
||||||
|
///
|
||||||
|
/// **Warum das strenger gehandhabt wird als die Lüfter:** dort liefert die
|
||||||
|
/// Firmware mit `F0Mn`/`F0Mx` ihre eigenen Grenzen mit, an denen sich das
|
||||||
|
/// Sicherheitsnetz festhalten kann. Hier gibt es nichts dergleichen — es gibt
|
||||||
|
/// nur die Werte, die wir gemessen haben. Ein falscher Wert im Lade-Subsystem
|
||||||
|
/// ist die eine Operation in diesem Projekt, die den Akku dauerhaft
|
||||||
|
/// beschädigen kann.
|
||||||
|
public enum ChargeSafety {
|
||||||
|
|
||||||
|
/// Der SMC-Key.
|
||||||
|
public static let key = "CHLT"
|
||||||
|
|
||||||
|
/// Untergrenze. macOS selbst bietet nichts darunter an; darunter ist
|
||||||
|
/// ungemessenes Gebiet.
|
||||||
|
public static let minimumPercent = 80
|
||||||
|
public static let maximumPercent = 100
|
||||||
|
|
||||||
|
/// „Kein Limit" heißt hundert Prozent.
|
||||||
|
///
|
||||||
|
/// Und nicht: das Aktiv-Byte auf null setzen. Diese Kodierung hat macOS nie
|
||||||
|
/// geschrieben, sie ist also ungemessen — hundert Prozent erreicht dasselbe
|
||||||
|
/// innerhalb dessen, was belegt ist.
|
||||||
|
public static let noLimit = 100
|
||||||
|
|
||||||
|
public static func clamp(_ percent: Int) -> Int {
|
||||||
|
// Anheben statt ablehnen: ein Regler, der bei einer Fehleingabe gar
|
||||||
|
// nichts tut, wirkt kaputt.
|
||||||
|
min(max(percent, minimumPercent), maximumPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die drei Bytes für einen Prozentwert.
|
||||||
|
///
|
||||||
|
/// Nur das erste Byte trägt den Wunsch. Die beiden anderen bekommen exakt
|
||||||
|
/// das, was macOS dort schreibt — ungemessene Bytes im Ladepfad werden
|
||||||
|
/// nicht angefasst.
|
||||||
|
public static func encode(_ percent: Int) -> [UInt8] {
|
||||||
|
[UInt8(clamp(percent)), 0x01, 0x00]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Prozentwert aus den gelesenen Bytes. `nil`, wenn kein Limit gilt
|
||||||
|
/// oder die Daten nicht plausibel sind.
|
||||||
|
public static func decode(_ bytes: [UInt8]) -> Int? {
|
||||||
|
guard bytes.count >= 2 else { return nil }
|
||||||
|
// Zweites Byte null: kein Limit, egal was im ersten steht.
|
||||||
|
guard bytes[1] != 0 else { return nil }
|
||||||
|
let percent = Int(bytes[0])
|
||||||
|
// Ein Wert außerhalb des Möglichen wird nicht angezeigt. „Unbekannt"
|
||||||
|
// ist eine ehrlichere Auskunft als eine Zahl, die es nicht geben kann.
|
||||||
|
guard (1...100).contains(percent) else { return nil }
|
||||||
|
return percent
|
||||||
|
}
|
||||||
|
}
|
||||||
80
Packages/OnyxKit/Sources/OnyxHelperProtocol/FanSafety.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
/// Das aktuelle Ladelimit in Prozent, `-1` wenn keines gilt.
|
||||||
|
///
|
||||||
|
/// Nur lesen. Der SMC lehnt Schreibzugriffe auf `CHLT` ab — nachgemessen,
|
||||||
|
/// siehe docs/spikes/A-smc.md. macOS setzt den Wert offenbar auf einem
|
||||||
|
/// anderen Weg als über den SMC-Userclient.
|
||||||
|
///
|
||||||
|
/// `Int` statt `Int?`: Optionals sind über `@objc` nicht darstellbar.
|
||||||
|
func chargeLimit(reply: @escaping (Int) -> 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\""
|
||||||
|
}
|
||||||
41
Packages/OnyxKit/Sources/OnyxMenuBar/GraphLayout.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
/// Wo die Punkte eines Verlaufsdiagramms liegen.
|
||||||
|
///
|
||||||
|
/// Die Schrittweite kommt aus der **vollen** Länge des Verlaufs, nicht aus der
|
||||||
|
/// Anzahl der bisher vorhandenen Werte. Frisch gestartet hat der Verlauf zwei
|
||||||
|
/// Messwerte statt sechzig — wurde daraus gerechnet, spannte sich der Weg
|
||||||
|
/// zwischen ihnen über die ganze Breite. Das sah nach viel Bewegung aus und
|
||||||
|
/// bedeutete nichts.
|
||||||
|
public enum GraphLayout {
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - count: wie viele Werte vorliegen.
|
||||||
|
/// - capacity: wie viele der Verlauf fasst.
|
||||||
|
public static func positions(count: Int, capacity: Int, in rect: CGRect) -> [CGFloat] {
|
||||||
|
// Eine Linie braucht zwei Punkte. Einen zu zeichnen ergäbe einen Strich
|
||||||
|
// am Rand, der wie ein Messwert aussieht.
|
||||||
|
guard count >= 2, capacity >= 2 else { return [] }
|
||||||
|
|
||||||
|
let shown = min(count, capacity)
|
||||||
|
let step = rect.width / CGFloat(capacity - 1)
|
||||||
|
// Neue Werte rechts: der Verlauf wächst nach links weg.
|
||||||
|
let start = rect.maxX - CGFloat(shown - 1) * step
|
||||||
|
return (0..<shown).map { start + CGFloat($0) * step }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Breite eines Balkens.
|
||||||
|
///
|
||||||
|
/// Aus der **vollen** Länge gerechnet, nicht aus den vorhandenen Werten:
|
||||||
|
/// sonst ist bei fünf Messwerten jeder Balken ein Fünftel breit, und mit
|
||||||
|
/// jedem weiteren werden sie schmaler. Fehlende Werte bleiben stattdessen
|
||||||
|
/// als freie Fläche links stehen.
|
||||||
|
public static func barWidth(capacity: Int, totalWidth: CGFloat,
|
||||||
|
spacing: CGFloat) -> CGFloat {
|
||||||
|
guard capacity > 0 else { return totalWidth }
|
||||||
|
let gaps = spacing * CGFloat(capacity - 1)
|
||||||
|
// Sichtbar bleiben, auch wenn es eng wird — sonst verschwindet der
|
||||||
|
// Verlauf bei schmalem Fenster ganz.
|
||||||
|
return max((totalWidth - gaps) / CGFloat(capacity), 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul.
|
/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul.
|
||||||
@@ -9,6 +10,9 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
public final class MenuBarController {
|
public final class MenuBarController {
|
||||||
|
|
||||||
|
/// Hält den Delegaten am Leben — `NSPopover` merkt ihn sich nur schwach.
|
||||||
|
private var popoverWatcher: PopoverWatcher?
|
||||||
|
|
||||||
/// Ein Modul, dessen Element keine Breite bekommen hat.
|
/// Ein Modul, dessen Element keine Breite bekommen hat.
|
||||||
///
|
///
|
||||||
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
|
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
|
||||||
@@ -42,6 +46,7 @@ public final class MenuBarController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func stop() {
|
public func stop() {
|
||||||
|
closeOpenPopover()
|
||||||
visibilityTimer?.invalidate()
|
visibilityTimer?.invalidate()
|
||||||
visibilityTimer = nil
|
visibilityTimer = nil
|
||||||
for id in entries.keys { setEnabled(false, forModuleID: id) }
|
for id in entries.keys { setEnabled(false, forModuleID: id) }
|
||||||
@@ -78,6 +83,12 @@ public final class MenuBarController {
|
|||||||
apply(entry.settings, toModuleID: id)
|
apply(entry.settings, toModuleID: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welches Popover gerade offen ist. Höchstens eines.
|
||||||
|
private var openPopoverID: String?
|
||||||
|
private var globalMonitor: Any?
|
||||||
|
private var localMonitor: Any?
|
||||||
|
private var keyMonitor: Any?
|
||||||
|
|
||||||
public func settings(forModuleID id: String) -> MenuBarModuleSettings? {
|
public func settings(forModuleID id: String) -> MenuBarModuleSettings? {
|
||||||
entries[id]?.settings
|
entries[id]?.settings
|
||||||
}
|
}
|
||||||
@@ -100,7 +111,14 @@ public final class MenuBarController {
|
|||||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||||
button.identifier = NSUserInterfaceItemIdentifier(id)
|
button.identifier = NSUserInterfaceItemIdentifier(id)
|
||||||
}
|
}
|
||||||
entry.item.length = view.fittingSize.width > 0 ? view.fittingSize.width : NSStatusItem.variableLength
|
applyWidth(view.intrinsicContentSize.width, to: entry)
|
||||||
|
// Die nötige Breite steht nicht von Anfang an fest: die Kernzahl
|
||||||
|
// kennt das Modul erst nach der ersten Messung, und ein Moduswechsel
|
||||||
|
// ändert „100 %" in „100 °C".
|
||||||
|
(view as? WidthReporting)?.onWidthChange = { [weak self] width in
|
||||||
|
guard let self, let entry = entries[id] else { return }
|
||||||
|
applyWidth(width, to: entry)
|
||||||
|
}
|
||||||
entry.module.activate()
|
entry.module.activate()
|
||||||
} else {
|
} else {
|
||||||
entry.module.deactivate()
|
entry.module.deactivate()
|
||||||
@@ -108,20 +126,98 @@ public final class MenuBarController {
|
|||||||
checkVisibility()
|
checkVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyWidth(_ width: CGFloat, to entry: Entry) {
|
||||||
|
entry.item.length = width > 0 ? width : NSStatusItem.variableLength
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Interaktion
|
// MARK: - Interaktion
|
||||||
|
|
||||||
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
||||||
guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return }
|
guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return }
|
||||||
|
|
||||||
if entry.popover.isShown {
|
// Immer nur eines offen. Zwei Popover nebeneinander sehen aus wie ein
|
||||||
entry.popover.performClose(nil)
|
// Fehler, und das zweite verdeckt oft das erste.
|
||||||
|
if openPopoverID == id {
|
||||||
|
closeOpenPopover()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
closeOpenPopover()
|
||||||
|
|
||||||
|
// Die Akzentfarbe hier und nicht in jedem Popover einzeln: `.tint`
|
||||||
|
// vererbt sich nach unten, und ohne sie fielen Haken, Regler und
|
||||||
|
// Auswahlfelder auf die Systemfarbe zurück — das war der Grund, warum
|
||||||
|
// die Popovers grün waren und die Widgets blau.
|
||||||
entry.popover.contentViewController = NSHostingController(
|
entry.popover.contentViewController = NSHostingController(
|
||||||
rootView: entry.module.makePopoverView())
|
rootView: entry.module.makePopoverView()
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx))
|
||||||
|
let watcher = PopoverWatcher { [weak self] in self?.popoverClosedByItself() }
|
||||||
|
popoverWatcher = watcher
|
||||||
|
entry.popover.delegate = watcher
|
||||||
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
||||||
|
entry.module.popoverDidOpen()
|
||||||
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
|
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
|
||||||
entry.popover.contentViewController?.view.window?.makeKey()
|
entry.popover.contentViewController?.view.window?.makeKey()
|
||||||
|
openPopoverID = id
|
||||||
|
startWatchingForOutsideClicks()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schließen bei Klick daneben
|
||||||
|
|
||||||
|
/// `NSPopover.behavior = .transient` genügt hier **nicht**.
|
||||||
|
///
|
||||||
|
/// Es schließt zuverlässig, solange die eigene App aktiv ist. Onyx läuft
|
||||||
|
/// aber als `.accessory` und wird durch einen Klick auf ein Statuselement
|
||||||
|
/// nicht aktiviert — ein Klick in ein fremdes Fenster erreicht das Popover
|
||||||
|
/// dann gar nicht, und es bleibt offen stehen. Ein globaler Beobachter sieht
|
||||||
|
/// solche Klicks trotzdem.
|
||||||
|
private func startWatchingForOutsideClicks() {
|
||||||
|
guard globalMonitor == nil else { return }
|
||||||
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(
|
||||||
|
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.closeOpenPopover() }
|
||||||
|
}
|
||||||
|
// Der globale Beobachter sieht keine Klicks in die eigene App — dafür
|
||||||
|
// der lokale. Er reicht das Ereignis weiter, damit Knöpfe im Popover
|
||||||
|
// weiterhin funktionieren.
|
||||||
|
localMonitor = NSEvent.addLocalMonitorForEvents(
|
||||||
|
matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
||||||
|
guard let self, let id = openPopoverID, let entry = entries[id] else { return event }
|
||||||
|
// Klicks **im** Popover schließen es nicht.
|
||||||
|
if event.window !== entry.popover.contentViewController?.view.window,
|
||||||
|
event.window !== entry.item.button?.window {
|
||||||
|
closeOpenPopover()
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
// Auch die Escape-Taste und ein Bildschirmwechsel beenden es.
|
||||||
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||||
|
if event.keyCode == 53 { self?.closeOpenPopover(); return nil }
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zugegangen, ohne dass wir es veranlasst haben.
|
||||||
|
private func popoverClosedByItself() {
|
||||||
|
guard let id = openPopoverID, let entry = entries[id] else { return }
|
||||||
|
entry.module.popoverDidClose()
|
||||||
|
openPopoverID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeOpenPopover() {
|
||||||
|
if let id = openPopoverID, let entry = entries[id] {
|
||||||
|
entry.popover.performClose(nil)
|
||||||
|
}
|
||||||
|
// Über denselben Weg wie das Zugehen von selbst: der räumt
|
||||||
|
// `openPopoverID` ab und ist damit genau einmal wirksam, egal ob der
|
||||||
|
// Delegat vorher, nachher oder gar nicht meldet.
|
||||||
|
popoverClosedByItself()
|
||||||
|
globalMonitor.map(NSEvent.removeMonitor)
|
||||||
|
localMonitor.map(NSEvent.removeMonitor)
|
||||||
|
keyMonitor.map(NSEvent.removeMonitor)
|
||||||
|
globalMonitor = nil
|
||||||
|
localMonitor = nil
|
||||||
|
keyMonitor = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkVisibility() {
|
private func checkVisibility() {
|
||||||
@@ -136,3 +232,21 @@ public final class MenuBarController {
|
|||||||
onVisibilityChanged?(hidden)
|
onVisibilityChanged?(hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Horcht, ob das Popover zugegangen ist.
|
||||||
|
///
|
||||||
|
/// Ein eigener Typ, weil ein Delegat von AppKit ein `NSObject` sein muss und
|
||||||
|
/// der Controller keines ist. Nötig ist er trotzdem: das Popover geht auch
|
||||||
|
/// ohne Zutun zu — beim Wechsel in eine andere App etwa —, und ohne diese
|
||||||
|
/// Meldung erfährt eine laufende Messung nie, dass niemand mehr hinsieht.
|
||||||
|
final class PopoverWatcher: NSObject, NSPopoverDelegate {
|
||||||
|
private let onClose: @MainActor () -> Void
|
||||||
|
|
||||||
|
init(onClose: @escaping @MainActor () -> Void) {
|
||||||
|
self.onClose = onClose
|
||||||
|
}
|
||||||
|
|
||||||
|
func popoverDidClose(_ notification: Notification) {
|
||||||
|
MainActor.assumeIsolated { onClose() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ public enum MenuBarPresentation: String, Codable, Sendable, CaseIterable, Identi
|
|||||||
case valueAndGraph
|
case valueAndGraph
|
||||||
/// Nur ein Glyph, nach Grenzwert eingefärbt.
|
/// Nur ein Glyph, nach Grenzwert eingefärbt.
|
||||||
case symbol
|
case symbol
|
||||||
|
/// Kürzel und Wert, z. B. `CPU 34 %`.
|
||||||
|
///
|
||||||
|
/// Fünf nackte Prozentzahlen nebeneinander sind unbrauchbar: man sieht
|
||||||
|
/// Zahlen und weiß nicht, welche wozu gehört.
|
||||||
|
case labelAndValue
|
||||||
|
/// Glyph und Wert, z. B. 🔋 `34 %`.
|
||||||
|
case symbolAndValue
|
||||||
|
|
||||||
public var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
}
|
}
|
||||||
@@ -25,6 +32,9 @@ public protocol MenuBarModule: AnyObject {
|
|||||||
var id: String { get }
|
var id: String { get }
|
||||||
var displayName: String { get }
|
var displayName: String { get }
|
||||||
|
|
||||||
|
/// Das Kürzel für die Menüleiste — kurz, sonst frisst es die Leiste auf.
|
||||||
|
var shortLabel: String { get }
|
||||||
|
|
||||||
/// Zeichnet den kompakten Zustand. Muss eine feste Breite je Darstellungsart
|
/// Zeichnet den kompakten Zustand. Muss eine feste Breite je Darstellungsart
|
||||||
/// liefern, sonst springt die ganze Menüleiste bei jedem Messwert.
|
/// liefern, sonst springt die ganze Menüleiste bei jedem Messwert.
|
||||||
func makeStatusView(presentation: MenuBarPresentation) -> NSView
|
func makeStatusView(presentation: MenuBarPresentation) -> NSView
|
||||||
@@ -32,6 +42,17 @@ public protocol MenuBarModule: AnyObject {
|
|||||||
/// Der Inhalt des Popovers beim Klick.
|
/// Der Inhalt des Popovers beim Klick.
|
||||||
func makePopoverView() -> AnyView
|
func makePopoverView() -> AnyView
|
||||||
|
|
||||||
|
/// Wird gerufen, wenn das Popover dieses Moduls auf- und wieder zugeht.
|
||||||
|
///
|
||||||
|
/// **Die einzige verlässliche Stelle für alles, was nur laufen darf,
|
||||||
|
/// solange jemand hinsieht.** SwiftUIs `onDisappear` bleibt bei einem
|
||||||
|
/// `NSPopover` aus: dessen Controller lebt nach dem Schließen weiter, die
|
||||||
|
/// View verschwindet nie aus der Hierarchie. Wer eine Messung daran hängt,
|
||||||
|
/// hängt sie an etwas, das nie eintritt — im Fall des Mikrofons mit dem
|
||||||
|
/// orangen Punkt als Beweis.
|
||||||
|
func popoverDidOpen()
|
||||||
|
func popoverDidClose()
|
||||||
|
|
||||||
/// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier
|
/// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier
|
||||||
/// meldet sich das Modul beim Provider an und wieder ab.
|
/// meldet sich das Modul beim Provider an und wieder ab.
|
||||||
func activate()
|
func activate()
|
||||||
@@ -47,7 +68,7 @@ public struct MenuBarModuleSettings: Codable, Sendable, Equatable {
|
|||||||
public var usesThresholdColors: Bool
|
public var usesThresholdColors: Bool
|
||||||
|
|
||||||
public init(isEnabled: Bool = false,
|
public init(isEnabled: Bool = false,
|
||||||
presentation: MenuBarPresentation = .value,
|
presentation: MenuBarPresentation = .symbolAndValue,
|
||||||
refreshInterval: TimeInterval = 2,
|
refreshInterval: TimeInterval = 2,
|
||||||
usesThresholdColors: Bool = true) {
|
usesThresholdColors: Bool = true) {
|
||||||
self.isEnabled = isEnabled
|
self.isEnabled = isEnabled
|
||||||
@@ -62,3 +83,9 @@ extension TimeInterval {
|
|||||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension MenuBarModule {
|
||||||
|
// Die meisten Module haben nichts abzuräumen.
|
||||||
|
func popoverDidOpen() {}
|
||||||
|
func popoverDidClose() {}
|
||||||
|
}
|
||||||
|
|||||||
181
Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Beschriftung und Breite eines Menüleisten-Elements.
|
||||||
|
///
|
||||||
|
/// Die Breite wird **gemessen**, nicht geschätzt. Vorher stand je Darstellungsart
|
||||||
|
/// eine feste Zahl im Code, großzügig gewählt, damit nichts abgeschnitten wird —
|
||||||
|
/// mit dem Ergebnis, dass neben jedem Wert Platz für ein weiteres Symbol blieb,
|
||||||
|
/// in dem nichts stand. In einer Menüleiste ist das der teuerste Platz des
|
||||||
|
/// ganzen Bildschirms.
|
||||||
|
///
|
||||||
|
/// Gemessen wird die **breiteste Zeichenfolge, die vorkommen kann**, nicht die
|
||||||
|
/// gerade angezeigte. Sonst ändert das Element bei jeder Messung seine Größe und
|
||||||
|
/// schiebt alle Symbole rechts davon hin und her.
|
||||||
|
public enum MenuBarText {
|
||||||
|
|
||||||
|
/// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung.
|
||||||
|
///
|
||||||
|
/// Als berechnete Eigenschaft, nicht als gespeicherte: `NSFont` ist nicht
|
||||||
|
/// `Sendable`, und die Schrift jedes Mal neu zu erfragen kostet nichts —
|
||||||
|
/// AppKit gibt dieselbe zwischengespeicherte Instanz zurück.
|
||||||
|
public static var font: NSFont {
|
||||||
|
NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Punkt je Seite. macOS legt um jedes Statuselement ohnehin rund
|
||||||
|
/// acht Punkte eigene Polsterung — was hier draufkommt, kommt oben drauf.
|
||||||
|
public static let sidePadding: CGFloat = 1
|
||||||
|
public static let symbolWidth: CGFloat = 13
|
||||||
|
/// Abstand zwischen Symbol und Zahl.
|
||||||
|
public static let innerGap: CGFloat = 2
|
||||||
|
/// Schmaler geht nicht, ohne dass ein Glyph anstößt.
|
||||||
|
public static let minimumWidth: CGFloat = 18
|
||||||
|
|
||||||
|
/// Die Breite eines Elements aus gemessener Textbreite. Rein, damit die
|
||||||
|
/// Rechnung prüfbar bleibt.
|
||||||
|
public static func itemWidth(textWidth: CGFloat, includesSymbol: Bool) -> CGFloat {
|
||||||
|
let symbol = includesSymbol ? symbolWidth + innerGap : 0
|
||||||
|
let content = textWidth > 0 ? ceil(textWidth) + 2 * sidePadding : 2 * sidePadding
|
||||||
|
return max(minimumWidth, content + symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func width(of text: String) -> CGFloat {
|
||||||
|
guard !text.isEmpty else { return 0 }
|
||||||
|
return NSAttributedString(string: text, attributes: [.font: font]).size().width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Breite für die breiteste vorkommende Zeichenfolge.
|
||||||
|
public static func width(for reference: String, includesSymbol: Bool = false) -> CGFloat {
|
||||||
|
itemWidth(textWidth: width(of: reference), includesSymbol: includesSymbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeichnet ein SF-Symbol in der gewünschten Farbe.
|
||||||
|
///
|
||||||
|
/// `color.set()` vor `image.draw(in:)` reicht **nicht**. Ein Template-Bild
|
||||||
|
/// trägt seine Farbe nicht aus dem Grafikzustand, sondern wird schwarz
|
||||||
|
/// gezeichnet — auf einer dunklen Menüleiste also unsichtbar. AppKit färbt
|
||||||
|
/// Template-Bilder nur ein, wenn sie als `image` eines Buttons gesetzt sind;
|
||||||
|
/// in einer selbst gezeichneten Ansicht muss die Farbe in die
|
||||||
|
/// Symbolkonfiguration.
|
||||||
|
public static func drawSymbol(_ name: String, color: NSColor, in rect: NSRect) {
|
||||||
|
guard let tinted = symbol(name, color: color, height: rect.height) else { return }
|
||||||
|
// Seitenverhältnis wahren. Ein Batteriesymbol ist doppelt so breit wie
|
||||||
|
// hoch; in ein Quadrat gezeichnet sieht es zerquetscht aus.
|
||||||
|
let size = fittedSize(of: tinted, height: rect.height)
|
||||||
|
tinted.draw(in: NSRect(x: rect.midX - size.width / 2,
|
||||||
|
y: rect.midY - size.height / 2,
|
||||||
|
width: size.width, height: size.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie breit ein Symbol bei dieser Höhe tatsächlich ist.
|
||||||
|
///
|
||||||
|
/// Ohne diese Messung bekommt ein breites Symbol denselben Platz wie ein
|
||||||
|
/// schmales — entweder gequetscht oder mit einer Lücke daneben.
|
||||||
|
public static func symbolWidth(_ name: String, height: CGFloat) -> CGFloat {
|
||||||
|
guard let image = symbol(name, color: .labelColor, height: height) else {
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
return fittedSize(of: image, height: height).width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeichnet ein beliebiges Bild in der gewünschten Farbe.
|
||||||
|
///
|
||||||
|
/// Für eigene Vorlagen statt SF-Symbole: dort greift die
|
||||||
|
/// Symbolkonfiguration nicht, und `color.set()` allein färbt ein
|
||||||
|
/// Template-Bild nicht. Der Weg ist, das Bild zu zeichnen und die Farbe
|
||||||
|
/// mit `sourceAtop` darüberzulegen — das trifft genau die deckenden
|
||||||
|
/// Bildpunkte und lässt den Rest frei.
|
||||||
|
public static func drawTemplate(_ image: NSImage, color: NSColor, in rect: NSRect) {
|
||||||
|
let natural = image.size
|
||||||
|
let fitted: NSSize = natural.height > 0
|
||||||
|
? NSSize(width: (natural.width / natural.height) * rect.height, height: rect.height)
|
||||||
|
: rect.size
|
||||||
|
let target = NSRect(x: rect.midX - fitted.width / 2,
|
||||||
|
y: rect.midY - fitted.height / 2,
|
||||||
|
width: fitted.width, height: fitted.height)
|
||||||
|
|
||||||
|
let tinted = NSImage(size: target.size, flipped: false) { bounds in
|
||||||
|
image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1)
|
||||||
|
color.set()
|
||||||
|
bounds.fill(using: .sourceAtop)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
tinted.draw(in: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func symbol(_ name: String, color: NSColor, height: CGFloat) -> NSImage? {
|
||||||
|
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
||||||
|
else { return nil }
|
||||||
|
let configuration = NSImage.SymbolConfiguration(pointSize: height, weight: .regular)
|
||||||
|
.applying(NSImage.SymbolConfiguration(paletteColors: [color]))
|
||||||
|
return image.withSymbolConfiguration(configuration) ?? image
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fittedSize(of image: NSImage, height: CGFloat) -> NSSize {
|
||||||
|
let natural = image.size
|
||||||
|
guard natural.height > 0 else { return NSSize(width: height, height: height) }
|
||||||
|
return NSSize(width: (natural.width / natural.height) * height, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func draw(_ text: String, color: NSColor, in rect: NSRect) {
|
||||||
|
let string = NSAttributedString(string: text,
|
||||||
|
attributes: [.font: font, .foregroundColor: color])
|
||||||
|
let size = string.size()
|
||||||
|
string.draw(at: NSPoint(x: rect.midX - size.width / 2,
|
||||||
|
y: rect.midY - size.height / 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Statusansicht, deren nötige Breite sich ändern kann.
|
||||||
|
///
|
||||||
|
/// Der Controller stellt das Statuselement daraufhin neu ein. Ohne diesen Weg
|
||||||
|
/// bliebe ein Element in der Breite stehen, die beim allerersten Zeichnen
|
||||||
|
/// gebraucht wurde — vor der ersten Messung also.
|
||||||
|
@MainActor
|
||||||
|
public protocol WidthReporting: AnyObject {
|
||||||
|
var onWidthChange: ((CGFloat) -> Void)? { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Breite, die mitwächst — aber nur ungern schrumpft.
|
||||||
|
///
|
||||||
|
/// Eine feste Breite für den schlimmsten Fall lässt neben jedem gewöhnlichen
|
||||||
|
/// Wert Platz stehen: „↓ 13 KB/s" braucht gut halb so viel wie „↓ 999,9 MB/s".
|
||||||
|
/// Eine Breite, die jedem Messwert folgt, schiebt dagegen bei jeder Messung
|
||||||
|
/// alle Symbole rechts davon hin und her.
|
||||||
|
///
|
||||||
|
/// Der Kompromiss ist unsymmetrisch, und das mit Absicht: **sofort wachsen**,
|
||||||
|
/// denn abgeschnittene Messwerte sind falsche Messwerte. **Zögernd
|
||||||
|
/// schrumpfen**, damit ein kurzer Lastausschlag die Leiste nicht zum Pumpen
|
||||||
|
/// bringt.
|
||||||
|
public struct AdaptiveWidth: Sendable {
|
||||||
|
|
||||||
|
/// Wie viele ruhigere Messungen es braucht, bevor Platz abgegeben wird.
|
||||||
|
/// Bei zwei Sekunden Abtastung sind das gut zwanzig Sekunden Geduld.
|
||||||
|
public static let shrinkAfter = 10
|
||||||
|
|
||||||
|
public let minimum: CGFloat
|
||||||
|
private var current: CGFloat = 0
|
||||||
|
private var calmSamples = 0
|
||||||
|
|
||||||
|
public init(minimum: CGFloat) {
|
||||||
|
self.minimum = minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func update(needed: CGFloat) -> CGFloat {
|
||||||
|
let wanted = max(needed, minimum)
|
||||||
|
|
||||||
|
if wanted > current {
|
||||||
|
current = wanted
|
||||||
|
calmSamples = 0
|
||||||
|
} else if wanted < current {
|
||||||
|
calmSamples += 1
|
||||||
|
if calmSamples >= Self.shrinkAfter {
|
||||||
|
current = wanted
|
||||||
|
calmSamples = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
calmSamples = 0
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Packages/OnyxKit/Sources/OnyxNotch/Localizable.xcstrings
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"trigger.height %lld": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Höhe, %lld pt nach unten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Height, %lld pt below"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trigger.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wie weit über die Notch hinaus der Zeiger das Panel öffnet. Breiter trifft man leichter; schmaler hält das Panel aus dem Weg, während man die Menüleiste benutzt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "How far past the notch the pointer opens the panel. Wider is easier to hit; narrower keeps the panel out of the way while you use the menu bar."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trigger.reset": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Auf Standard zurücksetzen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Reset to default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trigger.width %lld": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Breite, %lld pt je Seite"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Width, %lld pt per side"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.1"
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch")
|
||||||
|
|
||||||
/// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt
|
/// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt
|
||||||
/// alle mit der Zeigerposition.
|
/// alle mit der Zeigerposition.
|
||||||
@@ -17,6 +20,14 @@ import AppKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
public final class NotchCoordinator {
|
public final class NotchCoordinator {
|
||||||
|
|
||||||
|
/// Die Auslösefläche für alle Bildschirme. Ändern wirkt sofort.
|
||||||
|
public var trigger: TriggerArea = .standard {
|
||||||
|
didSet {
|
||||||
|
guard trigger != oldValue else { return }
|
||||||
|
controllers.values.forEach { $0.trigger = trigger }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public var policy: DisplayPolicy {
|
public var policy: DisplayPolicy {
|
||||||
didSet { guard policy != oldValue else { return }; rebuild() }
|
didSet { guard policy != oldValue else { return }; rebuild() }
|
||||||
}
|
}
|
||||||
@@ -30,6 +41,7 @@ public final class NotchCoordinator {
|
|||||||
private var suppressionTimer: Timer?
|
private var suppressionTimer: Timer?
|
||||||
/// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`.
|
/// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`.
|
||||||
private var pointerPollTimer: Timer?
|
private var pointerPollTimer: Timer?
|
||||||
|
private var pollInterval: TimeInterval = 0
|
||||||
|
|
||||||
/// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene.
|
/// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene.
|
||||||
/// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen
|
/// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen
|
||||||
@@ -37,9 +49,11 @@ public final class NotchCoordinator {
|
|||||||
private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView
|
private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView
|
||||||
|
|
||||||
public init(policy: DisplayPolicy = .builtInOnly,
|
public init(policy: DisplayPolicy = .builtInOnly,
|
||||||
|
trigger: TriggerArea = .standard,
|
||||||
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
|
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
|
||||||
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
|
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
|
self.trigger = trigger
|
||||||
self.virtualNotchSize = virtualNotchSize
|
self.virtualNotchSize = virtualNotchSize
|
||||||
self.makeContent = makeContent
|
self.makeContent = makeContent
|
||||||
}
|
}
|
||||||
@@ -49,6 +63,9 @@ public final class NotchCoordinator {
|
|||||||
public func start() {
|
public func start() {
|
||||||
rebuild()
|
rebuild()
|
||||||
installMonitors()
|
installMonitors()
|
||||||
|
// Sofort anlaufen lassen, nicht erst wenn etwas sichtbar wird: die
|
||||||
|
// Abfrage ist es, die überhaupt etwas sichtbar macht.
|
||||||
|
updatePolling()
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(screensChanged),
|
self, selector: #selector(screensChanged),
|
||||||
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||||
@@ -80,6 +97,7 @@ public final class NotchCoordinator {
|
|||||||
private func rebuild() {
|
private func rebuild() {
|
||||||
let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics))
|
let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics))
|
||||||
let wantedIDs = Set(wanted.map(\.displayID))
|
let wantedIDs = Set(wanted.map(\.displayID))
|
||||||
|
log.notice("Bildschirme neu aufgebaut: \(wantedIDs.count, privacy: .public) von \(NSScreen.screens.count, privacy: .public) verwendet")
|
||||||
|
|
||||||
for (id, controller) in controllers where !wantedIDs.contains(id) {
|
for (id, controller) in controllers where !wantedIDs.contains(id) {
|
||||||
controller.tearDown()
|
controller.tearDown()
|
||||||
@@ -96,6 +114,7 @@ public final class NotchCoordinator {
|
|||||||
let controller = NotchScreenController(
|
let controller = NotchScreenController(
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
virtualSize: virtualNotchSize,
|
virtualSize: virtualNotchSize,
|
||||||
|
trigger: trigger,
|
||||||
makeContent: { presentation in makeContent(id, presentation) })
|
makeContent: { presentation in makeContent(id, presentation) })
|
||||||
controller.onVisibilityChanged = { [weak self] _ in
|
controller.onVisibilityChanged = { [weak self] _ in
|
||||||
self?.updatePolling()
|
self?.updatePolling()
|
||||||
@@ -122,18 +141,19 @@ public final class NotchCoordinator {
|
|||||||
return event
|
return event
|
||||||
}) { monitors.append(local) }
|
}) { monitors.append(local) }
|
||||||
|
|
||||||
|
// Klicks in der eigenen App: in der Notch fixieren, sonst schließen.
|
||||||
if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in
|
if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in
|
||||||
MainActor.assumeIsolated { [weak self] in
|
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
|
||||||
guard let self else { return }
|
|
||||||
let point = NSEvent.mouseLocation
|
|
||||||
for controller in controllers.values
|
|
||||||
where controller.isVisible && controller.geometry.triggerRect.contains(point) {
|
|
||||||
controller.click()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return event
|
return event
|
||||||
}) { monitors.append(clicks) }
|
}) { monitors.append(clicks) }
|
||||||
|
|
||||||
|
// Klicks in fremden Programmen schließen ein fixiertes Panel. Ohne das
|
||||||
|
// bleibt es stehen, bis jemand Escape drückt oder die Regel kennt.
|
||||||
|
if let outside = NSEvent.addGlobalMonitorForEvents(
|
||||||
|
matching: [.leftMouseDown, .rightMouseDown], handler: { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
|
||||||
|
}) { monitors.append(outside) }
|
||||||
|
|
||||||
if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in
|
if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in
|
||||||
guard event.keyCode == 53 else { return event } // esc
|
guard event.keyCode == 53 else { return event } // esc
|
||||||
MainActor.assumeIsolated { [weak self] in
|
MainActor.assumeIsolated { [weak self] in
|
||||||
@@ -143,6 +163,20 @@ public final class NotchCoordinator {
|
|||||||
}) { monitors.append(keys) }
|
}) { monitors.append(keys) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Klick fixiert nur in der Notch selbst. Überall sonst schließt er —
|
||||||
|
/// auch das eigene Panel, denn wer hineinklickt, bedient ein Widget und
|
||||||
|
/// nicht das Panel.
|
||||||
|
private func handleClick() {
|
||||||
|
let point = NSEvent.mouseLocation
|
||||||
|
for controller in controllers.values where controller.isVisible {
|
||||||
|
if controller.geometry.triggerRect.contains(point) {
|
||||||
|
controller.click()
|
||||||
|
} else if !controller.containsVisiblePoint(point) {
|
||||||
|
controller.clickedOutside()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func pointerMoved() {
|
private func pointerMoved() {
|
||||||
let point = NSEvent.mouseLocation
|
let point = NSEvent.mouseLocation
|
||||||
for controller in controllers.values {
|
for controller in controllers.values {
|
||||||
@@ -150,39 +184,62 @@ public final class NotchCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fragt die Zeigerposition ab, solange ein Panel offen ist.
|
/// Fragt die Zeigerposition ab — immer, nur unterschiedlich oft.
|
||||||
///
|
///
|
||||||
/// Über dem eigenen Panel bekommt Onyx sonst gar nichts mit: der globale
|
/// **Warum überhaupt abgefragt wird und nicht nur zugehört:**
|
||||||
/// Monitor feuert nur für Ereignisse, die an **fremde** Programme gehen, und
|
|
||||||
/// der lokale nur, wenn Onyx das aktive Programm ist — was es als
|
|
||||||
/// `.accessory`-App nie ist. Genau in dem Moment, in dem der Zeiger das
|
|
||||||
/// Panel wieder verlässt, käme das Ereignis also verspätet oder gar nicht,
|
|
||||||
/// und das Schließen wirkt hakelig.
|
|
||||||
///
|
///
|
||||||
/// Abfragen ist hier das ehrlichere Mittel als noch mehr Monitore: es läuft
|
/// Über dem eigenen Panel bekommt Onyx über Ereignisse gar nichts mit: der
|
||||||
/// nur, während wirklich etwas offen ist, und kostet dabei einen
|
/// globale Monitor feuert nur für Ereignisse an **fremde** Programme, der
|
||||||
/// Rechteckvergleich pro Bild.
|
/// lokale nur, wenn Onyx das aktive Programm ist — was es als
|
||||||
|
/// `.accessory`-App nie ist.
|
||||||
|
///
|
||||||
|
/// Und der Auslösebereich liegt in der Menüleiste. Dorthin stellt macOS
|
||||||
|
/// Mausbewegungen nicht verlässlich an fremde Programme zu: mal kommen sie
|
||||||
|
/// an, mal nicht, je nachdem, was gerade den Zeiger führt. Genau daher kam
|
||||||
|
/// das „manchmal geht die Notch nicht auf". Ein Verfahren, das eine
|
||||||
|
/// Zustellung voraussetzt, die es nicht gibt, kann nicht zuverlässig sein.
|
||||||
|
///
|
||||||
|
/// Deshalb sind die Monitore nur noch Beschleunigung, und die Abfrage ist
|
||||||
|
/// die Wahrheit. Sie kostet einen Rechteckvergleich; bei zehn Abfragen je
|
||||||
|
/// Sekunde im Ruhezustand ist das nicht messbar.
|
||||||
private func updatePolling() {
|
private func updatePolling() {
|
||||||
let anyVisible = controllers.values.contains { $0.isVisible }
|
let anyVisible = controllers.values.contains { $0.isVisible }
|
||||||
|
let interval = anyVisible ? Self.visiblePollInterval : Self.idlePollInterval
|
||||||
|
guard interval != pollInterval || pointerPollTimer == nil else { return }
|
||||||
|
|
||||||
if anyVisible, pointerPollTimer == nil {
|
pointerPollTimer?.invalidate()
|
||||||
let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { _ in
|
let timer = Timer(timeInterval: interval, repeats: true) { _ in
|
||||||
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
|
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
|
||||||
}
|
}
|
||||||
// `.common` statt der Standard-Schleife: sonst steht die Abfrage,
|
// `.common` statt der Standard-Schleife: sonst steht die Abfrage,
|
||||||
// während ein Menü offen ist oder gescrollt wird.
|
// während ein Menü offen ist oder gescrollt wird.
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
pointerPollTimer = timer
|
pointerPollTimer = timer
|
||||||
} else if !anyVisible {
|
pollInterval = interval
|
||||||
pointerPollTimer?.invalidate()
|
log.notice("Zeigerabfrage: \(Int(1 / interval), privacy: .public) Hz, \(self.controllers.count, privacy: .public) Bildschirm(e)")
|
||||||
pointerPollTimer = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Im Ruhezustand genügt ein Zehntel: dazu kommt ohnehin die Entprellzeit
|
||||||
|
/// von 220 ms, bevor sich etwas rührt.
|
||||||
|
private static let idlePollInterval: TimeInterval = 1.0 / 10.0
|
||||||
|
/// Offen wird jedes Bild abgefragt, sonst wirkt das Schließen hakelig.
|
||||||
|
private static let visiblePollInterval: TimeInterval = 1.0 / 60.0
|
||||||
|
|
||||||
private func refreshSuppression() {
|
private func refreshSuppression() {
|
||||||
|
// Einmal je Durchgang abfragen statt einmal je Bildschirm: die Sperre
|
||||||
|
// gilt für das ganze System.
|
||||||
|
let playing = NotchSuppression.isPreventingDisplaySleep
|
||||||
for screen in NSScreen.screens {
|
for screen in NSScreen.screens {
|
||||||
controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow)
|
guard let controller = controllers[screen.displayID] else { continue }
|
||||||
|
let suppress = NotchSuppression.shouldSuppress(
|
||||||
|
hasFullScreenWindow: screen.isShowingFullScreenWindow,
|
||||||
|
preventsDisplaySleep: playing)
|
||||||
|
controller.setSuppressed(suppress)
|
||||||
}
|
}
|
||||||
|
// Fällt die Unterdrückung weg, während der Zeiger schon in der Notch
|
||||||
|
// steht, käme sonst nichts — die Maschine wartet auf ein Ereignis, das
|
||||||
|
// erst bei der nächsten Bewegung kommt.
|
||||||
|
pointerMoved()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zugriff für die App
|
// MARK: - Zugriff für die App
|
||||||
|
|||||||
@@ -70,11 +70,22 @@ public struct NotchGeometry: Equatable, Sendable {
|
|||||||
public let rect: CGRect
|
public let rect: CGRect
|
||||||
public let screenFrame: CGRect
|
public let screenFrame: CGRect
|
||||||
|
|
||||||
/// Wie weit die Auslösefläche über die Notch hinausreicht. Ohne diesen Rand
|
/// Wie weit die Auslösefläche über die Notch hinausreicht — einstellbar,
|
||||||
/// muss man die Notch pixelgenau treffen, und die unteren Bildpunkte der
|
/// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
|
||||||
/// Notch selbst nimmt die Menüleiste bereits für sich in Anspruch.
|
public var trigger: TriggerArea = .standard
|
||||||
|
|
||||||
|
/// Nur noch für Bestandscode: die frühere feste Zugabe.
|
||||||
public static let triggerInsetX: CGFloat = 16
|
public static let triggerInsetX: CGFloat = 16
|
||||||
public static let triggerInsetY: CGFloat = 4
|
/// Nach unten deutlich großzügiger als nach oben: von oben kommt niemand,
|
||||||
|
/// dort ist der Bildschirmrand. Vier Punkte waren zu wenig — eine Hand
|
||||||
|
/// hält den Zeiger nicht auf vier Punkte genau.
|
||||||
|
public static let triggerInsetY: CGFloat = 14
|
||||||
|
|
||||||
|
func with(trigger: TriggerArea) -> NotchGeometry {
|
||||||
|
var copy = self
|
||||||
|
copy.trigger = trigger
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
public init(kind: Kind, rect: CGRect, screenFrame: CGRect) {
|
public init(kind: Kind, rect: CGRect, screenFrame: CGRect) {
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
@@ -82,7 +93,8 @@ public struct NotchGeometry: Equatable, Sendable {
|
|||||||
self.screenFrame = screenFrame
|
self.screenFrame = screenFrame
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize) -> NotchGeometry {
|
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize,
|
||||||
|
trigger: TriggerArea = .standard) -> NotchGeometry {
|
||||||
// Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am
|
// Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am
|
||||||
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
|
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
|
||||||
// obwohl es nichts gibt, um das man herumlegen könnte.
|
// obwohl es nichts gibt, um das man herumlegen könnte.
|
||||||
@@ -97,7 +109,7 @@ public struct NotchGeometry: Equatable, Sendable {
|
|||||||
y: metrics.frame.maxY - metrics.safeAreaTop,
|
y: metrics.frame.maxY - metrics.safeAreaTop,
|
||||||
width: width,
|
width: width,
|
||||||
height: metrics.safeAreaTop),
|
height: metrics.safeAreaTop),
|
||||||
screenFrame: metrics.frame)
|
screenFrame: metrics.frame).with(trigger: trigger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,14 +120,12 @@ public struct NotchGeometry: Equatable, Sendable {
|
|||||||
y: metrics.frame.maxY - virtualSize.height,
|
y: metrics.frame.maxY - virtualSize.height,
|
||||||
width: width,
|
width: width,
|
||||||
height: virtualSize.height),
|
height: virtualSize.height),
|
||||||
screenFrame: metrics.frame)
|
screenFrame: metrics.frame).with(trigger: trigger)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Fläche, deren Betreten das Panel scharf schaltet.
|
/// Die Fläche, deren Betreten das Panel scharf schaltet.
|
||||||
public var triggerRect: CGRect {
|
public var triggerRect: CGRect {
|
||||||
rect
|
trigger.rect(notch: rect, screen: screenFrame)
|
||||||
.insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY)
|
|
||||||
.clamped(to: screenFrame)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Bereich, in dem Inhalt tatsächlich zu sehen ist.
|
/// Der Bereich, in dem Inhalt tatsächlich zu sehen ist.
|
||||||
|
|||||||
@@ -28,12 +28,75 @@ public final class NotchPresentation {
|
|||||||
self.contentTopInset = contentTopInset
|
self.contentTopInset = contentTopInset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Fläche, die das Panel oben einnehmen darf, ohne die Menüleiste
|
||||||
|
/// zuzudecken — also die Breite der Notch.
|
||||||
|
///
|
||||||
|
/// Darunter darf es beliebig breit werden; dort ist keine Menüleiste mehr.
|
||||||
|
public var notchWidth: CGFloat { collapsedSize.width }
|
||||||
|
|
||||||
/// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf.
|
/// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf.
|
||||||
/// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel
|
/// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel
|
||||||
/// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen.
|
/// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen.
|
||||||
public static let collapseDuration: TimeInterval = 0.30
|
public static let collapseDuration: TimeInterval = 0.30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Silhouette des Panels: oben nur so breit wie die Notch, darunter voll.
|
||||||
|
///
|
||||||
|
/// **Warum nicht einfach ein Rechteck.** Das Panel reicht bis an die
|
||||||
|
/// Bildschirmoberkante, damit seine Fläche mit der schwarzen Notch zu einem
|
||||||
|
/// Körper verschmilzt. Sobald es breiter wird als die Notch, deckt es links
|
||||||
|
/// und rechts davon aber die Menüleiste zu — bei sechs Kacheln die ganze,
|
||||||
|
/// samt aller Statuselemente.
|
||||||
|
///
|
||||||
|
/// Also ein T: oben ein Steg in Notch-Breite, ab Unterkante der Menüleiste die
|
||||||
|
/// volle Fläche. Die Notch bleibt verschmolzen, die Menüleiste bleibt frei.
|
||||||
|
struct NotchShape: Shape, InsettableShape {
|
||||||
|
var cornerRadius: CGFloat
|
||||||
|
var topBandHeight: CGFloat
|
||||||
|
var notchWidth: CGFloat
|
||||||
|
var inset: CGFloat = 0
|
||||||
|
|
||||||
|
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
||||||
|
get { .init(cornerRadius, notchWidth) }
|
||||||
|
set { cornerRadius = newValue.first; notchWidth = newValue.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
func inset(by amount: CGFloat) -> NotchShape {
|
||||||
|
var copy = self
|
||||||
|
copy.inset += amount
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
let rect = rect.insetBy(dx: inset, dy: inset)
|
||||||
|
// Ist das Panel nicht breiter als die Notch, ist der Steg die ganze
|
||||||
|
// Form — dann bleibt es die schlichte abgerundete Fläche.
|
||||||
|
let band = min(max(notchWidth - 2 * inset, 0), rect.width)
|
||||||
|
let radius = min(cornerRadius, rect.width / 2, rect.height / 2)
|
||||||
|
|
||||||
|
var path = Path()
|
||||||
|
guard topBandHeight > 0, band < rect.width - 0.5 else {
|
||||||
|
path.addRoundedRect(in: rect,
|
||||||
|
cornerSize: CGSize(width: radius, height: radius),
|
||||||
|
style: .continuous)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
let bandRect = CGRect(x: rect.midX - band / 2, y: rect.minY,
|
||||||
|
width: band, height: topBandHeight + radius)
|
||||||
|
let body = CGRect(x: rect.minX, y: rect.minY + topBandHeight,
|
||||||
|
width: rect.width, height: rect.height - topBandHeight)
|
||||||
|
|
||||||
|
// Der Steg bekommt keine Rundung nach oben: dort sitzt die Notch, und
|
||||||
|
// eine Rundung an der Bildschirmkante wäre ohnehin nicht zu sehen.
|
||||||
|
path.addRect(bandRect)
|
||||||
|
path.addRoundedRect(in: body,
|
||||||
|
cornerSize: CGSize(width: radius, height: radius),
|
||||||
|
style: .continuous)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Der Rahmen, der aus der Notch herauswächst.
|
/// Der Rahmen, der aus der Notch herauswächst.
|
||||||
///
|
///
|
||||||
/// Die Fläche wird **nicht** eingeblendet. Sie beginnt exakt in der Notch-Silhouette
|
/// Die Fläche wird **nicht** eingeblendet. Sie beginnt exakt in der Notch-Silhouette
|
||||||
@@ -66,7 +129,9 @@ public struct NotchContainer<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
let shape = NotchShape(cornerRadius: cornerRadius,
|
||||||
|
topBandHeight: presentation.contentTopInset,
|
||||||
|
notchWidth: presentation.notchWidth)
|
||||||
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
// Das Weichzeichner-Material behält **feste** Größe; animiert wird
|
// Das Weichzeichner-Material behält **feste** Größe; animiert wird
|
||||||
@@ -94,7 +159,7 @@ public struct NotchContainer<Content: View>: View {
|
|||||||
.init(color: .clear, location: 0.4)],
|
.init(color: .clear, location: 0.4)],
|
||||||
startPoint: .top, endPoint: .bottom))
|
startPoint: .top, endPoint: .bottom))
|
||||||
}
|
}
|
||||||
.overlay { shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1) }
|
.overlay { shape.stroke(Onyx.Color.hairline, lineWidth: 1) }
|
||||||
.frame(width: size.width, height: size.height)
|
.frame(width: size.width, height: size.height)
|
||||||
// Konstante Deckkraft: einen Schatten aufzublenden kostet in
|
// Konstante Deckkraft: einen Schatten aufzublenden kostet in
|
||||||
// jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
|
// jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
|
||||||
@@ -159,6 +224,17 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
|
|||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height)
|
height: size.height)
|
||||||
guard visible.contains(point) else { return nil }
|
guard visible.contains(point) else { return nil }
|
||||||
|
|
||||||
|
// Links und rechts neben der Notch gehört der oberste Streifen der
|
||||||
|
// Menüleiste. Dort ist das Panel durchsichtig — und muss auch
|
||||||
|
// durchlässig sein, sonst schluckt es Klicks auf fremde
|
||||||
|
// Statuselemente, ohne dass man sieht, warum.
|
||||||
|
if presentation.isExpanded, point.y < presentation.contentTopInset {
|
||||||
|
let band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0,
|
||||||
|
width: presentation.notchWidth,
|
||||||
|
height: presentation.contentTopInset)
|
||||||
|
guard band.contains(point) else { return nil }
|
||||||
|
}
|
||||||
return super.hitTest(point)
|
return super.hitTest(point)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch")
|
||||||
|
|
||||||
/// Führt für genau einen Bildschirm Geometrie, Zustandsmaschine und Fenster zusammen.
|
/// Führt für genau einen Bildschirm Geometrie, Zustandsmaschine und Fenster zusammen.
|
||||||
///
|
///
|
||||||
@@ -35,12 +38,23 @@ public final class NotchScreenController {
|
|||||||
CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height)
|
CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die eingestellte Auslösefläche. Ändern wirkt sofort.
|
||||||
|
public var trigger: TriggerArea {
|
||||||
|
didSet {
|
||||||
|
guard trigger != oldValue else { return }
|
||||||
|
geometry.trigger = trigger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public init(metrics: ScreenMetrics,
|
public init(metrics: ScreenMetrics,
|
||||||
virtualSize: CGSize,
|
virtualSize: CGSize,
|
||||||
|
trigger: TriggerArea = .standard,
|
||||||
contentSize: CGSize = CGSize(width: 640, height: 320),
|
contentSize: CGSize = CGSize(width: 640, height: 320),
|
||||||
makeContent: (NotchPresentation) -> NSView) {
|
makeContent: (NotchPresentation) -> NSView) {
|
||||||
self.displayID = metrics.displayID
|
self.displayID = metrics.displayID
|
||||||
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize)
|
self.trigger = trigger
|
||||||
|
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
|
||||||
|
trigger: trigger)
|
||||||
self.geometry = geometry
|
self.geometry = geometry
|
||||||
self.contentSize = contentSize
|
self.contentSize = contentSize
|
||||||
self.presentation = NotchPresentation(
|
self.presentation = NotchPresentation(
|
||||||
@@ -63,23 +77,45 @@ public final class NotchScreenController {
|
|||||||
// MARK: - Eingaben
|
// MARK: - Eingaben
|
||||||
|
|
||||||
/// Zeigerposition in globalen Bildschirmkoordinaten.
|
/// Zeigerposition in globalen Bildschirmkoordinaten.
|
||||||
|
/// Wo der Zeiger beim letzten Verlassen stand — nur fürs Protokoll.
|
||||||
|
private var lastPoint: CGPoint = .zero
|
||||||
|
|
||||||
public func pointerMoved(to point: CGPoint) {
|
public func pointerMoved(to point: CGPoint) {
|
||||||
|
lastPoint = point
|
||||||
// Nur die tatsächlich sichtbare Fläche zählt. Das Fenster ist immer so
|
// Nur die tatsächlich sichtbare Fläche zählt. Das Fenster ist immer so
|
||||||
// groß wie das ausgefahrene Panel, damit die Animation Platz hat —
|
// groß wie das ausgefahrene Panel, damit die Animation Platz hat —
|
||||||
// würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren
|
// würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren
|
||||||
// Fläche das Panel offen.
|
// Fläche das Panel offen.
|
||||||
let inside = geometry.triggerRect.contains(point)
|
let inside = geometry.triggerRect.contains(point) || containsVisiblePoint(point)
|
||||||
|| (presentation.isExpanded && panel.frame.contains(point))
|
|
||||||
send(inside ? .pointerEntered : .pointerExited)
|
send(inside ? .pointerEntered : .pointerExited)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func click() { send(.click) }
|
public func click() { send(.click) }
|
||||||
|
public func clickedOutside() { send(.clickedOutside) }
|
||||||
|
|
||||||
|
/// Ob ein Punkt auf der **sichtbaren** Fläche des Panels liegt.
|
||||||
|
///
|
||||||
|
/// Nicht `panel.frame`: das Fenster reicht bis an die Bildschirmoberkante
|
||||||
|
/// und ist links und rechts der Notch dort durchsichtig — dieser Streifen
|
||||||
|
/// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick
|
||||||
|
/// auf ein fremdes Statuselement für einen Klick ins Panel.
|
||||||
|
public func containsVisiblePoint(_ point: CGPoint) -> Bool {
|
||||||
|
guard presentation.isExpanded, panel.frame.contains(point) else { return false }
|
||||||
|
let menuBarBand = panel.frame.maxY - geometry.rect.height
|
||||||
|
guard point.y > menuBarBand else { return true }
|
||||||
|
return abs(point.x - panel.frame.midX) <= geometry.rect.width / 2
|
||||||
|
}
|
||||||
public func escape() { send(.escape) }
|
public func escape() { send(.escape) }
|
||||||
public func setSuppressed(_ suppressed: Bool) { send(.suppressed(suppressed)) }
|
public func setSuppressed(_ suppressed: Bool) {
|
||||||
|
guard suppressed != machine.isSuppressed else { return }
|
||||||
|
log.notice("Notch \(self.displayID, privacy: .public): Unterdrückung \(suppressed ? "an" : "aus", privacy: .public)")
|
||||||
|
send(.suppressed(suppressed))
|
||||||
|
}
|
||||||
|
|
||||||
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
|
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
|
||||||
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
|
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
|
||||||
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize)
|
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
|
||||||
|
trigger: trigger)
|
||||||
presentation.collapsedSize = geometry.rect.size
|
presentation.collapsedSize = geometry.rect.size
|
||||||
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
|
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
|
||||||
// (virtueller Balken) ändert sich der Freiraum mit.
|
// (virtueller Balken) ändert sich der Freiraum mit.
|
||||||
@@ -103,7 +139,22 @@ public final class NotchScreenController {
|
|||||||
/// Zustand dort bereits geändert, ein Vergleich käme also immer negativ zurück.
|
/// Zustand dort bereits geändert, ein Vergleich käme also immer negativ zurück.
|
||||||
private func send(_ input: NotchInput) {
|
private func send(_ input: NotchInput) {
|
||||||
let wasVisible = machine.isVisible
|
let wasVisible = machine.isVisible
|
||||||
|
let before = machine.phase
|
||||||
apply(machine.handle(input))
|
apply(machine.handle(input))
|
||||||
|
// Jeden Zustandswechsel protokollieren — außer den beiden, die
|
||||||
|
// sekündlich hunderte Male kommen. Ohne diese Spur ist ein Panel, das
|
||||||
|
// sich „manchmal nicht öffnet", nicht zu untersuchen: hinterher weiß
|
||||||
|
// niemand mehr, ob der Zeiger erkannt wurde, ob die Maschine
|
||||||
|
// umgeschaltet hat oder ob die Unterdrückung dazwischenkam.
|
||||||
|
if machine.phase != before {
|
||||||
|
log.notice("Notch \(self.displayID, privacy: .public): \(String(describing: before), privacy: .public) → \(String(describing: self.machine.phase), privacy: .public) durch \(String(describing: input), privacy: .public)")
|
||||||
|
// Beim Schließen zusätzlich, **warum** der Zeiger als draußen galt.
|
||||||
|
// Ohne diese drei Zahlen bleibt „es ging zu, obwohl ich noch drin
|
||||||
|
// war" eine Behauptung gegen eine andere.
|
||||||
|
if before == .open, self.machine.phase == .closing {
|
||||||
|
log.notice(" Zeiger \(Int(self.lastPoint.x), privacy: .public)/\(Int(self.lastPoint.y), privacy: .public), Auslösefläche \(String(describing: self.geometry.triggerRect), privacy: .public), Fenster \(String(describing: self.panel.frame), privacy: .public), ausgefahren \(self.presentation.isExpanded, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
if machine.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) }
|
if machine.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ public enum NotchInput: Equatable, Sendable {
|
|||||||
case pointerEntered
|
case pointerEntered
|
||||||
case pointerExited
|
case pointerExited
|
||||||
case click
|
case click
|
||||||
|
/// Klick irgendwo außerhalb von Panel und Notch.
|
||||||
|
///
|
||||||
|
/// Der Ausweg aus einem fixierten Panel. „Klick fixiert" ist eine gute
|
||||||
|
/// Regel, aber wer sie nicht kennt, sitzt sonst vor einem Panel, das offen
|
||||||
|
/// steht und auf nichts reagiert.
|
||||||
|
case clickedOutside
|
||||||
case escape
|
case escape
|
||||||
case armTimerFired
|
case armTimerFired
|
||||||
case closeTimerFired
|
case closeTimerFired
|
||||||
@@ -59,6 +65,16 @@ public struct NotchStateMachine: Equatable, Sendable {
|
|||||||
public private(set) var phase: NotchPhase = .idle
|
public private(set) var phase: NotchPhase = .idle
|
||||||
public private(set) var isSuppressed: Bool = false
|
public private(set) var isSuppressed: Bool = false
|
||||||
|
|
||||||
|
/// Wo der Zeiger zuletzt war. Die Maschine braucht das, um beim Ablauf der
|
||||||
|
/// Entprellzeit zu entscheiden — ohne diese Angabe würde ein beiläufiges
|
||||||
|
/// Streifen der Notch das Panel kurz aufblitzen lassen: betreten,
|
||||||
|
/// verlassen, Entprellzeit läuft trotzdem ab, Panel auf, Frist abgelaufen,
|
||||||
|
/// Panel zu.
|
||||||
|
private var isPointerInside = false
|
||||||
|
/// Ob die Entprellzeit schon abgelaufen ist, während der Zeiger draußen war.
|
||||||
|
/// Kommt er zurück, wird ohne weiteres Warten geöffnet.
|
||||||
|
private var debounceElapsed = false
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
/// Sichtbar, egal aus welchem Grund.
|
/// Sichtbar, egal aus welchem Grund.
|
||||||
@@ -88,19 +104,54 @@ public struct NotchStateMachine: Equatable, Sendable {
|
|||||||
|
|
||||||
guard !isSuppressed else { return [] }
|
guard !isSuppressed else { return [] }
|
||||||
|
|
||||||
|
switch input {
|
||||||
|
case .pointerEntered: isPointerInside = true
|
||||||
|
case .pointerExited: isPointerInside = false
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
switch (phase, input) {
|
switch (phase, input) {
|
||||||
|
|
||||||
// Öffnen
|
// Öffnen
|
||||||
case (.idle, .pointerEntered):
|
case (.idle, .pointerEntered):
|
||||||
phase = .arming
|
phase = .arming
|
||||||
|
debounceElapsed = false
|
||||||
return [.startArmTimer(Self.armDelay)]
|
return [.startArmTimer(Self.armDelay)]
|
||||||
|
|
||||||
case (.arming, .armTimerFired):
|
case (.arming, .armTimerFired):
|
||||||
|
// Nur öffnen, wenn der Zeiger auch da ist. Sonst weitermerken,
|
||||||
|
// dass die Entprellzeit vorbei ist — kommt er zurück, geht es
|
||||||
|
// sofort auf; bleibt er weg, beendet die Abbruchfrist die Sache.
|
||||||
|
guard isPointerInside else {
|
||||||
|
debounceElapsed = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
phase = .open
|
phase = .open
|
||||||
return [.show]
|
return [.show]
|
||||||
|
|
||||||
|
// Kurz herausrutschen bricht nicht ab.
|
||||||
|
//
|
||||||
|
// Ein Zeiger, den eine Hand an den oberen Rand führt, steht dort nicht
|
||||||
|
// still. Jedes Abtastbild außerhalb sofort als Abbruch zu werten hieß:
|
||||||
|
// bei zitternder Hand fing die Entprellzeit dauernd von vorn an und kam
|
||||||
|
// nie ans Ziel — das Panel öffnete „manchmal einfach nicht".
|
||||||
|
//
|
||||||
|
// Der Aufziehzeitgeber läuft weiter; parallel läuft die Frist, nach der
|
||||||
|
// wirklich abgebrochen wird.
|
||||||
case (.arming, .pointerExited):
|
case (.arming, .pointerExited):
|
||||||
|
return [.startCloseTimer(Self.closeDelay)]
|
||||||
|
|
||||||
|
case (.arming, .pointerEntered):
|
||||||
|
// Zurück in der Zone: nur die Abbruchfrist stoppen. Die Entprellzeit
|
||||||
|
// **nicht** neu ansetzen, sonst dauert es bei jedem Grenzübertritt
|
||||||
|
// wieder von vorn.
|
||||||
|
guard debounceElapsed else { return [.cancelCloseTimer] }
|
||||||
|
phase = .open
|
||||||
|
return [.cancelCloseTimer, .show]
|
||||||
|
|
||||||
|
case (.arming, .closeTimerFired):
|
||||||
phase = .idle
|
phase = .idle
|
||||||
|
debounceElapsed = false
|
||||||
return [.cancelArmTimer]
|
return [.cancelArmTimer]
|
||||||
|
|
||||||
// Schließen
|
// Schließen
|
||||||
@@ -128,9 +179,15 @@ public struct NotchStateMachine: Equatable, Sendable {
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
// Beenden
|
// Beenden
|
||||||
case (.open, .escape), (.pinned, .escape), (.closing, .escape):
|
case (.open, .escape), (.pinned, .escape), (.closing, .escape),
|
||||||
|
(.open, .clickedOutside), (.pinned, .clickedOutside), (.closing, .clickedOutside):
|
||||||
phase = .idle
|
phase = .idle
|
||||||
return [.hide]
|
return [.cancelCloseTimer, .hide]
|
||||||
|
|
||||||
|
case (.arming, .clickedOutside):
|
||||||
|
phase = .idle
|
||||||
|
debounceElapsed = false
|
||||||
|
return [.cancelArmTimer, .cancelCloseTimer]
|
||||||
|
|
||||||
case (.arming, .escape):
|
case (.arming, .escape):
|
||||||
phase = .idle
|
phase = .idle
|
||||||
|
|||||||
36
Packages/OnyxKit/Sources/OnyxNotch/NotchSuppression.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit.pwr_mgt
|
||||||
|
|
||||||
|
/// Wann das Panel wegbleibt.
|
||||||
|
public enum NotchSuppression {
|
||||||
|
|
||||||
|
/// Beide Bedingungen zusammen, nicht eine davon.
|
||||||
|
///
|
||||||
|
/// Die erste Fassung hat jedes Vollbildfenster als Grund gewertet. Damit war
|
||||||
|
/// die Notch tot, sobald jemand Xcode im Vollbild hatte — und das ist kein
|
||||||
|
/// Zustand, in dem man auf seinen Kalender verzichten will. Gemeint war
|
||||||
|
/// immer nur der Film über den ganzen Bildschirm.
|
||||||
|
public static func shouldSuppress(hasFullScreenWindow: Bool,
|
||||||
|
preventsDisplaySleep: Bool) -> Bool {
|
||||||
|
hasFullScreenWindow && preventsDisplaySleep
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob gerade etwas den Bildschirm wachhält.
|
||||||
|
///
|
||||||
|
/// Das ist der verlässlichste Hinweis auf laufende Wiedergabe, den macOS
|
||||||
|
/// ohne Sonderrechte hergibt: jedes Programm, das ein Video abspielt, setzt
|
||||||
|
/// eine solche Sperre, damit der Bildschirm nicht mitten im Film dunkel
|
||||||
|
/// wird. Ein Textfenster im Vollbild tut das nicht.
|
||||||
|
public static var isPreventingDisplaySleep: Bool {
|
||||||
|
var assertions: Unmanaged<CFDictionary>?
|
||||||
|
guard IOPMCopyAssertionsStatus(&assertions) == kIOReturnSuccess,
|
||||||
|
let status = assertions?.takeRetainedValue() as? [String: Int]
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
// Beide Namen kommen vor: der ältere von IOKit und der, den
|
||||||
|
// AVFoundation benutzt.
|
||||||
|
let names = [kIOPMAssertionTypeNoDisplaySleep as String,
|
||||||
|
kIOPMAssertionTypePreventUserIdleDisplaySleep as String]
|
||||||
|
return names.contains { (status[$0] ?? 0) > 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Packages/OnyxKit/Sources/OnyxNotch/TriggerArea.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
/// Wie weit die Auslösefläche über die Notch hinausreicht.
|
||||||
|
///
|
||||||
|
/// Feste Werte im Code waren dafür die falsche Antwort: wie genau jemand die
|
||||||
|
/// Notch trifft, hängt an der Hand und am Zeigegerät. Wer mit dem Trackpad
|
||||||
|
/// grob nach oben fährt, will eine großzügige Fläche; wer die Menüleiste viel
|
||||||
|
/// benutzt, will sie knapp, damit das Panel nicht dauernd aufgeht.
|
||||||
|
public struct TriggerArea: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Zugabe je Seite.
|
||||||
|
public let extraWidth: CGFloat
|
||||||
|
/// Zugabe nach unten.
|
||||||
|
public let extraHeight: CGFloat
|
||||||
|
|
||||||
|
public static let maximumExtraWidth: CGFloat = 200
|
||||||
|
public static let maximumExtraHeight: CGFloat = 120
|
||||||
|
|
||||||
|
/// Was bisher fest im Code stand. Wer nichts einstellt, merkt nichts.
|
||||||
|
public static let standard = TriggerArea(extraWidth: 16, extraHeight: 14)
|
||||||
|
|
||||||
|
public init(extraWidth: CGFloat, extraHeight: CGFloat) {
|
||||||
|
self.extraWidth = min(max(extraWidth, 0), Self.maximumExtraWidth)
|
||||||
|
self.extraHeight = min(max(extraHeight, 0), Self.maximumExtraHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Fläche, deren Betreten das Panel scharf schaltet.
|
||||||
|
///
|
||||||
|
/// Zur Seite wächst sie symmetrisch, nach unten einseitig — nach oben ist
|
||||||
|
/// der Bildschirmrand, von dort kommt niemand.
|
||||||
|
public func rect(notch: CGRect, screen: CGRect) -> CGRect {
|
||||||
|
CGRect(x: notch.minX - extraWidth,
|
||||||
|
y: notch.minY - extraHeight,
|
||||||
|
width: notch.width + 2 * extraWidth,
|
||||||
|
height: notch.height + extraHeight)
|
||||||
|
.intersection(screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
350
Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ImageIO
|
||||||
|
import OnyxDesign
|
||||||
|
|
||||||
|
/// Einstellung der Auslösefläche — mit Vorschau.
|
||||||
|
///
|
||||||
|
/// Zwei Zahlen in Punkten sagen niemandem etwas: „24 Punkte unter der Notch"
|
||||||
|
/// ist keine Vorstellung, die man im Kopf hat. Ein nachgebildeter Bildschirm
|
||||||
|
/// mit der Notch und der eingefärbten Fläche darüber schon — man sieht sofort,
|
||||||
|
/// wie weit man greifen muss.
|
||||||
|
public struct TriggerAreaSettings: View {
|
||||||
|
|
||||||
|
@Binding private var trigger: TriggerArea
|
||||||
|
|
||||||
|
public init(trigger: Binding<TriggerArea>) {
|
||||||
|
self._trigger = trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("trigger.hint", bundle: .module)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
TriggerPreview(trigger: trigger)
|
||||||
|
.frame(height: 150)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
LabeledContent {
|
||||||
|
Slider(value: Binding(
|
||||||
|
get: { trigger.extraWidth },
|
||||||
|
set: { trigger = TriggerArea(extraWidth: $0,
|
||||||
|
extraHeight: trigger.extraHeight) }),
|
||||||
|
in: 0...TriggerArea.maximumExtraWidth)
|
||||||
|
} label: {
|
||||||
|
Text("trigger.width \(Int(trigger.extraWidth))", bundle: .module)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent {
|
||||||
|
Slider(value: Binding(
|
||||||
|
get: { trigger.extraHeight },
|
||||||
|
set: { trigger = TriggerArea(extraWidth: trigger.extraWidth,
|
||||||
|
extraHeight: $0) }),
|
||||||
|
in: 0...TriggerArea.maximumExtraHeight)
|
||||||
|
} label: {
|
||||||
|
Text("trigger.height \(Int(trigger.extraHeight))", bundle: .module)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
trigger = .standard
|
||||||
|
} label: {
|
||||||
|
Text("trigger.reset", bundle: .module)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der nachgebildete Bildschirm.
|
||||||
|
///
|
||||||
|
/// Mit einem echten Hintergrundbild, damit man auf einen Blick sieht, dass hier
|
||||||
|
/// ein Mac-Bildschirm gemeint ist und nicht irgendein Rechteck. Zuerst der
|
||||||
|
/// eigene Schreibtischhintergrund — den erkennt man am schnellsten wieder —,
|
||||||
|
/// sonst einer von macOS.
|
||||||
|
private struct TriggerPreview: View {
|
||||||
|
let trigger: TriggerArea
|
||||||
|
|
||||||
|
@State private var wallpaper: NSImage?
|
||||||
|
|
||||||
|
/// Maße eines MacBook Pro mit Notch, auf die Vorschau heruntergerechnet.
|
||||||
|
private let screenAspect: CGFloat = 1800 / 1169
|
||||||
|
private let notchWidthFraction: CGFloat = 220 / 1800
|
||||||
|
private let notchHeightFraction: CGFloat = 38 / 1169
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let height = min(geometry.size.height, geometry.size.width / screenAspect)
|
||||||
|
let width = height * screenAspect
|
||||||
|
let scale = width / 1800
|
||||||
|
|
||||||
|
let notchWidth = width * notchWidthFraction
|
||||||
|
let notchHeight = height * notchHeightFraction
|
||||||
|
let areaWidth = min(notchWidth + 2 * trigger.extraWidth * scale, width)
|
||||||
|
let areaHeight = min(notchHeight + trigger.extraHeight * scale, height)
|
||||||
|
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
// Auf Maß gebracht, bevor es in den Stapel kommt: ein
|
||||||
|
// skalierbares Bild meldet seine **eigene** Größe als
|
||||||
|
// Wunschgröße, und die tausend Bildpunkte der Vorlage haben den
|
||||||
|
// Stapel auf tausend Punkte aufgeblasen. Notch, Menüleiste und
|
||||||
|
// Auslösefläche saßen dann am Rand dieser unsichtbaren Fläche
|
||||||
|
// und lagen außerhalb des Ausschnitts — das Bild war da, alles
|
||||||
|
// andere weg.
|
||||||
|
background
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
.clipped()
|
||||||
|
|
||||||
|
// Die Menüleiste als heller Streifen: sie erklärt, warum die
|
||||||
|
// Fläche nicht beliebig breit werden sollte.
|
||||||
|
Rectangle()
|
||||||
|
.fill(.black.opacity(0.35))
|
||||||
|
.frame(height: notchHeight)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
|
|
||||||
|
// Die Auslösefläche.
|
||||||
|
Rectangle()
|
||||||
|
.fill(Onyx.Color.accent.opacity(0.35))
|
||||||
|
.overlay { Rectangle().strokeBorder(Onyx.Color.accent, lineWidth: 1) }
|
||||||
|
.frame(width: areaWidth, height: areaHeight)
|
||||||
|
|
||||||
|
// Die Notch selbst — undurchsichtig, wie in echt.
|
||||||
|
UnevenRoundedRectangle(bottomLeadingRadius: 5, bottomTrailingRadius: 5,
|
||||||
|
style: .continuous)
|
||||||
|
.fill(.black)
|
||||||
|
.frame(width: notchWidth, height: notchHeight)
|
||||||
|
}
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
// Die Rundung zuletzt: sie schneidet Hintergrund, Menüleiste und
|
||||||
|
// Fläche gemeinsam ab, sonst ragt etwas über die Ecken hinaus.
|
||||||
|
.clipShape(.rect(cornerRadius: 8, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.animation(Onyx.Motion.value, value: areaWidth)
|
||||||
|
.animation(Onyx.Motion.value, value: areaHeight)
|
||||||
|
}
|
||||||
|
// Nicht beim Zeichnen laden: das Dekodieren dauert gut hundert
|
||||||
|
// Millisekunden, und die hätte das Fenster beim Öffnen gestanden.
|
||||||
|
.task { await loadWallpaper() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadWallpaper() async {
|
||||||
|
if let cached = Self.cache { wallpaper = cached.image; return }
|
||||||
|
// Den Pfad auf dem Hauptthread bestimmen — `NSScreen` und
|
||||||
|
// `NSWorkspace` gehören dorthin —, das Bild daneben.
|
||||||
|
let own = Self.ownWallpaperURL()
|
||||||
|
let display = Self.mainDisplayUUID()
|
||||||
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
|
Wallpaper(image: WallpaperLoader.load(own: own, display: display))
|
||||||
|
}.value
|
||||||
|
Self.cache = loaded
|
||||||
|
wallpaper = loaded.image
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einmal geladen und behalten: die Vorschau zeichnet sich bei jedem Zug am
|
||||||
|
/// Regler neu.
|
||||||
|
@MainActor private static var cache: Wallpaper?
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var background: some View {
|
||||||
|
if let image = wallpaper {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} else {
|
||||||
|
LinearGradient(colors: [Onyx.Color.elevated, Onyx.Color.surface],
|
||||||
|
startPoint: .top, endPoint: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Pfad des eigenen Schreibtischhintergrunds.
|
||||||
|
///
|
||||||
|
/// Liegt er in Schreibtisch, Dokumente oder Downloads, fragt macOS beim
|
||||||
|
/// ersten Lesen einmal nach — deshalb wird hier nicht vorab geprüft,
|
||||||
|
/// sondern gleich gelesen. Eine Prüfung mit `isReadableFile` beantwortet
|
||||||
|
/// macOS in diesen Ordnern mit „nein", ohne zu fragen; die Vorschau blieb
|
||||||
|
/// damit dauerhaft beim Ersatzbild, obwohl eine Freigabe möglich gewesen
|
||||||
|
/// wäre.
|
||||||
|
///
|
||||||
|
/// Wird die Freigabe verweigert, kommt ein Standardhintergrund von macOS.
|
||||||
|
@MainActor
|
||||||
|
private static func ownWallpaperURL() -> URL? {
|
||||||
|
guard let screen = NSScreen.main else { return nil }
|
||||||
|
return NSWorkspace.shared.desktopImageURL(for: screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kennung des Hauptbildschirms — unter ihr steht sein Hintergrundbild
|
||||||
|
/// in macOS' Verwaltung.
|
||||||
|
@MainActor
|
||||||
|
private static func mainDisplayUUID() -> String? {
|
||||||
|
guard let uuid = CGDisplayCreateUUIDFromDisplayID(CGMainDisplayID()) else { return nil }
|
||||||
|
return CFUUIDCreateString(nil, uuid.takeRetainedValue()) as String?
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Lädt das Hintergrundbild der Vorschau.
|
||||||
|
///
|
||||||
|
/// **Ein eigener Typ, kein Teil der View.** Was in einer `View` steht, ist an
|
||||||
|
/// den Hauptthread gebunden — die Konformität vererbt das, auch an statische
|
||||||
|
/// Methoden, die damit gar nichts zu tun haben. Aufgerufen wurde es trotzdem
|
||||||
|
/// nebenher; der Compiler hat das nur angemahnt, und zur Laufzeit hat die
|
||||||
|
/// Prüfung die App angehalten. Hier draußen gibt es die Bindung nicht.
|
||||||
|
enum WallpaperLoader {
|
||||||
|
|
||||||
|
static func load(own: URL?, display: String?) -> NSImage? {
|
||||||
|
// Der Reihe nach: was macOS als Pfad herausgibt, dann der Eintrag
|
||||||
|
// dieses Bildschirms in der Hintergrundverwaltung, dann ein Systembild.
|
||||||
|
if let own, let image = thumbnail(of: own) { return image }
|
||||||
|
if let indexed = indexedWallpaperURL(display: display),
|
||||||
|
let image = thumbnail(of: indexed) { return image }
|
||||||
|
return systemWallpaper()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Bild, das macOS für **diesen** Bildschirm verzeichnet hat.
|
||||||
|
///
|
||||||
|
/// `NSWorkspace.desktopImageURL` ist die alte Schnittstelle und meldet nur
|
||||||
|
/// das letzte Bild, das als Datei gesetzt wurde — auch dann noch, wenn es
|
||||||
|
/// längst gelöscht ist. Seit macOS 14 führt das System die Auswahl
|
||||||
|
/// stattdessen in `com.apple.wallpaper/Store/Index.plist`, je Bildschirm und
|
||||||
|
/// je Schreibtisch.
|
||||||
|
///
|
||||||
|
/// Gelesen wird **nur** der Eintrag dieses Bildschirms. In der Datei stehen
|
||||||
|
/// auch alte Bildschirme, der Bildschirmschoner und alles, was einmal
|
||||||
|
/// gesetzt war; das Neueste davon zu nehmen führt zuverlässig zu einem
|
||||||
|
/// Bild, das mit dem Schreibtisch nichts zu tun hat. Fehlt der Eintrag oder
|
||||||
|
/// die Datei, ist ein Systembild die ehrlichere Antwort als ein Rateergebnis.
|
||||||
|
static func indexedWallpaperURL(display: String?) -> URL? {
|
||||||
|
let index = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist")
|
||||||
|
guard let data = try? Data(contentsOf: index),
|
||||||
|
let root = try? PropertyListSerialization.propertyList(from: data,
|
||||||
|
options: [], format: nil)
|
||||||
|
as? [String: Any]
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
// Der eigene Bildschirm zuerst, sonst die Vorgabe für alle.
|
||||||
|
let displays = root["Displays"] as? [String: Any]
|
||||||
|
let candidates = [display.flatMap { displays?[$0] }, root["SystemDefault"]]
|
||||||
|
|
||||||
|
for candidate in candidates.compactMap({ $0 }) {
|
||||||
|
guard let entry = candidate as? [String: Any],
|
||||||
|
let desktop = entry["Desktop"] else { continue }
|
||||||
|
var found: [URL] = []
|
||||||
|
collectImageFiles(desktop, into: &found)
|
||||||
|
if let url = found.first(where: { FileManager.default.fileExists(atPath: $0.path) }) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sammelt alle Bilddateien aus der verschachtelten Liste.
|
||||||
|
///
|
||||||
|
/// Verschachtelt heißt hier wörtlich: die Konfiguration je Bildschirm steckt
|
||||||
|
/// als **eigene** Property-Liste in einem Datenfeld der äußeren. Ohne den
|
||||||
|
/// zweiten Durchgang findet man gar nichts.
|
||||||
|
static func collectImageFiles(_ node: Any, into found: inout [URL]) {
|
||||||
|
switch node {
|
||||||
|
case let dictionary as [String: Any]:
|
||||||
|
if dictionary["type"] as? String == "imageFile",
|
||||||
|
let url = dictionary["url"] as? [String: Any],
|
||||||
|
let text = url["relative"] as? String,
|
||||||
|
let parsed = URL(string: text), parsed.isFileURL {
|
||||||
|
found.append(parsed)
|
||||||
|
}
|
||||||
|
for value in dictionary.values { collectImageFiles(value, into: &found) }
|
||||||
|
|
||||||
|
case let array as [Any]:
|
||||||
|
for value in array { collectImageFiles(value, into: &found) }
|
||||||
|
|
||||||
|
case let data as Data:
|
||||||
|
guard data.starts(with: Array("bplist00".utf8)),
|
||||||
|
let inner = try? PropertyListSerialization.propertyList(from: data,
|
||||||
|
options: [], format: nil)
|
||||||
|
else { return }
|
||||||
|
collectImageFiles(inner, into: &found)
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Standardhintergrund von macOS.
|
||||||
|
///
|
||||||
|
/// Sie liegen auf jedem Mac und werden nur gelesen, nicht mitgeliefert.
|
||||||
|
///
|
||||||
|
/// Die Reihenfolge geht von neu nach alt und endet bei „irgendein Foto":
|
||||||
|
/// welche Bilder installiert sind, hängt an der macOS-Version.
|
||||||
|
static func systemWallpaper() -> NSImage? {
|
||||||
|
let directory = "/System/Library/Desktop Pictures"
|
||||||
|
let preferred = ["Tahoe", "Sequoia", "Sonoma", "Ventura", "Monterey",
|
||||||
|
"Big Sur Mountains", "Catalina Coast"]
|
||||||
|
|
||||||
|
// Zuerst die Originale, dann die kleinen Bilder aus `.thumbnails`.
|
||||||
|
//
|
||||||
|
// Die Originale sind zwanzig Megabyte groß, werden aber nie ganz
|
||||||
|
// gelesen — `CGImageSource` dekodiert gleich in der gebrauchten Größe.
|
||||||
|
// Die Vorschaubilder messen 214 Punkte in der Breite und wären auf
|
||||||
|
// einem Retina-Schirm sichtbar unscharf; sie sind die Reserve für die
|
||||||
|
// Motive, die macOS erst bei Bedarf nachlädt.
|
||||||
|
for folder in [directory, directory + "/.thumbnails"] {
|
||||||
|
let available = (try? FileManager.default.contentsOfDirectory(atPath: folder)) ?? []
|
||||||
|
for name in preferred {
|
||||||
|
// „Sonoma.heic" vor „Sonoma Dark.heic": das schlichte Motiv ist
|
||||||
|
// das bekannte, die Varianten sind Abwandlungen davon.
|
||||||
|
let matches = available
|
||||||
|
.filter { $0.hasPrefix(name) && $0.hasSuffix(".heic") }
|
||||||
|
.sorted { $0.count < $1.count }
|
||||||
|
if let match = matches.first,
|
||||||
|
let image = thumbnail(of: URL(fileURLWithPath: folder + "/" + match)) {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nichts Bekanntes dabei: dann das erste Bild, das sich lesen lässt.
|
||||||
|
for folder in [directory, directory + "/.thumbnails"] {
|
||||||
|
let available = ((try? FileManager.default.contentsOfDirectory(atPath: folder)) ?? [])
|
||||||
|
.filter { $0.hasSuffix(".heic") }.sorted()
|
||||||
|
for name in available {
|
||||||
|
if let image = thumbnail(of: URL(fileURLWithPath: folder + "/" + name)) {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt ein verkleinertes Bild.
|
||||||
|
///
|
||||||
|
/// Über `CGImageSource` statt `NSImage(contentsOf:)`: die Vorschau ist ein
|
||||||
|
/// paar hundert Bildpunkte breit, das Original hat sechstausend. Und ein
|
||||||
|
/// Hintergrundbild kann mehrere Bilder enthalten — hell und dunkel im
|
||||||
|
/// selben HEIC —, von denen hier nur das erste gebraucht wird.
|
||||||
|
static func thumbnail(of url: URL) -> NSImage? {
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: 1000,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
|
||||||
|
else { return nil }
|
||||||
|
return NSImage(cgImage: image, size: .init(width: image.width, height: image.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NSImage` über eine Aufgabengrenze zu reichen ist nicht von Haus aus
|
||||||
|
/// zugesichert. Hier ist es unbedenklich: das Bild wird einmal erzeugt und
|
||||||
|
/// danach nur noch gelesen.
|
||||||
|
private struct Wallpaper: @unchecked Sendable {
|
||||||
|
let image: NSImage?
|
||||||
|
}
|
||||||
@@ -2,58 +2,25 @@ import Foundation
|
|||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import OnyxDesign
|
import OnyxDesign
|
||||||
|
|
||||||
/// Die Größen, in denen ein Widget im Panel auftreten darf.
|
/// Ein Widget an seinem Platz im Layout, in der Reihenfolge, die der Nutzer
|
||||||
public enum WidgetSize: String, Codable, Sendable, CaseIterable, Identifiable {
|
/// festgelegt hat.
|
||||||
/// 1 × 1 — ein Messwert, ein Ring.
|
///
|
||||||
case small
|
/// Ohne Größe: alle Kacheln sind gleich groß. Eine Größenauswahl klingt nach
|
||||||
/// 2 × 1 — Messwert mit Verlauf, kurze Liste.
|
/// Freiheit und bringt vor allem Löcher — ein 2×1 neben einem 2×2 lässt oben
|
||||||
case medium
|
/// rechts eine Lücke, die niemand füllen kann und die aussieht wie ein Fehler.
|
||||||
/// 2 × 2 — Mini-Monat, Sensorliste.
|
|
||||||
case large
|
|
||||||
/// 4 × 1 — Medienzeile über die volle Breite.
|
|
||||||
case wide
|
|
||||||
|
|
||||||
public var id: String { rawValue }
|
|
||||||
|
|
||||||
public var columnSpan: Int {
|
|
||||||
switch self {
|
|
||||||
case .small: 1
|
|
||||||
case .medium, .large: 2
|
|
||||||
case .wide: 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var rowSpan: Int {
|
|
||||||
switch self {
|
|
||||||
case .small, .medium, .wide: 1
|
|
||||||
case .large: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ein Widget an seinem Platz im Layout, wie der Nutzer es angeordnet hat.
|
|
||||||
public struct WidgetPlacement: Codable, Equatable, Identifiable, Sendable {
|
public struct WidgetPlacement: Codable, Equatable, Identifiable, Sendable {
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
public var widgetID: String
|
public var widgetID: String
|
||||||
public var size: WidgetSize
|
|
||||||
|
|
||||||
public init(id: UUID = UUID(), widgetID: String, size: WidgetSize) {
|
public init(id: UUID = UUID(), widgetID: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.widgetID = widgetID
|
self.widgetID = widgetID
|
||||||
self.size = size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct GridCell: Hashable, Sendable, CustomStringConvertible {
|
/// Ältere Layouts trugen noch eine Größe. Sie wird beim Lesen verworfen,
|
||||||
public let row: Int
|
/// statt die Datei für ungültig zu erklären — sonst stünde nach dem
|
||||||
public let column: Int
|
/// Aktualisieren ein leeres Panel da.
|
||||||
|
private enum CodingKeys: String, CodingKey { case id, widgetID }
|
||||||
public init(row: Int, column: Int) {
|
|
||||||
self.row = row
|
|
||||||
self.column = column
|
|
||||||
}
|
|
||||||
|
|
||||||
public var description: String { "(\(row),\(column))" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Widget mit ausgerechneter Rasterposition.
|
/// Ein Widget mit ausgerechneter Rasterposition.
|
||||||
@@ -63,92 +30,67 @@ public struct ResolvedPlacement: Equatable, Identifiable, Sendable {
|
|||||||
public let column: Int
|
public let column: Int
|
||||||
|
|
||||||
public var id: UUID { placement.id }
|
public var id: UUID { placement.id }
|
||||||
public var columnSpan: Int { placement.size.columnSpan }
|
|
||||||
public var rowSpan: Int { placement.size.rowSpan }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt Widgets in ein Raster mit fester Spaltenzahl.
|
/// Setzt Widgets nebeneinander.
|
||||||
|
///
|
||||||
|
/// Das Panel wächst in die **Breite**. Es sitzt an der Notch, also am oberen
|
||||||
|
/// Rand — nach unten zu wachsen bedeckt den Bildschirm, nach rechts und links
|
||||||
|
/// legt es sich in den ohnehin leeren Streifen neben der Notch. Eine zweite
|
||||||
|
/// Reihe entsteht erst, wenn der Bildschirm keine weitere Kachel mehr hergibt.
|
||||||
public enum LayoutEngine {
|
public enum LayoutEngine {
|
||||||
|
|
||||||
public static let columns = 4
|
/// Die eine Kachelgröße.
|
||||||
/// Kantenlänge eines 1×1-Feldes.
|
|
||||||
public static let cellSize: CGFloat = 96
|
|
||||||
|
|
||||||
/// Ordnet die Widgets in der gespeicherten Reihenfolge ein, jedes an die
|
|
||||||
/// erste Stelle, an die es passt.
|
|
||||||
///
|
///
|
||||||
/// Der Reihe nach von links oben zu suchen statt stur hintereinander zu
|
/// Hochkant, weil fast jedes Widget eine Liste zeigt: Termine, Sensoren,
|
||||||
/// setzen ist der ganze Unterschied: neben einem 2×2-Widget bleiben rechts
|
/// Programme im Mischpult. Breiter als hoch wäre für ein einziges davon
|
||||||
/// zwei 1×1-Plätze frei, und ein Verfahren, das immer nur hinten anfügt,
|
/// besser und für alle anderen schlechter.
|
||||||
/// lässt sie dauerhaft leer.
|
public static let tileSize = CGSize(width: 180, height: 200)
|
||||||
public static func resolve(_ placements: [WidgetPlacement]) -> [ResolvedPlacement] {
|
public static var tileSpacing: CGFloat { Onyx.Metric.tileSpacing }
|
||||||
var occupied = Set<GridCell>()
|
private static var padding: CGFloat { Onyx.Metric.panelPadding }
|
||||||
var resolved: [ResolvedPlacement] = []
|
|
||||||
|
|
||||||
for placement in placements {
|
/// Wie viele Kacheln nebeneinander passen.
|
||||||
let span = placement.size
|
public static func columns(availableWidth: CGFloat) -> Int {
|
||||||
// Zu breit für das Raster: einpassen statt verwerfen. Ein Widget
|
let usable = availableWidth - 2 * padding + tileSpacing
|
||||||
// verschwinden zu lassen wäre für den Nutzer nicht erklärbar.
|
let fitting = Int(usable / (tileSize.width + tileSpacing))
|
||||||
let columnSpan = min(span.columnSpan, columns)
|
// Mindestens eine: lieber ein Panel, das über den Rand ragt, als eine
|
||||||
|
// Division durch null oder ein Panel ganz ohne Inhalt.
|
||||||
guard let cell = firstFreeCell(columnSpan: columnSpan,
|
return max(1, fitting)
|
||||||
rowSpan: span.rowSpan,
|
|
||||||
occupied: occupied) else { continue }
|
|
||||||
|
|
||||||
for row in cell.row..<(cell.row + span.rowSpan) {
|
|
||||||
for column in cell.column..<(cell.column + columnSpan) {
|
|
||||||
occupied.insert(GridCell(row: row, column: column))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolved.append(ResolvedPlacement(placement: placement,
|
|
||||||
row: cell.row,
|
|
||||||
column: cell.column))
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func firstFreeCell(columnSpan: Int,
|
public static func resolve(_ placements: [WidgetPlacement],
|
||||||
rowSpan: Int,
|
availableWidth: CGFloat) -> [ResolvedPlacement] {
|
||||||
occupied: Set<GridCell>) -> GridCell? {
|
let columnCount = columns(availableWidth: availableWidth)
|
||||||
let maxRow = (occupied.map(\.row).max() ?? -1) + rowSpan + 1
|
return placements.enumerated().map { index, placement in
|
||||||
|
ResolvedPlacement(placement: placement,
|
||||||
for row in 0...maxRow {
|
row: index / columnCount,
|
||||||
for column in 0...(columns - columnSpan) {
|
column: index % columnCount)
|
||||||
let fits = (row..<(row + rowSpan)).allSatisfy { r in
|
|
||||||
(column..<(column + columnSpan)).allSatisfy { c in
|
|
||||||
!occupied.contains(GridCell(row: r, column: c))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fits { return GridCell(row: row, column: column) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Die Größe, die das Panel für dieses Layout braucht.
|
/// Die Größe, die das Panel für dieses Layout braucht.
|
||||||
public static func panelSize(for resolved: [ResolvedPlacement]) -> CGSize {
|
public static func panelSize(for placements: [WidgetPlacement],
|
||||||
let spacing = Onyx.Metric.tileSpacing
|
availableWidth: CGFloat) -> CGSize {
|
||||||
let padding = Onyx.Metric.panelPadding
|
let columnCount = columns(availableWidth: availableWidth)
|
||||||
|
// Ein leeres Layout bekommt trotzdem eine Kachelfläche: ein Panel ohne
|
||||||
let rows = resolved.map { $0.row + $0.rowSpan }.max() ?? 1
|
// Ausdehnung wäre ein unsichtbares Panel, und der Nutzer hielte das
|
||||||
let usedRows = max(rows, 1)
|
// für einen Absturz.
|
||||||
|
let count = max(placements.count, 1)
|
||||||
|
let used = min(count, columnCount)
|
||||||
|
let rows = Int(ceil(Double(count) / Double(columnCount)))
|
||||||
|
|
||||||
return CGSize(
|
return CGSize(
|
||||||
width: CGFloat(columns) * cellSize + CGFloat(columns - 1) * spacing + 2 * padding,
|
width: CGFloat(used) * tileSize.width
|
||||||
height: CGFloat(usedRows) * cellSize + CGFloat(usedRows - 1) * spacing + 2 * padding)
|
+ CGFloat(used - 1) * tileSpacing + 2 * padding,
|
||||||
|
height: CGFloat(rows) * tileSize.height
|
||||||
|
+ CGFloat(rows - 1) * tileSpacing + 2 * padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Rahmen eines Widgets innerhalb des Panels, in SwiftUI-Koordinaten
|
/// Der Rahmen einer Kachel im Panel, in SwiftUI-Koordinaten (oben links).
|
||||||
/// (Ursprung oben links).
|
public static func frame(for item: ResolvedPlacement) -> CGRect {
|
||||||
public static func frame(for item: ResolvedPlacement, in panelSize: CGSize) -> CGRect {
|
CGRect(x: padding + CGFloat(item.column) * (tileSize.width + tileSpacing),
|
||||||
let spacing = Onyx.Metric.tileSpacing
|
y: padding + CGFloat(item.row) * (tileSize.height + tileSpacing),
|
||||||
let padding = Onyx.Metric.panelPadding
|
width: tileSize.width,
|
||||||
|
height: tileSize.height)
|
||||||
let x = padding + CGFloat(item.column) * (cellSize + spacing)
|
|
||||||
let y = padding + CGFloat(item.row) * (cellSize + spacing)
|
|
||||||
let width = CGFloat(item.columnSpan) * cellSize + CGFloat(item.columnSpan - 1) * spacing
|
|
||||||
let height = CGFloat(item.rowSpan) * cellSize + CGFloat(item.rowSpan - 1) * spacing
|
|
||||||
|
|
||||||
return CGRect(x: x, y: y, width: width, height: height)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,15 +62,9 @@ public struct LayoutStore: Sendable {
|
|||||||
/// wenige Widgets, die sofort etwas zeigen, als ein volles Panel, das
|
/// wenige Widgets, die sofort etwas zeigen, als ein volles Panel, das
|
||||||
/// erst konfiguriert werden will.
|
/// erst konfiguriert werden will.
|
||||||
public static func defaultLayout(knownWidgetIDs: Set<String>) -> [WidgetPlacement] {
|
public static func defaultLayout(knownWidgetIDs: Set<String>) -> [WidgetPlacement] {
|
||||||
let preferred: [(String, WidgetSize)] = [
|
let preferred = ["calendar", "weather", "media", "cpu", "battery"]
|
||||||
("calendar", .large),
|
|
||||||
("weather", .medium),
|
|
||||||
("cpu", .small),
|
|
||||||
("battery", .small),
|
|
||||||
("media", .wide),
|
|
||||||
]
|
|
||||||
return preferred
|
return preferred
|
||||||
.filter { knownWidgetIDs.contains($0.0) }
|
.filter { knownWidgetIDs.contains($0) }
|
||||||
.map { WidgetPlacement(widgetID: $0.0, size: $0.1) }
|
.map { WidgetPlacement(widgetID: $0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ public protocol OnyxWidget: Identifiable, Sendable {
|
|||||||
var displayName: String { get }
|
var displayName: String { get }
|
||||||
var symbolName: String { get }
|
var symbolName: String { get }
|
||||||
|
|
||||||
/// Welche Größen dieses Widget sinnvoll ausfüllen kann.
|
/// Der Inhalt der Kachel. Alle Kacheln sind gleich groß — was ein Widget
|
||||||
var supportedSizes: [WidgetSize] { get }
|
/// zeigt, entscheidet es selbst, nicht eine Größenauswahl.
|
||||||
|
@ViewBuilder func makeView() -> AnyView
|
||||||
@ViewBuilder func makeView(size: WidgetSize) -> AnyView
|
|
||||||
/// Einstellungen dieses Widgets, falls es welche hat.
|
/// Einstellungen dieses Widgets, falls es welche hat.
|
||||||
@ViewBuilder func makeSettingsView() -> AnyView?
|
@ViewBuilder func makeSettingsView() -> AnyView?
|
||||||
}
|
}
|
||||||
|
|||||||