Compare commits
80 Commits
d0692d0f86
...
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 |
@@ -8,34 +8,83 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
||||
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; };
|
||||
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
|
||||
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
|
||||
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 */; };
|
||||
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
|
||||
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
|
||||
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
|
||||
5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; };
|
||||
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
|
||||
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7A866E72010907DF4BEF025C /* bolt-dark.svg */; };
|
||||
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 */; };
|
||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
||||
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 */; };
|
||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; };
|
||||
C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; };
|
||||
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
38EC624350BE3AFC906F5004 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 32F5E88F3CF35AE8C0E00613 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = BABC85A062EFB668A81EBAA7;
|
||||
remoteInfo = OnyxHelper;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -44,13 +93,26 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */,
|
||||
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */,
|
||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */,
|
||||
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */,
|
||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */,
|
||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
|
||||
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */,
|
||||
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */,
|
||||
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */,
|
||||
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */,
|
||||
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */,
|
||||
B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */,
|
||||
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */,
|
||||
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */,
|
||||
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */,
|
||||
C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */,
|
||||
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */,
|
||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
|
||||
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
||||
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D4D0E94BCED22A73F694B2CB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -68,6 +130,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0804288DC4A8146DD9F3FC3E /* Onyx.app */,
|
||||
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -76,6 +139,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6653B90FCB65F334F91F2F1 /* Onyx */,
|
||||
6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */,
|
||||
573275C59A65A7C11CCEC90A /* Packages */,
|
||||
339A2C4AF25B5612E90B795D /* Products */,
|
||||
);
|
||||
@@ -89,19 +153,42 @@
|
||||
path = Packages;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B442E8A39C37C8FD92B4CA83 /* main.swift */,
|
||||
4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */,
|
||||
);
|
||||
path = OnyxHelper;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6653B90FCB65F334F91F2F1 /* Onyx */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0FBA13B97C53F1D0D740A633 /* Resources */,
|
||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
||||
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 */,
|
||||
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
||||
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
||||
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
||||
63A5510538846C8427AEB831 /* OnboardingView.swift */,
|
||||
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
|
||||
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
|
||||
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
|
||||
CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */,
|
||||
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
||||
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
||||
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
||||
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
|
||||
);
|
||||
path = Onyx;
|
||||
sourceTree = "<group>";
|
||||
@@ -116,16 +203,18 @@
|
||||
6B11A5AC704B6429B9484789 /* Sources */,
|
||||
B211CE3C0EF94A8D680E4AE0 /* Resources */,
|
||||
7FF994FE5530D53BEFBE13FE /* Frameworks */,
|
||||
E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */,
|
||||
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */,
|
||||
F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Onyx;
|
||||
packageProductDependencies = (
|
||||
C64A111F449D0F9D36ED46FB /* OnyxCore */,
|
||||
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */,
|
||||
3D680DD645941493A7D3559E /* OnyxDesign */,
|
||||
3A32CAE6CC4D01D660F0016F /* OnyxNotch */,
|
||||
E66577A40689DF225BCED184 /* OnyxWidgetKit */,
|
||||
@@ -133,11 +222,34 @@
|
||||
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
||||
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
|
||||
349226D70F51196CCD1C94F8 /* MediaProvider */,
|
||||
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
|
||||
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
||||
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
||||
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
|
||||
);
|
||||
productName = Onyx;
|
||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
BABC85A062EFB668A81EBAA7 /* OnyxHelper */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 517F123F9F5479E913E7B1AA /* Build configuration list for PBXNativeTarget "OnyxHelper" */;
|
||||
buildPhases = (
|
||||
A9C106AA3316FA03B999A567 /* Sources */,
|
||||
D4D0E94BCED22A73F694B2CB /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = OnyxHelper;
|
||||
packageProductDependencies = (
|
||||
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */,
|
||||
);
|
||||
productName = OnyxHelper;
|
||||
productReference = 8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */;
|
||||
productType = "com.apple.product-type.tool";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -151,6 +263,10 @@
|
||||
DevelopmentTeam = PP34X97WS3;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
BABC85A062EFB668A81EBAA7 = {
|
||||
DevelopmentTeam = PP34X97WS3;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 0A2ACEAA7E2F36634202B241 /* Build configuration list for PBXProject "Onyx" */;
|
||||
@@ -171,6 +287,7 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
BABC85A062EFB668A81EBAA7 /* OnyxHelper */,
|
||||
2EB428ADC48D89BCA28D3EE1 /* Onyx */,
|
||||
);
|
||||
};
|
||||
@@ -183,6 +300,12 @@
|
||||
files = (
|
||||
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon 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;
|
||||
};
|
||||
@@ -203,13 +326,13 @@
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl",
|
||||
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/quartz.svg",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "OUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n";
|
||||
shellScript = "# Im Release mit der echten Identität signieren: ad-hoc signierte\n# Bibliotheken lehnt die Notarisierung ab.\nif [ \"$CONFIGURATION\" = \"Release\" ]; then\n export SIGN_IDENTITY=\"$EXPANDED_CODE_SIGN_IDENTITY\"\nfi\nOUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n";
|
||||
};
|
||||
F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */ = {
|
||||
E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -217,17 +340,15 @@
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/Onyx/AppIcon.icon/Assets/quartz.svg",
|
||||
);
|
||||
name = "Glyph für die Menüleiste ins Bundle kopieren";
|
||||
name = "Helfer und Launchd-Plist einbetten";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/quartz.svg",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "SRC=\"$SRCROOT/Onyx/AppIcon.icon/Assets/quartz.svg\"\nDST=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/quartz.svg\"\nif [ -f \"$SRC\" ]; then\n mkdir -p \"$(dirname \"$DST\")\"\n cp \"$SRC\" \"$DST\"\nelse\n echo \"warning: $SRC fehlt — Menüleiste fällt auf ein SF-Symbol zurück\"\nfi\n";
|
||||
shellScript = "APP=\"$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH\"\nmkdir -p \"$APP/MacOS\" \"$APP/Library/LaunchDaemons\"\ncp \"$BUILT_PRODUCTS_DIR/OnyxHelper\" \"$APP/MacOS/OnyxHelper\"\ncp \"$SRCROOT/OnyxHelper/com.scarriffleservices.onyx.helper.plist\" \\\n \"$APP/Library/LaunchDaemons/\"\n# Der Helfer wird eigenständig signiert — die Signatur der App deckt\n# ihn nicht ab, und ohne gültige eigene Signatur lehnt launchd ihn ab.\n#\n# `-i` ist Pflicht: Xcode signiert Kommandozeilenprogramme sonst mit\n# dem Produktnamen (\"OnyxHelper\") als Bezeichner. Die App prüft aber\n# auf com.scarriffleservices.onyx.helper und würde ihren eigenen\n# Helfer ablehnen.\n# Zeitstempel im Release, keiner im Debug.\n#\n# Die Notarisierung verlangt einen sicheren Zeitstempel; ohne ihn\n# lehnt Apple das Paket ab, und zwar erst nach dem Hochladen. Im\n# Debug wäre er nur eine Netzanfrage bei jedem Bauen.\nif [ \"$CONFIGURATION\" = \"Release\" ]; then\n TIMESTAMP=\"--timestamp\"\nelse\n TIMESTAMP=\"--timestamp=none\"\nfi\ncodesign --force --options runtime $TIMESTAMP \\\n -i com.scarriffleservices.onyx.helper \\\n --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" \"$APP/MacOS/OnyxHelper\"\n\n# Kein Neusignieren der App hier: Xcode signiert sie als letzten\n# Schritt, nach diesem Skript. Das repariert das Siegel von selbst\n# und legt im Debug-Build auch get-task-allow bei, ohne die im\n# Skript nachzubauen wäre — nachgemessen an der Reihenfolge im\n# Bauprotokoll (PhaseScriptExecution vor CodeSign Onyx.app).\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
@@ -237,16 +358,59 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
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 */,
|
||||
5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */,
|
||||
39E29ADC71D4860543AD25EB /* SettingsView.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;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = BABC85A062EFB668A81EBAA7 /* OnyxHelper */;
|
||||
targetProxy = 38EC624350BE3AFC906F5004 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
2EBE219F7BAFEEBF99ACB8DE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
|
||||
INFOPLIST_FILE = OnyxHelper/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx.helper;
|
||||
PRODUCT_NAME = OnyxHelper;
|
||||
SDKROOT = macosx;
|
||||
SKIP_INSTALL = YES;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
42021FACB216529C4EA156E9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -319,7 +483,6 @@
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_CODE_SIGN_FLAGS = "--deep";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
@@ -329,6 +492,24 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B649EAAA14C09F6A9E4A558D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
|
||||
INFOPLIST_FILE = OnyxHelper/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx.helper;
|
||||
PRODUCT_NAME = OnyxHelper;
|
||||
SDKROOT = macosx;
|
||||
SKIP_INSTALL = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
DEB51EF5615CABC756F24ADD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -389,7 +570,6 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_CODE_SIGN_FLAGS = "--deep";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
@@ -430,6 +610,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
517F123F9F5479E913E7B1AA /* Build configuration list for PBXNativeTarget "OnyxHelper" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2EBE219F7BAFEEBF99ACB8DE /* Debug */,
|
||||
B649EAAA14C09F6A9E4A558D /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
C126F7CB2B14EC196CE8743D /* Build configuration list for PBXNativeTarget "Onyx" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
@@ -461,6 +650,18 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OnyxDesign;
|
||||
};
|
||||
5D8203CC3A09D838A4AC385D /* AudioProvider */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = AudioProvider;
|
||||
};
|
||||
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MetricsProvider;
|
||||
};
|
||||
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OnyxHelperProtocol;
|
||||
};
|
||||
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = CalendarProvider;
|
||||
@@ -481,6 +682,18 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OnyxWidgetKit;
|
||||
};
|
||||
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ShelfProvider;
|
||||
};
|
||||
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OnyxHelperProtocol;
|
||||
};
|
||||
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = NetworkProvider;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */;
|
||||
|
||||
@@ -1 +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="#1c1f1f" 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="#273a3d" 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="#273a3d" 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="#000000" 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="#0f1b1a" 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="#1c1f1f" 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="#273a3d" 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="#000000" 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="#273a3d" 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="#141414" 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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1 +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="#c3c3c3" 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="#f3f5f6" 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="#f3f5f6" 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="#d6d6d6" 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="#d6d6d6" 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="#c3c3c3" 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="#f3f5f6" 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="#d6d6d6" 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="#f3f5f6" 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="#a5a5a5" 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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,13 +1,44 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.21900,0.30045,0.61471,1.00000"
|
||||
"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" : [
|
||||
{
|
||||
"image-name" : "quartz.svg",
|
||||
"name" : "quartz",
|
||||
"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" : [
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import OnyxNotch
|
||||
import OnyxWidgetKit
|
||||
import OnyxMenuBar
|
||||
|
||||
/// 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) }
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
|
||||
static let displayPolicy = "onyx.displayPolicy"
|
||||
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
||||
static let showsDockIcon = "onyx.showsDockIcon"
|
||||
static let triggerArea = "onyx.triggerArea"
|
||||
}
|
||||
|
||||
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
|
||||
@@ -57,11 +104,40 @@ final class AppModel {
|
||||
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
|
||||
// zurück, wenn die Notch einmal nicht reagiert.
|
||||
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
|
||||
|
||||
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 {
|
||||
layout.contains { $0.widgetID == widgetID }
|
||||
@@ -69,19 +145,13 @@ final class AppModel {
|
||||
|
||||
func add(_ widget: any OnyxWidget) {
|
||||
guard !isInPanel(widget.id) else { return }
|
||||
let size = widget.supportedSizes.first ?? .small
|
||||
layout.append(WidgetPlacement(widgetID: widget.id, size: size))
|
||||
layout.append(WidgetPlacement(widgetID: widget.id))
|
||||
}
|
||||
|
||||
func remove(_ widgetID: String) {
|
||||
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) {
|
||||
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>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<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>
|
||||
<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()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.6 KiB 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ import OnyxWidgetKit
|
||||
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
|
||||
struct OnyxApp: App {
|
||||
@@ -34,7 +41,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
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 fanControl = FanControl()
|
||||
private let audioMixer = AudioMixer()
|
||||
private let systemAudio = SystemAudio()
|
||||
private let launchAtLogin = LaunchAtLogin()
|
||||
private let onboarding = OnboardingWindowController()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
@@ -52,8 +67,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
let model = AppModel()
|
||||
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(
|
||||
rootView: NotchContainer(presentation: presentation) {
|
||||
NotchPanelContent(model: model)
|
||||
@@ -67,14 +86,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem
|
||||
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
|
||||
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
|
||||
model.onTriggerAreaChanged = { [weak self] area in
|
||||
self?.coordinator?.trigger = area
|
||||
}
|
||||
model.onDisplayPolicyChanged = { [weak self] policy in
|
||||
self?.coordinator?.policy = policy
|
||||
self?.applyContentSize()
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
observeMenuBarPreference()
|
||||
@@ -90,12 +140,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// dort freischalten könnte. Genau dieser Zustand ist beim Testen
|
||||
/// aufgetreten.
|
||||
private func requestPermissions() {
|
||||
guard let calendarModel else { return }
|
||||
guard let calendarModel, let weatherModel else { return }
|
||||
log.notice("Start: Einrichtung erledigt = \(self.onboarding.isDone, privacy: .public)")
|
||||
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
|
||||
// das Widget den Berechtigungshinweis statt einer leeren Liste.
|
||||
calendarModel.refresh()
|
||||
// Fenster öffnen, solange irgendeine Berechtigung noch aussteht.
|
||||
let needsLocation = weatherModel?.locationIsUndetermined ?? false
|
||||
|
||||
// Die Einrichtung kommt beim ersten Start immer — auch wenn zufällig
|
||||
// schon alles erlaubt ist. Sie erklärt nicht nur Berechtigungen,
|
||||
// sondern auch den Helfer und den Autostart, und sie ist der einzige
|
||||
// Moment, in dem jemand erfährt, was Onyx eigentlich alles kann.
|
||||
guard onboarding.isDone else {
|
||||
onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||
mixer: audioMixer, fanControl: fanControl,
|
||||
launchAtLogin: launchAtLogin)
|
||||
return
|
||||
}
|
||||
|
||||
// Danach nur noch, wenn wirklich etwas fehlt.
|
||||
let needsLocation = weatherModel.locationIsUndetermined
|
||||
guard !calendarModel.hasAccess || needsLocation else { return }
|
||||
|
||||
// Nicht im Hintergrund anfragen.
|
||||
@@ -108,20 +171,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// `NSApp.activate` davor hilft nicht: Aktivierung wirkt asynchron und
|
||||
// ist beim Aufruf noch nicht durch.
|
||||
//
|
||||
// Deshalb der ehrliche Weg: beim ersten Start das Einstellungsfenster
|
||||
// öffnen. Dort ist ein echtes Fenster im Vordergrund, der Dialog
|
||||
// erscheint zuverlässig — und der Nutzer weiß, warum gefragt wird.
|
||||
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
|
||||
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
|
||||
// genügt der Berechtigungsbereich der Einstellungen.
|
||||
showSettings(tab: .permissions)
|
||||
}
|
||||
|
||||
/// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus.
|
||||
private func showOnboarding() {
|
||||
guard let calendarModel, let weatherModel else { return }
|
||||
onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||
mixer: audioMixer, fanControl: fanControl,
|
||||
launchAtLogin: launchAtLogin)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
coordinator?.stop()
|
||||
menuBar?.stop()
|
||||
// Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf
|
||||
// Sekunden auf Automatik zurück — auch wenn Onyx abstürzt statt sauber
|
||||
// zu beenden.
|
||||
fanControl.stop()
|
||||
// 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() {
|
||||
let calendarModel = CalendarModel(source: EventKitSource())
|
||||
let calendarModel = CalendarModel()
|
||||
self.calendarModel = calendarModel
|
||||
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
||||
|
||||
@@ -133,7 +220,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self.mediaModel = mediaModel
|
||||
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
|
||||
|
||||
PlaceholderWidgets.registerAll()
|
||||
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() {
|
||||
@@ -144,19 +252,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// MARK: - Menüleiste
|
||||
|
||||
/// Das feste Onyx-Element: Panel öffnen, Einstellungen, Beenden. Es ist auch
|
||||
/// das Sicherheitsnetz, falls die Notch einmal nicht reagiert.
|
||||
/// Das feste Onyx-Element.
|
||||
///
|
||||
/// **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() {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
item.autosaveName = "onyx.menubar.main"
|
||||
item.button?.image = Self.menuBarIcon()
|
||||
|
||||
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"),
|
||||
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(withTitle: String(localized: "menu.quit"),
|
||||
action: #selector(quit), keyEquivalent: "q").target = self
|
||||
@@ -165,6 +280,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
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
|
||||
@@ -172,28 +289,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// Menüleiste. Dort steht sie als farbiger Klotz zwischen lauter
|
||||
/// Strichsymbolen.
|
||||
///
|
||||
/// Geladen wird dieselbe SVG-Datei, aus der auch das App-Icon gebaut wird —
|
||||
/// NSImage liest sie als Vektor (`_NSSVGImageRep`), sie skaliert also
|
||||
/// verlustfrei.
|
||||
/// **Eine eigene Konturfassung, nicht das App-Icon.**
|
||||
///
|
||||
/// **Als Template-Bild.** Das Glyph besteht aus zehn deckenden Pfaden in
|
||||
/// sehr dunklen Tönen (#1c1f1f bis #000000). Im App-Icon liegt es auf
|
||||
/// blauem Verlauf und liest sich als Kristall; allein auf einer dunklen
|
||||
/// Menüleiste ist es schwarz auf schwarz. macOS färbt ausschließlich
|
||||
/// Template-Bilder ein, und die benutzen nur den Alphakanal — die Facetten
|
||||
/// verschmelzen dabei zu einer Silhouette.
|
||||
/// 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.
|
||||
///
|
||||
/// Das ist der bewusste Tausch: erkennbare Form in hellen wie dunklen
|
||||
/// Menüleisten statt Facetten, die man in keiner von beiden sieht. Eine
|
||||
/// Fassung mit durchsichtigen Facettenfugen behielte beides — dafür müsste
|
||||
/// das SVG entsprechend gezeichnet sein.
|
||||
/// `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: "quartz", withExtension: "svg"),
|
||||
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)
|
||||
@@ -232,7 +347,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private func showSettings(tab: SettingsTab) {
|
||||
guard let model, let calendarModel, let weatherModel else { return }
|
||||
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||
weatherModel: weatherModel, tab: tab)
|
||||
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) }
|
||||
@@ -244,6 +363,9 @@ private struct NotchPanelContent: View {
|
||||
let model: AppModel
|
||||
|
||||
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,70 +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: "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,31 +3,107 @@ import OnyxDesign
|
||||
import OnyxNotch
|
||||
import OnyxWidgetKit
|
||||
import CalendarProvider
|
||||
import ShelfProvider
|
||||
import OnyxMenuBar
|
||||
import MetricsProvider
|
||||
import OnyxHelperProtocol
|
||||
import WeatherProvider
|
||||
|
||||
enum SettingsTab: Hashable {
|
||||
case widgets, display, permissions
|
||||
case widgets, display, colors, sources, menubar, fans, permissions
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var model: AppModel
|
||||
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
|
||||
|
||||
/// 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 {
|
||||
TabView(selection: $selectedTab) {
|
||||
WidgetSettings(model: model)
|
||||
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
|
||||
.tag(SettingsTab.widgets)
|
||||
DisplaySettings(model: model)
|
||||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
||||
.tag(SettingsTab.display)
|
||||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||||
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
|
||||
.tag(SettingsTab.permissions)
|
||||
// Eigene Reiterleiste statt `TabView`.
|
||||
//
|
||||
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs
|
||||
// Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf.
|
||||
// Abstand nach oben half nicht: der Trenner wandert mit. Also die
|
||||
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst.
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
ForEach(Self.tabs, id: \.tab) { entry in
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,96 +118,311 @@ struct SettingsView: View {
|
||||
private struct PermissionSettings: View {
|
||||
let calendarModel: CalendarModel
|
||||
let weatherModel: WeatherModel
|
||||
@State private var granted = false
|
||||
@State private var locationGranted = false
|
||||
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 {
|
||||
Form {
|
||||
Section {
|
||||
Section("settings.general") {
|
||||
Toggle(isOn: Binding(
|
||||
get: { launchAtLogin.isEnabled },
|
||||
set: { _ = launchAtLogin.set($0) })) {
|
||||
Text("settings.launchAtLogin")
|
||||
}
|
||||
.disabled(launchAtLogin.needsSystemSettings)
|
||||
|
||||
// Der Zustand, der sonst in eine Schleife führt: der Schalter
|
||||
// steht auf aus, Drücken bewirkt nichts, weil das System den
|
||||
// Eintrag blockiert. Also steht hier, wo es weitergeht.
|
||||
if launchAtLogin.needsSystemSettings {
|
||||
HStack {
|
||||
Label("settings.permissions.calendar", systemImage: "calendar")
|
||||
Spacer()
|
||||
if granted {
|
||||
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.labelStyle(.titleAndIcon)
|
||||
} else {
|
||||
Button("settings.permissions.request") {
|
||||
Task {
|
||||
let ok = await calendarModel.requestAccessNow()
|
||||
granted = ok
|
||||
// macOS fragt nach einer Ablehnung nicht erneut.
|
||||
// Dann hilft nur noch der Weg über die
|
||||
// Systemeinstellungen — also dorthin führen.
|
||||
if !ok { openPrivacySettings() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("settings.permissions.calendar.why")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("settings.launchAtLogin.blocked")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
Button("settings.permissions.openSystem") {
|
||||
launchAtLogin.openSystemSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("settings.onboarding.show", action: showOnboarding)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Label("settings.permissions.location", systemImage: "location")
|
||||
Spacer()
|
||||
if locationGranted {
|
||||
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.labelStyle(.titleAndIcon)
|
||||
} else if weatherModel.locationIsDenied {
|
||||
Button("settings.permissions.openSystem") { openLocationSettings() }
|
||||
} else {
|
||||
Button("settings.permissions.request") {
|
||||
// Muss aus diesem Fenster kommen: macOS zeigt den
|
||||
// Dialog nur, wenn eine App im Vordergrund ist.
|
||||
weatherModel.requestLocationAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("settings.permissions.location.why")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
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 {
|
||||
Button("settings.permissions.openSystem") { openPrivacySettings() }
|
||||
PermissionRow(
|
||||
title: "settings.permissions.location",
|
||||
symbol: "location",
|
||||
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
|
||||
// sich, während dieses Fenster offen steht.
|
||||
state: locationState,
|
||||
explanation: "settings.permissions.location.why",
|
||||
pane: .location,
|
||||
request: { weatherModel.requestLocationAuthorization() })
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding()
|
||||
.task {
|
||||
granted = calendarModel.hasAccess
|
||||
guard !granted, !didAutoRequest,
|
||||
calendarModel.authorization == .undetermined else { return }
|
||||
didAutoRequest = true
|
||||
|
||||
// Kurz warten, bis das Fenster wirklich vorne steht. TCC zeigt den
|
||||
// Dialog nur, wenn eine App im Vordergrund ist, an die er gehören
|
||||
// kann — und Aktivierung wirkt asynchron. Ohne diese Pause fragt
|
||||
// Onyx, während es formal noch im Hintergrund liegt, und die
|
||||
// Anfrage verpufft kommentarlos.
|
||||
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))
|
||||
granted = await calendarModel.requestAccessNow()
|
||||
calendarGranted = await calendarModel.requestAccessNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var didAutoRequest = false
|
||||
/// 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
|
||||
|
||||
private func openLocationSettings() {
|
||||
guard let url = URL(string:
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
/// 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() {
|
||||
guard let url = URL(string:
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
let title: LocalizedStringKey
|
||||
let symbol: String
|
||||
let state: State
|
||||
let explanation: LocalizedStringKey
|
||||
let pane: Pane
|
||||
let request: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Label(title, systemImage: symbol)
|
||||
Spacer()
|
||||
switch state {
|
||||
case .granted:
|
||||
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
|
||||
// Akzentstil färbt sonst darüber. Grün heißt hier „erteilt"
|
||||
// und ist keine Geschmacksfrage.
|
||||
Label {
|
||||
Text("settings.permissions.granted")
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Onyx.Color.positive)
|
||||
}
|
||||
.foregroundStyle(Onyx.Color.positive)
|
||||
.labelStyle(.titleAndIcon)
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +430,7 @@ private struct PermissionSettings: View {
|
||||
|
||||
private struct WidgetSettings: View {
|
||||
@Bindable var model: AppModel
|
||||
let shelfStore: ShelfStore?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -177,6 +469,7 @@ private struct WidgetSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
@@ -184,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 {
|
||||
@Bindable var model: AppModel
|
||||
let placement: WidgetPlacement
|
||||
@@ -195,18 +521,6 @@ private struct PlacementRow: View {
|
||||
|
||||
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 {
|
||||
model.remove(placement.widgetID)
|
||||
} label: {
|
||||
@@ -238,24 +552,30 @@ private struct DisplaySettings: View {
|
||||
|
||||
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)
|
||||
Text("settings.display.menuBarIcon.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.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)
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +1,10 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import CalendarProvider
|
||||
import WeatherProvider
|
||||
import MetricsProvider
|
||||
import ShelfProvider
|
||||
|
||||
/// Führt das Einstellungsfenster selbst.
|
||||
///
|
||||
@@ -13,32 +16,82 @@ import WeatherProvider
|
||||
///
|
||||
/// Ein eigenes `NSWindow` hat diese Abhängigkeit nicht. Es kostet ein paar
|
||||
/// 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
|
||||
@Observable
|
||||
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.
|
||||
private var selectedTab: 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
|
||||
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 {
|
||||
let hosting = NSHostingController(
|
||||
rootView: SettingsView(model: model,
|
||||
calendarModel: calendarModel,
|
||||
weatherModel: weatherModel,
|
||||
metricsModel: metricsModel,
|
||||
shelfStore: shelfStore,
|
||||
fanControl: fanControl,
|
||||
launchAtLogin: launchAtLogin,
|
||||
showOnboarding: showOnboarding,
|
||||
selectedTab: Binding(
|
||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
||||
set: { [weak self] in self?.selectedTab = $0 })))
|
||||
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
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.delegate = self
|
||||
window.center()
|
||||
Self.place(window)
|
||||
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
||||
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
||||
window.setFrameAutosaveName("onyx.settings")
|
||||
@@ -51,9 +104,46 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
// Zurück in den Hintergrund: Onyx hat sonst weiter den Fokus, obwohl
|
||||
// kein Fenster mehr offen ist.
|
||||
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
|
||||
/// klebt.
|
||||
///
|
||||
/// `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)
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +9,7 @@ let package = Package(
|
||||
platforms: [.macOS(.v26)],
|
||||
products: [
|
||||
.library(name: "OnyxCore", targets: ["OnyxCore"]),
|
||||
.library(name: "OnyxHelperProtocol", targets: ["OnyxHelperProtocol"]),
|
||||
.library(name: "OnyxDesign", targets: ["OnyxDesign"]),
|
||||
.library(name: "OnyxNotch", targets: ["OnyxNotch"]),
|
||||
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
|
||||
@@ -16,6 +17,10 @@ let package = Package(
|
||||
.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: [
|
||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||
@@ -24,11 +29,20 @@ let package = Package(
|
||||
.target(name: "OnyxCore", resources: [.process("Localizable.xcstrings")]),
|
||||
.testTarget(name: "OnyxCoreTests", dependencies: ["OnyxCore"]),
|
||||
|
||||
// Geteilt zwischen App und privilegiertem Helfer — deshalb ohne jede
|
||||
// Abhängigkeit: der Helfer läuft als root und soll so wenig Code wie
|
||||
// möglich enthalten.
|
||||
.target(name: "OnyxHelperProtocol"),
|
||||
.testTarget(name: "OnyxHelperProtocolTests", dependencies: ["OnyxHelperProtocol"]),
|
||||
|
||||
.target(name: "OnyxDesign"),
|
||||
|
||||
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"]),
|
||||
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"],
|
||||
resources: [.process("Localizable.xcstrings")]),
|
||||
.testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]),
|
||||
|
||||
.testTarget(name: "OnyxDesignTests", dependencies: ["OnyxDesign"]),
|
||||
|
||||
.target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]),
|
||||
.testTarget(name: "OnyxWidgetKitTests", dependencies: ["OnyxWidgetKit"]),
|
||||
|
||||
@@ -46,5 +60,21 @@ let package = Package(
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,18 +14,139 @@ public final class CalendarModel {
|
||||
public private(set) var state: CalendarSourceState = .ready(.empty)
|
||||
public private(set) var isLoading = false
|
||||
|
||||
private let source: CalendarSource
|
||||
private var calendar: Calendar
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) {
|
||||
self.source = source
|
||||
self.calendar = calendar
|
||||
/// Welche Quellen gefragt werden. Umschalten lädt sofort neu.
|
||||
public var kind: CalendarSourceKind {
|
||||
didSet {
|
||||
guard kind != oldValue else { return }
|
||||
defaults.set(kind.rawValue, forKey: Self.kindKey)
|
||||
observeCalendarrIfNeeded()
|
||||
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.
|
||||
public var hasAccess: Bool { source.isAuthorized }
|
||||
public var authorization: CalendarAuthorization { source.authorization }
|
||||
///
|
||||
/// **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.
|
||||
///
|
||||
@@ -36,9 +157,11 @@ public final class CalendarModel {
|
||||
@discardableResult
|
||||
public func requestAccessNow() async -> Bool {
|
||||
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)")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -49,6 +172,15 @@ public final class CalendarModel {
|
||||
|
||||
/// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit
|
||||
/// 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()) {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task { [weak self] in
|
||||
@@ -56,20 +188,32 @@ public final class CalendarModel {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let start = calendar.date(byAdding: .day, value: -14,
|
||||
to: calendar.startOfDay(for: date))!
|
||||
let end = calendar.date(byAdding: .day, value: 60, to: start)!
|
||||
let interval = DateInterval(start: start, end: end)
|
||||
|
||||
var states: [CalendarSourceState] = []
|
||||
for source in activeSources {
|
||||
guard !Task.isCancelled else { return }
|
||||
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
||||
// 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
|
||||
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
||||
}
|
||||
let start = calendar.date(byAdding: .day, value: -14,
|
||||
to: calendar.startOfDay(for: date))!
|
||||
let end = calendar.date(byAdding: .day, value: 60, to: start)!
|
||||
guard !Task.isCancelled else { return }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +238,6 @@ public struct CalendarWidget: OnyxWidget {
|
||||
public let id = "calendar"
|
||||
public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) }
|
||||
public let symbolName = "calendar"
|
||||
public let supportedSizes: [WidgetSize] = [.medium, .large]
|
||||
|
||||
private let model: CalendarModel
|
||||
|
||||
@@ -102,21 +245,23 @@ public struct CalendarWidget: OnyxWidget {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
public func makeView(size: WidgetSize) -> AnyView {
|
||||
AnyView(CalendarWidgetView(model: model, size: size))
|
||||
public func makeView() -> AnyView {
|
||||
AnyView(CalendarWidgetView(model: model))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalendarWidgetView: View {
|
||||
let model: CalendarModel
|
||||
let size: WidgetSize
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch model.state {
|
||||
case .ready(let window):
|
||||
if size == .large {
|
||||
LargeCalendar(window: window, upcoming: model.upcoming)
|
||||
// Monatsraster oder Terminliste — das ist keine Frage der
|
||||
// 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 {
|
||||
AgendaList(events: model.upcoming, coverage: window.coverage)
|
||||
}
|
||||
@@ -266,10 +411,20 @@ private struct AgendaList: View {
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(events) { event in
|
||||
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
|
||||
let days = AgendaGrouping.group(events)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
|
||||
Text(AgendaGrouping.label(for: day.date))
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
// Luft nur zwischen den Tagen, nicht über dem ersten.
|
||||
.padding(.top, index == 0 ? 0 : 3)
|
||||
ForEach(day.events) { event in
|
||||
EventRow(event: event)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,353 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"": {},
|
||||
"%lld": {
|
||||
"comment": "A day of the month. The argument is the day of the month.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.apple": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Apple Kalender"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Apple Calendar"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.both": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Beide"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Both"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.calendarr": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.readOnly": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Onyx only reads. It never signs in and never writes to Calendarr."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.coverage": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Bekannt bis %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Known through %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.coverage %@": {
|
||||
"comment": "A footnote in the Calendarr settings, indicating the end of the coverage window.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Bekannt bis %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Known through %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.incompatible": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr writes a newer format. Please update Onyx."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.loggedOut": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Not signed in to Calendarr. Old events are not shown."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.neverWritten": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Open Calendarr once so it can write a snapshot."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.ready": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ Termine, Stand %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ events, as of %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.ready %@ %@": {
|
||||
"comment": "Die Anzahl der Termine in der The argument is the string “–”.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "calendar.source.status.ready %1$@ %2$@"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ Termine, Stand %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.unreadable": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Schnappschuss unlesbar: %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Snapshot unreadable: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.unreadable %@": {
|
||||
"comment": "A warning message explaining that a Calendarr source is not readable. The argument is a human-readable description of the problem.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Schnappschuss unlesbar: %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Snapshot unreadable: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.title": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Terminquelle"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendar source"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendarr.live.error.credentials": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Benutzername oder Passwort stimmen nicht."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wrong username or password."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendarr.live.error.response": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat geantwortet, aber nicht verständlich."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server replied, but not intelligibly."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendarr.live.error.server": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Diese Adresse ergibt keinen Server."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "That address doesn’t resolve to a server."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendarr.live.error.status %lld": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server meldet Fehler %lld."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server reports error %lld."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendarr.live.error.twoFactor": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dieses Konto verlangt einen Zwei-Faktor-Code."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "This account requires a two-factor code."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendarr.live.error.twoFactorWrong": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Zwei-Faktor-Code stimmt nicht — er gilt nur eine halbe Minute."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wrong two-factor code — it’s only valid for half a minute."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.allDay": {
|
||||
"localizations": {
|
||||
@@ -138,6 +479,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.today": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Heute"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Today"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.tomorrow": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Morgen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tomorrow"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.unreadable": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -64,7 +64,7 @@ public final class AdapterProcess {
|
||||
Task { @MainActor in self?.consume(chunk) }
|
||||
}
|
||||
|
||||
process.terminationHandler = { [weak self] _ in
|
||||
process.terminationHandler = { @Sendable [weak self] _ in
|
||||
Task { @MainActor in self?.handleTermination() }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,51 @@
|
||||
"strings": {
|
||||
"widget.media.name": {
|
||||
"localizations": {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Medien" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Media" } }
|
||||
"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" } }
|
||||
"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" } }
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,25 +7,23 @@ public struct MediaWidget: OnyxWidget {
|
||||
public let id = "media"
|
||||
public var displayName: String { String(localized: "widget.media.name", bundle: .module) }
|
||||
public let symbolName = "play.circle"
|
||||
public let supportedSizes: [WidgetSize] = [.medium, .wide]
|
||||
|
||||
private let model: MediaModel
|
||||
|
||||
public init(model: MediaModel) { self.model = model }
|
||||
|
||||
public func makeView(size: WidgetSize) -> AnyView {
|
||||
AnyView(MediaWidgetView(model: model, size: size))
|
||||
public func makeView() -> AnyView {
|
||||
AnyView(MediaWidgetView(model: model))
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaWidgetView: View {
|
||||
let model: MediaModel
|
||||
let size: WidgetSize
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let state = model.nowPlaying, state.hasMetadata {
|
||||
Playing(model: model, state: state, size: size)
|
||||
Playing(model: model, state: state)
|
||||
} else {
|
||||
// Auch ohne Angaben bleibt das Widget bedienbar: die
|
||||
// Medientasten erreichen jedes Programm. Ein Widget, das bei
|
||||
@@ -56,30 +54,38 @@ private struct Idle: View {
|
||||
private struct Playing: View {
|
||||
let model: MediaModel
|
||||
let state: NowPlaying
|
||||
let size: WidgetSize
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
// Ü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(width: artworkSize, height: artworkSize)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 88)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(state.title)
|
||||
.font(Onyx.Font.title)
|
||||
.font(Onyx.Font.caption.weight(.medium))
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
Text(state.artist)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 2)
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Scrubber(model: model, state: state)
|
||||
|
||||
HStack {
|
||||
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,
|
||||
@@ -91,13 +97,10 @@ private struct Playing: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) { model.activateSource() }
|
||||
}
|
||||
|
||||
private var artworkSize: CGFloat { size == .wide ? 64 : 56 }
|
||||
}
|
||||
|
||||
private struct Artwork: View {
|
||||
@@ -106,6 +109,9 @@ private struct Artwork: View {
|
||||
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)
|
||||
@@ -121,6 +127,7 @@ private struct Artwork: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipShape(shape)
|
||||
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1))
|
||||
}
|
||||
|
||||
326
Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
Normal file
@@ -0,0 +1,326 @@
|
||||
import AppKit
|
||||
import OnyxMenuBar
|
||||
|
||||
/// Der Akku, gezeichnet wie der von macOS.
|
||||
///
|
||||
/// Warum nicht als SF-Symbol: das kennt fünf Füllstufen und — außer bei
|
||||
/// hundert Prozent — keine Blitz-Variante. Der Blitz musste deshalb **neben**
|
||||
/// den Akku, und das sieht nach zwei Dingen aus statt nach einem. macOS
|
||||
/// zeichnet ihn mitten hinein, und der Ladestand läuft stufenlos.
|
||||
///
|
||||
/// Selbst zu zeichnen ist hier die kleinere Lösung als die Symbolakrobatik:
|
||||
/// ein Rahmen, ein Knubbel, eine Füllung und ein ausgestanzter Blitz.
|
||||
public enum BatteryGlyph {
|
||||
|
||||
/// Maße wie in der Menüleiste von macOS.
|
||||
public static let size = CGSize(width: 25, height: 12)
|
||||
/// Damit zwei Prozent nicht wie null aussehen.
|
||||
public static let minimumFill: CGFloat = 2
|
||||
/// Ab hier wird die Farbe zum Signal — dieselbe Schwelle wie bei macOS.
|
||||
public static let lowThreshold = 0.2
|
||||
|
||||
public static func fillWidth(charge: Double, inner: CGFloat) -> CGFloat {
|
||||
let clamped = min(max(charge, 0), 1)
|
||||
guard clamped > 0 else { return 0 }
|
||||
return min(max(inner * clamped, minimumFill), inner)
|
||||
}
|
||||
|
||||
/// Was mitten im Akku steht.
|
||||
public enum Indicator: CaseIterable, Sendable {
|
||||
case none
|
||||
/// Lädt.
|
||||
case bolt
|
||||
/// Am Netz, aber es fließt nichts — meist, weil das Ladelimit erreicht
|
||||
/// ist. Ohne eigenes Zeichen sieht das aus wie Akkubetrieb, und man
|
||||
/// fragt sich, ob das Kabel wirklich steckt.
|
||||
///
|
||||
/// Als Pausenzeichen und nicht als Stecker: „Laden angehalten" ist die
|
||||
/// Aussage, und ein Stecker sagt nur „Kabel steckt" — das sieht man
|
||||
/// ohnehin am Kabel.
|
||||
case paused
|
||||
|
||||
public var symbolName: String? {
|
||||
switch self {
|
||||
case .none: nil
|
||||
case .bolt: "bolt.fill"
|
||||
case .paused: "pause.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func indicator(isCharging: Bool, isPluggedIn: Bool) -> Indicator {
|
||||
if isCharging { return .bolt }
|
||||
return isPluggedIn ? .paused : .none
|
||||
}
|
||||
|
||||
/// Wo der Blitz auf der Füllung liegt und wo im Leerraum.
|
||||
///
|
||||
/// Er wird über der Füllung **ausgestanzt** und daneben **ausgemalt**. Nur
|
||||
/// auszustanzen reicht nicht: über dem leeren Teil ist das Transparenz auf
|
||||
/// Transparenz, und bei wenig Ladung sitzt der Blitz fast ganz dort.
|
||||
public static func boltSplit(charge: Double,
|
||||
inner: CGRect) -> (filled: CGRect, empty: CGRect) {
|
||||
let width = fillWidth(charge: charge, inner: inner.width)
|
||||
let filled = CGRect(x: inner.minX, y: inner.minY,
|
||||
width: width, height: inner.height)
|
||||
let empty = CGRect(x: inner.minX + width, y: inner.minY,
|
||||
width: inner.width - width, height: inner.height)
|
||||
return (filled, empty)
|
||||
}
|
||||
|
||||
/// Der Blitz in zwei Fassungen, hell und dunkel.
|
||||
///
|
||||
/// **Warum zwei Dateien statt einer eingefärbten:** eine helle Fläche mit
|
||||
/// dunklem Rand ließ sich aus einem einzelnen Symbol nur nachbauen, indem
|
||||
/// dasselbe Zeichen zweimal in verschiedener Größe übereinander gezeichnet
|
||||
/// wurde — und die Größenverhältnisse sahen nie stimmig aus. Zwei fertige
|
||||
/// Fassungen ineinander zu blenden ist der geradere Weg: gezeichnet wird
|
||||
/// genau das, was in der Datei steht.
|
||||
public static let boltLight: NSImage? = load("bolt-light")
|
||||
public static let boltDark: NSImage? = load("bolt-dark")
|
||||
|
||||
private static func load(_ name: String) -> NSImage? {
|
||||
guard let url = Bundle.main.url(forResource: name, withExtension: "svg")
|
||||
else { return nil }
|
||||
return NSImage(contentsOf: url)
|
||||
}
|
||||
|
||||
/// Wie dick der Rand um das Zeichen ist.
|
||||
public static let outlineWidth: CGFloat = 1
|
||||
|
||||
/// Der Rahmen für den Rand — dasselbe Zeichen, etwas größer.
|
||||
public static func outlineRect(_ glyph: CGRect) -> CGRect {
|
||||
glyph.insetBy(dx: -outlineWidth, dy: -outlineWidth)
|
||||
}
|
||||
|
||||
/// Wie hoch das Zeichen im Akku ist.
|
||||
///
|
||||
/// Bezugsgröße ist der **Innenraum**, nicht die Außenhöhe: der Rahmen
|
||||
/// nimmt oben und unten je einen Punkt, die Füllung sitzt zwei Punkte
|
||||
/// eingerückt, und um das Zeichen kommt noch sein eigener Rand. Wer von
|
||||
/// der Außenhöhe ausgeht, landet bei einem Blitz, der den ganzen Akku
|
||||
/// ausfüllt und an den Rahmen stößt.
|
||||
public static func glyphHeight(for indicator: Indicator, in height: CGFloat) -> CGFloat {
|
||||
let inner = max(height - 2 * fillInset, 1)
|
||||
switch indicator {
|
||||
// Das Pausenzeichen ist von Haus aus höher gebaut als der Blitz.
|
||||
case .paused: return inner * 0.8
|
||||
default: return inner
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie weit die Füllung vom Rahmen abrückt.
|
||||
public static let fillInset: CGFloat = 2
|
||||
|
||||
/// Wie lange ein voller Hin- und Rückweg des Blitzes dauert.
|
||||
public static let fadeDuration: TimeInterval = 2.2
|
||||
|
||||
/// Der Ping-Pong von 0 nach 1 und zurück.
|
||||
///
|
||||
/// Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den
|
||||
/// Umkehrpunkten sichtbar, der Kosinus wird dort flach. In der Menüleiste
|
||||
/// fällt so ein Knick auf.
|
||||
public static func fadeAmount(phase: Double) -> Double {
|
||||
(1 - cos(phase.truncatingRemainder(dividingBy: 1) * 2 * .pi)) / 2
|
||||
}
|
||||
|
||||
/// Wie weiß der Blitz gerade ist, 1 = weiß, 0 = schwarz.
|
||||
///
|
||||
/// Darum geht es: bei wenig Ladung sitzt der Blitz im leeren Teil des
|
||||
/// Akkus und braucht Weiß, über der Füllung braucht er Schwarz. Ein Faden
|
||||
/// dazwischen ist in beiden Fällen irgendwann gut lesbar — und man sieht
|
||||
/// am Wandern zusätzlich, dass geladen wird.
|
||||
public static func boltWhiteness(phase: Double) -> Double {
|
||||
1 - fadeAmount(phase: phase)
|
||||
}
|
||||
|
||||
public static func isLow(charge: Double, isCharging: Bool = false) -> Bool {
|
||||
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
|
||||
// sich schon erledigt.
|
||||
!isCharging && charge < lowThreshold
|
||||
}
|
||||
|
||||
/// Ein Symbol in der gewünschten Farbe. `color.set()` genügt nicht:
|
||||
/// Template-Bilder tragen ihre Farbe nicht aus dem Grafikzustand.
|
||||
private static func tint(_ image: NSImage, with color: NSColor) -> NSImage? {
|
||||
let configuration = NSImage.SymbolConfiguration(paletteColors: [color])
|
||||
return image.withSymbolConfiguration(configuration)
|
||||
}
|
||||
|
||||
/// Zeichnet den Akku in `rect`.
|
||||
///
|
||||
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
|
||||
/// kritischem Ladestand weicht die Füllung davon ab — Farbe ist in der
|
||||
/// Menüleiste ein Signal, keine Dekoration.
|
||||
public static func draw(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
|
||||
fadePhase: Double = 0,
|
||||
color: NSColor, in rect: CGRect) {
|
||||
let capWidth: CGFloat = 2
|
||||
let body = CGRect(x: rect.minX, y: rect.minY,
|
||||
width: rect.width - capWidth - 1, height: rect.height)
|
||||
|
||||
// Rahmen — zurückgenommen, damit die Füllung die Aussage trägt.
|
||||
let outline = NSBezierPath(roundedRect: body.insetBy(dx: 0.5, dy: 0.5),
|
||||
xRadius: 3, yRadius: 3)
|
||||
outline.lineWidth = 1
|
||||
color.withAlphaComponent(0.45).setStroke()
|
||||
outline.stroke()
|
||||
|
||||
// Der Knubbel am Pluspol.
|
||||
let cap = CGRect(x: body.maxX + 1, y: rect.midY - 2.5, width: capWidth, height: 5)
|
||||
color.withAlphaComponent(0.45).setFill()
|
||||
NSBezierPath(roundedRect: cap, xRadius: 1, yRadius: 1).fill()
|
||||
|
||||
// Die Füllung.
|
||||
let inner = body.insetBy(dx: 2, dy: 2)
|
||||
let width = fillWidth(charge: charge, inner: inner.width)
|
||||
if width > 0 {
|
||||
let fill = CGRect(x: inner.minX, y: inner.minY,
|
||||
width: width, height: inner.height)
|
||||
(isLow(charge: charge, isCharging: isCharging)
|
||||
? NSColor.systemRed : color).setFill()
|
||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
}
|
||||
|
||||
// Das Zeichen wird **ausgestanzt**, nicht daraufgelegt.
|
||||
//
|
||||
// `destinationOut` nimmt weg, was vorher gezeichnet wurde — Füllung und
|
||||
// Rahmen. Übrig bleibt ein Loch in Blitz- bzw. Steckerform, durch das
|
||||
// die Menüleiste scheint. Genau so sieht der von macOS aus, und es
|
||||
// funktioniert auf hellem wie dunklem Grund, ohne die Hintergrundfarbe
|
||||
// zu kennen.
|
||||
guard let name = indicator(isCharging: isCharging,
|
||||
isPluggedIn: isPluggedIn).symbolName,
|
||||
let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
||||
else { return }
|
||||
let indicator = self.indicator(isCharging: isCharging, isPluggedIn: isPluggedIn)
|
||||
let glyphHeight = glyphHeight(for: indicator, in: rect.height)
|
||||
let glyphWidth = (glyph.size.width / glyph.size.height) * glyphHeight
|
||||
let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
|
||||
y: rect.midY - glyphHeight / 2,
|
||||
width: glyphWidth, height: glyphHeight)
|
||||
guard isCharging else {
|
||||
// Das Pausenzeichen wird **ausgestanzt**: ein Loch in der Füllung.
|
||||
//
|
||||
// Weiß mit Rand funktioniert hier nicht — bei fast vollem Akku
|
||||
// liegt es auf weißer Füllung, das Weiß verschwindet darin und
|
||||
// übrig bleiben die dunklen Ränder als zwei Striche.
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
return
|
||||
}
|
||||
|
||||
// Der Blitz blendet zwischen den beiden Fassungen über.
|
||||
//
|
||||
// Erst die dunkle voll deckend, dann die helle mit steigender
|
||||
// Deckkraft darüber. So ist das Zeichen in jedem Moment vollständig
|
||||
// gedeckt — zeichnete man beide anteilig, schiene dazwischen der
|
||||
// Hintergrund durch und der Blitz wirkte blass.
|
||||
let whiteness = boltWhiteness(phase: fadePhase)
|
||||
if let dark = boltDark, let light = boltLight {
|
||||
dark.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
|
||||
light.draw(in: glyphRect, from: .zero, operation: .sourceOver,
|
||||
fraction: whiteness)
|
||||
return
|
||||
}
|
||||
if let filled = tint(glyph, with: NSColor(white: whiteness, alpha: 1)) {
|
||||
filled.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SwiftUI
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Derselbe Akku für die Kachel.
|
||||
///
|
||||
/// Dieselbe Form wie in der Menüleiste, damit nicht zwei verschiedene Akkus
|
||||
/// in derselben App stehen. Der Blitz ist hier kein Ausstanzen, sondern eine
|
||||
/// Maske — in SwiftUI ist das der geradere Weg zum selben Bild.
|
||||
public struct BatteryGlyphView: View {
|
||||
private let charge: Double
|
||||
private let isCharging: Bool
|
||||
private let height: CGFloat
|
||||
|
||||
private let isPluggedIn: Bool
|
||||
|
||||
public init(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
|
||||
height: CGFloat = 14) {
|
||||
self.charge = charge
|
||||
self.isCharging = isCharging
|
||||
self.isPluggedIn = isPluggedIn
|
||||
self.height = height
|
||||
}
|
||||
|
||||
private var scale: CGFloat { height / BatteryGlyph.size.height }
|
||||
private var width: CGFloat { BatteryGlyph.size.width * scale }
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 1 * scale) {
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous)
|
||||
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
|
||||
|
||||
GeometryReader { geometry in
|
||||
let inner = geometry.size.width - 4 * scale
|
||||
RoundedRectangle(cornerRadius: 1.5 * scale, style: .continuous)
|
||||
.fill(fillColor)
|
||||
.frame(width: BatteryGlyph.fillWidth(charge: charge, inner: inner))
|
||||
.padding(2 * scale)
|
||||
}
|
||||
}
|
||||
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
|
||||
.overlay {
|
||||
let indicator = BatteryGlyph.indicator(isCharging: isCharging,
|
||||
isPluggedIn: isPluggedIn)
|
||||
if let name = indicator.symbolName {
|
||||
// `TimelineView` gibt die Zeit her, ohne dass hier ein
|
||||
// Zeitgeber laufen muss.
|
||||
let size = BatteryGlyph.glyphHeight(for: indicator, in: height)
|
||||
if isCharging {
|
||||
TimelineView(.animation) { context in
|
||||
let phase = context.date.timeIntervalSinceReferenceDate
|
||||
/ BatteryGlyph.fadeDuration
|
||||
let whiteness = BatteryGlyph.boltWhiteness(phase: phase)
|
||||
bolt(side: size, whiteness: whiteness)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: name)
|
||||
.font(.system(size: size))
|
||||
.blendMode(.destinationOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
.compositingGroup()
|
||||
|
||||
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous)
|
||||
.fill(.primary.opacity(0.45))
|
||||
.frame(width: 2 * scale, height: 5 * scale)
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
|
||||
/// Dieselben zwei Fassungen wie in der Menüleiste, ineinander geblendet.
|
||||
@ViewBuilder
|
||||
private func bolt(side: CGFloat, whiteness: Double) -> some View {
|
||||
if let dark = BatteryGlyph.boltDark, let light = BatteryGlyph.boltLight {
|
||||
ZStack {
|
||||
Image(nsImage: dark).resizable().aspectRatio(contentMode: .fit)
|
||||
Image(nsImage: light).resizable().aspectRatio(contentMode: .fit)
|
||||
.opacity(whiteness)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
} else {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: side))
|
||||
.foregroundStyle(Color(white: whiteness))
|
||||
}
|
||||
}
|
||||
|
||||
private var fillColor: Color {
|
||||
BatteryGlyph.isLow(charge: charge, isCharging: isCharging) ? .red : .primary
|
||||
}
|
||||
}
|
||||
35
Packages/OnyxKit/Sources/MetricsProvider/BatterySymbol.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Das Akkusymbol zum jeweiligen Zustand.
|
||||
///
|
||||
/// Ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist Dekoration und
|
||||
/// keine Anzeige — es beantwortet die eine Frage nicht, für die es da ist. Wer
|
||||
/// in der Menüleiste nur das Symbol stehen hat, will es auf einen Blick lesen
|
||||
/// können, ohne die Zahl daneben.
|
||||
public enum BatterySymbol {
|
||||
|
||||
/// Kein Akku da — oder keiner zu lesen.
|
||||
public static let unavailable = "batteryblock.slash"
|
||||
|
||||
/// Der Blitz beim Laden — **getrennt** vom Füllstand.
|
||||
///
|
||||
/// Nachgemessen, nicht angenommen: von den kombinierten Symbolen existiert
|
||||
/// allein `battery.100percent.bolt`. Für 0, 25, 50 und 75 Prozent gibt es
|
||||
/// keine Blitz-Variante, und `battery.slash` gibt es auch nicht.
|
||||
///
|
||||
/// Den Füllstand beim Laden gegen einen Blitz einzutauschen wäre die
|
||||
/// falsche Wahl: dass geladen wird, sieht man am Netzstecker; **wie weit**
|
||||
/// es ist, sieht man nirgends sonst. Also bleibt der Füllstand, und der
|
||||
/// Blitz kommt als kleines Zeichen darüber.
|
||||
public static let chargingBolt = "bolt.fill"
|
||||
|
||||
/// SF Symbols kennt fünf Füllstufen. Gerundet wird zur nächsten: 60 % soll
|
||||
/// nicht halbleer aussehen und 40 % nicht dreiviertel voll.
|
||||
private static let steps = [0, 25, 50, 75, 100]
|
||||
|
||||
public static func name(charge: Double) -> String {
|
||||
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
|
||||
let step = steps.min { abs($0 - percent) < abs($1 - percent) } ?? 0
|
||||
return "battery.\(step)percent"
|
||||
}
|
||||
}
|
||||
125
Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
/// Die CPU-Zeitzähler eines Kerns, wie `host_processor_info` sie liefert.
|
||||
///
|
||||
/// Es sind **Summen seit dem Start**, keine Momentanwerte. Die Auslastung
|
||||
/// ergibt sich erst aus der Differenz zweier Abtastungen.
|
||||
public struct CPUTicks: Equatable, Sendable {
|
||||
public let user: UInt32
|
||||
public let system: UInt32
|
||||
public let idle: UInt32
|
||||
public let nice: UInt32
|
||||
|
||||
public init(user: UInt32, system: UInt32, idle: UInt32, nice: UInt32) {
|
||||
self.user = user
|
||||
self.system = system
|
||||
self.idle = idle
|
||||
self.nice = nice
|
||||
}
|
||||
|
||||
/// Auslastung zwischen zwei Abtastungen, 0 bis 1.
|
||||
public static func usage(from previous: CPUTicks, to current: CPUTicks) -> Double {
|
||||
// `&-` statt `-`: die Zähler sind 32 Bit breit und laufen nach gut
|
||||
// 400 Tagen über. Ein gewöhnlicher Abzug stürzt dabei ab oder liefert
|
||||
// einen absurden Sprung; die überlaufende Subtraktion ergibt genau die
|
||||
// richtige Differenz.
|
||||
let user = UInt64(current.user &- previous.user)
|
||||
let system = UInt64(current.system &- previous.system)
|
||||
let nice = UInt64(current.nice &- previous.nice)
|
||||
let idle = UInt64(current.idle &- previous.idle)
|
||||
|
||||
let busy = user + system + nice
|
||||
let total = busy + idle
|
||||
guard total > 0 else { return 0 }
|
||||
|
||||
return min(max(Double(busy) / Double(total), 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie angespannt die Speicherlage ist.
|
||||
public enum MemoryPressure: Equatable, Sendable {
|
||||
case normal
|
||||
case warning
|
||||
case critical
|
||||
|
||||
/// - Parameters:
|
||||
/// - free: Anteil wirklich freien Speichers, 0 bis 1.
|
||||
/// - compressed: Anteil komprimierten Speichers, 0 bis 1.
|
||||
/// - swapUsed: belegter Auslagerungsspeicher in Byte.
|
||||
public static func classify(free: Double, compressed: Double, swapUsed: UInt64) -> MemoryPressure {
|
||||
// Auslagerung schlägt alles andere: sobald sie läuft, bremst der
|
||||
// Rechner spürbar — auch wenn die Freispeicheranzeige harmlos aussieht.
|
||||
// Ein bisschen Swap hat macOS fast immer angelegt, deshalb erst ab
|
||||
// einem halben Gigabyte.
|
||||
if swapUsed > 512 * 1024 * 1024 { return .critical }
|
||||
if free < 0.10 || compressed > 0.20 { return .warning }
|
||||
return .normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Netzwerkdurchsatz aus zwei Zählerständen.
|
||||
public enum Throughput {
|
||||
|
||||
/// Byte pro Sekunde zwischen zwei Abtastungen.
|
||||
///
|
||||
/// Die Normierung auf die tatsächlich verstrichene Zeit ist der Kern: die
|
||||
/// Menüleisten-Module tasten je nach Einstellung alle 1 bis 10 Sekunden ab.
|
||||
/// Ohne sie zeigte ein Modul mit 5 Sekunden Intervall das Fünffache an.
|
||||
public static func rate(previous: UInt64, current: UInt64, elapsed: TimeInterval) -> Double {
|
||||
guard elapsed > 0 else { return 0 }
|
||||
|
||||
// Zähler kleiner als vorher heißt: er wurde zurückgesetzt. Das passiert
|
||||
// bei jedem Interface-Wechsel — WLAN aus, VPN an — und beim Neustart
|
||||
// des Dienstes.
|
||||
//
|
||||
// Ein Überlauf wäre die andere denkbare Erklärung, kommt hier aber
|
||||
// nicht vor: `if_data64` liefert 64-Bit-Zähler, die bei 10 Gbit/s
|
||||
// erst nach rund 470 Jahren umlaufen. Der Versuch, beides zu
|
||||
// unterscheiden, produziert nur den Ausreißer, den er verhindern soll —
|
||||
// deshalb null.
|
||||
guard current >= previous else { return 0 }
|
||||
|
||||
return Double(current - previous) / elapsed
|
||||
}
|
||||
|
||||
/// „2,4 MB/s" statt „2400000".
|
||||
public static func formatted(_ bytesPerSecond: Double) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .binary
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
formatter.zeroPadsFractionDigits = false
|
||||
// Ohne das steht bei null „Zero KB/s" statt „0 KB/s" — in einer Spalte
|
||||
// aus Zahlen fällt ein Wort unangenehm auf und ändert die Breite.
|
||||
formatter.allowsNonnumericFormatting = false
|
||||
return formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) + "/s"
|
||||
}
|
||||
}
|
||||
|
||||
/// Einheitliche Zahlendarstellung für alle Messwerte.
|
||||
public enum MetricFormat {
|
||||
|
||||
/// Ganzzahlig. Nachkommastellen bei der Auslastung täuschen eine
|
||||
/// Genauigkeit vor, die die Messung nicht hat — und sie zappeln im
|
||||
/// Sekundentakt, was die Anzeige unruhig macht.
|
||||
public static func percent(_ fraction: Double) -> String {
|
||||
"\(Int((min(max(fraction, 0), 1) * 100).rounded())) %"
|
||||
}
|
||||
|
||||
/// Ohne Leerzeichen — nur für die Menüleiste.
|
||||
///
|
||||
/// Dort ist die Breite fest für „100 %" reserviert, damit nichts springt,
|
||||
/// wenn der Wert eine Stelle mehr bekommt. Das Leerzeichen kostet in dieser
|
||||
/// Reservierung drei Punkte je Element, und in der Menüleiste ist Platz
|
||||
/// das knappste Gut. Im Panel und in den Popovern bleibt es stehen.
|
||||
public static func percentCompact(_ fraction: Double) -> String {
|
||||
"\(Int((min(max(fraction, 0), 1) * 100).rounded()))%"
|
||||
}
|
||||
|
||||
public static func temperature(_ celsius: Double) -> String {
|
||||
"\(Int(celsius.rounded()))°"
|
||||
}
|
||||
|
||||
public static func watts(_ value: Double) -> String {
|
||||
String(format: "%.1f W", max(value, 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Was mit dem Akku gerade geschieht — in Worten statt in Symbolen.
|
||||
///
|
||||
/// „Am Netz" allein beantwortet die Frage nicht, die man hat, wenn der
|
||||
/// Ladestand stehen bleibt: lädt es gerade nicht, weil das Limit erreicht ist,
|
||||
/// oder stimmt etwas nicht?
|
||||
public enum ChargeActivity: Equatable, Sendable {
|
||||
case charging
|
||||
/// Angehalten, weil das eingestellte Ladelimit erreicht ist.
|
||||
case pausedAtLimit(Int)
|
||||
/// Am Netz, lädt aber nicht — Grund unbekannt.
|
||||
case pluggedNotCharging
|
||||
case onBattery
|
||||
|
||||
/// Wie weit unter dem Limit noch als „am Limit" gilt.
|
||||
///
|
||||
/// Der Ladestand pendelt um die Grenze. Auf das Prozent genau zu
|
||||
/// vergleichen hieße, den Grund die halbe Zeit zu verschweigen.
|
||||
public static let limitTolerance = 2
|
||||
|
||||
public static func resolve(isCharging: Bool, isPluggedIn: Bool,
|
||||
charge: Double, limit: Int?) -> ChargeActivity {
|
||||
if isCharging { return .charging }
|
||||
guard isPluggedIn else { return .onBattery }
|
||||
|
||||
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
|
||||
// Ein Limit von hundert ist keines.
|
||||
if let limit, limit < 100, percent >= limit - limitTolerance {
|
||||
return .pausedAtLimit(limit)
|
||||
}
|
||||
// Steht das Laden aus einem anderen Grund — etwa weil macOS den Akku
|
||||
// schont —, ist das der ehrliche Befund. Einen falschen Grund zu
|
||||
// nennen wäre schlimmer als keinen.
|
||||
return .pluggedNotCharging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import OnyxDesign
|
||||
import OnyxHelperProtocol
|
||||
|
||||
/// Wer das Ladelimit **lesen** kann.
|
||||
///
|
||||
/// Nur lesen, und das ist eine gemessene Grenze, keine Bequemlichkeit: der
|
||||
/// SMC lehnt Schreibzugriffe auf `CHLT` ab. Der Helfer hat es versucht und
|
||||
/// `false` gemeldet, der Wert blieb unverändert. macOS setzt das Limit
|
||||
/// offenbar auf einem anderen Weg als über den SMC-Userclient.
|
||||
///
|
||||
/// Ein Regler, der sich ziehen lässt und dann zurückspringt, ist schlimmer als
|
||||
/// gar keiner — er behauptet eine Fähigkeit, die nicht existiert.
|
||||
@MainActor
|
||||
public protocol ChargeLimitReading: AnyObject {
|
||||
/// Das eingestellte Limit in Prozent, `nil` wenn keines gilt.
|
||||
var chargeLimit: Int? { get }
|
||||
/// Ob der Wert überhaupt gelesen werden kann — ohne Helfer nicht.
|
||||
var canReadCharge: Bool { get }
|
||||
func refreshChargeLimit()
|
||||
}
|
||||
|
||||
/// Zeigt das Ladelimit und führt dorthin, wo man es ändern kann.
|
||||
public struct ChargeLimitControl: View {
|
||||
|
||||
private let control: any ChargeLimitReading
|
||||
/// Im Popover ist Platz für den Hinweis, in der Kachel nicht.
|
||||
private let roomy: Bool
|
||||
|
||||
public init(control: any ChargeLimitReading, roomy: Bool) {
|
||||
self.control = control
|
||||
self.roomy = roomy
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Text("charge.limit", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if let limit = control.chargeLimit, limit < ChargeSafety.noLimit {
|
||||
Text(verbatim: "\(limit) %")
|
||||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
} else if control.canReadCharge {
|
||||
Text("charge.limit.off", bundle: .module)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
} else {
|
||||
Text(verbatim: "–")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
|
||||
if roomy {
|
||||
// Kein Regler, sondern der Weg dorthin, wo es wirklich geht.
|
||||
Button {
|
||||
NSWorkspace.shared.open(
|
||||
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.forward.app")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(Text("charge.limit.inSystemSettings", bundle: .module))
|
||||
}
|
||||
}
|
||||
.onAppear { control.refreshChargeLimit() }
|
||||
}
|
||||
}
|
||||
1064
Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,408 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import OnyxMenuBar
|
||||
|
||||
/// Ein Menüleisten-Modul je Hardwaregröße.
|
||||
///
|
||||
/// Teilt sich das Modell — und damit die Messschleife — mit dem Panel-Widget
|
||||
/// derselben Größe. Wer CPU im Panel **und** in der Menüleiste zeigt, bekommt
|
||||
/// trotzdem nur eine Messung.
|
||||
@MainActor
|
||||
public final class MetricMenuBarModule: MenuBarModule {
|
||||
|
||||
public let id: String
|
||||
public var displayName: String {
|
||||
String(localized: .init(metric.localizationKey), bundle: .module)
|
||||
}
|
||||
|
||||
private let metric: MetricKind
|
||||
private let model: MetricsModel
|
||||
private var token: UUID?
|
||||
private var view: MetricStatusView?
|
||||
private var observation: (any NSObjectProtocol)?
|
||||
|
||||
public init(metric: MetricKind, model: MetricsModel) {
|
||||
self.metric = metric
|
||||
self.model = model
|
||||
self.id = "metric.\(metric.rawValue)"
|
||||
}
|
||||
|
||||
/// Kurz genug für die Menüleiste. „Arbeitsspeicher" wäre dort ein Balken.
|
||||
public var shortLabel: String {
|
||||
switch metric {
|
||||
case .cpu: "CPU"
|
||||
case .gpu: "GPU"
|
||||
case .memory: "RAM"
|
||||
case .battery: String(localized: "menubar.short.battery", bundle: .module)
|
||||
// Der gewählte Sensor gibt sein eigenes Kürzel — „Temp" neben einer
|
||||
// Zahl sagt nicht, welcher Punkt gemeint ist.
|
||||
case .temperature: model.selectedSensorName
|
||||
?? String(localized: "menubar.short.temperature", bundle: .module)
|
||||
}
|
||||
}
|
||||
|
||||
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||
let view = MetricStatusView(metric: metric, presentation: presentation,
|
||||
label: shortLabel)
|
||||
view.sensorCelsius = model.selectedSensorCelsius
|
||||
view.sensorCelsius = model.selectedSensorCelsius
|
||||
view.update(snapshot: model.snapshot, history: model.series(metric),
|
||||
mode: model.displayMode(for: metric))
|
||||
self.view = view
|
||||
return view
|
||||
}
|
||||
|
||||
public func makePopoverView() -> AnyView {
|
||||
AnyView(MetricPopoverContent(model: model, metric: metric))
|
||||
}
|
||||
|
||||
public func activate() {
|
||||
guard token == nil else { return }
|
||||
token = model.addConsumer(interval: 2)
|
||||
startObserving()
|
||||
}
|
||||
|
||||
public func deactivate() {
|
||||
if let token { model.removeConsumer(token) }
|
||||
token = nil
|
||||
observation = nil
|
||||
view = nil
|
||||
}
|
||||
|
||||
/// `withObservationTracking` meldet sich **einmal** und muss danach neu
|
||||
/// eingerichtet werden — sonst friert die Anzeige nach der ersten Messung ein.
|
||||
private func startObserving() {
|
||||
withObservationTracking {
|
||||
_ = model.snapshot
|
||||
_ = model.displayModes // auch auf den Moduswechsel reagieren
|
||||
} onChange: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, token != nil else { return }
|
||||
view?.update(snapshot: model.snapshot, history: model.series(metric),
|
||||
mode: model.displayMode(for: metric))
|
||||
startObserving()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeichnet den Messwert in die Menüleiste.
|
||||
///
|
||||
/// Eigene `NSView` statt SwiftUI: die Menüleiste verlangt eine feste Breite je
|
||||
/// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit
|
||||
/// SwiftUI müsste man diese Breite doppelt führen — einmal für das Layout und
|
||||
/// einmal für `NSStatusItem.length`.
|
||||
final class MetricStatusView: NSView, WidthReporting {
|
||||
|
||||
private let metric: MetricKind
|
||||
private let presentation: MenuBarPresentation
|
||||
private var snapshot = MetricsSnapshot()
|
||||
private var history: [Double] = []
|
||||
private var mode: MetricDisplayMode = .usage
|
||||
|
||||
private let label: String
|
||||
|
||||
init(metric: MetricKind, presentation: MenuBarPresentation, label: String) {
|
||||
self.metric = metric
|
||||
self.presentation = presentation
|
||||
self.label = label
|
||||
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// Läuft nur, solange geladen wird — nur dann bewegt sich der Blitz.
|
||||
///
|
||||
/// 30 Bilder je Sekunde reichen für ein weiches Faden und kosten deutlich
|
||||
/// weniger als die 60, die man reflexhaft nähme. Steht das Ladekabel nicht
|
||||
/// drin, läuft gar nichts.
|
||||
private var fadeTimer: Timer?
|
||||
private var fadePhase: Double = 0
|
||||
|
||||
private var shownWidth: CGFloat = MenuBarText.minimumWidth
|
||||
|
||||
/// Der breiteste Wert, der vorkommen kann.
|
||||
///
|
||||
/// **Fest**, nicht am aktuellen Wert bemessen: ein Element, das bei
|
||||
/// „431" breiter wird als bei „14", schiebt alle Symbole rechts davon.
|
||||
///
|
||||
/// Der frühere Fehler war nicht die feste Breite, sondern der falsche
|
||||
/// Bezugswert — gezeichnet wird „63°", bemessen wurde „100 °C".
|
||||
private var reference: String {
|
||||
switch (metric, mode) {
|
||||
case (.temperature, _), (_, .temperature): "100°"
|
||||
default: "100%"
|
||||
}
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: shownWidth, height: 22)
|
||||
}
|
||||
|
||||
/// Der Platz für das, was **gerade** dasteht.
|
||||
///
|
||||
/// Auf die breiteste denkbare Zeichenfolge zu dimensionieren war zu teuer:
|
||||
/// „Temp 53°" stand in einer Box für „Temp 100 °C", und in der Menüleiste
|
||||
/// ist Platz das knappste Gut. `AdaptiveWidth` nimmt dem die Unruhe —
|
||||
/// sofort wachsen, zögernd schrumpfen.
|
||||
private var width: CGFloat {
|
||||
switch presentation {
|
||||
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
|
||||
case .graph: 28
|
||||
case .bars: barsWidth
|
||||
case .value: MenuBarText.width(for: reference)
|
||||
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
|
||||
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
|
||||
case .symbolAndValue:
|
||||
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Balken je Kern braucht Platz für jeden Kern. Bei fünfzehn ist eine
|
||||
/// feste Breite entweder zu eng oder überall sonst zu weit.
|
||||
private var barsWidth: CGFloat {
|
||||
let count = (metric == .cpu && mode == .usage) ? max(snapshot.cpu.perCore.count, 1) : 1
|
||||
guard count > 1 else { return MenuBarText.minimumWidth }
|
||||
return min(CGFloat(count) * 2 + CGFloat(count - 1) * 1 + 4, 60)
|
||||
}
|
||||
|
||||
/// Was tatsächlich dasteht. Bei den Sensoren der gewählte Punkt statt des
|
||||
/// stillen Maximums über alle.
|
||||
private var displayValue: String {
|
||||
if metric == .temperature, let celsius = sensorCelsius {
|
||||
return MetricFormat.temperature(celsius)
|
||||
}
|
||||
// In der Menüleiste ohne Leerzeichen vor dem Prozentzeichen — siehe
|
||||
// `MetricFormat.percentCompact`.
|
||||
let value = MetricSummary.value(metric, snapshot, mode: mode)
|
||||
return value.replacingOccurrences(of: " %", with: "%")
|
||||
}
|
||||
|
||||
|
||||
/// Meldet, wenn sich die nötige Breite geändert hat — der Controller stellt
|
||||
/// das Element danach neu ein.
|
||||
var onWidthChange: ((CGFloat) -> Void)?
|
||||
|
||||
/// Der gewählte Sensor, falls einer gewählt ist.
|
||||
var sensorCelsius: Double?
|
||||
|
||||
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
|
||||
self.snapshot = snapshot
|
||||
self.history = history
|
||||
self.mode = mode
|
||||
needsDisplay = true
|
||||
|
||||
updateFadeTimer()
|
||||
|
||||
let next = max(width, MenuBarText.minimumWidth)
|
||||
guard next != shownWidth else { return }
|
||||
shownWidth = next
|
||||
invalidateIntrinsicContentSize()
|
||||
onWidthChange?(next)
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
// `labelColor` statt der Onyx-Farben: die Menüleiste ist mal hell, mal
|
||||
// dunkel, und nur die Systemfarbe passt sich beidem an.
|
||||
let color = NSColor.labelColor
|
||||
|
||||
switch presentation {
|
||||
case .value:
|
||||
drawText(displayValue, color: color)
|
||||
case .symbol:
|
||||
drawSymbol(color: color)
|
||||
case .graph:
|
||||
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
|
||||
case .bars:
|
||||
drawBars(color: color)
|
||||
case .labelAndValue:
|
||||
drawText("\(label) \(displayValue)", color: color)
|
||||
|
||||
case .symbolAndValue:
|
||||
let glyph = symbolSlot
|
||||
if metric == .battery {
|
||||
drawBattery(color: color,
|
||||
at: NSPoint(x: 0, y: bounds.midY - BatteryGlyph.size.height / 2))
|
||||
} else if let image = Self.customGlyphs[metric] {
|
||||
MenuBarText.drawTemplate(image, color: color,
|
||||
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
||||
width: glyph, height: 13))
|
||||
} else {
|
||||
MenuBarText.drawSymbol(currentSymbol, color: color,
|
||||
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
||||
width: glyph, height: 13))
|
||||
}
|
||||
drawText(displayValue, color: color,
|
||||
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
|
||||
width: bounds.width - glyph - MenuBarText.innerGap,
|
||||
height: bounds.height))
|
||||
|
||||
case .valueAndGraph:
|
||||
let split = bounds.width * 0.55
|
||||
drawText(displayValue, color: color,
|
||||
in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
|
||||
drawGraph(color: color,
|
||||
in: NSRect(x: split + 2, y: 5,
|
||||
width: bounds.width - split - 4, height: bounds.height - 10))
|
||||
}
|
||||
}
|
||||
|
||||
private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) {
|
||||
MenuBarText.draw(text, color: color, in: rect ?? bounds)
|
||||
}
|
||||
|
||||
/// Eigene Vorlagen für CPU und GPU — SF Symbols hat für beides nur den
|
||||
/// gleichen Chip, und „cpu" neben „cpu.fill" ist kein Unterschied, den man
|
||||
/// in der Menüleiste erkennt.
|
||||
private static let customGlyphs: [MetricKind: NSImage] = {
|
||||
var glyphs: [MetricKind: NSImage] = [:]
|
||||
for (metric, name) in [(MetricKind.cpu, "chip"), (.gpu, "graphic-card")] {
|
||||
guard let url = Bundle.main.url(forResource: name, withExtension: "svg"),
|
||||
let image = NSImage(contentsOf: url) else { continue }
|
||||
glyphs[metric] = image
|
||||
}
|
||||
return glyphs
|
||||
}()
|
||||
|
||||
private func drawSymbol(color: NSColor) {
|
||||
if metric == .battery {
|
||||
drawBattery(color: color,
|
||||
at: NSPoint(x: bounds.midX - BatteryGlyph.size.width / 2,
|
||||
y: bounds.midY - BatteryGlyph.size.height / 2))
|
||||
return
|
||||
}
|
||||
let side: CGFloat = 15
|
||||
let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
|
||||
width: side, height: side)
|
||||
if let glyph = Self.customGlyphs[metric] {
|
||||
MenuBarText.drawTemplate(glyph, color: color, in: rect)
|
||||
return
|
||||
}
|
||||
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
|
||||
MenuBarText.drawSymbol(currentSymbol, color: color,
|
||||
in: NSRect(x: bounds.midX - width / 2,
|
||||
y: bounds.midY - side / 2,
|
||||
width: width, height: side))
|
||||
}
|
||||
|
||||
/// Der Akku wird selbst gezeichnet — mit stufenloser Füllung und dem Blitz
|
||||
/// mitten drin, so wie macOS es zeigt.
|
||||
private func drawBattery(color: NSColor, at origin: NSPoint) {
|
||||
guard let battery = snapshot.battery else {
|
||||
MenuBarText.drawSymbol(BatterySymbol.unavailable, color: color,
|
||||
in: NSRect(x: origin.x, y: origin.y,
|
||||
width: 15, height: 15))
|
||||
return
|
||||
}
|
||||
BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging,
|
||||
isPluggedIn: battery.isPluggedIn, fadePhase: fadePhase,
|
||||
color: color,
|
||||
in: CGRect(origin: origin, size: BatteryGlyph.size))
|
||||
}
|
||||
|
||||
/// Der Zeitgeber fürs Faden — an und aus mit dem Ladevorgang.
|
||||
private func updateFadeTimer() {
|
||||
let shouldFade = metric == .battery && snapshot.battery?.isCharging == true
|
||||
if shouldFade, fadeTimer == nil {
|
||||
let timer = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
self.fadePhase += (1.0 / 30) / BatteryGlyph.fadeDuration
|
||||
self.needsDisplay = true
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
fadeTimer = timer
|
||||
} else if !shouldFade, fadeTimer != nil {
|
||||
fadeTimer?.invalidate()
|
||||
fadeTimer = nil
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene
|
||||
/// Maße; alles andere ist ein SF-Symbol.
|
||||
private var symbolSlot: CGFloat {
|
||||
if metric == .battery { return BatteryGlyph.size.width }
|
||||
// Eigene Vorlagen sind quadratisch; ihre Breite folgt der Höhe.
|
||||
if Self.customGlyphs[metric] != nil { return 13 }
|
||||
return MenuBarText.symbolWidth(currentSymbol, height: 13)
|
||||
}
|
||||
|
||||
/// Das Symbol zum **Zustand**, nicht zur Metrik.
|
||||
///
|
||||
/// Beim Akku ändert es sich mit dem Ladestand und zeigt beim Laden einen
|
||||
/// Blitz — ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist
|
||||
/// Dekoration. Die übrigen Messgrößen haben nichts, was sich sinnvoll im
|
||||
/// Glyph abbilden ließe.
|
||||
private var currentSymbol: String {
|
||||
guard metric == .battery else { return metric.symbolName }
|
||||
guard let battery = snapshot.battery else { return BatterySymbol.unavailable }
|
||||
return BatterySymbol.name(charge: battery.charge)
|
||||
}
|
||||
|
||||
private func drawGraph(color: NSColor, in rect: NSRect) {
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
|
||||
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
|
||||
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
|
||||
// Breite.
|
||||
let xs = GraphLayout.positions(count: history.count,
|
||||
capacity: MetricsModel.historyLength, in: rect)
|
||||
guard !xs.isEmpty else { return }
|
||||
|
||||
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
|
||||
context.setLineWidth(1)
|
||||
context.setLineJoin(.round)
|
||||
for (index, x) in xs.enumerated() {
|
||||
let value = history[history.count - xs.count + index]
|
||||
let point = CGPoint(x: x,
|
||||
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
|
||||
index == 0 ? context.move(to: point) : context.addLine(to: point)
|
||||
}
|
||||
context.strokePath()
|
||||
}
|
||||
|
||||
/// Ein Balken je Kern. Bei fünfzehn Kernen ist das kein Diagramm mehr,
|
||||
/// sondern ein Muster — aber genau daran erkennt man auf einen Blick, ob
|
||||
/// eine einzelne Last läuft oder alles ausgelastet ist.
|
||||
private func drawBars(color: NSColor) {
|
||||
// Bei der CPU-Auslastung ein Balken je Kern; sonst — auch bei der
|
||||
// CPU-Temperatur — ein einzelner Balken.
|
||||
let values = (metric == .cpu && mode == .usage)
|
||||
? snapshot.cpu.perCore
|
||||
: [MetricSummary.fraction(metric, snapshot, mode: mode)]
|
||||
guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
|
||||
let area = bounds.insetBy(dx: 2, dy: 5)
|
||||
let gap: CGFloat = values.count > 4 ? 1 : 2
|
||||
let width = (area.width - gap * CGFloat(values.count - 1)) / CGFloat(values.count)
|
||||
|
||||
for (index, value) in values.enumerated() {
|
||||
let height = max(area.height * CGFloat(min(max(value, 0), 1)), 1)
|
||||
let rect = NSRect(x: area.minX + CGFloat(index) * (width + gap),
|
||||
y: area.minY, width: width, height: height)
|
||||
context.setFillColor(color.withAlphaComponent(0.85).cgColor)
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuBarPresentation {
|
||||
}
|
||||
|
||||
public extension MetricSummary {
|
||||
/// Der Messwert als Anteil von 0 bis 1 — für Balken und Graphen.
|
||||
static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Double {
|
||||
switch metric {
|
||||
case .cpu: snapshot.cpu.total
|
||||
case .gpu: snapshot.gpu ?? 0
|
||||
case .memory: snapshot.memory.usedFraction
|
||||
case .battery: snapshot.battery?.charge ?? 0
|
||||
case .temperature: (snapshot.sensors.map(\.celsius).max() ?? 0) / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
443
Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift
Normal file
@@ -0,0 +1,443 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
|
||||
/// Die Detailansichten hinter den Menüleisten-Symbolen.
|
||||
///
|
||||
/// Hier ist ausdrücklich Platz — anders als in der Menüleiste, wo jede Zahl um
|
||||
/// Breite kämpft. Ein Popover, das nur den Wert größer wiederholt, den man
|
||||
/// gerade angeklickt hat, ist verschenkt.
|
||||
@MainActor
|
||||
struct MetricPopoverContent: View {
|
||||
let model: MetricsModel
|
||||
let metric: MetricKind
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
|
||||
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
|
||||
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||
.font(.headline)
|
||||
|
||||
switch metric {
|
||||
case .cpu, .gpu: ProcessorDetail(model: model, metric: metric)
|
||||
case .memory: MemoryDetail(snapshot: model.snapshot)
|
||||
case .battery: BatteryDetail(snapshot: model.snapshot, control: model.chargeControl)
|
||||
case .temperature: SensorDetail(snapshot: model.snapshot)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
|
||||
/// lange Werteliste.
|
||||
private struct SectionTitle: View {
|
||||
let text: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
Text(text, bundle: .module)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Row: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var emphasis: Color?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label).font(.callout)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(emphasis ?? .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Mitte des **Rings** — nicht die des Rings samt Beschriftung.
|
||||
///
|
||||
/// Ein `HStack` zentriert seine Inhalte über die volle Höhe. Die Beschriftung
|
||||
/// unter dem Ring gehört aber dazu, und dadurch rutscht alles daneben um deren
|
||||
/// halbe Höhe nach unten. Bei einer einzelnen Zeile fällt das auf: sie steht
|
||||
/// dann sichtbar tiefer als die Mitte, auf die man schaut.
|
||||
private extension VerticalAlignment {
|
||||
enum DialCenterID: AlignmentID {
|
||||
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||
context[VerticalAlignment.center]
|
||||
}
|
||||
}
|
||||
static let dialCenter = VerticalAlignment(DialCenterID.self)
|
||||
}
|
||||
|
||||
/// Ein Ring mit Zahl in der Mitte.
|
||||
private struct Dial: View {
|
||||
let value: Double
|
||||
let caption: String
|
||||
let text: String
|
||||
/// Ohne Angabe die Akzentfarbe. Als Standardwert ginge sie nicht: der
|
||||
/// wird außerhalb des Hauptthreads erzeugt, die Farbe liegt aber dort.
|
||||
var tint: Color?
|
||||
|
||||
var body: some View {
|
||||
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
|
||||
// sah aus, als gehörte sie noch hinein.
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle().stroke(.quaternary, lineWidth: 5)
|
||||
Circle()
|
||||
.trim(from: 0, to: min(max(value, 0), 1))
|
||||
.stroke(tint ?? Onyx.Color.accent,
|
||||
style: .init(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit())
|
||||
}
|
||||
.frame(width: 62, height: 62)
|
||||
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
||||
Text(caption).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prozessor
|
||||
|
||||
private struct ProcessorDetail: View {
|
||||
let model: MetricsModel
|
||||
let metric: MetricKind
|
||||
|
||||
var body: some View {
|
||||
let snapshot = model.snapshot
|
||||
|
||||
HStack(spacing: 14) {
|
||||
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
|
||||
caption: metric == .cpu ? "CPU" : "GPU",
|
||||
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
|
||||
: (snapshot.gpu ?? 0)))
|
||||
if let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature {
|
||||
Dial(value: celsius / 100,
|
||||
caption: String(localized: "popover.temperature", bundle: .module),
|
||||
text: MetricFormat.temperature(celsius),
|
||||
tint: MetricSummary.temperatureColor(celsius))
|
||||
}
|
||||
if let fan = snapshot.fans.first {
|
||||
Dial(value: fan.fraction,
|
||||
caption: String(localized: "popover.fans", bundle: .module),
|
||||
text: MetricFormat.percent(fan.fraction))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
|
||||
SectionTitle(text: "popover.perCore")
|
||||
CoreGrid(values: model.snapshot.cpu.perCore)
|
||||
}
|
||||
|
||||
SectionTitle(text: "popover.power")
|
||||
if let cpu = snapshot.power.cpu {
|
||||
Row(label: String(localized: "popover.power.cpu", bundle: .module),
|
||||
value: MetricFormat.watts(cpu))
|
||||
}
|
||||
if let display = snapshot.power.display {
|
||||
Row(label: String(localized: "popover.power.display", bundle: .module),
|
||||
value: MetricFormat.watts(display))
|
||||
}
|
||||
if let total = snapshot.power.total {
|
||||
Row(label: String(localized: "popover.power.total", bundle: .module),
|
||||
value: MetricFormat.watts(total), emphasis: .primary)
|
||||
}
|
||||
|
||||
if !snapshot.fans.isEmpty {
|
||||
SectionTitle(text: "popover.fans")
|
||||
ForEach(snapshot.fans) { fan in
|
||||
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Picker("", selection: Binding(
|
||||
get: { model.displayMode(for: metric) },
|
||||
set: { model.setDisplayMode($0, for: metric) })) {
|
||||
Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage)
|
||||
Text("metric.mode.temperature", bundle: .module).tag(MetricDisplayMode.temperature)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Kästchen je Kern statt einer Balkenreihe.
|
||||
///
|
||||
/// Bei fünfzehn Kernen wird eine Reihe unleserlich schmal. Als Raster bleibt
|
||||
/// jeder Kern erkennbar, und man sieht auf einen Blick, ob eine einzelne Last
|
||||
/// läuft oder alles arbeitet.
|
||||
private struct CoreGrid: View {
|
||||
let values: [Double]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8),
|
||||
spacing: 3) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(Onyx.Color.accent.opacity(0.18))
|
||||
.overlay(alignment: .bottom) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(Onyx.Color.accent)
|
||||
.frame(height: max(14 * value, 1.5))
|
||||
}
|
||||
.frame(height: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Speicher
|
||||
|
||||
private struct MemoryDetail: View {
|
||||
let snapshot: MetricsSnapshot
|
||||
|
||||
var body: some View {
|
||||
let memory = snapshot.memory
|
||||
|
||||
HStack(spacing: 14) {
|
||||
Dial(value: memory.usedFraction,
|
||||
caption: String(localized: "popover.memory.used", bundle: .module),
|
||||
text: MetricFormat.percent(memory.usedFraction),
|
||||
tint: pressureColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
legend(Onyx.Color.accent, "popover.memory.app", memory.app)
|
||||
legend(.purple, "popover.memory.wired", memory.wired)
|
||||
legend(.teal, "popover.memory.compressed", memory.compressed)
|
||||
legend(.gray, "popover.memory.free", memory.free)
|
||||
}
|
||||
}
|
||||
|
||||
SectionTitle(text: "popover.memory.swap")
|
||||
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
|
||||
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
|
||||
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
|
||||
value: "\(bytes(memory.swapUsed)) / \(bytes(memory.swapTotal))",
|
||||
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil)
|
||||
|
||||
if !snapshot.topMemoryProcesses.isEmpty {
|
||||
SectionTitle(text: "popover.processes")
|
||||
ForEach(snapshot.topMemoryProcesses) { process in
|
||||
Row(label: process.name, value: bytes(process.bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pressureColor: Color {
|
||||
switch snapshot.memory.pressure {
|
||||
case .normal: Onyx.Color.positive
|
||||
case .warning: Onyx.Color.warning
|
||||
case .critical: Onyx.Color.critical
|
||||
}
|
||||
}
|
||||
|
||||
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(color).frame(width: 7, height: 7)
|
||||
Text(key, bundle: .module).font(.caption)
|
||||
Spacer(minLength: 8)
|
||||
Text(bytes(value)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func bytes(_ value: UInt64) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Akku
|
||||
|
||||
private struct BatteryDetail: View {
|
||||
let snapshot: MetricsSnapshot
|
||||
let control: (any ChargeLimitReading)?
|
||||
|
||||
var body: some View {
|
||||
if let battery = snapshot.battery {
|
||||
HStack(alignment: .dialCenter, spacing: 14) {
|
||||
Dial(value: battery.charge,
|
||||
caption: String(localized: "metric.battery", bundle: .module),
|
||||
text: MetricFormat.percent(battery.charge),
|
||||
tint: battery.isCharging ? Onyx.Color.positive
|
||||
: (battery.charge < 0.2 ? Onyx.Color.critical
|
||||
: Onyx.Color.accent))
|
||||
// Beides gehört neben den Ring: die Schätzung und der Grund
|
||||
// dafür. „Am Netzteil" über dem Ring und „Am Netz, lädt nicht"
|
||||
// darunter waren zweimal dieselbe Auskunft an zwei Stellen —
|
||||
// und die zweite stand allein unter dem Diagramm, wo sie
|
||||
// aussah, als gehörte sie zu dessen Beschriftung.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let remaining = battery.timeRemaining {
|
||||
Text(duration(remaining)).font(.title3.monospacedDigit())
|
||||
Text(battery.isCharging ? "popover.battery.untilFull"
|
||||
: "popover.battery.remaining",
|
||||
bundle: .module)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else if !battery.isPluggedIn {
|
||||
// Am Netz braucht es keine Entschuldigung für die
|
||||
// fehlende Schätzung — die Zeile darunter sagt ohnehin,
|
||||
// was los ist.
|
||||
Text("popover.battery.calculating", bundle: .module)
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Was gerade geschieht, in Worten. „Am Netz" allein
|
||||
// beantwortet die Frage nicht, die man hat, wenn der
|
||||
// Ladestand stehen bleibt.
|
||||
activityLine(battery)
|
||||
}
|
||||
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
||||
}
|
||||
|
||||
// Das Ladelimit steht hier und nicht in den Einstellungen: es ist
|
||||
// eine Akkusache, und man greift danach, während man auf den
|
||||
// Ladestand schaut.
|
||||
if let control {
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
ChargeLimitControl(control: control, roomy: true)
|
||||
}
|
||||
|
||||
SectionTitle(text: "popover.battery.condition")
|
||||
if let health = battery.health {
|
||||
Row(label: String(localized: "popover.battery.health", bundle: .module),
|
||||
value: MetricFormat.percent(health))
|
||||
}
|
||||
if let cycles = battery.cycleCount {
|
||||
Row(label: String(localized: "popover.battery.cycles", bundle: .module),
|
||||
value: "\(cycles)")
|
||||
}
|
||||
if let sensor = snapshot.sensors.first(where: { $0.key == "TB0T" }) {
|
||||
Row(label: String(localized: "popover.temperature", bundle: .module),
|
||||
value: MetricFormat.temperature(sensor.celsius))
|
||||
}
|
||||
|
||||
SectionTitle(text: "popover.power")
|
||||
if let watts = battery.watts {
|
||||
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
|
||||
: "popover.battery.draining",
|
||||
bundle: .module),
|
||||
value: MetricFormat.watts(abs(watts)), emphasis: .primary)
|
||||
}
|
||||
if let adapter = snapshot.power.adapter, adapter > 0 {
|
||||
Row(label: String(localized: "popover.power.adapter", bundle: .module),
|
||||
value: MetricFormat.watts(adapter))
|
||||
}
|
||||
if let voltage = snapshot.power.busVoltage {
|
||||
Row(label: String(localized: "popover.battery.voltage", bundle: .module),
|
||||
value: String(format: "%.2f V", voltage))
|
||||
}
|
||||
|
||||
Divider()
|
||||
// Ehrlich benennen, was fehlt: das Ladelimit braucht Schreibzugriff
|
||||
// auf den SMC und damit den privilegierten Helfer — und der Key ist
|
||||
// auf diesem Modell noch nicht identifiziert.
|
||||
Text("popover.battery.limitComing", bundle: .module)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
Text("popover.battery.none", bundle: .module).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func activityLine(_ battery: BatteryReading) -> some View {
|
||||
let activity = ChargeActivity.resolve(isCharging: battery.isCharging,
|
||||
isPluggedIn: battery.isPluggedIn,
|
||||
charge: battery.charge,
|
||||
limit: control?.chargeLimit)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: symbol(for: activity))
|
||||
.font(.caption)
|
||||
.foregroundStyle(tint(for: activity))
|
||||
switch activity {
|
||||
case .charging:
|
||||
Text("popover.battery.charging", bundle: .module)
|
||||
case .pausedAtLimit(let limit):
|
||||
Text("popover.battery.pausedAtLimit \(limit)", bundle: .module)
|
||||
case .pluggedNotCharging:
|
||||
Text("popover.battery.notCharging", bundle: .module)
|
||||
case .onBattery:
|
||||
Text("popover.battery.onBattery", bundle: .module)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private func symbol(for activity: ChargeActivity) -> String {
|
||||
switch activity {
|
||||
case .charging: "bolt.fill"
|
||||
case .pausedAtLimit: "pause.fill"
|
||||
case .pluggedNotCharging: "powerplug.fill"
|
||||
case .onBattery: "battery.50percent"
|
||||
}
|
||||
}
|
||||
|
||||
private func tint(for activity: ChargeActivity) -> Color {
|
||||
// Farbe nur, wo sie etwas meldet: Laden ist ein Zustand, den man
|
||||
// sucht. Alles andere bleibt zurückhaltend.
|
||||
activity == .charging ? Onyx.Color.positive : .secondary
|
||||
}
|
||||
|
||||
private func duration(_ seconds: TimeInterval) -> String {
|
||||
let total = Int(seconds / 60)
|
||||
return total >= 60 ? "\(total / 60) h \(total % 60) min" : "\(total) min"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sensoren
|
||||
|
||||
private struct SensorDetail: View {
|
||||
let snapshot: MetricsSnapshot
|
||||
|
||||
var body: some View {
|
||||
SectionTitle(text: "popover.temperature")
|
||||
ForEach(snapshot.sensors) { sensor in
|
||||
HStack {
|
||||
Text(sensor.name).font(.callout)
|
||||
Spacer(minLength: 12)
|
||||
Text(MetricFormat.temperature(sensor.celsius))
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
|
||||
// Der Ring macht aus einer Zahl einen Vergleich: 45° neben 79°
|
||||
// sieht man schneller als man es liest.
|
||||
Circle()
|
||||
.trim(from: 0, to: min(sensor.celsius / 100, 1))
|
||||
.stroke(MetricSummary.temperatureColor(sensor.celsius),
|
||||
style: .init(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
}
|
||||
|
||||
if !snapshot.fans.isEmpty {
|
||||
SectionTitle(text: "popover.fans")
|
||||
ForEach(snapshot.fans) { fan in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(fan.name).font(.callout)
|
||||
Spacer(minLength: 12)
|
||||
Text("\(Int(fan.rpm)) U/min").font(.callout.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Ohne Bezug sagt eine Drehzahl nichts: der Balken zeigt sie
|
||||
// zwischen dem Minimum und Maximum der Firmware.
|
||||
ProgressView(value: fan.fraction).controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("metric.fan.controlComing", bundle: .module)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
440
Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift
Normal file
@@ -0,0 +1,440 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import OnyxWidgetKit
|
||||
import OnyxMenuBar
|
||||
|
||||
/// Ein Widget je Hardwaregröße. Alle teilen sich dasselbe Modell und damit
|
||||
/// dieselbe Messschleife.
|
||||
public struct MetricWidget: OnyxWidget {
|
||||
public let id: String
|
||||
public let metric: MetricKind
|
||||
public var displayName: String {
|
||||
String(localized: .init(metric.localizationKey), bundle: .module)
|
||||
}
|
||||
public var symbolName: String { metric.symbolName }
|
||||
|
||||
private let model: MetricsModel
|
||||
|
||||
public init(metric: MetricKind, model: MetricsModel) {
|
||||
self.metric = metric
|
||||
self.model = model
|
||||
self.id = switch metric {
|
||||
case .cpu: "cpu"
|
||||
case .gpu: "gpu"
|
||||
case .memory: "memory"
|
||||
case .battery: "battery"
|
||||
case .temperature: "sensors"
|
||||
}
|
||||
}
|
||||
|
||||
public func makeView() -> AnyView {
|
||||
AnyView(MetricWidgetView(model: model, metric: metric))
|
||||
}
|
||||
}
|
||||
|
||||
extension MetricKind {
|
||||
var localizationKey: String {
|
||||
switch self {
|
||||
case .cpu: "metric.cpu"
|
||||
case .gpu: "metric.gpu"
|
||||
case .memory: "metric.memory"
|
||||
case .battery: "metric.battery"
|
||||
case .temperature: "metric.sensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetricWidgetView: View {
|
||||
let model: MetricsModel
|
||||
let metric: MetricKind
|
||||
@State private var token: UUID?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Die Kachel ist hochkant: die Sensorliste hat Platz für Details,
|
||||
// alles andere zeigt Zahl und Verlauf.
|
||||
if metric == .temperature {
|
||||
SensorList(snapshot: model.snapshot, detailed: true)
|
||||
} else if metric == .battery {
|
||||
BatteryTile(model: model)
|
||||
} else {
|
||||
WideMetric(model: model, metric: metric)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Bei offenem Panel schneller messen: dort sieht man die Zahl,
|
||||
// und ein Wert, der nur alle zwei Sekunden springt, wirkt träge.
|
||||
token = model.addConsumer(interval: 1)
|
||||
}
|
||||
.onDisappear {
|
||||
if let token { model.removeConsumer(token) }
|
||||
token = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private struct CompactMetric: View {
|
||||
let model: MetricsModel
|
||||
let metric: MetricKind
|
||||
|
||||
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label {
|
||||
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
} icon: {
|
||||
Image(systemName: metric.symbolName)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text(MetricSummary.value(metric, model.snapshot, mode: mode))
|
||||
.font(Onyx.Font.metric)
|
||||
.foregroundStyle(MetricSummary.color(metric, model.snapshot, mode: mode))
|
||||
.animation(Onyx.Motion.value,
|
||||
value: MetricSummary.value(metric, model.snapshot, mode: mode))
|
||||
|
||||
if let detail = MetricSummary.detail(metric, model.snapshot) {
|
||||
Text(detail)
|
||||
.font(.system(size: 9))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WideMetric: View {
|
||||
let model: MetricsModel
|
||||
let metric: MetricKind
|
||||
|
||||
var body: some View {
|
||||
// Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein
|
||||
// Verlauf über die volle Breite liest sich besser als einer, der sich
|
||||
// die Zeile mit der Zahl teilt.
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
CompactMetric(model: model, metric: metric)
|
||||
Spacer(minLength: 0)
|
||||
Sparkline(values: model.series(metric),
|
||||
tint: MetricSummary.color(metric, model.snapshot,
|
||||
mode: model.displayMode(for: metric)))
|
||||
.frame(height: 46)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verlaufskurve. Bewusst ohne Achsen und Beschriftung: auf 90 × 40 Punkten
|
||||
/// ist die Form die Information, nicht der Zahlenwert.
|
||||
public struct Sparkline: View {
|
||||
let values: [Double]
|
||||
let tint: Color
|
||||
/// Wie viele Werte der Verlauf fasst — die Schrittweite hängt daran, nicht
|
||||
/// an der Anzahl der bisher vorhandenen.
|
||||
let capacity: Int
|
||||
|
||||
public init(values: [Double], tint: Color, capacity: Int = MetricsModel.historyLength) {
|
||||
self.values = values
|
||||
self.tint = tint
|
||||
self.capacity = capacity
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let points = Array(values.suffix(capacity))
|
||||
if points.count > 1 {
|
||||
let size = geometry.size
|
||||
// Die Schrittweite kommt aus der **vollen** Länge des Verlaufs,
|
||||
// nicht aus der Anzahl vorhandener Werte. Frisch gestartet sind
|
||||
// es zwei statt sechzig; daraus gerechnet spannte sich der Weg
|
||||
// zwischen ihnen über die ganze Breite — ein riesiger Zickzack,
|
||||
// der nach viel Bewegung aussah und nichts bedeutete.
|
||||
let xs = GraphLayout.positions(count: points.count,
|
||||
capacity: capacity,
|
||||
in: CGRect(origin: .zero, size: size))
|
||||
let coordinates = zip(xs, points).map { x, value in
|
||||
CGPoint(x: x, y: size.height * (1 - CGFloat(min(max(value, 0), 1))))
|
||||
}
|
||||
|
||||
let line = Path { path in
|
||||
for (index, point) in coordinates.enumerated() {
|
||||
index == 0 ? path.move(to: point) : path.addLine(to: point)
|
||||
}
|
||||
}
|
||||
|
||||
// Dieselbe Kurve, unten geschlossen — die Fläche darunter gibt
|
||||
// dem Verlauf Gewicht, ohne dass eine zweite Linie nötig wäre.
|
||||
// Die Fläche beginnt am ersten Messwert, nicht am linken Rand:
|
||||
// sonst zieht sich bei wenigen Werten ein Keil über die ganze
|
||||
// Breite, obwohl dort noch nichts gemessen wurde.
|
||||
let area = Path { path in
|
||||
guard let first = coordinates.first, let last = coordinates.last
|
||||
else { return }
|
||||
path.move(to: CGPoint(x: first.x, y: size.height))
|
||||
coordinates.forEach { path.addLine(to: $0) }
|
||||
path.addLine(to: CGPoint(x: last.x, y: size.height))
|
||||
path.closeSubpath()
|
||||
}
|
||||
|
||||
ZStack {
|
||||
area.fill(LinearGradient(colors: [tint.opacity(0.22), .clear],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
line.stroke(tint, style: .init(lineWidth: 1.5, lineJoin: .round))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SensorList: View {
|
||||
let snapshot: MetricsSnapshot
|
||||
let detailed: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in
|
||||
HStack(spacing: 6) {
|
||||
Text(sensor.name)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
Text(MetricFormat.temperature(sensor.celsius))
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
|
||||
}
|
||||
}
|
||||
|
||||
if detailed, !snapshot.fans.isEmpty {
|
||||
Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2)
|
||||
ForEach(snapshot.fans) { fan in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "fan")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text("\(Int(fan.rpm))")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
Text(verbatim: "U/min")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.sensors.isEmpty {
|
||||
Text("metric.sensors.none", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie eine Größe zu Text und Farbe wird — an einer Stelle, damit Panel und
|
||||
/// Menüleiste nicht auseinanderlaufen.
|
||||
public enum MetricSummary {
|
||||
|
||||
/// Der anzuzeigende Wert, abhängig vom gewählten Modus.
|
||||
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
|
||||
mode: MetricDisplayMode) -> String {
|
||||
guard mode == .temperature, metric.supportsDisplayModes else {
|
||||
return value(metric, snapshot)
|
||||
}
|
||||
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||
// Solange die Fühlersuche im Hintergrund läuft, gibt es keinen Wert.
|
||||
// „—" ist dann die richtige Auskunft, nicht eine 0.
|
||||
return celsius.map(MetricFormat.temperature) ?? "—"
|
||||
}
|
||||
|
||||
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
|
||||
mode: MetricDisplayMode) -> Color {
|
||||
guard mode == .temperature, metric.supportsDisplayModes else {
|
||||
return color(metric, snapshot)
|
||||
}
|
||||
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||
return temperatureColor(celsius ?? 0)
|
||||
}
|
||||
|
||||
public static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
|
||||
mode: MetricDisplayMode) -> Double {
|
||||
guard mode == .temperature, metric.supportsDisplayModes else {
|
||||
return fraction(metric, snapshot)
|
||||
}
|
||||
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||
// Auf 100 °C normiert: darüber wird ohnehin gedrosselt.
|
||||
return min(max((celsius ?? 0) / 100, 0), 1)
|
||||
}
|
||||
|
||||
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String {
|
||||
switch metric {
|
||||
case .cpu: MetricFormat.percent(snapshot.cpu.total)
|
||||
case .gpu: snapshot.gpu.map(MetricFormat.percent) ?? "—"
|
||||
case .memory: MetricFormat.percent(snapshot.memory.usedFraction)
|
||||
case .battery: snapshot.battery.map { MetricFormat.percent($0.charge) } ?? "—"
|
||||
case .temperature: snapshot.sensors.map(\.celsius).max()
|
||||
.map(MetricFormat.temperature) ?? "—"
|
||||
}
|
||||
}
|
||||
|
||||
public static func detail(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String? {
|
||||
switch metric {
|
||||
case .cpu:
|
||||
return snapshot.cpu.watts.map(MetricFormat.watts)
|
||||
case .memory:
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(snapshot.memory.used),
|
||||
countStyle: .binary)
|
||||
case .battery:
|
||||
guard let battery = snapshot.battery else { return nil }
|
||||
if battery.isCharging {
|
||||
return String(localized: "metric.battery.charging", bundle: .module)
|
||||
}
|
||||
// Beim Entladen ist die Leistung negativ; der Betrag ist gemeint.
|
||||
return battery.watts.map { MetricFormat.watts(abs($0)) }
|
||||
case .gpu, .temperature:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Color {
|
||||
switch metric {
|
||||
case .memory:
|
||||
switch snapshot.memory.pressure {
|
||||
case .normal: return Onyx.Color.textPrimary
|
||||
case .warning: return Onyx.Color.warning
|
||||
case .critical: return Onyx.Color.critical
|
||||
}
|
||||
case .battery:
|
||||
guard let battery = snapshot.battery else { return Onyx.Color.textPrimary }
|
||||
if battery.isCharging { return Onyx.Color.positive }
|
||||
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
|
||||
case .temperature:
|
||||
return temperatureColor(snapshot.sensors.map(\.celsius).max() ?? 0)
|
||||
case .cpu, .gpu:
|
||||
return Onyx.Color.textPrimary
|
||||
}
|
||||
}
|
||||
|
||||
public static func temperatureColor(_ celsius: Double) -> Color {
|
||||
// Grenzwerte für Apple Silicon: unter 70 unauffällig, ab 90 wird
|
||||
// gedrosselt. Dazwischen ist es warm, aber unproblematisch.
|
||||
switch celsius {
|
||||
case ..<70: Onyx.Color.textPrimary
|
||||
case ..<90: Onyx.Color.warning
|
||||
default: Onyx.Color.critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Die Akku-Kachel.
|
||||
///
|
||||
/// „40 %" und ein Verlauf über die letzten Sekunden ist keine Auskunft — der
|
||||
/// Ladestand ändert sich in Minuten, nicht in Sekunden, und ein Graph darüber
|
||||
/// ist eine waagerechte Linie. Was man wissen will: wie lange noch, zieht oder
|
||||
/// gibt der Akku gerade, läuft der Energiesparmodus, wo endet das Laden.
|
||||
private struct BatteryTile: View {
|
||||
let model: MetricsModel
|
||||
|
||||
var body: some View {
|
||||
let battery = model.snapshot.battery
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 7) {
|
||||
// Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 %
|
||||
// dasselbe wie bei 95 % und trägt nichts bei.
|
||||
if let battery {
|
||||
BatteryGlyphView(charge: battery.charge,
|
||||
isCharging: battery.isCharging,
|
||||
isPluggedIn: battery.isPluggedIn, height: 15)
|
||||
.foregroundStyle(tint(battery))
|
||||
} else {
|
||||
Image(systemName: BatterySymbol.unavailable)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
Text(MetricFormat.percent(battery?.charge ?? 0))
|
||||
.font(Onyx.Font.metric).monospacedDigit()
|
||||
.foregroundStyle(tint(battery))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
if let battery {
|
||||
if let remaining = battery.timeRemaining {
|
||||
Text(battery.isCharging ? "battery.untilFull \(duration(remaining))"
|
||||
: "battery.remaining \(duration(remaining))",
|
||||
bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
} else if battery.isPluggedIn {
|
||||
Text("battery.pluggedIn", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
if let watts = battery.watts, abs(watts) > 0.1 {
|
||||
row(watts > 0 ? "battery.charging" : "battery.draining",
|
||||
value: String(format: "%.1f W", abs(watts)))
|
||||
}
|
||||
if let limit = model.chargeControl?.chargeLimit, limit < 100 {
|
||||
row("charge.limit", value: "\(limit) %")
|
||||
}
|
||||
if let health = battery.health {
|
||||
row("battery.health", value: MetricFormat.percent(health))
|
||||
}
|
||||
if let cycles = battery.cycleCount {
|
||||
row("battery.cycles", value: "\(cycles)")
|
||||
}
|
||||
}
|
||||
|
||||
// Der Energiesparmodus erklärt, warum die Maschine langsamer
|
||||
// wirkt. Ohne diesen Hinweis sucht man den Grund woanders.
|
||||
if battery.isLowPower {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "leaf.fill").font(.system(size: 9))
|
||||
Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall)
|
||||
}
|
||||
.foregroundStyle(Onyx.Color.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private func row(_ key: String.LocalizationValue, value: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(String(localized: key, bundle: .module))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Spacer(minLength: 4)
|
||||
Text(value).monospacedDigit().foregroundStyle(Onyx.Color.textSecondary)
|
||||
}
|
||||
.font(Onyx.Font.metricSmall)
|
||||
}
|
||||
|
||||
private func tint(_ battery: BatteryReading?) -> Color {
|
||||
guard let battery else { return Onyx.Color.textPrimary }
|
||||
if battery.isCharging { return Onyx.Color.positive }
|
||||
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
|
||||
}
|
||||
|
||||
private func duration(_ seconds: TimeInterval) -> String {
|
||||
let total = Int(seconds)
|
||||
return total >= 3600 ? "\(total / 3600) h \(total % 3600 / 60) min"
|
||||
: "\(total / 60) min"
|
||||
}
|
||||
}
|
||||
187
Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Messwertstrom mit Referenzzählung.
|
||||
///
|
||||
/// Panel-Widget und Menüleisten-Modul greifen auf **dieselbe** Messschleife zu.
|
||||
/// Ohne das liefe für jede Anzeige ein eigener Timer, der dieselben IOKit- und
|
||||
/// SMC-Abfragen doppelt macht — bei sechs Modulen und ebenso vielen Widgets
|
||||
/// wäre das ein Vielfaches der nötigen Arbeit.
|
||||
///
|
||||
/// Meldet sich der letzte Konsument ab, hört die Schleife auf. Im Ruhezustand
|
||||
/// misst Onyx nichts.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class MetricsModel {
|
||||
|
||||
/// Wer das Ladelimit setzen kann — die App reicht den Helfer herein.
|
||||
/// `nil` heißt: kein Regler, weil es nichts zu regeln gibt.
|
||||
public weak var chargeControl: (any ChargeLimitReading)?
|
||||
|
||||
/// Welcher Sensor in der Menüleiste steht. `nil` = der wärmste.
|
||||
///
|
||||
/// Ohne diese Wahl zeigte das Modul stumm das Maximum über alle Sensoren —
|
||||
/// eine Zahl, von der niemand weiß, woher sie kommt. „Der wärmste Punkt"
|
||||
/// bleibt die Voreinstellung, weil das die Frage ist, die man meistens
|
||||
/// hat; aber wer den Akku oder die GPU im Blick behalten will, soll das
|
||||
/// sagen können.
|
||||
public var selectedSensorKey: String? {
|
||||
didSet {
|
||||
guard selectedSensorKey != oldValue else { return }
|
||||
defaults.set(selectedSensorKey, forKey: "onyx.metric.sensorKey")
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Wert des gewählten Sensors — oder der wärmste, wenn keiner gewählt
|
||||
/// ist oder der gewählte gerade nicht meldet.
|
||||
public var selectedSensorCelsius: Double? {
|
||||
if let key = selectedSensorKey,
|
||||
let sensor = snapshot.sensors.first(where: { $0.key == key }) {
|
||||
return sensor.celsius
|
||||
}
|
||||
return snapshot.sensors.map(\.celsius).max()
|
||||
}
|
||||
|
||||
/// Wie der gewählte Sensor heißt — fürs Kürzel in der Menüleiste.
|
||||
public var selectedSensorName: String? {
|
||||
guard let key = selectedSensorKey else { return nil }
|
||||
return snapshot.sensors.first(where: { $0.key == key })?.name
|
||||
}
|
||||
|
||||
public private(set) var snapshot = MetricsSnapshot()
|
||||
/// Verlauf für die Graphen, jüngster Wert zuletzt.
|
||||
public private(set) var history: [MetricsSnapshot] = []
|
||||
|
||||
/// So viele Punkte zeigt ein Menüleisten-Graph. Mehr aufzuheben kostet
|
||||
/// Speicher für etwas, das niemand sieht.
|
||||
public static let historyLength = 60
|
||||
|
||||
/// Was CPU und GPU zeigen. Beobachtbar, damit Panel und Menüleiste sofort
|
||||
/// umschalten.
|
||||
public private(set) var displayModes: [MetricKind: MetricDisplayMode] = [:]
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let source = SystemMetrics()
|
||||
private var timer: Timer?
|
||||
/// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten.
|
||||
private var demands: [UUID: TimeInterval] = [:]
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.selectedSensorKey = defaults.string(forKey: "onyx.metric.sensorKey")
|
||||
self.defaults = defaults
|
||||
for metric in MetricKind.allCases where metric.supportsDisplayModes {
|
||||
let stored = defaults.string(forKey: Self.key(metric))
|
||||
displayModes[metric] = stored.flatMap(MetricDisplayMode.init(rawValue:)) ?? .usage
|
||||
}
|
||||
}
|
||||
|
||||
public func displayMode(for metric: MetricKind) -> MetricDisplayMode {
|
||||
displayModes[metric] ?? .usage
|
||||
}
|
||||
|
||||
public func setDisplayMode(_ mode: MetricDisplayMode, for metric: MetricKind) {
|
||||
guard metric.supportsDisplayModes else { return }
|
||||
displayModes[metric] = mode
|
||||
defaults.set(mode.rawValue, forKey: Self.key(metric))
|
||||
}
|
||||
|
||||
private static func key(_ metric: MetricKind) -> String {
|
||||
"onyx.metric.\(metric.rawValue).displayMode"
|
||||
}
|
||||
|
||||
/// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht.
|
||||
@discardableResult
|
||||
public func addConsumer(interval: TimeInterval = 2) -> UUID {
|
||||
let token = UUID()
|
||||
demands[token] = min(max(interval, 1), 10)
|
||||
restartTimer()
|
||||
return token
|
||||
}
|
||||
|
||||
public func removeConsumer(_ token: UUID) {
|
||||
demands[token] = nil
|
||||
restartTimer()
|
||||
}
|
||||
|
||||
/// Ändert das Intervall eines bestehenden Konsumenten, ohne die Schleife
|
||||
/// anzuhalten — etwa wenn das Panel aufgeht und schneller messen will.
|
||||
public func updateConsumer(_ token: UUID, interval: TimeInterval) {
|
||||
guard demands[token] != nil else { return }
|
||||
demands[token] = min(max(interval, 1), 10)
|
||||
restartTimer()
|
||||
}
|
||||
|
||||
private func restartTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
|
||||
guard let interval = demands.values.min() else {
|
||||
history.removeAll()
|
||||
return
|
||||
}
|
||||
|
||||
sample()
|
||||
let timer = Timer(timeInterval: interval, repeats: true) { _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.sample() }
|
||||
}
|
||||
// `.common`, sonst steht die Messung, während ein Menü offen ist.
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
private func sample() {
|
||||
let value = source.sample()
|
||||
snapshot = value
|
||||
history.append(value)
|
||||
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
|
||||
}
|
||||
}
|
||||
|
||||
public extension MetricsModel {
|
||||
/// Verlauf einer einzelnen Größe, für die Mini-Graphen.
|
||||
func series(_ metric: MetricKind) -> [Double] {
|
||||
let mode = displayMode(for: metric)
|
||||
return history.compactMap { snapshot in
|
||||
if mode == .temperature, metric.supportsDisplayModes {
|
||||
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
|
||||
return celsius.map { $0 / 100 }
|
||||
}
|
||||
switch metric {
|
||||
case .cpu: return snapshot.cpu.total
|
||||
case .gpu: return snapshot.gpu
|
||||
case .memory: return snapshot.memory.usedFraction
|
||||
case .battery: return snapshot.battery?.charge
|
||||
case .temperature: return snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was ein CPU- oder GPU-Element zeigt.
|
||||
///
|
||||
/// Beide Werte kommen aus derselben Messung — die Umschaltung kostet nichts und
|
||||
/// erspart ein zweites Element in einer ohnehin knappen Menüleiste.
|
||||
public enum MetricDisplayMode: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||
case usage
|
||||
case temperature
|
||||
public var id: String { rawValue }
|
||||
}
|
||||
|
||||
public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
|
||||
case cpu, gpu, memory, battery, temperature
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var symbolName: String {
|
||||
switch self {
|
||||
case .cpu: "cpu"
|
||||
case .gpu: "cpu.fill"
|
||||
case .memory: "memorychip"
|
||||
case .battery: "battery.100"
|
||||
case .temperature: "thermometer"
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur CPU und GPU haben beides. Speicher und Akku haben keine eigene
|
||||
/// Temperatur, die Sensorkarte zeigt ohnehin nur Temperaturen.
|
||||
public var supportsDisplayModes: Bool { self == .cpu || self == .gpu }
|
||||
}
|
||||
199
Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift
Normal file
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
import OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "SMC")
|
||||
|
||||
/// Liest Werte aus dem System Management Controller. **Ausschließlich lesend.**
|
||||
///
|
||||
/// Es gibt hier bewusst keinen Schreibpfad. Lüfterdrehzahl und Ladegrenze
|
||||
/// verlangen root und gehören in den privilegierten Helfer aus Phase 6 — nicht
|
||||
/// in einen Typ, der im normalen App-Prozess läuft und von jedem Widget
|
||||
/// erreichbar ist.
|
||||
///
|
||||
/// Die Struktur und die Erkenntnisse stammen aus `docs/spikes/A-smc.md`, gemessen
|
||||
/// auf Mac17,9. Wichtigster Punkt von dort: **alle Mehrbyte-Werte sind
|
||||
/// little-endian.** Der verbreitete Beispielcode nimmt big-endian an — er
|
||||
/// stammt aus der Intel-Ära und liefert auf Apple Silicon Unsinn.
|
||||
public final class SMCReader: @unchecked Sendable {
|
||||
|
||||
// MARK: - Aufbau des AppleSMC-UserClients
|
||||
|
||||
private struct Version { var major: UInt8 = 0; var minor: UInt8 = 0
|
||||
var build: UInt8 = 0; var reserved: UInt8 = 0
|
||||
var release: UInt16 = 0 }
|
||||
private struct PLimitData { var version: UInt16 = 0; var length: UInt16 = 0
|
||||
var cpuPLimit: UInt32 = 0; var gpuPLimit: UInt32 = 0
|
||||
var memPLimit: UInt32 = 0 }
|
||||
private struct KeyInfo { var dataSize: UInt32 = 0; var dataType: UInt32 = 0
|
||||
var dataAttributes: UInt8 = 0 }
|
||||
|
||||
private struct Param {
|
||||
var key: UInt32 = 0
|
||||
var vers = Version()
|
||||
var pLimitData = PLimitData()
|
||||
var keyInfo = KeyInfo()
|
||||
var padding: UInt16 = 0
|
||||
var result: UInt8 = 0
|
||||
var status: UInt8 = 0
|
||||
var data8: UInt8 = 0
|
||||
var data32: UInt32 = 0
|
||||
var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) =
|
||||
(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
private static let handleYPCEvent: UInt32 = 2
|
||||
private static let readKey: UInt8 = 5
|
||||
private static let getKeyFromIndex: UInt8 = 8
|
||||
private static let getKeyInfo: UInt8 = 9
|
||||
|
||||
private var connection: io_connect_t = 0
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Typ und Größe je Key. Beides steht in der Firmware fest und ändert sich
|
||||
/// zur Laufzeit nicht — es bei jedem Lesen erneut zu erfragen verdoppelt
|
||||
/// die Zahl der IOKit-Aufrufe. Bei fünfzig Temperaturfühlern im Sekundentakt
|
||||
/// ist das der Unterschied zwischen spürbar und unmerklich.
|
||||
private var keyInfoCache: [String: KeyInfo] = [:]
|
||||
|
||||
public init?() {
|
||||
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
||||
IOServiceMatching("AppleSMC"))
|
||||
guard service != 0 else {
|
||||
log.error("AppleSMC nicht gefunden — Sensoren bleiben leer")
|
||||
return nil
|
||||
}
|
||||
defer { IOObjectRelease(service) }
|
||||
guard IOServiceOpen(service, mach_task_self_, 0, &connection) == kIOReturnSuccess else {
|
||||
log.error("AppleSMC nicht zu öffnen")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
deinit { if connection != 0 { IOServiceClose(connection) } }
|
||||
|
||||
// MARK: - Lesen
|
||||
|
||||
/// Ein `flt`-Wert (Lüfterdrehzahl, Temperatur, Leistung).
|
||||
public func float(_ key: String) -> Double? {
|
||||
guard let value = read(key), value.type == "flt ", value.bytes.count >= 4 else { return nil }
|
||||
// Little-endian, siehe Klassenkommentar.
|
||||
let bits = UInt32(value.bytes[3]) << 24 | UInt32(value.bytes[2]) << 16
|
||||
| UInt32(value.bytes[1]) << 8 | UInt32(value.bytes[0])
|
||||
return Double(Float(bitPattern: bits))
|
||||
}
|
||||
|
||||
/// Ein vorzeichenloser Ganzzahlwert beliebiger Breite.
|
||||
public func integer(_ key: String) -> UInt64? {
|
||||
guard let value = read(key) else { return nil }
|
||||
var result: UInt64 = 0
|
||||
for (index, byte) in value.bytes.prefix(8).enumerated() {
|
||||
result |= UInt64(byte) << (8 * index) // little-endian
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func exists(_ key: String) -> Bool { read(key) != nil }
|
||||
|
||||
/// Alle Keys, deren Name mit einem der Präfixe beginnt.
|
||||
///
|
||||
/// Wird einmal beim Start aufgerufen. Fest verdrahtete Listen wären hier
|
||||
/// falsch: welche Fühler es gibt, hängt am Modell — auf dem M5 sind es
|
||||
/// 285 Temperaturfühler, auf einem anderen Mac ganz andere.
|
||||
public func keys(matching prefixes: [String]) -> [String] {
|
||||
guard let total = keyCount() else { return [] }
|
||||
|
||||
var found: [String] = []
|
||||
for index in 0..<UInt32(total) {
|
||||
guard let name = key(atIndex: index) else { continue }
|
||||
if prefixes.contains(where: name.hasPrefix) { found.append(name) }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Die Anzahl der Keys — **big-endian**.
|
||||
///
|
||||
/// Die Byte-Reihenfolge ist im SMC nicht durchgängig, und das ist keine
|
||||
/// Nachlässigkeit dieser Klasse, sondern der Hardware: Messwerte wie `B0AV`
|
||||
/// kommen little-endian aus dem Akku-Baustein, die Metadaten des SMC selbst
|
||||
/// dagegen big-endian. `#KEY` little-endian gelesen ergibt 2 651 652 096
|
||||
/// statt 3486 — die Suche brach damit still ab und alle Temperaturen
|
||||
/// blieben leer.
|
||||
private func keyCount() -> UInt32? {
|
||||
guard let value = read("#KEY"), value.bytes.count >= 4 else { return nil }
|
||||
let count = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16
|
||||
| UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3])
|
||||
return (0..<100_000).contains(count) ? count : nil
|
||||
}
|
||||
|
||||
private func key(atIndex index: UInt32) -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
var command = Param()
|
||||
command.data8 = Self.getKeyFromIndex
|
||||
command.data32 = index
|
||||
guard let output = call(command) else { return nil }
|
||||
return Self.fourCCString(output.key)
|
||||
}
|
||||
|
||||
private struct Value { let type: String; let bytes: [UInt8] }
|
||||
|
||||
private func read(_ key: String) -> Value? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let described: KeyInfo
|
||||
if let cached = keyInfoCache[key] {
|
||||
described = cached
|
||||
} else {
|
||||
var info = Param()
|
||||
info.key = Self.fourCC(key)
|
||||
info.data8 = Self.getKeyInfo
|
||||
guard let result = call(info) else { return nil }
|
||||
described = result.keyInfo
|
||||
keyInfoCache[key] = described
|
||||
}
|
||||
|
||||
var command = Param()
|
||||
command.key = Self.fourCC(key)
|
||||
command.keyInfo = described
|
||||
command.data8 = Self.readKey
|
||||
guard let output = call(command) else { return nil }
|
||||
|
||||
let size = Int(min(described.dataSize, 32))
|
||||
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
|
||||
return Value(type: Self.fourCCString(described.dataType),
|
||||
bytes: Array(all.prefix(size)))
|
||||
}
|
||||
|
||||
private func call(_ input: Param) -> Param? {
|
||||
var input = input
|
||||
var output = Param()
|
||||
var size = MemoryLayout<Param>.stride
|
||||
|
||||
let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
|
||||
&input, MemoryLayout<Param>.stride,
|
||||
&output, &size)
|
||||
// Ein nicht vorhandener Key meldet sich über `result` und ist kein
|
||||
// Fehler: welche Keys es gibt, unterscheidet sich je nach Modell.
|
||||
guard result == kIOReturnSuccess, output.result == 0 else { return nil }
|
||||
return output
|
||||
}
|
||||
|
||||
private static func fourCC(_ string: String) -> UInt32 {
|
||||
var value: UInt32 = 0
|
||||
for character in string.utf8.prefix(4) { value = value << 8 | UInt32(character) }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func fourCCString(_ value: UInt32) -> String {
|
||||
let bytes = [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
|
||||
UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
|
||||
return String(bytes: bytes, encoding: .ascii) ?? "????"
|
||||
}
|
||||
}
|
||||
483
Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
Normal file
@@ -0,0 +1,483 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
import IOKit.ps
|
||||
import Darwin
|
||||
|
||||
/// Ein vollständiger Messwertsatz.
|
||||
public struct MetricsSnapshot: Equatable, Sendable {
|
||||
public var cpu = CPUReading()
|
||||
public var gpu: Double?
|
||||
/// Wärmster Punkt der GPU in Grad Celsius.
|
||||
public var gpuTemperature: Double?
|
||||
public var memory = MemoryReading()
|
||||
public var battery: BatteryReading?
|
||||
public var sensors: [SensorReading] = []
|
||||
public var fans: [FanReading] = []
|
||||
public var power = PowerReading()
|
||||
public var topMemoryProcesses: [ProcessMemory] = []
|
||||
public var asOf = Date()
|
||||
}
|
||||
|
||||
/// Wohin die Leistung geht.
|
||||
///
|
||||
/// Die Schlüssel sind gegen den Aktivitätsmonitor und gegen iStat Menus
|
||||
/// abgeglichen: `PHPC` deckt sich mit deren CPU-Wert, `PSTR` mit der
|
||||
/// Gesamtleistung.
|
||||
public struct PowerReading: Equatable, Sendable {
|
||||
public var total: Double?
|
||||
public var cpu: Double?
|
||||
public var display: Double?
|
||||
public var adapter: Double?
|
||||
public var busVoltage: Double?
|
||||
}
|
||||
|
||||
public struct CPUReading: Equatable, Sendable {
|
||||
/// Gesamtauslastung, 0 bis 1.
|
||||
public var total: Double = 0
|
||||
/// Auslastung je Kern, in der Reihenfolge des Systems.
|
||||
public var perCore: [Double] = []
|
||||
/// Leistungsaufnahme in Watt, falls messbar.
|
||||
public var watts: Double?
|
||||
/// Der wärmste Kern in Grad Celsius.
|
||||
///
|
||||
/// Apple Silicon hat pro Kern einen Fühler — auf dem M5 Pro sind es
|
||||
/// über zwanzig. Der Höchstwert ist der aussagekräftige: gedrosselt wird
|
||||
/// nach dem heißesten Punkt, nicht nach dem Durchschnitt.
|
||||
public var temperature: Double?
|
||||
}
|
||||
|
||||
public struct MemoryReading: Equatable, Sendable {
|
||||
public var used: UInt64 = 0
|
||||
public var total: UInt64 = 0
|
||||
public var compressed: UInt64 = 0
|
||||
public var swapUsed: UInt64 = 0
|
||||
public var swapTotal: UInt64 = 0
|
||||
/// Von Programmen belegt (aktiv + inaktiv).
|
||||
public var app: UInt64 = 0
|
||||
/// Vom Kernel festgehalten, nicht auslagerbar.
|
||||
public var wired: UInt64 = 0
|
||||
public var free: UInt64 = 0
|
||||
public var pressure: MemoryPressure = .normal
|
||||
|
||||
public var usedFraction: Double {
|
||||
total > 0 ? Double(used) / Double(total) : 0
|
||||
}
|
||||
}
|
||||
|
||||
public struct BatteryReading: Equatable, Sendable {
|
||||
public var charge: Double = 0 // 0 bis 1
|
||||
public var isCharging = false
|
||||
public var isPluggedIn = false
|
||||
public var cycleCount: Int?
|
||||
public var health: Double? // 0 bis 1
|
||||
public var watts: Double? // negativ = Entladung
|
||||
public var timeRemaining: TimeInterval?
|
||||
/// Energiesparmodus. Erklärt, warum die Maschine langsamer wirkt — und
|
||||
/// gehört deshalb sichtbar dorthin, wo man nach dem Grund sucht.
|
||||
public var isLowPower = false
|
||||
/// Temperatur der Zelle, falls der SMC sie hergibt.
|
||||
public var celsius: Double?
|
||||
}
|
||||
|
||||
/// Ein Programm mit seinem Speicherverbrauch.
|
||||
public struct ProcessMemory: Equatable, Sendable, Identifiable {
|
||||
public let pid: pid_t
|
||||
public let name: String
|
||||
public let bytes: UInt64
|
||||
public var id: pid_t { pid }
|
||||
}
|
||||
|
||||
public struct SensorReading: Equatable, Sendable, Identifiable {
|
||||
public let key: String
|
||||
public let name: String
|
||||
public let celsius: Double
|
||||
public var id: String { key }
|
||||
}
|
||||
|
||||
public struct FanReading: Equatable, Sendable, Identifiable {
|
||||
public let index: Int
|
||||
public let name: String
|
||||
public let rpm: Double
|
||||
public let minimum: Double
|
||||
public let maximum: Double
|
||||
public var id: Int { index }
|
||||
|
||||
/// Anteil zwischen Minimum und Maximum, 0 bis 1.
|
||||
public var fraction: Double {
|
||||
guard maximum > minimum else { return 0 }
|
||||
return min(max((rpm - minimum) / (maximum - minimum), 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest die Hardwarewerte des Systems.
|
||||
///
|
||||
/// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil
|
||||
/// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell
|
||||
/// sie es brauchen.
|
||||
public final class SystemMetrics: @unchecked Sendable {
|
||||
|
||||
private let smc = SMCReader()
|
||||
private var previousCPUTicks: [CPUTicks] = []
|
||||
|
||||
/// Welche Kern- und GPU-Fühler dieses Modell hat. `Tp*` sind die
|
||||
/// CPU-Kerne, `Tg*` die GPU-Cluster — die Konvention gilt für die ganze
|
||||
/// M-Serie.
|
||||
///
|
||||
/// Die Suche läuft im Hintergrund: sie geht alle 3486 Keys durch und
|
||||
/// braucht dafür knapp eine Sekunde. Auf dem Hauptthread wäre das eine
|
||||
/// spürbare Startverzögerung für etwas, das nach dem ersten Messwert
|
||||
/// niemandem auffällt.
|
||||
private var temperatureKeys: (cpu: [String], gpu: [String]) = ([], [])
|
||||
private let keysLock = NSLock()
|
||||
|
||||
/// Zwischenspeicher für die Temperaturen.
|
||||
///
|
||||
/// Fünfzig Fühler zu lesen kostet gut 15 ms. Bei 1 Hz wären das anderthalb
|
||||
/// Prozent CPU für Werte, die sich in einer Sekunde kaum bewegen — deshalb
|
||||
/// höchstens alle zwei Sekunden, unabhängig von der Abtastrate ringsum.
|
||||
private var cachedTemperatures: (cpu: Double?, gpu: Double?) = (nil, nil)
|
||||
private var temperaturesReadAt = Date.distantPast
|
||||
private static let temperatureInterval: TimeInterval = 2
|
||||
|
||||
/// Seit dem Start unveränderlich — einmal ermitteln reicht.
|
||||
private static let pageSize: UInt64 = {
|
||||
var size: UInt64 = 0
|
||||
var length = MemoryLayout<UInt64>.size
|
||||
guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else {
|
||||
return 16384 // Apple Silicon
|
||||
}
|
||||
return size
|
||||
}()
|
||||
|
||||
public init() {
|
||||
// Nicht im Initialisierer blockieren: der läuft beim App-Start.
|
||||
// `DispatchQueue` statt `Task.detached`: die Suche ist blockierende
|
||||
// IOKit-Arbeit ohne Suspendierungspunkt, sie gehört nicht in den
|
||||
// Nebenläufigkeitspool von Swift — und `NSLock` ist aus einem
|
||||
// asynchronen Kontext ohnehin nicht erlaubt.
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self, let smc else { return }
|
||||
let cpu = smc.keys(matching: ["Tp"])
|
||||
let gpu = smc.keys(matching: ["Tg"])
|
||||
keysLock.lock()
|
||||
temperatureKeys = (cpu, gpu)
|
||||
keysLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
public func sample() -> MetricsSnapshot {
|
||||
var snapshot = MetricsSnapshot()
|
||||
snapshot.cpu = readCPU()
|
||||
snapshot.gpu = readGPU()
|
||||
snapshot.memory = readMemory()
|
||||
snapshot.battery = readBattery()
|
||||
snapshot.sensors = readSensors()
|
||||
snapshot.fans = readFans()
|
||||
snapshot.topMemoryProcesses = readTopMemoryProcesses()
|
||||
snapshot.power = readPower()
|
||||
snapshot.cpu.watts = snapshot.power.cpu ?? snapshot.power.total
|
||||
let temperatures = readTemperatures()
|
||||
snapshot.cpu.temperature = temperatures.cpu
|
||||
snapshot.gpuTemperature = temperatures.gpu
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - CPU
|
||||
|
||||
private func readCPU() -> CPUReading {
|
||||
var count: natural_t = 0
|
||||
var info: processor_info_array_t?
|
||||
var infoCount: mach_msg_type_number_t = 0
|
||||
|
||||
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
|
||||
&count, &info, &infoCount) == KERN_SUCCESS,
|
||||
let info else { return CPUReading() }
|
||||
|
||||
defer {
|
||||
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
|
||||
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
|
||||
}
|
||||
|
||||
var current: [CPUTicks] = []
|
||||
current.reserveCapacity(Int(count))
|
||||
for core in 0..<Int(count) {
|
||||
let base = core * Int(CPU_STATE_MAX)
|
||||
current.append(CPUTicks(
|
||||
user: UInt32(bitPattern: info[base + Int(CPU_STATE_USER)]),
|
||||
system: UInt32(bitPattern: info[base + Int(CPU_STATE_SYSTEM)]),
|
||||
idle: UInt32(bitPattern: info[base + Int(CPU_STATE_IDLE)]),
|
||||
nice: UInt32(bitPattern: info[base + Int(CPU_STATE_NICE)])))
|
||||
}
|
||||
|
||||
// Die erste Abtastung hat keinen Vorgänger und kann deshalb keine
|
||||
// Auslastung ergeben. Null zu zeigen ist ehrlicher als der Anteil seit
|
||||
// dem Systemstart, den man sonst bekäme — der liegt immer bei ein paar
|
||||
// Prozent und sieht aus wie ein Messwert.
|
||||
defer { previousCPUTicks = current }
|
||||
guard previousCPUTicks.count == current.count else { return CPUReading() }
|
||||
|
||||
let perCore = zip(previousCPUTicks, current).map(CPUTicks.usage(from:to:))
|
||||
return CPUReading(total: perCore.isEmpty ? 0 : perCore.reduce(0, +) / Double(perCore.count),
|
||||
perCore: perCore,
|
||||
watts: nil)
|
||||
}
|
||||
|
||||
// MARK: - GPU
|
||||
|
||||
private func readGPU() -> Double? {
|
||||
var iterator: io_iterator_t = 0
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault,
|
||||
IOServiceMatching("IOAccelerator"),
|
||||
&iterator) == kIOReturnSuccess else { return nil }
|
||||
defer { IOObjectRelease(iterator) }
|
||||
|
||||
while case let entry = IOIteratorNext(iterator), entry != 0 {
|
||||
defer { IOObjectRelease(entry) }
|
||||
|
||||
var unmanaged: Unmanaged<CFMutableDictionary>?
|
||||
guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0)
|
||||
== kIOReturnSuccess,
|
||||
let properties = unmanaged?.takeRetainedValue() as? [String: Any],
|
||||
let statistics = properties["PerformanceStatistics"] as? [String: Any]
|
||||
else { continue }
|
||||
|
||||
// Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple
|
||||
// Silicon ist es „Device Utilization %".
|
||||
for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] {
|
||||
if let value = statistics[name] as? Int { return Double(value) / 100 }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Arbeitsspeicher
|
||||
|
||||
private func readMemory() -> MemoryReading {
|
||||
var statistics = vm_statistics64_data_t()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size
|
||||
/ MemoryLayout<integer_t>.size)
|
||||
let result = withUnsafeMutablePointer(to: &statistics) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
|
||||
}
|
||||
}
|
||||
guard result == KERN_SUCCESS else { return MemoryReading() }
|
||||
|
||||
// `vm_kernel_page_size` ist eine globale Variable und damit unter
|
||||
// strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start
|
||||
// fest, also einmal über sysctl holen.
|
||||
let pageSize = Self.pageSize
|
||||
let total = ProcessInfo.processInfo.physicalMemory
|
||||
let compressed = UInt64(statistics.compressor_page_count) * pageSize
|
||||
|
||||
// „Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert.
|
||||
// Der Dateicache (`external_page_count`) zählt nicht mit — er wird bei
|
||||
// Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll
|
||||
// aussehen.
|
||||
let used = UInt64(statistics.active_count) * pageSize
|
||||
+ UInt64(statistics.wire_count) * pageSize
|
||||
+ compressed
|
||||
|
||||
var swap = xsw_usage()
|
||||
var swapSize = MemoryLayout<xsw_usage>.size
|
||||
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
|
||||
|
||||
let app = UInt64(statistics.active_count + statistics.inactive_count) * pageSize
|
||||
let wired = UInt64(statistics.wire_count) * pageSize
|
||||
let freeBytes = UInt64(statistics.free_count) * pageSize
|
||||
|
||||
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
|
||||
return MemoryReading(used: used, total: total, compressed: compressed,
|
||||
swapUsed: swap.xsu_used, swapTotal: swap.xsu_total,
|
||||
app: app, wired: wired, free: freeBytes,
|
||||
pressure: MemoryPressure.classify(
|
||||
free: free,
|
||||
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
|
||||
swapUsed: swap.xsu_used))
|
||||
}
|
||||
|
||||
// MARK: - Akku
|
||||
|
||||
private func readBattery() -> BatteryReading? {
|
||||
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
|
||||
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef],
|
||||
let source = sources.first,
|
||||
let description = IOPSGetPowerSourceDescription(blob, source)?
|
||||
.takeUnretainedValue() as? [String: Any] else { return nil }
|
||||
|
||||
var reading = BatteryReading()
|
||||
if let current = description[kIOPSCurrentCapacityKey] as? Int,
|
||||
let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 {
|
||||
reading.charge = Double(current) / Double(maximum)
|
||||
}
|
||||
reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
|
||||
reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String)
|
||||
== kIOPSACPowerValue
|
||||
|
||||
if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 {
|
||||
reading.timeRemaining = TimeInterval(seconds * 60)
|
||||
}
|
||||
if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 {
|
||||
reading.timeRemaining = TimeInterval(seconds * 60)
|
||||
}
|
||||
|
||||
// Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung,
|
||||
// sondern in der Batterie selbst.
|
||||
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
||||
IOServiceMatching("AppleSmartBattery"))
|
||||
if service != 0 {
|
||||
defer { IOObjectRelease(service) }
|
||||
var unmanaged: Unmanaged<CFMutableDictionary>?
|
||||
if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0)
|
||||
== kIOReturnSuccess,
|
||||
let properties = unmanaged?.takeRetainedValue() as? [String: Any] {
|
||||
reading.cycleCount = properties["CycleCount"] as? Int
|
||||
if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 {
|
||||
reading.health = Double(maximum) / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen.
|
||||
reading.isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
|
||||
// TB0T ist die Akkutemperatur; nicht jede Maschine führt sie.
|
||||
reading.celsius = smc?.float("TB0T").map { Double($0) }
|
||||
if let watts = smc?.float("PPBR") {
|
||||
reading.watts = reading.isCharging ? watts : -watts
|
||||
}
|
||||
return reading
|
||||
}
|
||||
|
||||
/// Bei zwei Lüftern sind es links und rechts — so beschriftet Apple sie
|
||||
/// selbst. Bei einer anderen Anzahl bleibt die Nummer, weil die Anordnung
|
||||
/// dann nicht bekannt ist.
|
||||
private static func fanName(index: Int, of count: Int) -> String {
|
||||
guard count == 2 else { return "Lüfter \(index + 1)" }
|
||||
return index == 0 ? "Lüfter links" : "Lüfter rechts"
|
||||
}
|
||||
|
||||
// MARK: - Leistung
|
||||
|
||||
private func readPower() -> PowerReading {
|
||||
guard let smc else { return PowerReading() }
|
||||
return PowerReading(total: smc.float("PSTR"),
|
||||
cpu: smc.float("PHPC"),
|
||||
display: smc.float("PDBR"),
|
||||
adapter: smc.float("PDTR"),
|
||||
busVoltage: smc.float("VP0R"))
|
||||
}
|
||||
|
||||
// MARK: - Speicher je Programm
|
||||
|
||||
/// Die größten Speicherverbraucher.
|
||||
///
|
||||
/// `phys_footprint` ist derselbe Wert, den der Aktivitätsmonitor unter
|
||||
/// „Speicher" zeigt — nicht der virtuelle Adressraum, der bei modernen
|
||||
/// Programmen sinnlos groß ist. Fremde Nutzer bleiben außen vor: dafür
|
||||
/// bräuchte es root, und für einen Blick aufs eigene System reicht das hier.
|
||||
private func readTopMemoryProcesses(limit: Int = 5) -> [ProcessMemory] {
|
||||
var pids = [pid_t](repeating: 0, count: 4096)
|
||||
let size = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids,
|
||||
Int32(pids.count * MemoryLayout<pid_t>.size))
|
||||
guard size > 0 else { return [] }
|
||||
|
||||
let count = Int(size) / MemoryLayout<pid_t>.size
|
||||
var results: [ProcessMemory] = []
|
||||
|
||||
for pid in pids.prefix(count) where pid > 0 {
|
||||
var usage = rusage_info_v4()
|
||||
let result = withUnsafeMutablePointer(to: &usage) {
|
||||
$0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
|
||||
proc_pid_rusage(pid, RUSAGE_INFO_V4, $0)
|
||||
}
|
||||
}
|
||||
guard result == 0, usage.ri_phys_footprint > 64 * 1024 * 1024 else { continue }
|
||||
|
||||
var nameBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN))
|
||||
proc_name(pid, &nameBuffer, UInt32(nameBuffer.count))
|
||||
let name = String(cString: nameBuffer)
|
||||
guard !name.isEmpty else { continue }
|
||||
|
||||
results.append(ProcessMemory(pid: pid, name: name, bytes: usage.ri_phys_footprint))
|
||||
}
|
||||
return Array(results.sorted { $0.bytes > $1.bytes }.prefix(limit))
|
||||
}
|
||||
|
||||
// MARK: - Sensoren und Lüfter
|
||||
|
||||
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
|
||||
///
|
||||
/// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind
|
||||
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
|
||||
/// fällt beim Lesen einfach weg.
|
||||
private static let temperatureKeys: [(String, String)] = [
|
||||
("TB0T", "Akku"),
|
||||
("Ts0P", "Handballenauflage"),
|
||||
("Ts1P", "Gehäuse hinten"),
|
||||
("TW0P", "WLAN"),
|
||||
("TH0x", "SSD"),
|
||||
("TCMb", "Luftstrom"),
|
||||
("TDeL", "Thunderbolt links"),
|
||||
("TDeR", "Thunderbolt rechts"),
|
||||
]
|
||||
|
||||
private func readTemperatures() -> (cpu: Double?, gpu: Double?) {
|
||||
guard Date().timeIntervalSince(temperaturesReadAt) >= Self.temperatureInterval else {
|
||||
return cachedTemperatures
|
||||
}
|
||||
keysLock.lock()
|
||||
let keys = temperatureKeys
|
||||
keysLock.unlock()
|
||||
|
||||
// Solange die Suche im Hintergrund läuft, bleiben die Werte leer — die
|
||||
// Oberfläche zeigt dann „—" statt einer erfundenen Zahl.
|
||||
guard !keys.cpu.isEmpty || !keys.gpu.isEmpty else { return (nil, nil) }
|
||||
|
||||
cachedTemperatures = (hottest(of: keys.cpu), hottest(of: keys.gpu))
|
||||
temperaturesReadAt = Date()
|
||||
return cachedTemperatures
|
||||
}
|
||||
|
||||
/// Der höchste plausible Messwert einer Fühlergruppe.
|
||||
///
|
||||
/// Die Plausibilitätsgrenze ist nötig: einzelne Fühler liefern gelegentlich
|
||||
/// 0 oder Werte jenseits von 200 °C, und ein einziger Ausreißer würde die
|
||||
/// Anzeige übernehmen, weil hier das Maximum gebildet wird.
|
||||
private func hottest(of keys: [String]) -> Double? {
|
||||
guard let smc, !keys.isEmpty else { return nil }
|
||||
return keys.compactMap { key -> Double? in
|
||||
guard let value = smc.float(key), value > 5, value < 150 else { return nil }
|
||||
return value
|
||||
}.max()
|
||||
}
|
||||
|
||||
private func readSensors() -> [SensorReading] {
|
||||
guard let smc else { return [] }
|
||||
let named = Self.temperatureKeys.compactMap { key, name -> SensorReading? in
|
||||
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
|
||||
return SensorReading(key: key, name: name, celsius: celsius)
|
||||
}
|
||||
// CPU und GPU nach vorn: das sind die Werte, wegen derer man hinschaut.
|
||||
let temperatures = readTemperatures()
|
||||
var result: [SensorReading] = []
|
||||
if let cpu = temperatures.cpu {
|
||||
result.append(SensorReading(key: "cpu", name: "CPU-Kerne", celsius: cpu))
|
||||
}
|
||||
if let gpu = temperatures.gpu {
|
||||
result.append(SensorReading(key: "gpu", name: "Grafik", celsius: gpu))
|
||||
}
|
||||
return result + named
|
||||
}
|
||||
|
||||
private func readFans() -> [FanReading] {
|
||||
guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] }
|
||||
return (0..<Int(count)).compactMap { index in
|
||||
guard let rpm = smc.float("F\(index)Ac") else { return nil }
|
||||
return FanReading(index: index,
|
||||
name: Self.fanName(index: index, of: Int(count)),
|
||||
rpm: rpm,
|
||||
minimum: smc.float("F\(index)Mn") ?? 0,
|
||||
maximum: smc.float("F\(index)Mx") ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
376
Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"menubar.short.network": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Netz"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Net"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.addresses": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Adressen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Addresses"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.download": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Download"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Download"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.field.interface": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Schnittstelle"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Interface"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.field.ipv4": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "IP"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "IP"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.field.ipv6": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "IPv6"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "IPv6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.field.router": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Router"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Router"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.name": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Netzwerk"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Network"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.offline": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nicht verbunden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Not connected"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.peak.download": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Spitze ↓"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Peak ↓"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.peak.upload": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Spitze ↑"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Peak ↑"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.perProcess.unavailable": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Durchsatz je Programm zeigt Onyx nicht: dafür gibt es ohne root keine Schnittstelle."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Onyx doesn't show per-app throughput: there is no interface for it without root."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.processes": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Programme"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Per app"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.public": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Öffentliche Adresse"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Public address"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.public.enable": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Öffentliche IP anzeigen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Show public IP"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.public.failed": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nicht abrufbar"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.public.hint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dafür fragt Onyx einen fremden Dienst — standardmäßig aus, weil ein Systemmonitor nicht ungefragt nach draußen telefonieren sollte."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "This asks an external service — off by default, because a system monitor shouldn't phone home unasked."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.total.received": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Empfangen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Received"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.total.sent": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Gesendet"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.transferred": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Übertragen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Transferred"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.upload": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Upload"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.vpn.active": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Verkehr läuft durch einen Tunnel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Traffic goes through a tunnel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network.wifi.connected": {
|
||||
"comment": "Wenn der Netzwerkname mangels Ortungsberechtigung fehlt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "WLAN verbunden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wi-Fi connected"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
153
Packages/OnyxKit/Sources/NetworkProvider/NetworkExtras.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import MetricsProvider
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Network")
|
||||
|
||||
/// Durchsatz je Programm.
|
||||
public struct ProcessTraffic: Equatable, Sendable, Identifiable {
|
||||
public let name: String
|
||||
public let pid: pid_t
|
||||
public let downloadRate: Double
|
||||
public let uploadRate: Double
|
||||
public var id: pid_t { pid }
|
||||
}
|
||||
|
||||
/// Liest den Durchsatz je Programm über `nettop`.
|
||||
///
|
||||
/// Es gibt dafür keine Bibliotheksschnittstelle — die zugrundeliegende
|
||||
/// `NetworkStatistics`-API ist privat und verlangt eine Entitlement, die Apple
|
||||
/// nicht vergibt. `nettop` ist Apples eigenes Werkzeug dafür, liegt im System
|
||||
/// und läuft **ohne root** für die Prozesse des eigenen Nutzers.
|
||||
///
|
||||
/// Deshalb wird es nur abgefragt, solange jemand hinschaut: einen
|
||||
/// Fremdprozess im Sekundentakt zu starten wäre teurer als der gesamte übrige
|
||||
/// Monitor zusammen.
|
||||
public final class ProcessTrafficSampler: @unchecked Sendable {
|
||||
|
||||
private var previous: [pid_t: (received: UInt64, sent: UInt64)] = [:]
|
||||
private var previousTime: Date?
|
||||
private let lock = NSLock()
|
||||
|
||||
public init() {}
|
||||
|
||||
public func sample(limit: Int = 6) -> [ProcessTraffic] {
|
||||
guard let output = runNettop() else { return [] }
|
||||
|
||||
let now = Date()
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let elapsed = previousTime.map { now.timeIntervalSince($0) } ?? 0
|
||||
var current: [pid_t: (received: UInt64, sent: UInt64)] = [:]
|
||||
var results: [ProcessTraffic] = []
|
||||
|
||||
// Format: `name.pid,bytes_in,bytes_out,`
|
||||
for line in output.split(separator: "\n").dropFirst() {
|
||||
let fields = line.split(separator: ",", omittingEmptySubsequences: false)
|
||||
guard fields.count >= 3 else { continue }
|
||||
|
||||
let identifier = String(fields[0])
|
||||
guard let separator = identifier.lastIndex(of: "."),
|
||||
let pid = pid_t(identifier[identifier.index(after: separator)...]),
|
||||
let received = UInt64(fields[1]), let sent = UInt64(fields[2])
|
||||
else { continue }
|
||||
|
||||
let name = String(identifier[..<separator])
|
||||
current[pid] = (received, sent)
|
||||
|
||||
guard elapsed > 0, let before = previous[pid] else { continue }
|
||||
let down = Throughput.rate(previous: before.received, current: received, elapsed: elapsed)
|
||||
let up = Throughput.rate(previous: before.sent, current: sent, elapsed: elapsed)
|
||||
guard down + up > 0 else { continue }
|
||||
|
||||
results.append(ProcessTraffic(name: name, pid: pid,
|
||||
downloadRate: down, uploadRate: up))
|
||||
}
|
||||
|
||||
previous = current
|
||||
previousTime = now
|
||||
return Array(results.sorted { $0.downloadRate + $0.uploadRate
|
||||
> $1.downloadRate + $1.uploadRate }.prefix(limit))
|
||||
}
|
||||
|
||||
private func runNettop() -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
|
||||
// `-P` fasst je Prozess zusammen, `-x` unterdrückt Einheiten, `-L 1`
|
||||
// nimmt genau eine Messung und beendet sich dann.
|
||||
process.arguments = ["-P", "-x", "-L", "1", "-J", "bytes_in,bytes_out"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return String(data: data, encoding: .utf8)
|
||||
} catch {
|
||||
log.error("nettop nicht ausführbar: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt die öffentliche IP-Adresse.
|
||||
///
|
||||
/// **Standardmäßig aus.** Dafür geht eine Anfrage an einen fremden Dienst, und
|
||||
/// wer einen Systemmonitor benutzt, rechnet nicht damit, dass der nach draußen
|
||||
/// telefoniert. Wird sie eingeschaltet, steht der benutzte Dienst in der
|
||||
/// Oberfläche — man soll nachlesen können, wem die Adresse bekannt wird.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class PublicAddressLookup {
|
||||
|
||||
public private(set) var address: String?
|
||||
public private(set) var isLoading = false
|
||||
public private(set) var failed = false
|
||||
|
||||
/// Der Dienst, der gefragt wird. Bewusst als sichtbare Angabe und nicht
|
||||
/// versteckt im Code.
|
||||
public static let serviceName = "api.ipify.org"
|
||||
private static let endpoint = URL(string: "https://api.ipify.org?format=json")!
|
||||
|
||||
private var lastFetch: Date?
|
||||
/// Mindestabstand zwischen zwei Abfragen. Die öffentliche Adresse ändert
|
||||
/// sich selten; sie bei jedem Öffnen des Popovers neu zu holen wäre
|
||||
/// unnötiger Verkehr nach draußen.
|
||||
private static let minimumInterval: TimeInterval = 15 * 60
|
||||
|
||||
public init() {}
|
||||
|
||||
public func refresh(force: Bool = false) {
|
||||
if !force, let last = lastFetch,
|
||||
Date().timeIntervalSince(last) < Self.minimumInterval { return }
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
failed = false
|
||||
Task { [weak self] in
|
||||
defer { Task { @MainActor in self?.isLoading = false } }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: Self.endpoint)
|
||||
let decoded = try JSONDecoder().decode([String: String].self, from: data)
|
||||
await MainActor.run {
|
||||
self?.address = decoded["ip"]
|
||||
self?.lastFetch = Date()
|
||||
}
|
||||
} catch {
|
||||
log.error("Öffentliche IP nicht abrufbar: \(error.localizedDescription, privacy: .public)")
|
||||
await MainActor.run { self?.failed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vergisst die Adresse — beim Abschalten der Funktion.
|
||||
public func clear() {
|
||||
address = nil
|
||||
lastFetch = nil
|
||||
failed = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import OnyxMenuBar
|
||||
import MetricsProvider
|
||||
|
||||
/// Das Netzwerk in der Menüleiste.
|
||||
///
|
||||
/// Anders als die Hardwaregrößen hat es **zwei** Werte, die beide interessieren.
|
||||
/// Sie untereinander zu setzen ist der einzige Weg, beide in der Höhe einer
|
||||
/// Menüleiste unterzubringen — nebeneinander bräuchte es die doppelte Breite,
|
||||
/// und die hat man dort nicht.
|
||||
@MainActor
|
||||
public final class NetworkMenuBarModule: MenuBarModule {
|
||||
|
||||
public let id = "network"
|
||||
public var displayName: String { String(localized: "network.name", bundle: .module) }
|
||||
|
||||
private let model: NetworkModel
|
||||
private var token: UUID?
|
||||
private var view: NetworkStatusView?
|
||||
|
||||
public init(model: NetworkModel) { self.model = model }
|
||||
|
||||
public var shortLabel: String { String(localized: "menubar.short.network", bundle: .module) }
|
||||
|
||||
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||
let view = NetworkStatusView(presentation: presentation)
|
||||
view.update(model.snapshot, history: model.series(download: true))
|
||||
self.view = view
|
||||
return view
|
||||
}
|
||||
|
||||
public func makePopoverView() -> AnyView {
|
||||
AnyView(NetworkPopoverContent(model: model))
|
||||
}
|
||||
|
||||
public func activate() {
|
||||
guard token == nil else { return }
|
||||
token = model.addConsumer(interval: 2)
|
||||
startObserving()
|
||||
}
|
||||
|
||||
public func deactivate() {
|
||||
if let token { model.removeConsumer(token) }
|
||||
token = nil
|
||||
view = nil
|
||||
}
|
||||
|
||||
private func startObserving() {
|
||||
withObservationTracking {
|
||||
_ = model.snapshot
|
||||
} onChange: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, token != nil else { return }
|
||||
view?.update(model.snapshot, history: model.series(download: true))
|
||||
startObserving()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class NetworkStatusView: NSView, WidthReporting {
|
||||
|
||||
private let presentation: MenuBarPresentation
|
||||
private var snapshot = NetworkSnapshot()
|
||||
private var history: [Double] = []
|
||||
|
||||
init(presentation: MenuBarPresentation) {
|
||||
self.presentation = presentation
|
||||
super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// Breiter als die Hardwaremodule: „↓ 2,4 MB/s" braucht schlicht mehr Platz
|
||||
/// als „46 %".
|
||||
/// Der Platz für die **aktuellen** Raten.
|
||||
///
|
||||
/// Auf „↓ 999,9 MB/s" zu dimensionieren hieße, neben „↓ 13 KB/s" dauerhaft
|
||||
/// die halbe Breite leer zu lassen — und genau so sah es aus. Gemessen wird
|
||||
/// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die
|
||||
/// Leiste dabei nicht zappelt.
|
||||
/// Feste Breite, bemessen am breitesten Wert, der vorkommen kann.
|
||||
///
|
||||
/// Nicht am aktuellen: aus „14 KB/s" wird „431 KB/s", und mit jeder
|
||||
/// zusätzlichen Stelle wanderte die halbe Menüleiste. „999 MB/s" kostet
|
||||
/// gegenüber einem dreistelligen KB-Wert nur wenige Punkte — das ist der
|
||||
/// Preis dafür, dass nie etwas springt.
|
||||
private static var ratesWidth: CGFloat {
|
||||
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
|
||||
let widest = NSAttributedString(string: "↓ 999 MB/s", attributes: [.font: font])
|
||||
return ceil(widest.size().width) + 2 * MenuBarText.sidePadding
|
||||
}
|
||||
|
||||
private func neededWidth() -> CGFloat {
|
||||
switch presentation {
|
||||
case .symbol:
|
||||
max(MenuBarText.minimumWidth,
|
||||
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4)
|
||||
case .graph, .bars: 28
|
||||
case .value: Self.ratesWidth
|
||||
case .valueAndGraph: Self.ratesWidth + 26
|
||||
// Die Raten tragen mit ↓ und ↑ ihre Beschriftung schon in sich; ein
|
||||
// Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich —
|
||||
// es zeigt WLAN, Ethernet oder VPN.
|
||||
case .labelAndValue, .symbolAndValue:
|
||||
Self.ratesWidth + symbolSlot + MenuBarText.innerGap
|
||||
}
|
||||
}
|
||||
|
||||
private var symbolSlot: CGFloat {
|
||||
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 13)
|
||||
}
|
||||
|
||||
private var width: CGFloat = MenuBarText.minimumWidth
|
||||
var onWidthChange: ((CGFloat) -> Void)?
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: width, height: 22)
|
||||
}
|
||||
|
||||
func update(_ snapshot: NetworkSnapshot, history: [Double]) {
|
||||
self.snapshot = snapshot
|
||||
self.history = history
|
||||
needsDisplay = true
|
||||
|
||||
let next = max(neededWidth(), MenuBarText.minimumWidth)
|
||||
guard next != width else { return }
|
||||
width = next
|
||||
invalidateIntrinsicContentSize()
|
||||
onWidthChange?(next)
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
let color = NSColor.labelColor
|
||||
|
||||
switch presentation {
|
||||
case .symbol:
|
||||
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
|
||||
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
|
||||
width: 15, height: 15))
|
||||
|
||||
case .graph, .bars:
|
||||
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
|
||||
|
||||
case .labelAndValue, .symbolAndValue:
|
||||
let glyph = symbolSlot
|
||||
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
|
||||
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
||||
width: glyph, height: 13))
|
||||
drawRates(color: color,
|
||||
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
|
||||
width: bounds.width - glyph - MenuBarText.innerGap,
|
||||
height: bounds.height))
|
||||
|
||||
case .value, .valueAndGraph:
|
||||
drawRates(color: color,
|
||||
in: presentation == .value ? bounds
|
||||
: NSRect(x: 0, y: 0, width: bounds.width - 26, height: bounds.height))
|
||||
if presentation == .valueAndGraph {
|
||||
drawGraph(color: color,
|
||||
in: NSRect(x: bounds.width - 24, y: 5, width: 22, height: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwei Zeilen à 9 pt — die einzige Art, Hoch und Runter in 22 pt Höhe
|
||||
/// unterzubringen.
|
||||
private func drawRates(color: NSColor, in rect: NSRect) {
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
|
||||
.foregroundColor: color,
|
||||
]
|
||||
let down = NSAttributedString(string: "↓ " + Throughput.formatted(snapshot.downloadRate),
|
||||
attributes: attributes)
|
||||
let up = NSAttributedString(string: "↑ " + Throughput.formatted(snapshot.uploadRate),
|
||||
attributes: attributes)
|
||||
// Mittig statt links: bleibt doch einmal Platz übrig — etwa weil die
|
||||
// zweite Zeile schmaler ist —, verteilt er sich auf beide Seiten statt
|
||||
// als Lücke rechts zu stehen.
|
||||
down.draw(at: NSPoint(x: rect.midX - down.size().width / 2, y: rect.midY - 0.5))
|
||||
up.draw(at: NSPoint(x: rect.midX - up.size().width / 2, y: rect.midY - 10.5))
|
||||
}
|
||||
|
||||
private func drawGraph(color: NSColor, in rect: NSRect) {
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
|
||||
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
|
||||
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
|
||||
// Breite.
|
||||
let xs = GraphLayout.positions(count: history.count,
|
||||
capacity: NetworkModel.historyLength, in: rect)
|
||||
guard !xs.isEmpty else { return }
|
||||
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
|
||||
context.setLineWidth(1)
|
||||
for (index, x) in xs.enumerated() {
|
||||
let value = history[history.count - xs.count + index]
|
||||
let point = CGPoint(x: x,
|
||||
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
|
||||
index == 0 ? context.move(to: point) : context.addLine(to: point)
|
||||
}
|
||||
context.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
214
Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
Normal file
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import SystemConfiguration
|
||||
import CoreWLAN
|
||||
import MetricsProvider
|
||||
|
||||
public struct NetworkSnapshot: Equatable, Sendable {
|
||||
/// Byte pro Sekunde.
|
||||
public var downloadRate: Double = 0
|
||||
public var uploadRate: Double = 0
|
||||
/// Summen seit dem Systemstart.
|
||||
public var totalReceived: UInt64 = 0
|
||||
public var totalSent: UInt64 = 0
|
||||
|
||||
public var interfaceName: String?
|
||||
public var interfaceKind: InterfaceKind = .unknown
|
||||
public var ipv4: String?
|
||||
public var ipv6: String?
|
||||
public var router: String?
|
||||
|
||||
/// `nil`, solange die Ortungsberechtigung fehlt — macOS gibt den Namen
|
||||
/// sonst nicht heraus. Siehe `wifiName`.
|
||||
public var ssid: String?
|
||||
public var isWiFiConnected = false
|
||||
|
||||
public var asOf = Date()
|
||||
}
|
||||
|
||||
public enum InterfaceKind: String, Equatable, Sendable {
|
||||
case wifi, ethernet, vpn, cellular, loopback, unknown
|
||||
|
||||
public var symbolName: String {
|
||||
switch self {
|
||||
case .wifi: "wifi"
|
||||
case .ethernet: "cable.connector"
|
||||
case .vpn: "lock.shield"
|
||||
case .cellular: "antenna.radiowaves.left.and.right"
|
||||
case .loopback, .unknown: "network"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest Durchsatz, Adressen und Verbindungsart.
|
||||
///
|
||||
/// Alles ohne root und ohne private Schnittstellen. Der Durchsatz kommt aus den
|
||||
/// Byte-Zählern des Kernels; sie sind Summen seit dem Start, die Rate ergibt
|
||||
/// sich erst aus der Differenz — siehe `Throughput.rate`.
|
||||
public final class NetworkMetrics: @unchecked Sendable {
|
||||
|
||||
private var previousReceived: UInt64 = 0
|
||||
private var previousSent: UInt64 = 0
|
||||
private var previousSampleTime: Date?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func sample() -> NetworkSnapshot {
|
||||
var snapshot = NetworkSnapshot()
|
||||
|
||||
let counters = readCounters()
|
||||
snapshot.totalReceived = counters.received
|
||||
snapshot.totalSent = counters.sent
|
||||
|
||||
let now = Date()
|
||||
if let previous = previousSampleTime {
|
||||
let elapsed = now.timeIntervalSince(previous)
|
||||
snapshot.downloadRate = Throughput.rate(previous: previousReceived,
|
||||
current: counters.received, elapsed: elapsed)
|
||||
snapshot.uploadRate = Throughput.rate(previous: previousSent,
|
||||
current: counters.sent, elapsed: elapsed)
|
||||
}
|
||||
previousReceived = counters.received
|
||||
previousSent = counters.sent
|
||||
previousSampleTime = now
|
||||
|
||||
let primary = primaryInterface()
|
||||
snapshot.interfaceName = primary.name
|
||||
snapshot.interfaceKind = primary.kind
|
||||
snapshot.router = primary.router
|
||||
|
||||
let addresses = localAddresses(for: primary.name)
|
||||
snapshot.ipv4 = addresses.ipv4
|
||||
snapshot.ipv6 = addresses.ipv6
|
||||
|
||||
snapshot.isWiFiConnected = primary.kind == .wifi
|
||||
snapshot.ssid = wifiName()
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - Durchsatz
|
||||
|
||||
/// Summiert alle Interfaces außer Loopback.
|
||||
///
|
||||
/// Einzeln zu zählen wäre genauer, aber praktisch nutzlos: bei einem
|
||||
/// Wechsel von WLAN auf VPN wandert der Verkehr auf ein anderes Interface,
|
||||
/// und eine Anzeige, die dabei auf null fällt, ist falsch.
|
||||
private func readCounters() -> (received: UInt64, sent: UInt64) {
|
||||
var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0]
|
||||
var length = 0
|
||||
guard sysctl(&mib, 6, nil, &length, nil, 0) == 0, length > 0 else { return (0, 0) }
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: length)
|
||||
guard sysctl(&mib, 6, &buffer, &length, nil, 0) == 0 else { return (0, 0) }
|
||||
|
||||
var received: UInt64 = 0
|
||||
var sent: UInt64 = 0
|
||||
|
||||
buffer.withUnsafeBytes { raw in
|
||||
var offset = 0
|
||||
while offset < length {
|
||||
let header = raw.baseAddress!.advanced(by: offset)
|
||||
.assumingMemoryBound(to: if_msghdr.self).pointee
|
||||
guard header.ifm_msglen > 0 else { break }
|
||||
defer { offset += Int(header.ifm_msglen) }
|
||||
|
||||
guard header.ifm_type == RTM_IFINFO2 else { continue }
|
||||
let message = raw.baseAddress!.advanced(by: offset)
|
||||
.assumingMemoryBound(to: if_msghdr2.self).pointee
|
||||
|
||||
// Loopback ausschließen: der lokale Verkehr zwischen Programmen
|
||||
// hat mit „was geht über die Leitung" nichts zu tun und kann
|
||||
// ein Vielfaches davon sein.
|
||||
guard message.ifm_data.ifi_type != UInt8(IFT_LOOP) else { continue }
|
||||
|
||||
received += message.ifm_data.ifi_ibytes
|
||||
sent += message.ifm_data.ifi_obytes
|
||||
}
|
||||
}
|
||||
return (received, sent)
|
||||
}
|
||||
|
||||
// MARK: - Interface und Adressen
|
||||
|
||||
private struct Primary {
|
||||
var name: String?
|
||||
var kind: InterfaceKind = .unknown
|
||||
var router: String?
|
||||
}
|
||||
|
||||
private func primaryInterface() -> Primary {
|
||||
var primary = Primary()
|
||||
guard let store = SCDynamicStoreCreate(nil, "Onyx" as CFString, nil, nil),
|
||||
let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString)
|
||||
as? [String: Any] else { return primary }
|
||||
|
||||
primary.name = global["PrimaryInterface"] as? String
|
||||
primary.router = global["Router"] as? String
|
||||
primary.kind = kind(of: primary.name)
|
||||
return primary
|
||||
}
|
||||
|
||||
private func kind(of name: String?) -> InterfaceKind {
|
||||
guard let name else { return .unknown }
|
||||
// Die Namensschemata sind stabil: en0/en1 sind Ethernet oder WLAN,
|
||||
// utun/ipsec/ppp gehören zu VPNs, lo0 ist die Loopback-Schnittstelle.
|
||||
if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn }
|
||||
if name.hasPrefix("lo") { return .loopback }
|
||||
if name.hasPrefix("pdp_ip") { return .cellular }
|
||||
if name.hasPrefix("en") {
|
||||
// en0 ist auf Notebooks das WLAN, aber nicht zwingend — CoreWLAN
|
||||
// weiß es genau.
|
||||
let wifiNames = CWWiFiClient.interfaceNames() ?? []
|
||||
return wifiNames.contains(name) ? .wifi : .ethernet
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
private func localAddresses(for interface: String?) -> (ipv4: String?, ipv6: String?) {
|
||||
guard let interface else { return (nil, nil) }
|
||||
|
||||
var pointer: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&pointer) == 0, let first = pointer else { return (nil, nil) }
|
||||
defer { freeifaddrs(pointer) }
|
||||
|
||||
var ipv4: String?
|
||||
var ipv6: String?
|
||||
|
||||
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
guard String(cString: entry.pointee.ifa_name) == interface,
|
||||
let address = entry.pointee.ifa_addr else { continue }
|
||||
|
||||
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
guard getnameinfo(address, socklen_t(address.pointee.sa_len),
|
||||
&host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0
|
||||
else { continue }
|
||||
|
||||
let text = String(cString: host)
|
||||
switch Int32(address.pointee.sa_family) {
|
||||
case AF_INET where ipv4 == nil:
|
||||
ipv4 = text
|
||||
case AF_INET6 where ipv6 == nil:
|
||||
// Link-lokale Adressen (fe80::) tragen einen Zonen-Anhang und
|
||||
// sagen dem Nutzer nichts.
|
||||
if !text.hasPrefix("fe80") { ipv6 = text }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return (ipv4, ipv6)
|
||||
}
|
||||
|
||||
// MARK: - WLAN
|
||||
|
||||
/// Der Netzwerkname — oder `nil`, wenn macOS ihn nicht herausgibt.
|
||||
///
|
||||
/// Seit macOS 14 liefert `ssid()` nur mit erteilter Ortungsberechtigung
|
||||
/// einen Wert. Fehlt sie, kommt `nil` zurück, **ohne** dass ein Fehler
|
||||
/// erscheint. Deshalb wird hier nicht geraten: die Oberfläche zeigt dann
|
||||
/// „WLAN verbunden" statt eines leeren Feldes, das nach einem Defekt
|
||||
/// aussieht.
|
||||
private func wifiName() -> String? {
|
||||
CWWiFiClient.shared().interface()?.ssid()
|
||||
}
|
||||
}
|
||||
203
Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
import MetricsProvider
|
||||
import OnyxDesign
|
||||
import OnyxMenuBar
|
||||
|
||||
@MainActor
|
||||
struct NetworkPopoverContent: View {
|
||||
let model: NetworkModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top) {
|
||||
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
|
||||
Spacer()
|
||||
rate(model.snapshot.uploadRate, "network.upload", Onyx.Color.upload)
|
||||
}
|
||||
|
||||
TrafficChart(history: model.history)
|
||||
.frame(height: 56)
|
||||
|
||||
HStack {
|
||||
peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download")
|
||||
Spacer()
|
||||
peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName)
|
||||
.font(.callout)
|
||||
|
||||
if model.snapshot.interfaceKind == .vpn {
|
||||
// Ein VPN verändert, was die Zahlen darüber bedeuten — der
|
||||
// Verkehr läuft dann durch den Tunnel, nicht direkt.
|
||||
// Über Text statt Label(_:systemImage:): letzteres kennt kein
|
||||
// Bundle und zeigt sonst den rohen Schlüssel an.
|
||||
Label {
|
||||
Text("network.vpn.active", bundle: .module)
|
||||
} icon: {
|
||||
Image(systemName: "lock.shield")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
section("network.addresses")
|
||||
field("network.field.interface", model.snapshot.interfaceName)
|
||||
field("network.field.ipv4", model.snapshot.ipv4)
|
||||
field("network.field.ipv6", model.snapshot.ipv6)
|
||||
field("network.field.router", model.snapshot.router)
|
||||
|
||||
section("network.transferred")
|
||||
field("network.total.received", bytes(model.snapshot.totalReceived))
|
||||
field("network.total.sent", bytes(model.snapshot.totalSent))
|
||||
|
||||
section("network.public")
|
||||
if model.showsPublicAddress {
|
||||
HStack {
|
||||
if model.publicAddress.isLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let address = model.publicAddress.address {
|
||||
Text(address).font(.callout.monospacedDigit()).textSelection(.enabled)
|
||||
} else if model.publicAddress.failed {
|
||||
Text("network.public.failed", bundle: .module)
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { model.publicAddress.refresh(force: true) } label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
// Wer die Adresse erfährt, gehört sichtbar dazu.
|
||||
Text(verbatim: PublicAddressLookup.serviceName)
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
} else {
|
||||
Toggle(isOn: Binding(get: { model.showsPublicAddress },
|
||||
set: { model.showsPublicAddress = $0 })) {
|
||||
Text("network.public.enable", bundle: .module).font(.callout)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
Text("network.public.hint", bundle: .module)
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if !model.processes.isEmpty {
|
||||
section("network.processes")
|
||||
ForEach(model.processes) { process in
|
||||
HStack {
|
||||
Text(process.name).font(.callout).lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(Throughput.formatted(process.downloadRate))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(Onyx.Color.download)
|
||||
Text(Throughput.formatted(process.uploadRate))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(Onyx.Color.upload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 300)
|
||||
.onAppear { model.addDetailConsumer() }
|
||||
.onDisappear { model.removeDetailConsumer() }
|
||||
}
|
||||
|
||||
private var connectionName: String {
|
||||
if let ssid = model.snapshot.ssid { return ssid }
|
||||
if model.snapshot.isWiFiConnected {
|
||||
return String(localized: "network.wifi.connected", bundle: .module)
|
||||
}
|
||||
return model.snapshot.interfaceName
|
||||
?? String(localized: "network.offline", bundle: .module)
|
||||
}
|
||||
|
||||
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(Throughput.formatted(value))
|
||||
.font(.system(size: 18, weight: .medium).monospacedDigit())
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(tint).frame(width: 6, height: 6)
|
||||
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
|
||||
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
|
||||
}
|
||||
}
|
||||
|
||||
private func section(_ key: LocalizedStringKey) -> some View {
|
||||
Text(key, bundle: .module)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
|
||||
if let value {
|
||||
HStack {
|
||||
Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary)
|
||||
Spacer(minLength: 12)
|
||||
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bytes(_ value: UInt64) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verlauf in beide Richtungen: Download nach unten, Upload nach oben.
|
||||
///
|
||||
/// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu
|
||||
/// lesen — hier sieht man sofort, ob gerade geladen oder gesendet wird, und in
|
||||
/// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst
|
||||
/// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s.
|
||||
private struct TrafficChart: View {
|
||||
let history: [NetworkSnapshot]
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let points = history.suffix(NetworkModel.historyLength)
|
||||
let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1)
|
||||
// Die Balkenbreite kommt aus der **vollen** Länge des Verlaufs.
|
||||
// Vorher war sie `Breite / Anzahl`: bei fünf Messwerten also ein
|
||||
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
|
||||
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
|
||||
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
|
||||
totalWidth: geometry.size.width, spacing: 1)
|
||||
let half = geometry.size.height / 2
|
||||
|
||||
HStack(alignment: .center, spacing: 1) {
|
||||
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
|
||||
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
|
||||
Spacer(minLength: 0)
|
||||
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
|
||||
VStack(spacing: 1) {
|
||||
Rectangle()
|
||||
.fill(Onyx.Color.upload)
|
||||
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
Rectangle()
|
||||
.fill(Onyx.Color.download)
|
||||
.frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(width: width)
|
||||
}
|
||||
}
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
238
Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
Normal file
@@ -0,0 +1,238 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import OnyxWidgetKit
|
||||
import MetricsProvider
|
||||
|
||||
/// Wie `MetricsModel`, aber fürs Netz: eine Messschleife für Panel und
|
||||
/// Menüleiste, mit Referenzzählung.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class NetworkModel {
|
||||
|
||||
public private(set) var snapshot = NetworkSnapshot()
|
||||
public private(set) var history: [NetworkSnapshot] = []
|
||||
|
||||
public static let historyLength = 60
|
||||
|
||||
/// Durchsatz je Programm — nur gefüllt, solange ein Popover offen ist.
|
||||
public private(set) var processes: [ProcessTraffic] = []
|
||||
/// Die öffentliche Adresse, wenn eingeschaltet.
|
||||
public let publicAddress = PublicAddressLookup()
|
||||
|
||||
/// Ob die öffentliche Adresse überhaupt abgefragt werden darf.
|
||||
public var showsPublicAddress: Bool {
|
||||
didSet {
|
||||
guard showsPublicAddress != oldValue else { return }
|
||||
defaults.set(showsPublicAddress, forKey: Self.publicAddressKey)
|
||||
showsPublicAddress ? publicAddress.refresh() : publicAddress.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private static let publicAddressKey = "onyx.network.showsPublicAddress"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let source = NetworkMetrics()
|
||||
private let processSampler = ProcessTrafficSampler()
|
||||
private var timer: Timer?
|
||||
private var demands: [UUID: TimeInterval] = [:]
|
||||
/// Zählt die offenen Popovers. Die Prozessliste kostet einen Fremdprozess
|
||||
/// je Messung — sie läuft nur, während wirklich jemand hinsieht.
|
||||
private var detailConsumers = 0
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.showsPublicAddress = defaults.bool(forKey: Self.publicAddressKey)
|
||||
}
|
||||
|
||||
/// Meldet ein offenes Popover an. Erst dann werden Prozesse abgefragt.
|
||||
public func addDetailConsumer() {
|
||||
detailConsumers += 1
|
||||
if showsPublicAddress { publicAddress.refresh() }
|
||||
sampleProcesses()
|
||||
}
|
||||
|
||||
public func removeDetailConsumer() {
|
||||
detailConsumers = max(detailConsumers - 1, 0)
|
||||
if detailConsumers == 0 { processes = [] }
|
||||
}
|
||||
|
||||
private func sampleProcesses() {
|
||||
guard detailConsumers > 0 else { return }
|
||||
// `nettop` blockiert kurz — nicht auf dem Hauptthread.
|
||||
let sampler = processSampler
|
||||
Task.detached(priority: .utility) {
|
||||
let result = sampler.sample()
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, detailConsumers > 0 else { return }
|
||||
processes = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func addConsumer(interval: TimeInterval = 2) -> UUID {
|
||||
let token = UUID()
|
||||
demands[token] = min(max(interval, 1), 10)
|
||||
restartTimer()
|
||||
return token
|
||||
}
|
||||
|
||||
public func removeConsumer(_ token: UUID) {
|
||||
demands[token] = nil
|
||||
restartTimer()
|
||||
}
|
||||
|
||||
private func restartTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
guard let interval = demands.values.min() else {
|
||||
history.removeAll()
|
||||
return
|
||||
}
|
||||
sample()
|
||||
let timer = Timer(timeInterval: interval, repeats: true) { _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.sample() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
private func sample() {
|
||||
let value = source.sample()
|
||||
snapshot = value
|
||||
sampleProcesses()
|
||||
history.append(value)
|
||||
if history.count > Self.historyLength {
|
||||
history.removeFirst(history.count - Self.historyLength)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verläufe, auf den bisherigen Höchstwert normiert.
|
||||
///
|
||||
/// Eine feste Obergrenze wäre unbrauchbar: bei 100 Mbit/s wäre eine
|
||||
/// Video-Wiedergabe ein flacher Strich, und bei 1 Mbit/s ginge jeder
|
||||
/// Download über den Rand. Die Kurve zeigt deshalb den Verlauf relativ zum
|
||||
/// bisherigen Maximum — die absoluten Zahlen stehen daneben.
|
||||
public func series(download: Bool) -> [Double] {
|
||||
let values = history.map { download ? $0.downloadRate : $0.uploadRate }
|
||||
guard let peak = values.max(), peak > 0 else { return values.map { _ in 0 } }
|
||||
return values.map { $0 / peak }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
public struct NetworkWidget: OnyxWidget {
|
||||
public let id = "network"
|
||||
public var displayName: String { String(localized: "network.name", bundle: .module) }
|
||||
public let symbolName = "network"
|
||||
|
||||
private let model: NetworkModel
|
||||
|
||||
public init(model: NetworkModel) { self.model = model }
|
||||
|
||||
public func makeView() -> AnyView {
|
||||
AnyView(NetworkWidgetView(model: model))
|
||||
}
|
||||
}
|
||||
|
||||
private struct NetworkWidgetView: View {
|
||||
let model: NetworkModel
|
||||
@State private var token: UUID?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: model.snapshot.interfaceKind.symbolName)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Onyx.Color.download)
|
||||
Text(connectionName)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Rates(snapshot: model.snapshot, compact: false)
|
||||
|
||||
Sparkline(values: model.series(download: true), tint: Onyx.Color.download,
|
||||
capacity: NetworkModel.historyLength)
|
||||
.frame(height: 18)
|
||||
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
Details(snapshot: model.snapshot)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear { token = model.addConsumer(interval: 1) }
|
||||
.onDisappear {
|
||||
if let token { model.removeConsumer(token) }
|
||||
token = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Netzwerkname, wenn macOS ihn herausgibt — sonst die Verbindungsart.
|
||||
///
|
||||
/// Seit macOS 14 liefert die SSID nur mit Ortungsberechtigung einen Wert.
|
||||
/// Fehlt sie, wäre ein leeres Feld eine Falschaussage: verbunden ist man ja.
|
||||
private var connectionName: String {
|
||||
if let ssid = model.snapshot.ssid { return ssid }
|
||||
if model.snapshot.isWiFiConnected {
|
||||
return String(localized: "network.wifi.connected", bundle: .module)
|
||||
}
|
||||
return model.snapshot.interfaceName ?? String(localized: "network.offline", bundle: .module)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Rates: View {
|
||||
let snapshot: NetworkSnapshot
|
||||
let compact: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download)
|
||||
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
Text(text)
|
||||
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Details: View {
|
||||
let snapshot: NetworkSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
field("network.field.interface", snapshot.interfaceName)
|
||||
field("network.field.ipv4", snapshot.ipv4)
|
||||
field("network.field.router", snapshot.router)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
|
||||
if let value {
|
||||
HStack(spacing: 4) {
|
||||
Text(label, bundle: .module)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text(value)
|
||||
.font(.system(size: 9))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"singleInstance.blocked.message" : {
|
||||
"comment" : "Titel des Dialogs, wenn eine zweite Instanz zurücktritt",
|
||||
"common.ok": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value" : "Onyx läuft bereits"
|
||||
"value": "OK"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value" : "Onyx is already running"
|
||||
"value": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,18 +34,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"common.ok" : {
|
||||
"singleInstance.blocked.message": {
|
||||
"comment": "Titel des Dialogs, wenn eine zweite Instanz zurücktritt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value" : "OK"
|
||||
"value": "Onyx läuft bereits"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value" : "OK"
|
||||
"value": "Onyx is already running"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
Packages/OnyxKit/Sources/OnyxCore/LoginItemState.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Der Zustand des Anmeldeobjekts — die Werte von `SMAppService.Status`,
|
||||
/// aber ohne ServiceManagement, damit sie prüfbar bleiben.
|
||||
public enum LoginItemState: Equatable, Sendable {
|
||||
/// Nicht eingetragen. Ein Aufruf von `register()` behebt es.
|
||||
case notRegistered
|
||||
case enabled
|
||||
/// Eingetragen, aber vom Nutzer in den Systemeinstellungen abgeschaltet.
|
||||
case requiresApproval
|
||||
/// Die App findet sich selbst nicht — ein Installationsproblem.
|
||||
case notFound
|
||||
case unknown
|
||||
|
||||
public init(statusRawValue: Int) {
|
||||
switch statusRawValue {
|
||||
case 0: self = .notRegistered
|
||||
case 1: self = .enabled
|
||||
case 2: self = .requiresApproval
|
||||
case 3: self = .notFound
|
||||
default: self = .unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Startet Onyx mit? Im Zweifel nein — lieber einen überflüssigen Klick
|
||||
/// verlangen als behaupten, es sei eingeschaltet, und es startet nicht.
|
||||
public var isEnabled: Bool { self == .enabled }
|
||||
|
||||
/// Ob der Weg über die Systemeinstellungen führt.
|
||||
///
|
||||
/// Das ist der Zustand, den man nicht verschweigen darf: wer das
|
||||
/// Anmeldeobjekt dort abgeschaltet hat, kann in Onyx auf den Schalter
|
||||
/// drücken, so oft er will — erneutes Registrieren ändert daran nichts.
|
||||
public var needsSystemSettings: Bool { self == .requiresApproval }
|
||||
}
|
||||
30
Packages/OnyxKit/Sources/OnyxDesign/OnyxLabelStyle.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Färbt das Symbol eines `Label` in der eingestellten Akzentfarbe.
|
||||
///
|
||||
/// **Nötig, weil `.tint` hier nicht greift.** SwiftUI zeichnet Symbole in
|
||||
/// Listen und Formularen auf macOS in der Akzentfarbe des *Systems* — nicht in
|
||||
/// der des Fensters. Das Ergebnis war eine Widget-Liste in Systemgrün neben
|
||||
/// einer Reiterleiste in Onyx-Blau, obwohl beides derselben Einstellung folgen
|
||||
/// soll.
|
||||
///
|
||||
/// Ein Stil statt einer Farbe an jeder Fundstelle: er vererbt sich über die
|
||||
/// Umgebung, also genügt eine Zeile an der Wurzel. Ein Symbol, das seine Farbe
|
||||
/// selbst mitbringt — etwa der grüne Haken für „erteilt" —, behält sie: die
|
||||
/// innere Angabe schlägt die äußere.
|
||||
public struct OnyxAccentLabelStyle: LabelStyle {
|
||||
|
||||
public init() {}
|
||||
|
||||
public func makeBody(configuration: Configuration) -> some View {
|
||||
Label {
|
||||
configuration.title
|
||||
} icon: {
|
||||
configuration.icon.foregroundStyle(Onyx.Color.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension LabelStyle where Self == OnyxAccentLabelStyle {
|
||||
static var onyx: OnyxAccentLabelStyle { OnyxAccentLabelStyle() }
|
||||
}
|
||||
106
Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Die einstellbaren Farben.
|
||||
///
|
||||
/// **Nicht alle Farben sind einstellbar, und das ist Absicht.** Gelb heißt
|
||||
/// Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
|
||||
/// Lüfter und Fehlermeldungen. Wer sie umstellen kann, kann ihre Bedeutung
|
||||
/// zerstören; ein rotes „alles gut" liest niemand richtig. Einstellbar ist
|
||||
/// deshalb die Akzentfarbe, mit der Onyx sich selbst zeichnet, und das Paar für
|
||||
/// Herunter- und Hochladen — dort ist die Zuordnung willkürlich und nur eine
|
||||
/// Gewohnheit.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class OnyxTheme {
|
||||
|
||||
/// Die Instanz, aus der `Onyx.Color` liest.
|
||||
public static let shared = OnyxTheme()
|
||||
|
||||
/// Kühl und entsättigt — Farbe ist in Onyx ein Signal, keine Dekoration.
|
||||
public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0)
|
||||
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
|
||||
/// aber eine verbreitete. Wer sie anders kennt, dreht sie um.
|
||||
public static let defaultDownload = Color(red: 0.435, green: 0.812, blue: 0.592)
|
||||
public static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612)
|
||||
|
||||
public var accent: Color {
|
||||
didSet { store(accent, as: Keys.accent) }
|
||||
}
|
||||
public var download: Color {
|
||||
didSet { store(download, as: Keys.download) }
|
||||
}
|
||||
public var upload: Color {
|
||||
didSet { store(upload, as: Keys.upload) }
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
private enum Keys {
|
||||
static let accent = "onyx.theme.accent"
|
||||
static let download = "onyx.theme.download"
|
||||
static let upload = "onyx.theme.upload"
|
||||
}
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
|
||||
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
|
||||
upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload
|
||||
}
|
||||
|
||||
/// Ob alles auf den Werkseinstellungen steht — für den Zurücksetzen-Knopf,
|
||||
/// der sonst auch dann anböte, etwas zu tun, wenn es nichts zu tun gibt.
|
||||
public var isDefault: Bool {
|
||||
accent.onyxHex == Self.defaultAccent.onyxHex
|
||||
&& download.onyxHex == Self.defaultDownload.onyxHex
|
||||
&& upload.onyxHex == Self.defaultUpload.onyxHex
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
accent = Self.defaultAccent
|
||||
download = Self.defaultDownload
|
||||
upload = Self.defaultUpload
|
||||
}
|
||||
|
||||
/// Übernimmt die Akzentfarbe des Systems.
|
||||
///
|
||||
/// Genau die stand hier vorher an vielen Stellen — unfreiwillig, weil
|
||||
/// `.tint` und `.accentColor` ohne eigene Angabe dorthin durchgereicht
|
||||
/// werden. Wem das gefiel, soll es behalten können.
|
||||
public func adoptSystemAccent() {
|
||||
accent = Color(nsColor: .controlAccentColor)
|
||||
}
|
||||
|
||||
private func store(_ color: Color, as key: String) {
|
||||
defaults.set(color.onyxHex, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
public extension Color {
|
||||
|
||||
/// `#RRGGBB`, immer in sRGB.
|
||||
///
|
||||
/// Der Umweg über `NSColor` ist nötig, weil eine `Color` aus dem Farbwähler
|
||||
/// in einem beliebigen Farbraum liegen kann — ohne Umrechnung ergäbe die
|
||||
/// Zerlegung in Komponenten je nach Bildschirm etwas anderes.
|
||||
var onyxHex: String {
|
||||
guard let srgb = NSColor(self).usingColorSpace(.sRGB) else { return "#7FA8FF" }
|
||||
let red = Int((srgb.redComponent * 255).rounded())
|
||||
let green = Int((srgb.greenComponent * 255).rounded())
|
||||
let blue = Int((srgb.blueComponent * 255).rounded())
|
||||
return String(format: "#%02X%02X%02X", red, green, blue)
|
||||
}
|
||||
|
||||
/// Liest `#RRGGBB` und `RRGGBB`.
|
||||
init?(onyxHex: String?) {
|
||||
guard let onyxHex else { return nil }
|
||||
var text = onyxHex.trimmingCharacters(in: .whitespaces)
|
||||
if text.hasPrefix("#") { text.removeFirst() }
|
||||
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
|
||||
self.init(.sRGB,
|
||||
red: Double((value >> 16) & 0xFF) / 255,
|
||||
green: Double((value >> 8) & 0xFF) / 255,
|
||||
blue: Double(value & 0xFF) / 255)
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,22 @@ public enum Onyx {
|
||||
public static let textSecondary = SwiftUI.Color.white.opacity(0.58)
|
||||
public static let textTertiary = SwiftUI.Color.white.opacity(0.34)
|
||||
|
||||
/// Kühl und entsättigt. Farbe ist in Onyx ein Signal, keine Dekoration.
|
||||
public static let accent = SwiftUI.Color(red: 0.498, green: 0.659, blue: 1.0)
|
||||
/// Die Farbe, mit der Onyx sich selbst zeichnet — einstellbar.
|
||||
///
|
||||
/// 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 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)
|
||||
|
||||
@@ -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 OnyxDesign
|
||||
import SwiftUI
|
||||
|
||||
/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul.
|
||||
@@ -9,6 +10,9 @@ import SwiftUI
|
||||
@MainActor
|
||||
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.
|
||||
///
|
||||
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
|
||||
@@ -42,6 +46,7 @@ public final class MenuBarController {
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
closeOpenPopover()
|
||||
visibilityTimer?.invalidate()
|
||||
visibilityTimer = nil
|
||||
for id in entries.keys { setEnabled(false, forModuleID: id) }
|
||||
@@ -78,6 +83,12 @@ public final class MenuBarController {
|
||||
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? {
|
||||
entries[id]?.settings
|
||||
}
|
||||
@@ -100,7 +111,14 @@ public final class MenuBarController {
|
||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
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()
|
||||
} else {
|
||||
entry.module.deactivate()
|
||||
@@ -108,20 +126,98 @@ public final class MenuBarController {
|
||||
checkVisibility()
|
||||
}
|
||||
|
||||
private func applyWidth(_ width: CGFloat, to entry: Entry) {
|
||||
entry.item.length = width > 0 ? width : NSStatusItem.variableLength
|
||||
}
|
||||
|
||||
// MARK: - Interaktion
|
||||
|
||||
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
||||
guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return }
|
||||
|
||||
if entry.popover.isShown {
|
||||
entry.popover.performClose(nil)
|
||||
// Immer nur eines offen. Zwei Popover nebeneinander sehen aus wie ein
|
||||
// Fehler, und das zweite verdeckt oft das erste.
|
||||
if openPopoverID == id {
|
||||
closeOpenPopover()
|
||||
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(
|
||||
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.module.popoverDidOpen()
|
||||
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
|
||||
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() {
|
||||
@@ -136,3 +232,21 @@ public final class MenuBarController {
|
||||
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
|
||||
/// Nur ein Glyph, nach Grenzwert eingefärbt.
|
||||
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 }
|
||||
}
|
||||
@@ -25,6 +32,9 @@ public protocol MenuBarModule: AnyObject {
|
||||
var id: 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
|
||||
/// liefern, sonst springt die ganze Menüleiste bei jedem Messwert.
|
||||
func makeStatusView(presentation: MenuBarPresentation) -> NSView
|
||||
@@ -32,6 +42,17 @@ public protocol MenuBarModule: AnyObject {
|
||||
/// Der Inhalt des Popovers beim Klick.
|
||||
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
|
||||
/// meldet sich das Modul beim Provider an und wieder ab.
|
||||
func activate()
|
||||
@@ -47,7 +68,7 @@ public struct MenuBarModuleSettings: Codable, Sendable, Equatable {
|
||||
public var usesThresholdColors: Bool
|
||||
|
||||
public init(isEnabled: Bool = false,
|
||||
presentation: MenuBarPresentation = .value,
|
||||
presentation: MenuBarPresentation = .symbolAndValue,
|
||||
refreshInterval: TimeInterval = 2,
|
||||
usesThresholdColors: Bool = true) {
|
||||
self.isEnabled = isEnabled
|
||||
@@ -62,3 +83,9 @@ extension TimeInterval {
|
||||
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 OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch")
|
||||
|
||||
/// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt
|
||||
/// alle mit der Zeigerposition.
|
||||
@@ -17,6 +20,14 @@ import AppKit
|
||||
@MainActor
|
||||
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 {
|
||||
didSet { guard policy != oldValue else { return }; rebuild() }
|
||||
}
|
||||
@@ -30,6 +41,7 @@ public final class NotchCoordinator {
|
||||
private var suppressionTimer: Timer?
|
||||
/// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`.
|
||||
private var pointerPollTimer: Timer?
|
||||
private var pollInterval: TimeInterval = 0
|
||||
|
||||
/// 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
|
||||
@@ -37,9 +49,11 @@ public final class NotchCoordinator {
|
||||
private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView
|
||||
|
||||
public init(policy: DisplayPolicy = .builtInOnly,
|
||||
trigger: TriggerArea = .standard,
|
||||
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
|
||||
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
|
||||
self.policy = policy
|
||||
self.trigger = trigger
|
||||
self.virtualNotchSize = virtualNotchSize
|
||||
self.makeContent = makeContent
|
||||
}
|
||||
@@ -49,6 +63,9 @@ public final class NotchCoordinator {
|
||||
public func start() {
|
||||
rebuild()
|
||||
installMonitors()
|
||||
// Sofort anlaufen lassen, nicht erst wenn etwas sichtbar wird: die
|
||||
// Abfrage ist es, die überhaupt etwas sichtbar macht.
|
||||
updatePolling()
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(screensChanged),
|
||||
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||
@@ -80,6 +97,7 @@ public final class NotchCoordinator {
|
||||
private func rebuild() {
|
||||
let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics))
|
||||
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) {
|
||||
controller.tearDown()
|
||||
@@ -96,6 +114,7 @@ public final class NotchCoordinator {
|
||||
let controller = NotchScreenController(
|
||||
metrics: metrics,
|
||||
virtualSize: virtualNotchSize,
|
||||
trigger: trigger,
|
||||
makeContent: { presentation in makeContent(id, presentation) })
|
||||
controller.onVisibilityChanged = { [weak self] _ in
|
||||
self?.updatePolling()
|
||||
@@ -122,18 +141,19 @@ public final class NotchCoordinator {
|
||||
return event
|
||||
}) { 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
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self else { return }
|
||||
let point = NSEvent.mouseLocation
|
||||
for controller in controllers.values
|
||||
where controller.isVisible && controller.geometry.triggerRect.contains(point) {
|
||||
controller.click()
|
||||
}
|
||||
}
|
||||
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
|
||||
return event
|
||||
}) { 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
|
||||
guard event.keyCode == 53 else { return event } // esc
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
@@ -143,6 +163,20 @@ public final class NotchCoordinator {
|
||||
}) { 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() {
|
||||
let point = NSEvent.mouseLocation
|
||||
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
|
||||
/// 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.
|
||||
/// **Warum überhaupt abgefragt wird und nicht nur zugehört:**
|
||||
///
|
||||
/// Abfragen ist hier das ehrlichere Mittel als noch mehr Monitore: es läuft
|
||||
/// nur, während wirklich etwas offen ist, und kostet dabei einen
|
||||
/// Rechteckvergleich pro Bild.
|
||||
/// Über dem eigenen Panel bekommt Onyx über Ereignisse gar nichts mit: der
|
||||
/// globale Monitor feuert nur für Ereignisse an **fremde** Programme, der
|
||||
/// 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() {
|
||||
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 {
|
||||
let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { _ in
|
||||
pointerPollTimer?.invalidate()
|
||||
let timer = Timer(timeInterval: interval, repeats: true) { _ in
|
||||
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
|
||||
}
|
||||
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() {
|
||||
// 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 {
|
||||
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
|
||||
|
||||
@@ -70,11 +70,22 @@ public struct NotchGeometry: Equatable, Sendable {
|
||||
public let rect: CGRect
|
||||
public let screenFrame: CGRect
|
||||
|
||||
/// Wie weit die Auslösefläche über die Notch hinausreicht. Ohne diesen Rand
|
||||
/// muss man die Notch pixelgenau treffen, und die unteren Bildpunkte der
|
||||
/// Notch selbst nimmt die Menüleiste bereits für sich in Anspruch.
|
||||
/// Wie weit die Auslösefläche über die Notch hinausreicht — einstellbar,
|
||||
/// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
|
||||
public var trigger: TriggerArea = .standard
|
||||
|
||||
/// Nur noch für Bestandscode: die frühere feste Zugabe.
|
||||
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) {
|
||||
self.kind = kind
|
||||
@@ -82,7 +93,8 @@ public struct NotchGeometry: Equatable, Sendable {
|
||||
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
|
||||
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
|
||||
// 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,
|
||||
width: width,
|
||||
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,
|
||||
width: width,
|
||||
height: virtualSize.height),
|
||||
screenFrame: metrics.frame)
|
||||
screenFrame: metrics.frame).with(trigger: trigger)
|
||||
}
|
||||
|
||||
/// Die Fläche, deren Betreten das Panel scharf schaltet.
|
||||
public var triggerRect: CGRect {
|
||||
rect
|
||||
.insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY)
|
||||
.clamped(to: screenFrame)
|
||||
trigger.rect(notch: rect, screen: screenFrame)
|
||||
}
|
||||
|
||||
/// Der Bereich, in dem Inhalt tatsächlich zu sehen ist.
|
||||
|
||||
@@ -28,12 +28,75 @@ public final class NotchPresentation {
|
||||
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.
|
||||
/// 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.
|
||||
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.
|
||||
///
|
||||
/// 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 {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
let shape = NotchShape(cornerRadius: cornerRadius,
|
||||
topBandHeight: presentation.contentTopInset,
|
||||
notchWidth: presentation.notchWidth)
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
// 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)],
|
||||
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)
|
||||
// Konstante Deckkraft: einen Schatten aufzublenden kostet in
|
||||
// jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
|
||||
@@ -159,6 +224,17 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
|
||||
width: size.width,
|
||||
height: size.height)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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.
|
||||
///
|
||||
@@ -35,12 +38,23 @@ public final class NotchScreenController {
|
||||
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,
|
||||
virtualSize: CGSize,
|
||||
trigger: TriggerArea = .standard,
|
||||
contentSize: CGSize = CGSize(width: 640, height: 320),
|
||||
makeContent: (NotchPresentation) -> NSView) {
|
||||
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.contentSize = contentSize
|
||||
self.presentation = NotchPresentation(
|
||||
@@ -63,23 +77,45 @@ public final class NotchScreenController {
|
||||
// MARK: - Eingaben
|
||||
|
||||
/// 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) {
|
||||
lastPoint = point
|
||||
// Nur die tatsächlich sichtbare Fläche zählt. Das Fenster ist immer so
|
||||
// groß wie das ausgefahrene Panel, damit die Animation Platz hat —
|
||||
// würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren
|
||||
// Fläche das Panel offen.
|
||||
let inside = geometry.triggerRect.contains(point)
|
||||
|| (presentation.isExpanded && panel.frame.contains(point))
|
||||
let inside = geometry.triggerRect.contains(point) || containsVisiblePoint(point)
|
||||
send(inside ? .pointerEntered : .pointerExited)
|
||||
}
|
||||
|
||||
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 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.
|
||||
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
|
||||
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
|
||||
// (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.
|
||||
private func send(_ input: NotchInput) {
|
||||
let wasVisible = machine.isVisible
|
||||
let before = machine.phase
|
||||
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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ public enum NotchInput: Equatable, Sendable {
|
||||
case pointerEntered
|
||||
case pointerExited
|
||||
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 armTimerFired
|
||||
case closeTimerFired
|
||||
@@ -59,6 +65,16 @@ public struct NotchStateMachine: Equatable, Sendable {
|
||||
public private(set) var phase: NotchPhase = .idle
|
||||
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() {}
|
||||
|
||||
/// Sichtbar, egal aus welchem Grund.
|
||||
@@ -88,19 +104,54 @@ public struct NotchStateMachine: Equatable, Sendable {
|
||||
|
||||
guard !isSuppressed else { return [] }
|
||||
|
||||
switch input {
|
||||
case .pointerEntered: isPointerInside = true
|
||||
case .pointerExited: isPointerInside = false
|
||||
default: break
|
||||
}
|
||||
|
||||
switch (phase, input) {
|
||||
|
||||
// Öffnen
|
||||
case (.idle, .pointerEntered):
|
||||
phase = .arming
|
||||
debounceElapsed = false
|
||||
return [.startArmTimer(Self.armDelay)]
|
||||
|
||||
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
|
||||
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):
|
||||
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
|
||||
debounceElapsed = false
|
||||
return [.cancelArmTimer]
|
||||
|
||||
// Schließen
|
||||
@@ -128,9 +179,15 @@ public struct NotchStateMachine: Equatable, Sendable {
|
||||
return []
|
||||
|
||||
// Beenden
|
||||
case (.open, .escape), (.pinned, .escape), (.closing, .escape):
|
||||
case (.open, .escape), (.pinned, .escape), (.closing, .escape),
|
||||
(.open, .clickedOutside), (.pinned, .clickedOutside), (.closing, .clickedOutside):
|
||||
phase = .idle
|
||||
return [.hide]
|
||||
return [.cancelCloseTimer, .hide]
|
||||
|
||||
case (.arming, .clickedOutside):
|
||||
phase = .idle
|
||||
debounceElapsed = false
|
||||
return [.cancelArmTimer, .cancelCloseTimer]
|
||||
|
||||
case (.arming, .escape):
|
||||
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 OnyxDesign
|
||||
|
||||
/// Die Größen, in denen ein Widget im Panel auftreten darf.
|
||||
public enum WidgetSize: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||
/// 1 × 1 — ein Messwert, ein Ring.
|
||||
case small
|
||||
/// 2 × 1 — Messwert mit Verlauf, kurze Liste.
|
||||
case medium
|
||||
/// 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.
|
||||
/// Ein Widget an seinem Platz im Layout, in der Reihenfolge, die der Nutzer
|
||||
/// festgelegt hat.
|
||||
///
|
||||
/// Ohne Größe: alle Kacheln sind gleich groß. Eine Größenauswahl klingt nach
|
||||
/// Freiheit und bringt vor allem Löcher — ein 2×1 neben einem 2×2 lässt oben
|
||||
/// rechts eine Lücke, die niemand füllen kann und die aussieht wie ein Fehler.
|
||||
public struct WidgetPlacement: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
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.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.
|
||||
@@ -63,92 +30,67 @@ public struct ResolvedPlacement: Equatable, Identifiable, Sendable {
|
||||
public let column: Int
|
||||
|
||||
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 static let columns = 4
|
||||
/// 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.
|
||||
/// Die eine Kachelgröße.
|
||||
///
|
||||
/// Der Reihe nach von links oben zu suchen statt stur hintereinander zu
|
||||
/// setzen ist der ganze Unterschied: neben einem 2×2-Widget bleiben rechts
|
||||
/// zwei 1×1-Plätze frei, und ein Verfahren, das immer nur hinten anfügt,
|
||||
/// lässt sie dauerhaft leer.
|
||||
public static func resolve(_ placements: [WidgetPlacement]) -> [ResolvedPlacement] {
|
||||
var occupied = Set<GridCell>()
|
||||
var resolved: [ResolvedPlacement] = []
|
||||
/// Hochkant, weil fast jedes Widget eine Liste zeigt: Termine, Sensoren,
|
||||
/// Programme im Mischpult. Breiter als hoch wäre für ein einziges davon
|
||||
/// besser und für alle anderen schlechter.
|
||||
public static let tileSize = CGSize(width: 180, height: 200)
|
||||
public static var tileSpacing: CGFloat { Onyx.Metric.tileSpacing }
|
||||
private static var padding: CGFloat { Onyx.Metric.panelPadding }
|
||||
|
||||
for placement in placements {
|
||||
let span = placement.size
|
||||
// Zu breit für das Raster: einpassen statt verwerfen. Ein Widget
|
||||
// verschwinden zu lassen wäre für den Nutzer nicht erklärbar.
|
||||
let columnSpan = min(span.columnSpan, columns)
|
||||
|
||||
guard let cell = firstFreeCell(columnSpan: columnSpan,
|
||||
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
|
||||
/// Wie viele Kacheln nebeneinander passen.
|
||||
public static func columns(availableWidth: CGFloat) -> Int {
|
||||
let usable = availableWidth - 2 * padding + tileSpacing
|
||||
let fitting = Int(usable / (tileSize.width + tileSpacing))
|
||||
// Mindestens eine: lieber ein Panel, das über den Rand ragt, als eine
|
||||
// Division durch null oder ein Panel ganz ohne Inhalt.
|
||||
return max(1, fitting)
|
||||
}
|
||||
|
||||
private static func firstFreeCell(columnSpan: Int,
|
||||
rowSpan: Int,
|
||||
occupied: Set<GridCell>) -> GridCell? {
|
||||
let maxRow = (occupied.map(\.row).max() ?? -1) + rowSpan + 1
|
||||
|
||||
for row in 0...maxRow {
|
||||
for column in 0...(columns - columnSpan) {
|
||||
let fits = (row..<(row + rowSpan)).allSatisfy { r in
|
||||
(column..<(column + columnSpan)).allSatisfy { c in
|
||||
!occupied.contains(GridCell(row: r, column: c))
|
||||
public static func resolve(_ placements: [WidgetPlacement],
|
||||
availableWidth: CGFloat) -> [ResolvedPlacement] {
|
||||
let columnCount = columns(availableWidth: availableWidth)
|
||||
return placements.enumerated().map { index, placement in
|
||||
ResolvedPlacement(placement: placement,
|
||||
row: index / columnCount,
|
||||
column: index % columnCount)
|
||||
}
|
||||
}
|
||||
if fits { return GridCell(row: row, column: column) }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Die Größe, die das Panel für dieses Layout braucht.
|
||||
public static func panelSize(for resolved: [ResolvedPlacement]) -> CGSize {
|
||||
let spacing = Onyx.Metric.tileSpacing
|
||||
let padding = Onyx.Metric.panelPadding
|
||||
|
||||
let rows = resolved.map { $0.row + $0.rowSpan }.max() ?? 1
|
||||
let usedRows = max(rows, 1)
|
||||
public static func panelSize(for placements: [WidgetPlacement],
|
||||
availableWidth: CGFloat) -> CGSize {
|
||||
let columnCount = columns(availableWidth: availableWidth)
|
||||
// Ein leeres Layout bekommt trotzdem eine Kachelfläche: ein Panel ohne
|
||||
// Ausdehnung wäre ein unsichtbares Panel, und der Nutzer hielte das
|
||||
// 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(
|
||||
width: CGFloat(columns) * cellSize + CGFloat(columns - 1) * spacing + 2 * padding,
|
||||
height: CGFloat(usedRows) * cellSize + CGFloat(usedRows - 1) * spacing + 2 * padding)
|
||||
width: CGFloat(used) * tileSize.width
|
||||
+ 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
|
||||
/// (Ursprung oben links).
|
||||
public static func frame(for item: ResolvedPlacement, in panelSize: CGSize) -> CGRect {
|
||||
let spacing = Onyx.Metric.tileSpacing
|
||||
let padding = Onyx.Metric.panelPadding
|
||||
|
||||
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)
|
||||
/// Der Rahmen einer Kachel im Panel, in SwiftUI-Koordinaten (oben links).
|
||||
public static func frame(for item: ResolvedPlacement) -> CGRect {
|
||||
CGRect(x: padding + CGFloat(item.column) * (tileSize.width + tileSpacing),
|
||||
y: padding + CGFloat(item.row) * (tileSize.height + tileSpacing),
|
||||
width: tileSize.width,
|
||||
height: tileSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,15 +62,9 @@ public struct LayoutStore: Sendable {
|
||||
/// wenige Widgets, die sofort etwas zeigen, als ein volles Panel, das
|
||||
/// erst konfiguriert werden will.
|
||||
public static func defaultLayout(knownWidgetIDs: Set<String>) -> [WidgetPlacement] {
|
||||
let preferred: [(String, WidgetSize)] = [
|
||||
("calendar", .large),
|
||||
("weather", .medium),
|
||||
("cpu", .small),
|
||||
("battery", .small),
|
||||
("media", .wide),
|
||||
]
|
||||
let preferred = ["calendar", "weather", "media", "cpu", "battery"]
|
||||
return preferred
|
||||
.filter { knownWidgetIDs.contains($0.0) }
|
||||
.map { WidgetPlacement(widgetID: $0.0, size: $0.1) }
|
||||
.filter { knownWidgetIDs.contains($0) }
|
||||
.map { WidgetPlacement(widgetID: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,9 @@ public protocol OnyxWidget: Identifiable, Sendable {
|
||||
var displayName: String { get }
|
||||
var symbolName: String { get }
|
||||
|
||||
/// Welche Größen dieses Widget sinnvoll ausfüllen kann.
|
||||
var supportedSizes: [WidgetSize] { get }
|
||||
|
||||
@ViewBuilder func makeView(size: WidgetSize) -> AnyView
|
||||
/// Der Inhalt der Kachel. Alle Kacheln sind gleich groß — was ein Widget
|
||||
/// zeigt, entscheidet es selbst, nicht eine Größenauswahl.
|
||||
@ViewBuilder func makeView() -> AnyView
|
||||
/// Einstellungen dieses Widgets, falls es welche hat.
|
||||
@ViewBuilder func makeSettingsView() -> AnyView?
|
||||
}
|
||||
|
||||
@@ -11,18 +11,22 @@ public struct WidgetGrid: View {
|
||||
private let resolved: [ResolvedPlacement]
|
||||
private let panelSize: CGSize
|
||||
|
||||
public init(placements: [WidgetPlacement]) {
|
||||
self.resolved = LayoutEngine.resolve(placements)
|
||||
self.panelSize = LayoutEngine.panelSize(for: resolved)
|
||||
public init(placements: [WidgetPlacement], availableWidth: CGFloat) {
|
||||
self.resolved = LayoutEngine.resolve(placements, availableWidth: availableWidth)
|
||||
self.panelSize = LayoutEngine.panelSize(for: placements, availableWidth: availableWidth)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(resolved) { item in
|
||||
let frame = LayoutEngine.frame(for: item, in: panelSize)
|
||||
let frame = LayoutEngine.frame(for: item)
|
||||
|
||||
widgetView(for: item)
|
||||
.frame(width: frame.width, height: frame.height)
|
||||
// Beschneiden ist Pflicht, kein Feinschliff: quillt der
|
||||
// Inhalt über, zeichnet er über die Nachbarkachel — und
|
||||
// ganz oben über die Notch.
|
||||
.clipped()
|
||||
.offset(x: frame.minX, y: frame.minY)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +36,7 @@ public struct WidgetGrid: View {
|
||||
@ViewBuilder
|
||||
private func widgetView(for item: ResolvedPlacement) -> some View {
|
||||
if let widget = WidgetRegistry.shared.widget(id: item.placement.widgetID) {
|
||||
OnyxTile { widget.makeView(size: item.placement.size) }
|
||||
OnyxTile { widget.makeView() }
|
||||
} else {
|
||||
// Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch,
|
||||
// ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall.
|
||||
@@ -50,7 +54,8 @@ public struct WidgetGrid: View {
|
||||
|
||||
/// Die Größe, die das Panel für dieses Layout braucht — der Controller
|
||||
/// stellt das Fenster darauf ein.
|
||||
public static func panelSize(for placements: [WidgetPlacement]) -> CGSize {
|
||||
LayoutEngine.panelSize(for: LayoutEngine.resolve(placements))
|
||||
public static func panelSize(for placements: [WidgetPlacement],
|
||||
availableWidth: CGFloat) -> CGSize {
|
||||
LayoutEngine.panelSize(for: placements, availableWidth: availableWidth)
|
||||
}
|
||||
}
|
||||
|
||||
88
Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import OnyxDesign
|
||||
import OnyxWidgetKit
|
||||
import OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "AirDrop")
|
||||
|
||||
/// Der Weg zur Empfängerauswahl.
|
||||
public enum AirDrop {
|
||||
|
||||
/// Öffnet AirDrop mit den übergebenen Dateien.
|
||||
///
|
||||
/// - Returns: ob AirDrop überhaupt zur Verfügung stand.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
public static func send(_ urls: [URL]) -> Bool {
|
||||
guard !urls.isEmpty else { return false }
|
||||
guard let service = NSSharingService(named: .sendViaAirDrop) else {
|
||||
log.error("AirDrop steht nicht zur Verfügung")
|
||||
return false
|
||||
}
|
||||
guard service.canPerform(withItems: urls) else {
|
||||
log.error("AirDrop kann diese Auswahl nicht senden")
|
||||
return false
|
||||
}
|
||||
service.perform(withItems: urls)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Fläche, auf die man Dateien fallen lässt, um sie zu verschicken.
|
||||
public struct AirDropView: View {
|
||||
/// Schmal, wenn sie sich die Kachel mit der Ablage teilt.
|
||||
private let compact: Bool
|
||||
@State private var isTargeted = false
|
||||
@State private var failed = false
|
||||
|
||||
public init(compact: Bool = false) {
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if compact {
|
||||
// Nebeneinander statt übereinander: in 52 Punkten Höhe ist für
|
||||
// Symbol über Text kein Platz.
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "shareplay")
|
||||
.font(.system(size: 15, weight: .light))
|
||||
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
||||
: Onyx.Color.textTertiary)
|
||||
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(failed ? Onyx.Color.warning
|
||||
: Onyx.Color.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "shareplay")
|
||||
.font(.system(size: 20, weight: .light))
|
||||
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
||||
: Onyx.Color.textTertiary)
|
||||
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(failed ? Onyx.Color.warning
|
||||
: Onyx.Color.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
|
||||
// Versprechen an, nicht als fertige Datei.
|
||||
.contentShape(.rect)
|
||||
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
|
||||
.animation(Onyx.Motion.value, value: isTargeted)
|
||||
}
|
||||
}
|
||||
86
Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
|
||||
|
||||
/// Nimmt gezogene Dateien an — auch die, die es noch gar nicht gibt.
|
||||
///
|
||||
/// Mail, WhatsApp und die meisten Chat-Programme ziehen Anhänge nicht als
|
||||
/// fertige Datei heraus, sondern als **Versprechen**: die Datei entsteht erst,
|
||||
/// wenn das Ziel sagt, wohin. Genau diese Anhänge sind der Hauptgrund für eine
|
||||
/// Ablage, also darf die Fläche nicht nur `public.file-url` annehmen.
|
||||
///
|
||||
/// `loadFileRepresentation` ist der Weg, der beides abdeckt: bei einer echten
|
||||
/// Datei reicht es sie durch, bei einem Versprechen löst es dieses ein. Deshalb
|
||||
/// `UTType.item` als angenommener Typ — die Wurzel, der alles entspricht.
|
||||
/// Was sich nicht als Datei materialisieren lässt, fällt hinten heraus.
|
||||
public struct FileDropModifier: ViewModifier {
|
||||
|
||||
@Binding private var isTargeted: Bool
|
||||
private let onFiles: ([URL]) -> Void
|
||||
|
||||
public init(isTargeted: Binding<Bool>, onFiles: @escaping ([URL]) -> Void) {
|
||||
self._isTargeted = isTargeted
|
||||
self.onFiles = onFiles
|
||||
}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
content.onDrop(of: [.item], isTargeted: $isTargeted) { providers in
|
||||
for provider in providers { receive(provider) }
|
||||
return !providers.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(_ provider: NSItemProvider) {
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
|
||||
if let error {
|
||||
log.error("Ablage: Datei nicht erhalten: \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
guard let url else { return }
|
||||
|
||||
// Die übergebene Datei existiert **nur innerhalb dieses Blocks**;
|
||||
// danach räumt das System sie weg. Wer sie erst auf dem MainActor
|
||||
// anfassen will, greift ins Leere — also hier und sofort sichern.
|
||||
guard let staged = Self.stage(url) else { return }
|
||||
Task { @MainActor in onFiles([staged]) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rettet die Datei aus dem flüchtigen Verzeichnis in einen eigenen
|
||||
/// Zwischenordner.
|
||||
///
|
||||
/// `nonisolated`, und das ist keine Formalie: der Block oben läuft auf
|
||||
/// irgendeinem Thread, diese Methode gehört aber zu einem `ViewModifier`
|
||||
/// und ist damit an den Hauptthread gebunden — die Bindung kommt über die
|
||||
/// Konformität, nicht über den Inhalt. Der Compiler mahnt das nur an, zur
|
||||
/// Laufzeit hält die Prüfung das Programm an. Hier ist ohnehin nichts
|
||||
/// dabei, was den Hauptthread bräuchte.
|
||||
nonisolated private static func stage(_ url: URL) -> URL? {
|
||||
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("Onyx-Drop-\(UUID().uuidString)", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory,
|
||||
withIntermediateDirectories: true)
|
||||
let target = directory.appendingPathComponent(url.lastPathComponent)
|
||||
// Verschieben, wenn möglich: bei einem Video von zwei Gigabyte ist
|
||||
// der Unterschied zum Kopieren spürbar. Über Datenträgergrenzen
|
||||
// hinweg scheitert es, dann wird kopiert.
|
||||
do { try FileManager.default.moveItem(at: url, to: target) }
|
||||
catch { try FileManager.default.copyItem(at: url, to: target) }
|
||||
return target
|
||||
} catch {
|
||||
log.error("Ablage: Zwischenspeichern gescheitert: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
/// Macht die ganze Fläche zum Ziel für gezogene Dateien.
|
||||
func onFileDrop(isTargeted: Binding<Bool>,
|
||||
perform: @escaping ([URL]) -> Void) -> some View {
|
||||
modifier(FileDropModifier(isTargeted: isTargeted, onFiles: perform))
|
||||
}
|
||||
}
|
||||
167
Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"airdrop.hint" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zum Senden ablegen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Drop to send"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"airdrop.name" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "AirDrop"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "AirDrop"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"airdrop.unavailable" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "AirDrop kann das nicht senden"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "AirDrop cannot send this"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.airdrop" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Per AirDrop senden"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Send via AirDrop"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.clear" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Alles leeren"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Clear all"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.delete" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "In den Papierkorb"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Move to Trash"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.empty" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Dateien hierhin ziehen, um sie zu parken."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Drop files here to park them."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.name" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ablage & AirDrop"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Shelf & AirDrop"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.open" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Öffnen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Open"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.reveal" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Im Finder zeigen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Show in Finder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
83
Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine abgelegte Datei.
|
||||
public struct ShelfItem: Identifiable, Equatable, Sendable {
|
||||
public let url: URL
|
||||
public let name: String
|
||||
public let size: Int64
|
||||
public let addedAt: Date
|
||||
|
||||
public var id: String { url.path }
|
||||
|
||||
public init(url: URL, name: String, size: Int64, addedAt: Date) {
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.size = size
|
||||
self.addedAt = addedAt
|
||||
}
|
||||
|
||||
/// Neuestes zuerst.
|
||||
///
|
||||
/// Der Name als Zweitschlüssel ist kein Detail: das Dateisystem sagt keine
|
||||
/// Reihenfolge zu, und ohne festen zweiten Vergleich springen gleich alte
|
||||
/// Dateien bei jeder Aktualisierung durcheinander.
|
||||
public static func sorted(_ items: [ShelfItem]) -> [ShelfItem] {
|
||||
items.sorted {
|
||||
$0.addedAt == $1.addedAt ? $0.name < $1.name : $0.addedAt > $1.addedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Findet freie Namen, ohne je etwas zu überschreiben.
|
||||
public enum ShelfNaming {
|
||||
|
||||
/// Der erste freie Name in der Reihe `Vertrag.pdf`, `Vertrag 2.pdf`, …
|
||||
///
|
||||
/// Gleichnamige Dateien sind der Normalfall — `IMG_0001.jpg` aus zwei
|
||||
/// verschiedenen Chats. Überschreiben wäre stiller Datenverlust.
|
||||
public static func uniqueName(for name: String, taken: Set<String>) -> String {
|
||||
let name = name.isEmpty ? "Datei" : name
|
||||
guard taken.contains(name) else { return name }
|
||||
|
||||
let (base, ext) = split(name)
|
||||
var counter = 2
|
||||
while true {
|
||||
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
|
||||
if !taken.contains(candidate) { return candidate }
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Zerlegt in Namensteil und Endung.
|
||||
///
|
||||
/// Ein führender Punkt zählt nicht als Endung — sonst würde aus
|
||||
/// `.gitignore` ein ` 2.gitignore` mit führendem Leerzeichen. Und bei
|
||||
/// `archiv.tar.gz` zählt nur die letzte Endung: `archiv 2.tar.gz` wäre
|
||||
/// zwar hübscher, aber `archiv.tar 2.gz` ist die Regel, die auch bei
|
||||
/// unbekannten Doppelendungen nichts kaputt macht.
|
||||
private static func split(_ name: String) -> (base: String, ext: String) {
|
||||
guard let dot = name.lastIndex(of: "."), dot != name.startIndex else {
|
||||
return (name, "")
|
||||
}
|
||||
return (String(name[name.startIndex..<dot]),
|
||||
String(name[name.index(after: dot)...]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Räumt alte Dateien weg — wenn man es ausdrücklich möchte.
|
||||
public enum ShelfCleanup {
|
||||
|
||||
/// Was über die Frist hinaus liegt.
|
||||
///
|
||||
/// `nil` und `0` heißen beide „gar nicht". Eine Ablage, die ungefragt
|
||||
/// löscht, ist keine Ablage; und ein Fehlgriff im Einstellungsfeld darf
|
||||
/// nicht der Verlust aller Dateien sein.
|
||||
public static func expired(_ items: [ShelfItem], now: Date,
|
||||
afterDays: Int?) -> [ShelfItem] {
|
||||
guard let days = afterDays, days > 0 else { return [] }
|
||||
let limit = now.addingTimeInterval(-Double(days) * 86_400)
|
||||
// Streng älter: genau auf der Frist bleibt es liegen. Löschen ist
|
||||
// unwiderruflich, einen Tag länger warten ist es nicht.
|
||||
return items.filter { $0.addedAt < limit }
|
||||
}
|
||||
}
|
||||
137
Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
|
||||
|
||||
/// Die Ablage: ein Ordner, in dem Dateien zwischenlagern.
|
||||
///
|
||||
/// Kopiert wird, nicht verwiesen. Anhänge aus Mail oder WhatsApp liegen in
|
||||
/// temporären Verzeichnissen, die das System aufräumt — ein Verweis darauf
|
||||
/// wäre in Stunden tot, und die Ablage wäre genau dann leer, wenn man sie
|
||||
/// braucht.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class ShelfStore {
|
||||
|
||||
public private(set) var items: [ShelfItem] = []
|
||||
public private(set) var lastError: String?
|
||||
|
||||
/// Automatisches Aufräumen nach N Tagen. `nil` = aus, und das ist Standard.
|
||||
public var autoCleanupDays: Int? {
|
||||
didSet {
|
||||
defaults.set(autoCleanupDays ?? 0, forKey: Self.cleanupKey)
|
||||
cleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
private static let cleanupKey = "shelf.cleanupDays"
|
||||
|
||||
public let directory: URL
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = .standard, directory: URL? = nil) {
|
||||
self.defaults = defaults
|
||||
self.directory = directory ?? FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Onyx/Shelf", isDirectory: true)
|
||||
let stored = defaults.integer(forKey: Self.cleanupKey)
|
||||
self.autoCleanupDays = stored > 0 ? stored : nil
|
||||
try? FileManager.default.createDirectory(at: self.directory,
|
||||
withIntermediateDirectories: true)
|
||||
reload()
|
||||
cleanUp()
|
||||
}
|
||||
|
||||
// MARK: - Lesen
|
||||
|
||||
public func reload() {
|
||||
let keys: [URLResourceKey] = [.fileSizeKey, .addedToDirectoryDateKey,
|
||||
.contentModificationDateKey, .isDirectoryKey]
|
||||
guard let urls = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory, includingPropertiesForKeys: keys,
|
||||
options: [.skipsHiddenFiles]) else {
|
||||
items = []
|
||||
return
|
||||
}
|
||||
items = ShelfItem.sorted(urls.compactMap { url in
|
||||
let values = try? url.resourceValues(forKeys: Set(keys))
|
||||
return ShelfItem(
|
||||
url: url,
|
||||
name: url.lastPathComponent,
|
||||
size: Int64(values?.fileSize ?? 0),
|
||||
// Wann die Datei **hier** ankam, nicht wann sie erzeugt wurde.
|
||||
// Ein zehn Jahre altes Foto von gestern soll nicht sofort
|
||||
// ablaufen, nur weil die Kamera ein altes Datum trägt.
|
||||
addedAt: values?.addedToDirectoryDate
|
||||
?? values?.contentModificationDate ?? Date())
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Hinzufügen
|
||||
|
||||
/// Legt Kopien in der Ablage an und meldet, was tatsächlich ankam.
|
||||
@discardableResult
|
||||
public func add(_ urls: [URL]) -> [ShelfItem] {
|
||||
var added: [ShelfItem] = []
|
||||
var taken = Set(items.map(\.name))
|
||||
|
||||
for source in urls {
|
||||
// Dateien aus fremden Apps kommen als sicherheitsbereichte URLs.
|
||||
// Ohne das Paar aus Start und Stop scheitert das Kopieren mit
|
||||
// „Berechtigung verweigert" — und zwar erst bei manchen Quellen,
|
||||
// was den Fehler besonders unangenehm macht.
|
||||
let scoped = source.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { source.stopAccessingSecurityScopedResource() } }
|
||||
|
||||
let name = ShelfNaming.uniqueName(for: source.lastPathComponent, taken: taken)
|
||||
let target = directory.appendingPathComponent(name)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: source, to: target)
|
||||
taken.insert(name)
|
||||
let size = (try? target.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
||||
added.append(ShelfItem(url: target, name: name,
|
||||
size: Int64(size), addedAt: Date()))
|
||||
} catch {
|
||||
log.error("Ablage: \(source.lastPathComponent, privacy: .public) nicht kopiert: \(error.localizedDescription, privacy: .public)")
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
if !added.isEmpty { reload() }
|
||||
return added
|
||||
}
|
||||
|
||||
// MARK: - Entfernen
|
||||
|
||||
public func remove(_ item: ShelfItem) {
|
||||
// In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager,
|
||||
// und ein Fehlklick darf keine Datei vernichten.
|
||||
NSWorkspace.shared.recycle([item.url]) { @Sendable [weak self] _, error in
|
||||
if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") }
|
||||
Task { @MainActor in self?.reload() }
|
||||
}
|
||||
}
|
||||
|
||||
public func removeAll() {
|
||||
guard !items.isEmpty else { return }
|
||||
NSWorkspace.shared.recycle(items.map(\.url)) { @Sendable [weak self] _, _ in
|
||||
Task { @MainActor in self?.reload() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Räumt ab, was über die eingestellte Frist hinaus liegt.
|
||||
public func cleanUp(now: Date = Date()) {
|
||||
let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays)
|
||||
guard !expired.isEmpty else { return }
|
||||
log.notice("Ablage: \(expired.count, privacy: .public) Dateien abgelaufen")
|
||||
NSWorkspace.shared.recycle(expired.map(\.url)) { @Sendable [weak self] _, _ in
|
||||
Task { @MainActor in self?.reload() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet den Ablageordner im Finder — der Notausgang, wenn die Oberfläche
|
||||
/// nicht das kann, was man gerade braucht.
|
||||
public func revealInFinder() {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([directory])
|
||||
}
|
||||
}
|
||||
219
Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift
Normal file
@@ -0,0 +1,219 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import QuickLookThumbnailing
|
||||
import OnyxDesign
|
||||
import OnyxWidgetKit
|
||||
|
||||
/// Vorschaubilder, einmal erzeugt und behalten.
|
||||
///
|
||||
/// `QLThumbnailGenerator` ist nicht billig, und die Ablage zeichnet sich bei
|
||||
/// jeder Änderung neu. Ohne diesen Zwischenspeicher würde für jedes Bild bei
|
||||
/// jedem Tastendruck ein neuer Auftrag laufen.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ThumbnailCache {
|
||||
private var images: [String: NSImage] = [:]
|
||||
private var pending: Set<String> = []
|
||||
|
||||
func image(for item: ShelfItem, size: CGFloat) -> NSImage? {
|
||||
if let cached = images[item.id] { return cached }
|
||||
guard !pending.contains(item.id) else { return nil }
|
||||
pending.insert(item.id)
|
||||
|
||||
let request = QLThumbnailGenerator.Request(
|
||||
fileAt: item.url, size: CGSize(width: size, height: size),
|
||||
scale: NSScreen.main?.backingScaleFactor ?? 2,
|
||||
representationTypes: .all)
|
||||
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { @Sendable rep, _ in
|
||||
guard let rep else { return }
|
||||
let image = NSImage(cgImage: rep.cgImage,
|
||||
size: CGSize(width: size, height: size))
|
||||
Task { @MainActor [weak self] in
|
||||
self?.images[item.id] = image
|
||||
self?.pending.remove(item.id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Ablage.
|
||||
public struct ShelfView: View {
|
||||
private let store: ShelfStore
|
||||
private let compact: Bool
|
||||
@State private var isTargeted = false
|
||||
@State private var thumbnails = ThumbnailCache()
|
||||
|
||||
public init(store: ShelfStore, compact: Bool = false) {
|
||||
self.store = store
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if store.items.isEmpty {
|
||||
EmptyShelf()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
|
||||
ShelfRow(item: item, store: store, thumbnails: thumbnails)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button { store.removeAll() } label: {
|
||||
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
|
||||
Button { store.revealInFinder() } label: {
|
||||
Text("shelf.reveal", bundle: .module).font(Onyx.Font.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
|
||||
.contentShape(.rect)
|
||||
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
|
||||
.overlay {
|
||||
if isTargeted {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
||||
.background(Onyx.Color.accent.opacity(0.08),
|
||||
in: .rect(cornerRadius: 10, style: .continuous))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.animation(Onyx.Motion.value, value: isTargeted)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyShelf: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text("shelf.empty", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShelfRow: View {
|
||||
let item: ShelfItem
|
||||
let store: ShelfStore
|
||||
let thumbnails: ThumbnailCache
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
thumbnail
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.name)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(item.size.formatted(.byteCount(style: .file)))
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if isHovered {
|
||||
Button { AirDrop.send([item.url]) } label: {
|
||||
Image(systemName: "shareplay").font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
|
||||
Button { store.remove(item) } label: {
|
||||
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(.rect)
|
||||
.onHover { isHovered = $0 }
|
||||
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
|
||||
// reicht die URL als Text weiter, und im Finder landet eine
|
||||
// Internetverknüpfung statt der Datei.
|
||||
.onDrag { NSItemProvider(contentsOf: item.url) ?? NSItemProvider() }
|
||||
.onTapGesture(count: 2) { NSWorkspace.shared.open(item.url) }
|
||||
.contextMenu {
|
||||
Button { NSWorkspace.shared.open(item.url) } label: {
|
||||
Text("shelf.open", bundle: .module)
|
||||
}
|
||||
Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: {
|
||||
Text("shelf.reveal", bundle: .module)
|
||||
}
|
||||
Button { AirDrop.send([item.url]) } label: {
|
||||
Text("shelf.airdrop", bundle: .module)
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) { store.remove(item) } label: {
|
||||
Text("shelf.delete", bundle: .module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnail: some View {
|
||||
if let image = thumbnails.image(for: item, size: 22) {
|
||||
Image(nsImage: image)
|
||||
.resizable().aspectRatio(contentMode: .fit)
|
||||
.frame(width: 22, height: 22)
|
||||
.clipShape(.rect(cornerRadius: 3, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(Onyx.Color.elevated)
|
||||
.frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
/// Ablage und AirDrop in **einer** Kachel.
|
||||
///
|
||||
/// Zwei eigene Kacheln waren zu viel des Guten: beide zeigen im Ruhezustand
|
||||
/// nichts als eine leere Fläche mit einem Satz darin, und zusammen nahmen sie
|
||||
/// ein Drittel des Panels. Übereinander in einer Kachel bleibt beides
|
||||
/// erreichbar und kostet halb so viel Platz.
|
||||
public struct ShelfWidget: OnyxWidget {
|
||||
public let id = "shelf"
|
||||
public var displayName: String { String(localized: "shelf.name", bundle: .module) }
|
||||
public let symbolName = "tray.full"
|
||||
|
||||
private let store: ShelfStore
|
||||
|
||||
public init(store: ShelfStore) { self.store = store }
|
||||
|
||||
public func makeView() -> AnyView {
|
||||
AnyView(
|
||||
VStack(spacing: 8) {
|
||||
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
|
||||
// Zustand, die Ablage darunter braucht den Platz für ihre Liste.
|
||||
AirDropView(compact: true)
|
||||
.frame(height: 52)
|
||||
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
|
||||
ShelfView(store: store)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||