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") {
|
||||||
HStack {
|
Toggle(isOn: Binding(
|
||||||
Label("settings.permissions.calendar", systemImage: "calendar")
|
get: { launchAtLogin.isEnabled },
|
||||||
Spacer()
|
set: { _ = launchAtLogin.set($0) })) {
|
||||||
if granted {
|
Text("settings.launchAtLogin")
|
||||||
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
|
}
|
||||||
.foregroundStyle(.green)
|
.disabled(launchAtLogin.needsSystemSettings)
|
||||||
.labelStyle(.titleAndIcon)
|
|
||||||
} else {
|
// Der Zustand, der sonst in eine Schleife führt: der Schalter
|
||||||
Button("settings.permissions.request") {
|
// steht auf aus, Drücken bewirkt nichts, weil das System den
|
||||||
Task {
|
// Eintrag blockiert. Also steht hier, wo es weitergeht.
|
||||||
let ok = await calendarModel.requestAccessNow()
|
if launchAtLogin.needsSystemSettings {
|
||||||
granted = ok
|
HStack {
|
||||||
// macOS fragt nach einer Ablehnung nicht erneut.
|
Text("settings.launchAtLogin.blocked")
|
||||||
// Dann hilft nur noch der Weg über die
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
// Systemeinstellungen — also dorthin führen.
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if !ok { openPrivacySettings() }
|
Spacer()
|
||||||
}
|
Button("settings.permissions.openSystem") {
|
||||||
|
launchAtLogin.openSystemSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("settings.permissions.calendar.why")
|
|
||||||
.font(.callout)
|
HStack {
|
||||||
.foregroundStyle(.secondary)
|
Spacer()
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Button("settings.onboarding.show", action: showOnboarding)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("settings.permissions.openSystem") { openPrivacySettings() }
|
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)
|
.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()
|
||||||
.onAppear { granted = calendarModel.hasAccess }
|
.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openPrivacySettings() {
|
let title: LocalizedStringKey
|
||||||
guard let url = URL(string:
|
let symbol: String
|
||||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return }
|
let state: State
|
||||||
NSWorkspace.shared.open(url)
|
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)
|
||||||
|
case .undetermined:
|
||||||
|
Button("settings.permissions.request") { Task { await request() } }
|
||||||
|
case .denied:
|
||||||
|
// macOS fragt nach einer Ablehnung nicht mehr — ein
|
||||||
|
// Anfragen-Knopf wäre hier eine Lüge.
|
||||||
|
Button("settings.permissions.openSystem") {
|
||||||
|
if let url = pane.url { NSWorkspace.shared.open(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(explanation)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Menüleiste
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.inset)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
// 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 struct ModuleRow: View {
|
||||||
|
@Bindable var model: AppModel
|
||||||
|
let id: String
|
||||||
|
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 }
|
||||||
|
|
||||||
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
|
||||||
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
|
||||||
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
|
||||||
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
|
||||||
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
|
||||||
guard source.isAuthorized else {
|
|
||||||
state = .denied
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let start = calendar.date(byAdding: .day, value: -14,
|
let start = calendar.date(byAdding: .day, value: -14,
|
||||||
to: calendar.startOfDay(for: date))!
|
to: calendar.startOfDay(for: date))!
|
||||||
let end = calendar.date(byAdding: .day, value: 60, to: start)!
|
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
|
||||||
|
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
||||||
|
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
||||||
|
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
||||||
|
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
||||||
|
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
||||||
|
}
|
||||||
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,9 +411,19 @@ 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)
|
||||||
EventRow(event: event)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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,10 +16,18 @@ 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) {
|
||||||
case .fullAccess:
|
case .fullAccess:
|
||||||
|
|||||||
@@ -1,159 +1,532 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage": "en",
|
||||||
"strings" : {
|
"strings": {
|
||||||
"" : {
|
"": {},
|
||||||
|
"%lld": {
|
||||||
},
|
"comment": "A day of the month. The argument is the day of the month.",
|
||||||
"%lld" : {
|
"isCommentAutoGenerated": true,
|
||||||
"comment" : "A day of the month. The argument is the day of the month.",
|
"localizations": {
|
||||||
"isCommentAutoGenerated" : true
|
"de": {
|
||||||
},
|
"stringUnit": {
|
||||||
"widget.calendar.allDay" : {
|
"state": "translated",
|
||||||
"localizations" : {
|
"value": "%lld"
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Ganztägig"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "All day"
|
"value": "%lld"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.day.unknown" : {
|
"calendar.source.apple": {
|
||||||
"comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
|
"localizations": {
|
||||||
"localizations" : {
|
"de": {
|
||||||
"de" : {
|
"stringUnit": {
|
||||||
"stringUnit" : {
|
"state": "translated",
|
||||||
"state" : "translated",
|
"value": "Apple Kalender"
|
||||||
"value" : "Für diesen Tag liegen keine Daten vor"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "No data available for this day"
|
"value": "Apple Calendar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.denied" : {
|
"calendar.source.both": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
|
"value": "Beide"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "No calendar access.\nAllow it in System Settings."
|
"value": "Both"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.incompatible" : {
|
"calendar.source.calendarr": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
|
"value": "Calendarr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Onyx is too old for this\nCalendarr version. Please update."
|
"value": "Calendarr"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.loggedOut" : {
|
"calendar.source.readOnly": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "In Calendarr anmelden"
|
"value": "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Sign in to Calendarr"
|
"value": "Onyx only reads. It never signs in and never writes to Calendarr."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.name" : {
|
"calendar.source.status.coverage": {
|
||||||
"localizations" : {
|
"extractionState": "stale",
|
||||||
"de" : {
|
"localizations": {
|
||||||
"stringUnit" : {
|
"de": {
|
||||||
"state" : "translated",
|
"stringUnit": {
|
||||||
"value" : "Kalender"
|
"state": "translated",
|
||||||
|
"value": "Bekannt bis %@"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Calendar"
|
"value": "Known through %@"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.neverWritten" : {
|
"calendar.source.status.coverage %@": {
|
||||||
"localizations" : {
|
"comment": "A footnote in the Calendarr settings, indicating the end of the coverage window.",
|
||||||
"de" : {
|
"isCommentAutoGenerated": true,
|
||||||
"stringUnit" : {
|
"localizations": {
|
||||||
"state" : "translated",
|
"de": {
|
||||||
"value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bekannt bis %@"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Open Calendarr once\nso events become available."
|
"value": "Known through %@"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.noUpcoming" : {
|
"calendar.source.status.incompatible": {
|
||||||
"comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
|
"localizations": {
|
||||||
"localizations" : {
|
"de": {
|
||||||
"de" : {
|
"stringUnit": {
|
||||||
"stringUnit" : {
|
"state": "translated",
|
||||||
"state" : "translated",
|
"value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren."
|
||||||
"value" : "Nichts mehr für heute"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Nothing left today"
|
"value": "Calendarr writes a newer format. Please update Onyx."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widget.calendar.unreadable" : {
|
"calendar.source.status.loggedOut": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Termine nicht lesbar.\nKonfigurationsfehler."
|
"value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Events unreadable.\nConfiguration error."
|
"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": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ganztägig"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "All day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.day.unknown": {
|
||||||
|
"comment": "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Für diesen Tag liegen keine Daten vor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No data available for this day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.denied": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No calendar access.\nAllow it in System Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.incompatible": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Onyx is too old for this\nCalendarr version. Please update."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.loggedOut": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "In Calendarr anmelden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sign in to Calendarr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.name": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Kalender"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.neverWritten": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Open Calendarr once\nso events become available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.noUpcoming": {
|
||||||
|
"comment": "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nichts mehr für heute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nothing left today"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Termine nicht lesbar.\nKonfigurationsfehler."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Events unreadable.\nConfiguration error."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version": "1.1"
|
||||||
}
|
}
|
||||||
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,56 +1,56 @@
|
|||||||
{
|
{
|
||||||
"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": "OK"
|
||||||
"value" : "Onyx läuft bereits"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Onyx is already running"
|
"value": "OK"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"singleInstance.blocked.informative" : {
|
"singleInstance.blocked.informative": {
|
||||||
"comment" : "Erklärung samt Hinweis auf Xcode. %@ ist der Pfad der laufenden Instanz.",
|
"comment": "Erklärung samt Hinweis auf Xcode. %@ ist der Pfad der laufenden Instanz.",
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Eine andere Onyx-Instanz läuft und ließ sich nicht beenden. Diese Instanz wird deshalb nicht gestartet, damit nicht zwei Panels gleichzeitig auf der Notch arbeiten.\n\nLäuft sie aus Xcode, beende sie dort mit ⌘. und starte erneut.\n\n%@"
|
"value": "Eine andere Onyx-Instanz läuft und ließ sich nicht beenden. Diese Instanz wird deshalb nicht gestartet, damit nicht zwei Panels gleichzeitig auf der Notch arbeiten.\n\nLäuft sie aus Xcode, beende sie dort mit ⌘. und starte erneut.\n\n%@"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Another Onyx instance is running and could not be quit. This instance will not start, so that two panels don't fight over the same notch.\n\nIf it was launched from Xcode, stop it there with ⌘. and try again.\n\n%@"
|
"value": "Another Onyx instance is running and could not be quit. This instance will not start, so that two panels don't fight over the same notch.\n\nIf it was launched from Xcode, stop it there with ⌘. and try again.\n\n%@"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common.ok" : {
|
"singleInstance.blocked.message": {
|
||||||
"localizations" : {
|
"comment": "Titel des Dialogs, wenn eine zweite Instanz zurücktritt",
|
||||||
"de" : {
|
"localizations": {
|
||||||
"stringUnit" : {
|
"de": {
|
||||||
"state" : "translated",
|
"stringUnit": {
|
||||||
"value" : "OK"
|
"state": "translated",
|
||||||
|
"value": "Onyx läuft bereits"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "OK"
|
"value": "Onyx is already running"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version": "1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
|
||||||
// während ein Menü offen ist oder gescrollt wird.
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
pointerPollTimer = timer
|
|
||||||
} else if !anyVisible {
|
|
||||||
pointerPollTimer?.invalidate()
|
|
||||||
pointerPollTimer = nil
|
|
||||||
}
|
}
|
||||||
|
// `.common` statt der Standard-Schleife: sonst steht die Abfrage,
|
||||||
|
// während ein Menü offen ist oder gescrollt wird.
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
pointerPollTimer = timer
|
||||||
|
pollInterval = interval
|
||||||
|
log.notice("Zeigerabfrage: \(Int(1 / interval), privacy: .public) Hz, \(self.controllers.count, privacy: .public) Bildschirm(e)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
|
||||||
public let row: Int
|
|
||||||
public let column: Int
|
|
||||||
|
|
||||||
public init(row: Int, column: Int) {
|
|
||||||
self.row = row
|
|
||||||
self.column = column
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String { "(\(row),\(column))" }
|
/// Ältere Layouts trugen noch eine Größe. Sie wird beim Lesen verworfen,
|
||||||
|
/// statt die Datei für ungültig zu erklären — sonst stünde nach dem
|
||||||
|
/// Aktualisieren ein leeres Panel da.
|
||||||
|
private enum CodingKeys: String, CodingKey { case id, widgetID }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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?
|
||||||
}
|
}
|
||||||
|
|||||||