Compare commits
10 Commits
40bd83e49e
...
d0692d0f86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0692d0f86 | ||
|
|
95bc3b1931 | ||
|
|
433cdcc3dd | ||
|
|
03dd7b945e | ||
|
|
4b559ba840 | ||
|
|
3d21cac3b7 | ||
|
|
a557c81baf | ||
|
|
fc92d226b4 | ||
|
|
d345a4fcab | ||
|
|
873c763d50 |
@@ -8,9 +8,11 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
||||||
|
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; };
|
||||||
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
|
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
|
||||||
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
||||||
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
|
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 */; };
|
5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; };
|
||||||
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
|
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
|
||||||
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
|
||||||
@@ -19,9 +21,11 @@
|
|||||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
||||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
||||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||||
|
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
@@ -45,6 +49,8 @@
|
|||||||
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */,
|
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */,
|
||||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */,
|
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */,
|
||||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
|
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
|
||||||
|
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */,
|
||||||
|
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -87,6 +93,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0FBA13B97C53F1D0D740A633 /* Resources */,
|
0FBA13B97C53F1D0D740A633 /* Resources */,
|
||||||
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
||||||
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
||||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
||||||
@@ -109,6 +116,8 @@
|
|||||||
6B11A5AC704B6429B9484789 /* Sources */,
|
6B11A5AC704B6429B9484789 /* Sources */,
|
||||||
B211CE3C0EF94A8D680E4AE0 /* Resources */,
|
B211CE3C0EF94A8D680E4AE0 /* Resources */,
|
||||||
7FF994FE5530D53BEFBE13FE /* Frameworks */,
|
7FF994FE5530D53BEFBE13FE /* Frameworks */,
|
||||||
|
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */,
|
||||||
|
F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -122,6 +131,8 @@
|
|||||||
E66577A40689DF225BCED184 /* OnyxWidgetKit */,
|
E66577A40689DF225BCED184 /* OnyxWidgetKit */,
|
||||||
A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */,
|
A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */,
|
||||||
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
||||||
|
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
|
||||||
|
349226D70F51196CCD1C94F8 /* MediaProvider */,
|
||||||
);
|
);
|
||||||
productName = Onyx;
|
productName = Onyx;
|
||||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||||
@@ -170,12 +181,56 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */,
|
||||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */,
|
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/Scripts/build-mediaremote-adapter.sh",
|
||||||
|
);
|
||||||
|
name = "MediaRemote-Adapter bauen und beilegen";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "OUT=\"$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 */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/Onyx/AppIcon.icon/Assets/quartz.svg",
|
||||||
|
);
|
||||||
|
name = "Glyph für die Menüleiste ins Bundle kopieren";
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
6B11A5AC704B6429B9484789 /* Sources */ = {
|
6B11A5AC704B6429B9484789 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
@@ -394,6 +449,10 @@
|
|||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
349226D70F51196CCD1C94F8 /* MediaProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MediaProvider;
|
||||||
|
};
|
||||||
3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = {
|
3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxNotch;
|
productName = OnyxNotch;
|
||||||
@@ -414,6 +473,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxCore;
|
productName = OnyxCore;
|
||||||
};
|
};
|
||||||
|
D4578EB64F7A3372B49BC50C /* WeatherProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = WeatherProvider;
|
||||||
|
};
|
||||||
E66577A40689DF225BCED184 /* OnyxWidgetKit */ = {
|
E66577A40689DF225BCED184 /* OnyxWidgetKit */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxWidgetKit;
|
productName = OnyxWidgetKit;
|
||||||
|
|||||||
1
Onyx/AppIcon.icon/Assets/quartz.svg
Normal file
1
Onyx/AppIcon.icon/Assets/quartz.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#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>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
36
Onyx/AppIcon.icon/icon.json
Normal file
36
Onyx/AppIcon.icon/icon.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "display-p3:0.21900,0.30045,0.61471,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "quartz.svg",
|
||||||
|
"name" : "quartz",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.39,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,6 +150,123 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.permissions.calendar" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Kalender"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.permissions.calendar.why" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Onyx shows your events in the calendar widget. It only ever reads, never writes."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.permissions.granted" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Erteilt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Granted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.permissions.location" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Standort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Location"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.permissions.location.why" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Onyx shows the weather for your current location. Without access you can pick a fixed place instead."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.permissions.openSystem" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Systemeinstellungen öffnen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Open System Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.permissions.request" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Anfragen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.size.large" : {
|
"settings.size.large" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -235,6 +352,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.tab.permissions" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Berechtigungen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Permissions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.tab.widgets" : {
|
"settings.tab.widgets" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -351,102 +485,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"settings.tab.permissions": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Berechtigungen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Permissions"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.permissions.calendar": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Kalender"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Calendar"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.permissions.granted": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Erteilt"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Granted"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.permissions.request": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Anfragen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Request"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.permissions.calendar.why": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Onyx shows your events in the calendar widget. It only ever reads, never writes."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.permissions.openSystem": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Systemeinstellungen öffnen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Open System Settings"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
|
|||||||
@@ -23,5 +23,47 @@
|
|||||||
der Host mit Team-Zertifikat. Ohne das wird es nicht geladen. -->
|
der Host mit Team-Zertifikat. Ohne das wird es nicht geladen. -->
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
||||||
|
<!-- Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements —
|
||||||
|
auch ohne Sandbox. Das ist die verbreitete Fehlannahme: sie gelten
|
||||||
|
nicht nur für sandboxed Apps.
|
||||||
|
|
||||||
|
Fehlt eine davon, weigert sich macOS, den Berechtigungsdialog
|
||||||
|
überhaupt anzuzeigen. Die Anfrage kommt kommentarlos mit `false`
|
||||||
|
zurück, der Status bleibt `notDetermined`, und die App taucht in den
|
||||||
|
Systemeinstellungen nie auf — dort lässt sich auch nichts von Hand
|
||||||
|
nachtragen. Der Grund steht ausschließlich im Protokoll von tccd:
|
||||||
|
|
||||||
|
"Prompting policy for hardened runtime; service: kTCCServiceCalendar
|
||||||
|
requires entitlement com.apple.security.personal-information.calendars
|
||||||
|
but it is missing"
|
||||||
|
|
||||||
|
Nachgemessen mit einer minimalen Test-App: ohne die Entitlement kein
|
||||||
|
Dialog, mit ihr sofort. -->
|
||||||
|
|
||||||
|
<!-- Kalender-Widget (Phase 3) -->
|
||||||
|
<key>com.apple.security.personal-information.calendars</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Wetter am aktuellen Standort (Phase 3) -->
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- AppleScript-Rückfall für Spotify und Musik (Phase 4) -->
|
||||||
|
<key>com.apple.security.automation.apple-events</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Per-App-Lautstärke über Core-Audio-Taps (Phase 7). Aufgezeichnet wird
|
||||||
|
nichts; die Berechtigung heißt nur so. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Wetterdaten von Apple.
|
||||||
|
Anders als die Einträge darüber genügt hier die Entitlement allein
|
||||||
|
nicht: sie erzwingt ein echtes Provisioning-Profil, und die App-ID
|
||||||
|
com.scarriffleservices.onyx muss im Developer-Portal die
|
||||||
|
WeatherKit-Capability tragen. -->
|
||||||
|
<key>com.apple.developer.weatherkit</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import OnyxNotch
|
|||||||
import OnyxMenuBar
|
import OnyxMenuBar
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import WeatherProvider
|
||||||
|
import MediaProvider
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct OnyxApp: App {
|
struct OnyxApp: App {
|
||||||
@@ -30,6 +32,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var menuBar: MenuBarController?
|
private var menuBar: MenuBarController?
|
||||||
private var onyxItem: NSStatusItem?
|
private var onyxItem: NSStatusItem?
|
||||||
private var calendarModel: CalendarModel?
|
private var calendarModel: CalendarModel?
|
||||||
|
private var weatherModel: WeatherModel?
|
||||||
|
private var mediaModel: MediaModel?
|
||||||
private let settingsWindow = SettingsWindowController()
|
private let settingsWindow = SettingsWindowController()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
@@ -90,7 +94,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
|
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
|
||||||
// das Widget den Berechtigungshinweis statt einer leeren Liste.
|
// das Widget den Berechtigungshinweis statt einer leeren Liste.
|
||||||
calendarModel.refresh()
|
calendarModel.refresh()
|
||||||
guard !calendarModel.hasAccess else { return }
|
// Fenster öffnen, solange irgendeine Berechtigung noch aussteht.
|
||||||
|
let needsLocation = weatherModel?.locationIsUndetermined ?? false
|
||||||
|
guard !calendarModel.hasAccess || needsLocation else { return }
|
||||||
|
|
||||||
// Nicht im Hintergrund anfragen.
|
// Nicht im Hintergrund anfragen.
|
||||||
//
|
//
|
||||||
@@ -119,6 +125,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
self.calendarModel = calendarModel
|
self.calendarModel = calendarModel
|
||||||
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
||||||
|
|
||||||
|
let weatherModel = WeatherModel()
|
||||||
|
self.weatherModel = weatherModel
|
||||||
|
WidgetRegistry.shared.register(WeatherWidget(model: weatherModel))
|
||||||
|
|
||||||
|
let mediaModel = MediaModel()
|
||||||
|
self.mediaModel = mediaModel
|
||||||
|
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
|
||||||
|
|
||||||
PlaceholderWidgets.registerAll()
|
PlaceholderWidgets.registerAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +149,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private func installOnyxStatusItem() {
|
private func installOnyxStatusItem() {
|
||||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
item.autosaveName = "onyx.menubar.main"
|
item.autosaveName = "onyx.menubar.main"
|
||||||
item.button?.image = NSImage(systemSymbolName: "circle.hexagongrid.fill",
|
item.button?.image = Self.menuBarIcon()
|
||||||
accessibilityDescription: "Onyx")
|
|
||||||
item.button?.image?.isTemplate = true
|
|
||||||
|
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
menu.addItem(withTitle: String(localized: "menu.openPanel"),
|
menu.addItem(withTitle: String(localized: "menu.openPanel"),
|
||||||
@@ -153,6 +165,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
onyxItem = item
|
onyxItem = item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
|
||||||
|
///
|
||||||
|
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
|
||||||
|
/// Farbverlauf mit, und die gehört in Dock und Finder, nicht in die
|
||||||
|
/// Menüleiste. Dort steht sie als farbiger Klotz zwischen lauter
|
||||||
|
/// Strichsymbolen.
|
||||||
|
///
|
||||||
|
/// Geladen wird dieselbe SVG-Datei, aus der auch das App-Icon gebaut wird —
|
||||||
|
/// NSImage liest sie als Vektor (`_NSSVGImageRep`), sie skaliert also
|
||||||
|
/// verlustfrei.
|
||||||
|
///
|
||||||
|
/// **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 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.
|
||||||
|
///
|
||||||
|
/// 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"),
|
||||||
|
let glyph = NSImage(contentsOf: url) {
|
||||||
|
let image = NSImage(size: size, flipped: false) { rect in
|
||||||
|
glyph.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
image.isTemplate = true
|
||||||
|
image.accessibilityDescription = "Onyx"
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Menüleisteneintrag ohne Bild wäre unsichtbar und die App darüber
|
||||||
|
// unerreichbar — deshalb ein Rückfall statt eines leeren Knopfs.
|
||||||
|
let fallback = NSImage(systemSymbolName: "circle.hexagongrid.fill",
|
||||||
|
accessibilityDescription: "Onyx") ?? NSImage()
|
||||||
|
fallback.isTemplate = true
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
private func observeMenuBarPreference() {
|
private func observeMenuBarPreference() {
|
||||||
guard let model else { return }
|
guard let model else { return }
|
||||||
withObservationTracking {
|
withObservationTracking {
|
||||||
@@ -171,8 +230,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
@objc private func openSettings() { showSettings(tab: .widgets) }
|
@objc private func openSettings() { showSettings(tab: .widgets) }
|
||||||
|
|
||||||
private func showSettings(tab: SettingsTab) {
|
private func showSettings(tab: SettingsTab) {
|
||||||
guard let model, let calendarModel else { return }
|
guard let model, let calendarModel, let weatherModel else { return }
|
||||||
settingsWindow.show(model: model, calendarModel: calendarModel, tab: tab)
|
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||||
|
weatherModel: weatherModel, tab: tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quit() { NSApp.terminate(nil) }
|
@objc private func quit() { NSApp.terminate(nil) }
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ enum PlaceholderWidgets {
|
|||||||
/// damit gespeicherte Layouts weitergelten.
|
/// damit gespeicherte Layouts weitergelten.
|
||||||
static func registerAll() {
|
static func registerAll() {
|
||||||
let widgets: [PlaceholderWidget] = [
|
let widgets: [PlaceholderWidget] = [
|
||||||
.init(id: "weather", displayName: "Wetter", symbolName: "cloud.sun",
|
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 3"),
|
|
||||||
.init(id: "media", displayName: "Medien", symbolName: "play.circle",
|
|
||||||
supportedSizes: [.medium, .wide], phase: "Phase 4"),
|
|
||||||
.init(id: "cpu", displayName: "CPU", symbolName: "cpu",
|
.init(id: "cpu", displayName: "CPU", symbolName: "cpu",
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 5"),
|
supportedSizes: [.small, .medium], phase: "Phase 5"),
|
||||||
.init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill",
|
.init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import OnyxDesign
|
|||||||
import OnyxNotch
|
import OnyxNotch
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import WeatherProvider
|
||||||
|
|
||||||
enum SettingsTab: Hashable {
|
enum SettingsTab: Hashable {
|
||||||
case widgets, display, permissions
|
case widgets, display, permissions
|
||||||
@@ -11,6 +12,7 @@ enum SettingsTab: Hashable {
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let calendarModel: CalendarModel
|
let calendarModel: CalendarModel
|
||||||
|
let weatherModel: WeatherModel
|
||||||
@Binding var selectedTab: SettingsTab
|
@Binding var selectedTab: SettingsTab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -21,7 +23,7 @@ struct SettingsView: View {
|
|||||||
DisplaySettings(model: model)
|
DisplaySettings(model: model)
|
||||||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
||||||
.tag(SettingsTab.display)
|
.tag(SettingsTab.display)
|
||||||
PermissionSettings(calendarModel: calendarModel)
|
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||||||
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
|
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
|
||||||
.tag(SettingsTab.permissions)
|
.tag(SettingsTab.permissions)
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,9 @@ struct SettingsView: View {
|
|||||||
/// Systemeinstellungen.
|
/// Systemeinstellungen.
|
||||||
private struct PermissionSettings: View {
|
private struct PermissionSettings: View {
|
||||||
let calendarModel: CalendarModel
|
let calendarModel: CalendarModel
|
||||||
|
let weatherModel: WeatherModel
|
||||||
@State private var granted = false
|
@State private var granted = false
|
||||||
|
@State private var locationGranted = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -70,13 +74,58 @@ private struct PermissionSettings: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("settings.permissions.openSystem") { openPrivacySettings() }
|
Button("settings.permissions.openSystem") { openPrivacySettings() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.padding()
|
.padding()
|
||||||
.onAppear { granted = calendarModel.hasAccess }
|
.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.
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
granted = await calendarModel.requestAccessNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var didAutoRequest = false
|
||||||
|
|
||||||
|
private func openLocationSettings() {
|
||||||
|
guard let url = URL(string:
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openPrivacySettings() {
|
private func openPrivacySettings() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import WeatherProvider
|
||||||
|
|
||||||
/// Führt das Einstellungsfenster selbst.
|
/// Führt das Einstellungsfenster selbst.
|
||||||
///
|
///
|
||||||
@@ -19,13 +20,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
|
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
|
||||||
private var selectedTab: SettingsTab = .widgets
|
private var selectedTab: SettingsTab = .widgets
|
||||||
|
|
||||||
func show(model: AppModel, calendarModel: CalendarModel, tab: SettingsTab = .widgets) {
|
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
||||||
|
tab: SettingsTab = .widgets) {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
|
|
||||||
if window == nil {
|
if window == nil {
|
||||||
let hosting = NSHostingController(
|
let hosting = NSHostingController(
|
||||||
rootView: SettingsView(model: model,
|
rootView: SettingsView(model: model,
|
||||||
calendarModel: calendarModel,
|
calendarModel: calendarModel,
|
||||||
|
weatherModel: weatherModel,
|
||||||
selectedTab: Binding(
|
selectedTab: Binding(
|
||||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
get: { [weak self] in self?.selectedTab ?? .widgets },
|
||||||
set: { [weak self] in self?.selectedTab = $0 })))
|
set: { [weak self] in self?.selectedTab = $0 })))
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ let package = Package(
|
|||||||
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
|
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
|
||||||
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
|
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
|
||||||
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
|
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
|
||||||
|
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
|
||||||
|
.library(name: "MediaProvider", targets: ["MediaProvider"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||||
@@ -36,5 +38,13 @@ let package = Package(
|
|||||||
.target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
.target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
resources: [.process("Localizable.xcstrings")]),
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]),
|
.testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]),
|
||||||
|
|
||||||
|
.target(name: "WeatherProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "WeatherProviderTests", dependencies: ["WeatherProvider"]),
|
||||||
|
|
||||||
|
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,12 +80,25 @@ public enum CalendarSourceState: Equatable, Sendable {
|
|||||||
case unreadable(reason: String)
|
case unreadable(reason: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Berechtigungszustand einer Quelle.
|
||||||
|
///
|
||||||
|
/// `undetermined` und `denied` müssen getrennt bleiben: im ersten Fall lohnt
|
||||||
|
/// eine Anfrage, im zweiten zeigt macOS nie wieder einen Dialog und es hilft
|
||||||
|
/// nur der Weg über die Systemeinstellungen.
|
||||||
|
public enum CalendarAuthorization: Equatable, Sendable {
|
||||||
|
case granted
|
||||||
|
case denied
|
||||||
|
case undetermined
|
||||||
|
/// Die Quelle braucht gar keine Berechtigung (z. B. Calendarr).
|
||||||
|
case notRequired
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies.
|
/// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies.
|
||||||
public protocol CalendarSource: Sendable {
|
public protocol CalendarSource: Sendable {
|
||||||
var id: String { get }
|
var id: String { get }
|
||||||
/// Ob bereits gelesen werden darf — **ohne** dabei nachzufragen.
|
/// Der Zustand — **ohne** dabei nachzufragen. Die Einstellungen brauchen
|
||||||
/// Die Einstellungen brauchen das, um den Zustand anzuzeigen, ohne
|
/// das, um ihn anzuzeigen, ohne nebenbei einen Systemdialog auszulösen.
|
||||||
/// nebenbei einen Systemdialog auszulösen.
|
var authorization: CalendarAuthorization { get }
|
||||||
var isAuthorized: Bool { get }
|
var isAuthorized: Bool { get }
|
||||||
func requestAccess() async -> Bool
|
func requestAccess() async -> Bool
|
||||||
func calendars() async -> [OnyxCalendar]
|
func calendars() async -> [OnyxCalendar]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public final class CalendarModel {
|
|||||||
|
|
||||||
/// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen.
|
/// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen.
|
||||||
public var hasAccess: Bool { source.isAuthorized }
|
public var hasAccess: Bool { source.isAuthorized }
|
||||||
|
public var authorization: CalendarAuthorization { source.authorization }
|
||||||
|
|
||||||
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
|
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ public final class EventKitSource: CalendarSource, @unchecked Sendable {
|
|||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var isAuthorized: Bool {
|
public var authorization: CalendarAuthorization {
|
||||||
EKEventStore.authorizationStatus(for: .event) == .fullAccess
|
switch EKEventStore.authorizationStatus(for: .event) {
|
||||||
|
case .fullAccess: .granted
|
||||||
|
case .denied, .restricted: .denied
|
||||||
|
// `writeOnly` ist für uns so gut wie nichts — Onyx zeigt nur an.
|
||||||
|
case .notDetermined, .writeOnly: .undetermined
|
||||||
|
@unknown default: .denied
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isAuthorized: Bool { authorization == .granted }
|
||||||
|
|
||||||
public func requestAccess() async -> Bool {
|
public func requestAccess() async -> Bool {
|
||||||
switch EKEventStore.authorizationStatus(for: .event) {
|
switch EKEventStore.authorizationStatus(for: .event) {
|
||||||
|
|||||||
80
Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Normal file
80
Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Übersetzt die JSON-Zeilen des mediaremote-adapter.
|
||||||
|
///
|
||||||
|
/// Der Adapter sendet im `stream`-Modus Zeilen der Form
|
||||||
|
/// `{"type":"data","diff":false,"payload":{…}}`. Bei `diff = true` enthält
|
||||||
|
/// `payload` **nur die geänderten Felder** — ein vollständiges Ersetzen würde
|
||||||
|
/// dann Titel und Cover löschen, sobald sich nur die Position ändert.
|
||||||
|
public enum AdapterPayload {
|
||||||
|
|
||||||
|
public struct Envelope: Decodable {
|
||||||
|
public let type: String
|
||||||
|
public let diff: Bool?
|
||||||
|
public let payload: [String: AnyDecodable]?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wendet eine Zeile auf den bisherigen Stand an.
|
||||||
|
///
|
||||||
|
/// - Returns: der neue Stand, oder `nil` wenn die Zeile nichts aussagt.
|
||||||
|
public static func apply(line: String, to previous: NowPlaying?) -> NowPlaying? {
|
||||||
|
guard let data = line.data(using: .utf8),
|
||||||
|
let envelope = try? JSONDecoder().decode(Envelope.self, from: data),
|
||||||
|
envelope.type == "data" else { return nil }
|
||||||
|
|
||||||
|
guard let payload = envelope.payload, !payload.isEmpty else {
|
||||||
|
// Ein leerer Payload bei einer Vollmeldung heißt: es läuft nichts.
|
||||||
|
// Bei einer Teilmeldung heißt er gar nichts und wird verworfen.
|
||||||
|
return envelope.diff == true ? previous : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei einer Teilmeldung auf dem alten Stand aufbauen, sonst neu beginnen.
|
||||||
|
var state = (envelope.diff == true ? previous : nil) ?? NowPlaying()
|
||||||
|
state.origin = .adapter
|
||||||
|
|
||||||
|
if let value = payload["title"]?.string { state.title = value }
|
||||||
|
if let value = payload["artist"]?.string { state.artist = value }
|
||||||
|
if let value = payload["album"]?.string { state.album = value }
|
||||||
|
if let value = payload["playing"]?.bool { state.isPlaying = value }
|
||||||
|
if let value = payload["duration"]?.double { state.duration = value > 0 ? value : nil }
|
||||||
|
if let value = payload["elapsedTime"]?.double { state.elapsed = value }
|
||||||
|
if let value = payload["playbackRate"]?.double { state.playbackRate = value }
|
||||||
|
if let value = payload["bundleIdentifier"]?.string { state.bundleIdentifier = value }
|
||||||
|
if let value = payload["artworkData"]?.string {
|
||||||
|
state.artwork = Data(base64Encoded: value, options: .ignoreUnknownCharacters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `timestamp` ist der Zeitpunkt, zu dem `elapsedTime` galt — nicht der
|
||||||
|
// Empfangszeitpunkt. Ohne ihn springt der Scrubber bei jedem Ereignis.
|
||||||
|
if let raw = payload["timestamp"]?.string,
|
||||||
|
let parsed = ISO8601DateFormatter().date(from: raw) {
|
||||||
|
state.asOf = parsed
|
||||||
|
} else {
|
||||||
|
state.asOf = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimaler dynamischer Wert — der Payload hat kein festes Schema, und ein
|
||||||
|
/// vollständiger `Decodable`-Typ müsste bei jedem neuen Feld nachgezogen werden.
|
||||||
|
public struct AnyDecodable: Decodable, Sendable {
|
||||||
|
private enum Storage: Sendable {
|
||||||
|
case string(String), double(Double), bool(Bool), other
|
||||||
|
}
|
||||||
|
private let storage: Storage
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
// Reihenfolge zählt: `Bool` vor `Double`, sonst wird `true` zu 1.
|
||||||
|
if let value = try? container.decode(Bool.self) { storage = .bool(value) }
|
||||||
|
else if let value = try? container.decode(Double.self) { storage = .double(value) }
|
||||||
|
else if let value = try? container.decode(String.self) { storage = .string(value) }
|
||||||
|
else { storage = .other }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var string: String? { if case .string(let v) = storage { v } else { nil } }
|
||||||
|
public var double: Double? { if case .double(let v) = storage { v } else { nil } }
|
||||||
|
public var bool: Bool? { if case .bool(let v) = storage { v } else { nil } }
|
||||||
|
}
|
||||||
166
Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Normal file
166
Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
||||||
|
|
||||||
|
/// Hält den mediaremote-adapter am Leben und liest seinen Datenstrom.
|
||||||
|
///
|
||||||
|
/// Der Umweg über `/usr/bin/perl` ist kein Trick um seiner selbst willen:
|
||||||
|
/// seit macOS 15.4 prüft der `mediaremoted`-Daemon Entitlements, und
|
||||||
|
/// Fremdprogramme bekommen keine Now-Playing-Daten mehr. Perl ist ein
|
||||||
|
/// mitgeliefertes, berechtigtes Systemprogramm; es lädt das beigelegte
|
||||||
|
/// Framework und gibt die Daten zeilenweise aus.
|
||||||
|
@MainActor
|
||||||
|
public final class AdapterProcess {
|
||||||
|
|
||||||
|
/// Befehlskennungen aus `MediaRemote.h` des Adapters.
|
||||||
|
private enum Command: Int {
|
||||||
|
case play = 0, pause = 1, togglePlayPause = 2, next = 4, previous = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var isRunning = false
|
||||||
|
/// Wird bei jeder Änderung gerufen. `nil` heißt: es läuft nichts.
|
||||||
|
public var onUpdate: ((NowPlaying?) -> Void)?
|
||||||
|
|
||||||
|
private var process: Process?
|
||||||
|
private var buffer = Data()
|
||||||
|
private var current: NowPlaying?
|
||||||
|
private var restartAttempts = 0
|
||||||
|
private var restartTimer: Timer?
|
||||||
|
|
||||||
|
private let perl = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Ob die beigelegten Dateien vorhanden sind. Fehlen sie, greift die
|
||||||
|
/// nächste Stufe der Kaskade — ohne dass irgendwo ein Prozess scheitert.
|
||||||
|
public static var isAvailable: Bool { resources != nil }
|
||||||
|
|
||||||
|
private static var resources: (script: URL, framework: URL)? {
|
||||||
|
guard let vendor = Bundle.main.resourceURL?.appendingPathComponent("Vendor") else { return nil }
|
||||||
|
let script = vendor.appendingPathComponent("mediaremote-adapter.pl")
|
||||||
|
let framework = vendor.appendingPathComponent("MediaRemoteAdapter.framework")
|
||||||
|
guard FileManager.default.fileExists(atPath: script.path),
|
||||||
|
FileManager.default.fileExists(atPath: framework.path) else { return nil }
|
||||||
|
return (script, framework)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lebenszyklus
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
guard !isRunning, let resources = Self.resources else { return }
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = perl
|
||||||
|
process.arguments = [resources.script.path, resources.framework.path, "stream"]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
|
||||||
|
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||||
|
let chunk = handle.availableData
|
||||||
|
guard !chunk.isEmpty else { return }
|
||||||
|
Task { @MainActor in self?.consume(chunk) }
|
||||||
|
}
|
||||||
|
|
||||||
|
process.terminationHandler = { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.handleTermination() }
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
self.process = process
|
||||||
|
isRunning = true
|
||||||
|
log.notice("MediaRemote-Adapter gestartet")
|
||||||
|
} catch {
|
||||||
|
log.error("Adapter konnte nicht starten: \(error.localizedDescription, privacy: .public)")
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
restartTimer?.invalidate()
|
||||||
|
restartTimer = nil
|
||||||
|
(process?.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil
|
||||||
|
process?.terminationHandler = nil
|
||||||
|
process?.terminate()
|
||||||
|
process = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTermination() {
|
||||||
|
isRunning = false
|
||||||
|
process = nil
|
||||||
|
// Der Adapter beendet sich auch im Normalbetrieb gelegentlich. Ein
|
||||||
|
// Neustart mit wachsendem Abstand hält ihn am Leben, ohne bei einem
|
||||||
|
// echten Defekt in einer Schleife zu landen.
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRestart() {
|
||||||
|
guard restartAttempts < 5 else {
|
||||||
|
log.error("Adapter startet nicht mehr — Kaskade fällt auf AppleScript zurück")
|
||||||
|
onUpdate?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let delay = pow(2.0, Double(restartAttempts))
|
||||||
|
restartAttempts += 1
|
||||||
|
restartTimer?.invalidate()
|
||||||
|
restartTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.start() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Datenstrom
|
||||||
|
|
||||||
|
private func consume(_ chunk: Data) {
|
||||||
|
buffer.append(chunk)
|
||||||
|
|
||||||
|
// Zeilenweise: ein Lesevorgang kann mehrere Zeilen oder eine halbe
|
||||||
|
// enthalten. Cover kommen base64-kodiert und sprengen einen Puffer
|
||||||
|
// schnell, deshalb wird der Rest aufgehoben statt verworfen.
|
||||||
|
while let newline = buffer.firstIndex(of: 0x0A) {
|
||||||
|
let lineData = buffer[buffer.startIndex..<newline]
|
||||||
|
buffer.removeSubrange(buffer.startIndex...newline)
|
||||||
|
|
||||||
|
guard let line = String(data: lineData, encoding: .utf8), !line.isEmpty else { continue }
|
||||||
|
restartAttempts = 0
|
||||||
|
|
||||||
|
if let updated = AdapterPayload.apply(line: line, to: current) {
|
||||||
|
current = updated
|
||||||
|
onUpdate?(updated)
|
||||||
|
} else if line.contains("\"payload\":{}") {
|
||||||
|
current = nil
|
||||||
|
onUpdate?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Steuerung
|
||||||
|
|
||||||
|
public func send(_ command: MediaCommand) {
|
||||||
|
switch command {
|
||||||
|
case .playPause: run(["send", String(Command.togglePlayPause.rawValue)])
|
||||||
|
case .next: run(["send", String(Command.next.rawValue)])
|
||||||
|
case .previous: run(["send", String(Command.previous.rawValue)])
|
||||||
|
case .seek(let position):
|
||||||
|
// Der Adapter erwartet Mikrosekunden.
|
||||||
|
run(["seek", String(Int(position * 1_000_000))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Befehle laufen als eigener, kurzlebiger Prozess.
|
||||||
|
///
|
||||||
|
/// Den Datenstrom dafür zu unterbrechen wäre der falsche Weg: er würde bei
|
||||||
|
/// jedem Tastendruck neu aufgebaut, und in der Lücke fehlen Ereignisse.
|
||||||
|
private func run(_ arguments: [String]) {
|
||||||
|
guard let resources = Self.resources else { return }
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = perl
|
||||||
|
process.arguments = [resources.script.path, resources.framework.path] + arguments
|
||||||
|
process.standardOutput = FileHandle.nullDevice
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
try? process.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Packages/OnyxKit/Sources/MediaProvider/Localizable.xcstrings
Normal file
25
Packages/OnyxKit/Sources/MediaProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"widget.media.name" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Medien" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Media" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.media.nothing" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nichts läuft gerade" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing playing" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.media.viaAppleScript" : {
|
||||||
|
"comment" : "Hinweis, dass die schwächere Quelle greift",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Über AppleScript — ohne Cover, nur Spotify und Musik" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Via AppleScript — no artwork, Spotify and Music only" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version" : "1.0"
|
||||||
|
}
|
||||||
138
Packages/OnyxKit/Sources/MediaProvider/MediaFallbacks.swift
Normal file
138
Packages/OnyxKit/Sources/MediaProvider/MediaFallbacks.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
||||||
|
|
||||||
|
/// Zweite Stufe: AppleScript für Spotify und Musik.
|
||||||
|
///
|
||||||
|
/// Deckt weit weniger ab als der Adapter — Browser und alle anderen Programme
|
||||||
|
/// fallen heraus. Dafür ist es offiziell unterstützt und überlebt
|
||||||
|
/// macOS-Aktualisierungen, die den Adapter brechen könnten.
|
||||||
|
public enum AppleScriptSource {
|
||||||
|
|
||||||
|
private struct Player {
|
||||||
|
let bundleID: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let players = [
|
||||||
|
Player(bundleID: "com.spotify.client", name: "Spotify"),
|
||||||
|
Player(bundleID: "com.apple.Music", name: "Music"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Fragt die laufenden Programme ab. Nicht laufende werden übersprungen —
|
||||||
|
/// ein AppleScript an ein beendetes Programm würde es sonst **starten**.
|
||||||
|
public static func poll() -> NowPlaying? {
|
||||||
|
for player in players {
|
||||||
|
guard NSRunningApplication
|
||||||
|
.runningApplications(withBundleIdentifier: player.bundleID)
|
||||||
|
.isEmpty == false else { continue }
|
||||||
|
if let state = query(player) { return state }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func query(_ player: Player) -> NowPlaying? {
|
||||||
|
let script = """
|
||||||
|
tell application id "\(player.bundleID)"
|
||||||
|
if player state is playing or player state is paused then
|
||||||
|
set t to name of current track
|
||||||
|
set a to artist of current track
|
||||||
|
set al to album of current track
|
||||||
|
set dur to duration of current track
|
||||||
|
set pos to player position
|
||||||
|
set st to (player state as text)
|
||||||
|
return t & "\\n" & a & "\\n" & al & "\\n" & dur & "\\n" & pos & "\\n" & st
|
||||||
|
end if
|
||||||
|
end tell
|
||||||
|
"""
|
||||||
|
|
||||||
|
var error: NSDictionary?
|
||||||
|
guard let result = NSAppleScript(source: script)?
|
||||||
|
.executeAndReturnError(&error).stringValue else {
|
||||||
|
if let error { log.debug("AppleScript \(player.name): \(String(describing: error))") }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = result.components(separatedBy: "\n")
|
||||||
|
guard parts.count >= 6 else { return nil }
|
||||||
|
|
||||||
|
// Spotify gibt die Länge in Millisekunden, Musik in Sekunden.
|
||||||
|
let rawDuration = Double(parts[3]) ?? 0
|
||||||
|
let duration = player.bundleID == "com.spotify.client" ? rawDuration / 1000 : rawDuration
|
||||||
|
|
||||||
|
return NowPlaying(title: parts[0], artist: parts[1], album: parts[2],
|
||||||
|
isPlaying: parts[5].hasPrefix("playing"),
|
||||||
|
duration: duration > 0 ? duration : nil,
|
||||||
|
elapsed: Double(parts[4]) ?? 0,
|
||||||
|
playbackRate: 1, asOf: Date(), artwork: nil,
|
||||||
|
bundleIdentifier: player.bundleID, origin: .appleScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func send(_ command: MediaCommand) -> Bool {
|
||||||
|
for player in players {
|
||||||
|
guard !NSRunningApplication
|
||||||
|
.runningApplications(withBundleIdentifier: player.bundleID).isEmpty else { continue }
|
||||||
|
|
||||||
|
let action = switch command {
|
||||||
|
case .playPause: "playpause"
|
||||||
|
case .next: "next track"
|
||||||
|
case .previous: "previous track"
|
||||||
|
case .seek(let position): "set player position to \(position)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var error: NSDictionary?
|
||||||
|
NSAppleScript(source: "tell application id \"\(player.bundleID)\" to \(action)")?
|
||||||
|
.executeAndReturnError(&error)
|
||||||
|
if error == nil { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letzte Stufe: die Medientasten der Tastatur.
|
||||||
|
///
|
||||||
|
/// Sie kennt keine Titel und keine Position — aber sie steuert **jedes**
|
||||||
|
/// Programm, auch solche, die weder der Adapter noch AppleScript erreichen.
|
||||||
|
/// Damit bleibt das Widget selbst dann bedienbar, wenn Apple die
|
||||||
|
/// Now-Playing-Schnittstelle weiter zumacht.
|
||||||
|
public enum MediaKeys {
|
||||||
|
|
||||||
|
private enum Key: Int32 {
|
||||||
|
case playPause = 16 // NX_KEYTYPE_PLAY
|
||||||
|
case next = 17 // NX_KEYTYPE_NEXT
|
||||||
|
case previous = 18 // NX_KEYTYPE_PREVIOUS
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func send(_ command: MediaCommand) {
|
||||||
|
let key: Key? = switch command {
|
||||||
|
case .playPause: .playPause
|
||||||
|
case .next: .next
|
||||||
|
case .previous: .previous
|
||||||
|
case .seek: nil // Über Tasten nicht möglich.
|
||||||
|
}
|
||||||
|
guard let key else { return }
|
||||||
|
post(key, down: true)
|
||||||
|
post(key, down: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(_ key: Key, down: Bool) {
|
||||||
|
// Medientasten sind keine gewöhnlichen Tastendrücke, sondern
|
||||||
|
// NSSystemDefined-Ereignisse mit Subtyp 8. Ein normaler CGEvent käme
|
||||||
|
// nirgends an.
|
||||||
|
let flags = NSEvent.ModifierFlags(rawValue: UInt(down ? 0xA00 : 0xB00))
|
||||||
|
let data1 = Int((key.rawValue << 16) | ((down ? 0xA : 0xB) << 8))
|
||||||
|
|
||||||
|
guard let event = NSEvent.otherEvent(with: .systemDefined,
|
||||||
|
location: .zero,
|
||||||
|
modifierFlags: flags,
|
||||||
|
timestamp: 0,
|
||||||
|
windowNumber: 0,
|
||||||
|
context: nil,
|
||||||
|
subtype: 8,
|
||||||
|
data1: data1,
|
||||||
|
data2: -1)?.cgEvent else { return }
|
||||||
|
event.post(tap: .cghidEventTap)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift
Normal file
129
Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
||||||
|
|
||||||
|
/// Führt die drei Stufen zusammen.
|
||||||
|
///
|
||||||
|
/// **Adapter** liefert alles inklusive Browser. **AppleScript** springt für
|
||||||
|
/// Spotify und Musik ein, wenn der Adapter schweigt. **Medientasten** steuern
|
||||||
|
/// immer, auch ohne jede Angabe.
|
||||||
|
///
|
||||||
|
/// Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die
|
||||||
|
/// Now-Playing-Schnittstelle in macOS 15.4 bereits einmal zugemacht. Fällt der
|
||||||
|
/// Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class MediaModel {
|
||||||
|
|
||||||
|
public private(set) var nowPlaying: NowPlaying?
|
||||||
|
/// Für die laufende Fortschrittsanzeige. Wird sekündlich gesetzt, aber nur
|
||||||
|
/// solange etwas abspielt und das Panel offen ist.
|
||||||
|
public private(set) var tick = Date()
|
||||||
|
|
||||||
|
private let adapter = AdapterProcess()
|
||||||
|
private var fallbackTimer: Timer?
|
||||||
|
private var tickTimer: Timer?
|
||||||
|
private var consumers = 0
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
adapter.onUpdate = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
// Der Adapter hat Vorrang. Nur wenn er nichts weiß, wird gefragt.
|
||||||
|
if let state {
|
||||||
|
nowPlaying = state
|
||||||
|
} else {
|
||||||
|
nowPlaying = AppleScriptSource.poll()
|
||||||
|
}
|
||||||
|
updateTicking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An- und Abmelden statt Start und Stopp: das Widget im Panel und ein
|
||||||
|
/// künftiges Menüleisten-Modul teilen sich dieselbe Quelle, und der
|
||||||
|
/// Adapter soll genau einmal laufen.
|
||||||
|
public func addConsumer() {
|
||||||
|
consumers += 1
|
||||||
|
guard consumers == 1 else { return }
|
||||||
|
|
||||||
|
if AdapterProcess.isAvailable {
|
||||||
|
adapter.start()
|
||||||
|
} else {
|
||||||
|
log.notice("Adapter nicht im Bundle — nur AppleScript und Medientasten")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppleScript wird abgefragt, nicht gemeldet. Zwei Sekunden reichen für
|
||||||
|
// eine Anzeige, die ohnehin nur beim geöffneten Panel sichtbar ist.
|
||||||
|
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.pollFallbackIfNeeded() }
|
||||||
|
}
|
||||||
|
pollFallbackIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeConsumer() {
|
||||||
|
consumers = max(consumers - 1, 0)
|
||||||
|
guard consumers == 0 else { return }
|
||||||
|
adapter.stop()
|
||||||
|
fallbackTimer?.invalidate()
|
||||||
|
fallbackTimer = nil
|
||||||
|
tickTimer?.invalidate()
|
||||||
|
tickTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollFallbackIfNeeded() {
|
||||||
|
// Nur einspringen, wenn der Adapter nichts liefert — sonst würde die
|
||||||
|
// schwächere Quelle die bessere überschreiben.
|
||||||
|
guard nowPlaying == nil || nowPlaying?.origin == .appleScript else { return }
|
||||||
|
nowPlaying = AppleScriptSource.poll()
|
||||||
|
updateTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Uhr läuft nur, wenn wirklich etwas abspielt. Ein Timer, der bei
|
||||||
|
/// pausierter Wiedergabe weiterläuft, kostet ohne jeden Nutzen.
|
||||||
|
private func updateTicking() {
|
||||||
|
let shouldTick = nowPlaying?.isPlaying == true
|
||||||
|
if shouldTick, tickTimer == nil {
|
||||||
|
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in self?.tick = Date() }
|
||||||
|
}
|
||||||
|
} else if !shouldTick {
|
||||||
|
tickTimer?.invalidate()
|
||||||
|
tickTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Steuerung
|
||||||
|
|
||||||
|
public func send(_ command: MediaCommand) {
|
||||||
|
switch nowPlaying?.origin {
|
||||||
|
case .adapter:
|
||||||
|
adapter.send(command)
|
||||||
|
case .appleScript:
|
||||||
|
if !AppleScriptSource.send(command) { MediaKeys.send(command) }
|
||||||
|
case .mediaKeys, .none:
|
||||||
|
// Ohne bekannte Quelle bleibt die Taste — sie erreicht auch
|
||||||
|
// Programme, die keine der beiden anderen Stufen kennt.
|
||||||
|
MediaKeys.send(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort umschalten statt auf das nächste Ereignis zu warten. Ein
|
||||||
|
// Play-Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt
|
||||||
|
// an; kommt das echte Ereignis, korrigiert es den Stand ohnehin.
|
||||||
|
if case .playPause = command, var state = nowPlaying {
|
||||||
|
state.elapsed = state.elapsed(at: Date())
|
||||||
|
state.asOf = Date()
|
||||||
|
state.isPlaying.toggle()
|
||||||
|
nowPlaying = state
|
||||||
|
updateTicking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet das Programm, das gerade abspielt.
|
||||||
|
public func activateSource() {
|
||||||
|
guard let bundleID = nowPlaying?.bundleIdentifier,
|
||||||
|
let app = NSRunningApplication
|
||||||
|
.runningApplications(withBundleIdentifier: bundleID).first else { return }
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
198
Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
Normal file
198
Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
|
||||||
|
public struct MediaWidget: OnyxWidget {
|
||||||
|
public let id = "media"
|
||||||
|
public var displayName: String { String(localized: "widget.media.name", bundle: .module) }
|
||||||
|
public let symbolName = "play.circle"
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
// Auch ohne Angaben bleibt das Widget bedienbar: die
|
||||||
|
// Medientasten erreichen jedes Programm. Ein Widget, das bei
|
||||||
|
// fehlenden Metadaten gar nichts mehr kann, wäre die schlechtere
|
||||||
|
// Antwort auf ein Problem, das nur die Anzeige betrifft.
|
||||||
|
Idle(model: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { model.addConsumer() }
|
||||||
|
.onDisappear { model.removeConsumer() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Idle: View {
|
||||||
|
let model: MediaModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("widget.media.nothing", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
TransportControls(model: model, size: 13)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Playing: View {
|
||||||
|
let model: MediaModel
|
||||||
|
let state: NowPlaying
|
||||||
|
let size: WidgetSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Artwork(data: state.artwork)
|
||||||
|
.frame(width: artworkSize, height: artworkSize)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(state.title)
|
||||||
|
.font(Onyx.Font.title)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(state.artist)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 2)
|
||||||
|
|
||||||
|
Scrubber(model: model, state: state)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TransportControls(model: model, size: 15)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if state.origin == .appleScript {
|
||||||
|
// Sichtbar machen, dass gerade die schwächere Quelle
|
||||||
|
// greift: dann fehlen Cover und Browser-Wiedergaben,
|
||||||
|
// und das soll erklärbar sein statt rätselhaft.
|
||||||
|
Image(systemName: "applescript")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.help(Text("widget.media.viaAppleScript", bundle: .module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(count: 2) { model.activateSource() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var artworkSize: CGFloat { size == .wide ? 64 : 56 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Artwork: View {
|
||||||
|
let data: Data?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if let data, let image = NSImage(data: data) {
|
||||||
|
Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
|
||||||
|
} else {
|
||||||
|
// Kein Cover ist der Normalfall bei Browser-Wiedergaben und
|
||||||
|
// beim AppleScript-Rückfall — deshalb ein ruhiger Platzhalter
|
||||||
|
// statt eines Fehlerzeichens.
|
||||||
|
shape.fill(Onyx.Color.elevated)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(shape)
|
||||||
|
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Scrubber: View {
|
||||||
|
let model: MediaModel
|
||||||
|
let state: NowPlaying
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// `model.tick` wird sekündlich gesetzt; das Lesen hier sorgt dafür,
|
||||||
|
// dass die Ansicht zwischen den Adapter-Ereignissen mitläuft.
|
||||||
|
let now = model.tick
|
||||||
|
let progress = state.progress(at: now)
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Onyx.Color.hairline)
|
||||||
|
Capsule()
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(width: geometry.size.width * (progress ?? 0))
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { location in
|
||||||
|
guard let duration = state.duration, duration > 0 else { return }
|
||||||
|
let ratio = min(max(location.x / geometry.size.width, 0), 1)
|
||||||
|
model.send(.seek(to: duration * ratio))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
|
||||||
|
if state.duration != nil {
|
||||||
|
HStack {
|
||||||
|
Text(format(state.elapsed(at: now)))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(format(state.duration ?? 0))
|
||||||
|
}
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.linear(duration: 0.9), value: progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func format(_ seconds: TimeInterval) -> String {
|
||||||
|
let total = Int(seconds.rounded())
|
||||||
|
return String(format: "%d:%02d", total / 60, total % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TransportControls: View {
|
||||||
|
let model: MediaModel
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
button("backward.fill") { model.send(.previous) }
|
||||||
|
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
|
||||||
|
model.send(.playPause)
|
||||||
|
}
|
||||||
|
button("forward.fill") { model.send(.next) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func button(_ symbol: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: size))
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift
Normal file
83
Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Woher die Angaben stammen. Bestimmt, was steuerbar ist.
|
||||||
|
public enum MediaOrigin: String, Equatable, Sendable {
|
||||||
|
/// mediaremote-adapter — kennt alle Quellen inkl. Browser.
|
||||||
|
case adapter
|
||||||
|
/// AppleScript für Spotify oder Musik.
|
||||||
|
case appleScript
|
||||||
|
/// Nur Medientasten: Steuerung ohne Angaben.
|
||||||
|
case mediaKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was gerade läuft.
|
||||||
|
public struct NowPlaying: Equatable, Sendable {
|
||||||
|
public var title: String
|
||||||
|
public var artist: String
|
||||||
|
public var album: String
|
||||||
|
public var isPlaying: Bool
|
||||||
|
/// Gesamtlänge in Sekunden. `nil` bei Livestreams.
|
||||||
|
public var duration: TimeInterval?
|
||||||
|
/// Position **zum Zeitpunkt `asOf`** — nicht jetzt. Siehe `elapsed(at:)`.
|
||||||
|
public var elapsed: TimeInterval
|
||||||
|
public var playbackRate: Double
|
||||||
|
/// Wann die Angaben gültig waren.
|
||||||
|
public var asOf: Date
|
||||||
|
public var artwork: Data?
|
||||||
|
public var bundleIdentifier: String?
|
||||||
|
public var origin: MediaOrigin
|
||||||
|
|
||||||
|
public init(title: String = "", artist: String = "", album: String = "",
|
||||||
|
isPlaying: Bool = false, duration: TimeInterval? = nil,
|
||||||
|
elapsed: TimeInterval = 0, playbackRate: Double = 1,
|
||||||
|
asOf: Date = Date(), artwork: Data? = nil,
|
||||||
|
bundleIdentifier: String? = nil, origin: MediaOrigin = .adapter) {
|
||||||
|
self.title = title
|
||||||
|
self.artist = artist
|
||||||
|
self.album = album
|
||||||
|
self.isPlaying = isPlaying
|
||||||
|
self.duration = duration
|
||||||
|
self.elapsed = elapsed
|
||||||
|
self.playbackRate = playbackRate
|
||||||
|
self.asOf = asOf
|
||||||
|
self.artwork = artwork
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.origin = origin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Position zum angegebenen Zeitpunkt.
|
||||||
|
///
|
||||||
|
/// Der Adapter liefert `elapsedTime` **nicht** laufend nach — bei
|
||||||
|
/// unveränderter Wiedergabe kommt minutenlang kein Ereignis. Ein Scrubber,
|
||||||
|
/// der stur `elapsed` anzeigt, steht deshalb still, obwohl der Titel läuft.
|
||||||
|
/// Er muss zwischen den Ereignissen selbst hochzählen, ausgehend von
|
||||||
|
/// `asOf` und `playbackRate`.
|
||||||
|
public func elapsed(at date: Date) -> TimeInterval {
|
||||||
|
guard isPlaying else { return clamp(elapsed) }
|
||||||
|
let advanced = elapsed + date.timeIntervalSince(asOf) * playbackRate
|
||||||
|
return clamp(advanced)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fortschritt von 0 bis 1, oder `nil` ohne bekannte Länge.
|
||||||
|
public func progress(at date: Date) -> Double? {
|
||||||
|
guard let duration, duration > 0 else { return nil }
|
||||||
|
return min(max(elapsed(at: date) / duration, 0), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hasMetadata: Bool { !title.isEmpty || !artist.isEmpty }
|
||||||
|
|
||||||
|
private func clamp(_ value: TimeInterval) -> TimeInterval {
|
||||||
|
// Nie über die Länge hinaus: sonst zeigt die Anzeige nach dem Ende
|
||||||
|
// eines Titels weiter steigende Zahlen, solange kein Ereignis kommt.
|
||||||
|
guard let duration, duration > 0 else { return max(value, 0) }
|
||||||
|
return min(max(value, 0), duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was das Widget auslösen kann.
|
||||||
|
public enum MediaCommand: Equatable, Sendable {
|
||||||
|
case playPause
|
||||||
|
case next
|
||||||
|
case previous
|
||||||
|
case seek(to: TimeInterval)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"↑ %@": {
|
||||||
|
"comment": "A label that shows the high temperature. The argument is the high temperature.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
|
"↓ %@": {
|
||||||
|
"comment": "A label that reads \"low\" in the weather app.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
|
"widget.weather.asOf": {
|
||||||
|
"comment": "%@ ist die Uhrzeit des Messwerts",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Stand %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "As of %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.weather.locationDenied": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Kein Standortzugriff.\nOrt in den Einstellungen wählen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No location access.\nPick a place in settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.weather.name": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wetter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Weather"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.weather.unavailable": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wetter nicht abrufbar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Weather unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.weather.locationUndetermined": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Standort noch nicht erlaubt.\nIn den Einstellungen freigeben."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Location not allowed yet.\nGrant it in settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.1"
|
||||||
|
}
|
||||||
79
Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift
Normal file
79
Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Hält den zuletzt geladenen Wetterstand je Ort.
|
||||||
|
///
|
||||||
|
/// WeatherKit rechnet nach Abrufen ab. Ein Widget, das bei jedem Öffnen des
|
||||||
|
/// Panels neu lädt, verbrennt das Kontingent für Daten, die sich stündlich
|
||||||
|
/// ändern — der Zwischenspeicher ist deshalb Teil der Funktion und kein
|
||||||
|
/// Feinschliff.
|
||||||
|
public struct WeatherCache: Sendable {
|
||||||
|
|
||||||
|
/// Wie lange ein Stand als aktuell gilt.
|
||||||
|
public static let maxAge: TimeInterval = 15 * 60
|
||||||
|
|
||||||
|
private var entries: [String: WeatherSnapshot] = [:]
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Der Stand, wenn er noch frisch genug ist.
|
||||||
|
public func valid(for place: WeatherPlace, now: Date = Date()) -> WeatherSnapshot? {
|
||||||
|
guard let entry = entries[Self.key(place)] else { return nil }
|
||||||
|
return now.timeIntervalSince(entry.asOf) <= Self.maxAge ? entry : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der letzte bekannte Stand, unabhängig vom Alter.
|
||||||
|
///
|
||||||
|
/// Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel
|
||||||
|
/// deutlich besser als ein leeres Feld — man sieht, dass etwas da ist und
|
||||||
|
/// wie alt es ist.
|
||||||
|
public func lastKnown(for place: WeatherPlace) -> WeatherSnapshot? {
|
||||||
|
entries[Self.key(place)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func store(_ snapshot: WeatherSnapshot, for place: WeatherPlace) {
|
||||||
|
entries[Self.key(place)] = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Schlüssel muss den Ort mitführen, sonst zeigt das Widget nach einem
|
||||||
|
/// Ortswechsel weiter das alte Wetter — und zwar überzeugend, weil Zahlen
|
||||||
|
/// dastehen.
|
||||||
|
private static func key(_ place: WeatherPlace) -> String {
|
||||||
|
switch place {
|
||||||
|
case .current:
|
||||||
|
"current"
|
||||||
|
case .fixed(_, let latitude, let longitude):
|
||||||
|
// Auf vier Nachkommastellen gerundet: das sind gut zehn Meter,
|
||||||
|
// feiner unterscheidet sich das Wetter ohnehin nicht.
|
||||||
|
String(format: "%.4f,%.4f", latitude, longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension WeatherCondition {
|
||||||
|
/// Das SF-Symbol zur Lage.
|
||||||
|
///
|
||||||
|
/// Nur dort zwischen Tag und Nacht unterscheiden, wo es ein etabliertes
|
||||||
|
/// Symbolpaar gibt. Eine erzwungene Unterscheidung erzeugt sonst Symbole,
|
||||||
|
/// die niemand wiedererkennt.
|
||||||
|
func symbolName(isDaylight: Bool) -> String {
|
||||||
|
switch self {
|
||||||
|
case .clear: isDaylight ? "sun.max.fill" : "moon.stars.fill"
|
||||||
|
case .mostlyClear: isDaylight ? "sun.min.fill" : "moon.fill"
|
||||||
|
case .partlyCloudy: isDaylight ? "cloud.sun.fill" : "cloud.moon.fill"
|
||||||
|
case .cloudy: "cloud.fill"
|
||||||
|
case .fog: "cloud.fog.fill"
|
||||||
|
case .drizzle: "cloud.drizzle.fill"
|
||||||
|
case .rain: "cloud.rain.fill"
|
||||||
|
case .heavyRain: "cloud.heavyrain.fill"
|
||||||
|
case .sleet: "cloud.sleet.fill"
|
||||||
|
case .snow: "cloud.snow.fill"
|
||||||
|
case .heavySnow: "snowflake"
|
||||||
|
case .hail: "cloud.hail.fill"
|
||||||
|
case .thunderstorm: "cloud.bolt.rain.fill"
|
||||||
|
case .windy: "wind"
|
||||||
|
case .hot: "thermometer.sun.fill"
|
||||||
|
case .frigid: "thermometer.snowflake"
|
||||||
|
case .unknown: "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift
Normal file
198
Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import CoreLocation
|
||||||
|
import WeatherKit
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Weather")
|
||||||
|
|
||||||
|
/// Liefert den aktuellen Standort, oder sagt warum nicht.
|
||||||
|
///
|
||||||
|
/// Eigene Klasse statt eines Aufrufs mittendrin: `CLLocationManager` meldet
|
||||||
|
/// asynchron über einen Delegaten, und die Berechtigung kann sich jederzeit
|
||||||
|
/// ändern. Beides gehört an eine Stelle.
|
||||||
|
@MainActor
|
||||||
|
final class LocationProvider: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var pending: [CheckedContinuation<CLLocation?, Never>] = []
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyKilometer
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorization: CLAuthorizationStatus { manager.authorizationStatus }
|
||||||
|
|
||||||
|
var isDenied: Bool {
|
||||||
|
authorization == .denied || authorization == .restricted
|
||||||
|
}
|
||||||
|
|
||||||
|
var isUndetermined: Bool { authorization == .notDetermined }
|
||||||
|
|
||||||
|
func requestAuthorization() {
|
||||||
|
guard authorization == .notDetermined else { return }
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine einzelne Ortung. Liefert `nil`, wenn keine Berechtigung vorliegt
|
||||||
|
/// oder nichts hereinkommt.
|
||||||
|
func currentLocation() async -> CLLocation? {
|
||||||
|
guard !isDenied, !isUndetermined else { return nil }
|
||||||
|
if let cached = manager.location, cached.timestamp.timeIntervalSinceNow > -900 {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
pending.append(continuation)
|
||||||
|
manager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resume(with location: CLLocation?) {
|
||||||
|
let waiting = pending
|
||||||
|
pending.removeAll()
|
||||||
|
waiting.forEach { $0.resume(returning: location) }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||||
|
didUpdateLocations locations: [CLLocation]) {
|
||||||
|
Task { @MainActor in self.resume(with: locations.last) }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||||
|
didFailWithError error: Error) {
|
||||||
|
Task { @MainActor in
|
||||||
|
log.error("Ortung fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.resume(with: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wetter aus Apples WeatherKit.
|
||||||
|
@MainActor
|
||||||
|
public final class WeatherKitSource {
|
||||||
|
|
||||||
|
private let service = WeatherService.shared
|
||||||
|
private let location = LocationProvider()
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var locationIsDenied: Bool { location.isDenied }
|
||||||
|
public var locationIsUndetermined: Bool { location.isUndetermined }
|
||||||
|
public var locationIsAuthorized: Bool { !location.isDenied && !location.isUndetermined }
|
||||||
|
|
||||||
|
/// Fragt die Ortungsberechtigung an.
|
||||||
|
///
|
||||||
|
/// Muss aus einem Vordergrundfenster gerufen werden — wie beim Kalender
|
||||||
|
/// zeigt macOS den Dialog sonst nicht an, und die App wirkt kaputt.
|
||||||
|
public func requestLocationAuthorization() { location.requestAuthorization() }
|
||||||
|
|
||||||
|
public func load(_ place: WeatherPlace) async -> WeatherState {
|
||||||
|
let coordinate: CLLocation
|
||||||
|
let placeName: String
|
||||||
|
|
||||||
|
switch place {
|
||||||
|
case .current:
|
||||||
|
if location.isUndetermined {
|
||||||
|
log.notice("Wetter: Ortungsberechtigung noch nicht erteilt")
|
||||||
|
return .locationUndetermined
|
||||||
|
}
|
||||||
|
guard let here = await location.currentLocation() else {
|
||||||
|
let denied = location.isDenied
|
||||||
|
log.error("Wetter: keine Ortung erhalten (verweigert: \(denied, privacy: .public))")
|
||||||
|
return denied
|
||||||
|
? .locationDenied
|
||||||
|
: .unavailable(lastKnown: nil, reason: "Standort nicht verfügbar")
|
||||||
|
}
|
||||||
|
coordinate = here
|
||||||
|
placeName = await name(for: here)
|
||||||
|
|
||||||
|
case .fixed(let name, let latitude, let longitude):
|
||||||
|
coordinate = CLLocation(latitude: latitude, longitude: longitude)
|
||||||
|
placeName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let weather = try await service.weather(for: coordinate)
|
||||||
|
return .ready(Self.snapshot(from: weather,
|
||||||
|
placeName: placeName,
|
||||||
|
attribution: try? await attribution()))
|
||||||
|
} catch {
|
||||||
|
log.error("WeatherKit: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return .unavailable(lastKnown: nil, reason: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apples Namensnennung. Keine Kür — sie ist Bedingung der Nutzung.
|
||||||
|
private func attribution() async throws -> WeatherAttribution {
|
||||||
|
let legal = try await service.attribution
|
||||||
|
return WeatherAttribution(name: "Apple Weather",
|
||||||
|
legalPageURL: legal.legalPageURL,
|
||||||
|
logoURL: legal.combinedMarkDarkURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func name(for location: CLLocation) async -> String {
|
||||||
|
guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else {
|
||||||
|
return "Aktueller Ort"
|
||||||
|
}
|
||||||
|
return placemark.locality ?? placemark.subAdministrativeArea
|
||||||
|
?? placemark.administrativeArea ?? "Aktueller Ort"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Umwandlung
|
||||||
|
|
||||||
|
private static func snapshot(from weather: Weather,
|
||||||
|
placeName: String,
|
||||||
|
attribution: WeatherAttribution?) -> WeatherSnapshot {
|
||||||
|
let current = weather.currentWeather
|
||||||
|
let today = weather.dailyForecast.first
|
||||||
|
|
||||||
|
return WeatherSnapshot(
|
||||||
|
placeName: placeName,
|
||||||
|
temperature: current.temperature,
|
||||||
|
apparentTemperature: current.apparentTemperature,
|
||||||
|
condition: map(current.condition),
|
||||||
|
isDaylight: current.isDaylight,
|
||||||
|
humidity: current.humidity,
|
||||||
|
windSpeed: current.wind.speed,
|
||||||
|
high: today?.highTemperature,
|
||||||
|
low: today?.lowTemperature,
|
||||||
|
hourly: weather.hourlyForecast.forecast
|
||||||
|
.filter { $0.date >= Date() }
|
||||||
|
.prefix(12)
|
||||||
|
.map {
|
||||||
|
HourlyPoint(date: $0.date,
|
||||||
|
temperature: $0.temperature,
|
||||||
|
condition: map($0.condition),
|
||||||
|
precipitationChance: $0.precipitationChance)
|
||||||
|
},
|
||||||
|
asOf: current.date,
|
||||||
|
attribution: attribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WeatherKit kennt deutlich mehr Lagen als hier unterschieden werden.
|
||||||
|
/// Feiner aufzulösen bringt nichts: das Widget hat ein Symbol und ein Wort
|
||||||
|
/// Platz, und „mäßiger Sprühregen" liest dort niemand.
|
||||||
|
private static func map(_ condition: WeatherKit.WeatherCondition) -> WeatherCondition {
|
||||||
|
switch condition {
|
||||||
|
case .clear: .clear
|
||||||
|
case .mostlyClear: .mostlyClear
|
||||||
|
case .partlyCloudy, .mostlyCloudy: .partlyCloudy
|
||||||
|
case .cloudy: .cloudy
|
||||||
|
case .foggy, .haze, .smoky: .fog
|
||||||
|
case .drizzle, .freezingDrizzle: .drizzle
|
||||||
|
case .rain, .sunShowers, .freezingRain: .rain
|
||||||
|
case .heavyRain: .heavyRain
|
||||||
|
case .sleet, .wintryMix, .hail: .sleet
|
||||||
|
case .snow, .flurries, .sunFlurries, .blowingSnow: .snow
|
||||||
|
case .heavySnow, .blizzard: .heavySnow
|
||||||
|
case .thunderstorms, .isolatedThunderstorms, .scatteredThunderstorms,
|
||||||
|
.strongStorms, .tropicalStorm, .hurricane: .thunderstorm
|
||||||
|
case .windy, .breezy, .blowingDust: .windy
|
||||||
|
case .hot: .hot
|
||||||
|
case .frigid: .frigid
|
||||||
|
@unknown default: .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift
Normal file
133
Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wetterlage, unabhängig von der Quelle.
|
||||||
|
///
|
||||||
|
/// Eine eigene Aufzählung statt WeatherKits Typ: die Widget-Ebene soll nicht
|
||||||
|
/// gegen ein Framework gebunden sein, das eine kostenpflichtige Mitgliedschaft
|
||||||
|
/// und eine Entitlement voraussetzt. Fällt WeatherKit einmal weg, wird nur die
|
||||||
|
/// Abbildung ausgetauscht.
|
||||||
|
public enum WeatherCondition: String, Equatable, Sendable, CaseIterable {
|
||||||
|
case clear
|
||||||
|
case mostlyClear
|
||||||
|
case partlyCloudy
|
||||||
|
case cloudy
|
||||||
|
case fog
|
||||||
|
case drizzle
|
||||||
|
case rain
|
||||||
|
case heavyRain
|
||||||
|
case sleet
|
||||||
|
case snow
|
||||||
|
case heavySnow
|
||||||
|
case hail
|
||||||
|
case thunderstorm
|
||||||
|
case windy
|
||||||
|
case hot
|
||||||
|
case frigid
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Ort, für den Wetter angezeigt wird.
|
||||||
|
public enum WeatherPlace: Equatable, Sendable, Codable {
|
||||||
|
/// Aktueller Standort über CoreLocation.
|
||||||
|
case current
|
||||||
|
/// Fester Ort, in den Widget-Einstellungen gewählt.
|
||||||
|
case fixed(name: String, latitude: Double, longitude: Double)
|
||||||
|
|
||||||
|
public var isCurrent: Bool { self == .current }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HourlyPoint: Equatable, Sendable, Identifiable {
|
||||||
|
public let date: Date
|
||||||
|
public let temperature: Measurement<UnitTemperature>
|
||||||
|
public let condition: WeatherCondition
|
||||||
|
public let precipitationChance: Double
|
||||||
|
|
||||||
|
public var id: Date { date }
|
||||||
|
|
||||||
|
public init(date: Date, temperature: Measurement<UnitTemperature>,
|
||||||
|
condition: WeatherCondition, precipitationChance: Double) {
|
||||||
|
self.date = date
|
||||||
|
self.temperature = temperature
|
||||||
|
self.condition = condition
|
||||||
|
self.precipitationChance = precipitationChance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was Apple bei Nutzung von WeatherKit sichtbar verlangt.
|
||||||
|
///
|
||||||
|
/// Das ist keine Kür: die Nennung samt Link auf die Rechtshinweise ist
|
||||||
|
/// Bedingung der Nutzung. Deshalb steht sie im Datenmodell und nicht in einer
|
||||||
|
/// Notiz, die man beim Bauen der Oberfläche übersieht.
|
||||||
|
public struct WeatherAttribution: Equatable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let legalPageURL: URL
|
||||||
|
public let logoURL: URL?
|
||||||
|
|
||||||
|
public init(name: String, legalPageURL: URL, logoURL: URL?) {
|
||||||
|
self.name = name
|
||||||
|
self.legalPageURL = legalPageURL
|
||||||
|
self.logoURL = logoURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct WeatherSnapshot: Equatable, Sendable {
|
||||||
|
public let placeName: String
|
||||||
|
public let temperature: Measurement<UnitTemperature>
|
||||||
|
public let apparentTemperature: Measurement<UnitTemperature>
|
||||||
|
public let condition: WeatherCondition
|
||||||
|
public let isDaylight: Bool
|
||||||
|
public let humidity: Double
|
||||||
|
public let windSpeed: Measurement<UnitSpeed>
|
||||||
|
public let high: Measurement<UnitTemperature>?
|
||||||
|
public let low: Measurement<UnitTemperature>?
|
||||||
|
public let hourly: [HourlyPoint]
|
||||||
|
public let asOf: Date
|
||||||
|
public let attribution: WeatherAttribution?
|
||||||
|
|
||||||
|
public init(placeName: String,
|
||||||
|
temperature: Measurement<UnitTemperature>,
|
||||||
|
apparentTemperature: Measurement<UnitTemperature>,
|
||||||
|
condition: WeatherCondition,
|
||||||
|
isDaylight: Bool,
|
||||||
|
humidity: Double,
|
||||||
|
windSpeed: Measurement<UnitSpeed>,
|
||||||
|
high: Measurement<UnitTemperature>?,
|
||||||
|
low: Measurement<UnitTemperature>?,
|
||||||
|
hourly: [HourlyPoint],
|
||||||
|
asOf: Date,
|
||||||
|
attribution: WeatherAttribution?) {
|
||||||
|
self.placeName = placeName
|
||||||
|
self.temperature = temperature
|
||||||
|
self.apparentTemperature = apparentTemperature
|
||||||
|
self.condition = condition
|
||||||
|
self.isDaylight = isDaylight
|
||||||
|
self.humidity = humidity
|
||||||
|
self.windSpeed = windSpeed
|
||||||
|
self.high = high
|
||||||
|
self.low = low
|
||||||
|
self.hourly = hourly
|
||||||
|
self.asOf = asOf
|
||||||
|
self.attribution = attribution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie beim Kalender: jeder Fehlerfall bekommt eine eigene Antwort in der
|
||||||
|
/// Oberfläche. „Keine Daten" ist keine.
|
||||||
|
public enum WeatherState: Equatable, Sendable {
|
||||||
|
case ready(WeatherSnapshot)
|
||||||
|
/// Noch nie geladen.
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
/// Ortungsberechtigung wurde verweigert — betrifft nur `.current`.
|
||||||
|
/// macOS fragt dann nicht mehr; es hilft nur der Weg über die
|
||||||
|
/// Systemeinstellungen oder ein fest gewählter Ort.
|
||||||
|
case locationDenied
|
||||||
|
/// Es wurde noch nie gefragt. Anders als `denied` lohnt hier eine Anfrage —
|
||||||
|
/// und sie muss aus einem Vordergrundfenster kommen, sonst zeigt macOS
|
||||||
|
/// keinen Dialog. Ein eigener Zustand, weil "nicht abrufbar" hier eine
|
||||||
|
/// falsche Auskunft wäre: abrufbar ist es sehr wohl, es fehlt nur die
|
||||||
|
/// Erlaubnis.
|
||||||
|
case locationUndetermined
|
||||||
|
/// Kein Netz oder Dienst nicht erreichbar. Der letzte Stand bleibt sichtbar.
|
||||||
|
case unavailable(lastKnown: WeatherSnapshot?, reason: String)
|
||||||
|
}
|
||||||
279
Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift
Normal file
279
Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
|
||||||
|
/// Hält den Wetterstand für das Widget.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class WeatherModel {
|
||||||
|
|
||||||
|
public private(set) var state: WeatherState = .idle
|
||||||
|
public var place: WeatherPlace {
|
||||||
|
didSet { guard place != oldValue else { return }; load(force: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let source: WeatherKitSource
|
||||||
|
private var cache = WeatherCache()
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
public init(source: WeatherKitSource = WeatherKitSource(), place: WeatherPlace = .current) {
|
||||||
|
self.source = source
|
||||||
|
self.place = place
|
||||||
|
}
|
||||||
|
|
||||||
|
public var locationIsDenied: Bool { source.locationIsDenied }
|
||||||
|
public var locationIsUndetermined: Bool { source.locationIsUndetermined }
|
||||||
|
public var locationIsAuthorized: Bool { source.locationIsAuthorized }
|
||||||
|
|
||||||
|
/// Fragt die Ortungsberechtigung an und lädt danach.
|
||||||
|
public func requestLocationAuthorization() {
|
||||||
|
source.requestLocationAuthorization()
|
||||||
|
// Die Entscheidung fällt asynchron. Kurz warten und dann laden — die
|
||||||
|
// Alternative wäre ein Delegat quer durch drei Schichten für einen
|
||||||
|
// Vorgang, der einmal im Leben der App passiert.
|
||||||
|
Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
self?.load(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt, wenn der Zwischenspeicher abgelaufen ist.
|
||||||
|
///
|
||||||
|
/// `force` gilt nur für einen Ortswechsel — nicht für jedes Öffnen des
|
||||||
|
/// Panels. WeatherKit rechnet nach Abrufen ab, und das Wetter ändert sich
|
||||||
|
/// nicht im Sekundentakt.
|
||||||
|
public func load(force: Bool = false) {
|
||||||
|
if !force, let fresh = cache.valid(for: place) {
|
||||||
|
state = .ready(fresh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task?.cancel()
|
||||||
|
let place = place
|
||||||
|
task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if case .ready = state {} else { state = .loading }
|
||||||
|
|
||||||
|
let result = await source.load(place)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .ready(let snapshot):
|
||||||
|
cache.store(snapshot, for: place)
|
||||||
|
state = .ready(snapshot)
|
||||||
|
case .unavailable(_, let reason):
|
||||||
|
// Der letzte bekannte Stand bleibt sichtbar. Ein alter Messwert
|
||||||
|
// mit Zeitstempel ist ehrlicher und nützlicher als ein leeres Feld.
|
||||||
|
state = .unavailable(lastKnown: cache.lastKnown(for: place), reason: reason)
|
||||||
|
default:
|
||||||
|
state = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
public struct WeatherWidget: OnyxWidget {
|
||||||
|
public let id = "weather"
|
||||||
|
public var displayName: String { String(localized: "widget.weather.name", bundle: .module) }
|
||||||
|
public let symbolName = "cloud.sun"
|
||||||
|
public let supportedSizes: [WidgetSize] = [.small, .medium]
|
||||||
|
|
||||||
|
private let model: WeatherModel
|
||||||
|
|
||||||
|
public init(model: WeatherModel) { self.model = model }
|
||||||
|
|
||||||
|
public func makeView(size: WidgetSize) -> AnyView {
|
||||||
|
AnyView(WeatherWidgetView(model: model, size: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WeatherWidgetView: View {
|
||||||
|
let model: WeatherModel
|
||||||
|
let size: WidgetSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch model.state {
|
||||||
|
case .ready(let snapshot):
|
||||||
|
content(snapshot, isStale: false)
|
||||||
|
case .unavailable(let lastKnown, _):
|
||||||
|
if let lastKnown {
|
||||||
|
content(lastKnown, isStale: true)
|
||||||
|
} else {
|
||||||
|
Notice(symbol: "wifi.slash", text: "widget.weather.unavailable")
|
||||||
|
}
|
||||||
|
case .locationDenied:
|
||||||
|
Notice(symbol: "location.slash", text: "widget.weather.locationDenied")
|
||||||
|
case .locationUndetermined:
|
||||||
|
Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined")
|
||||||
|
case .loading, .idle:
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { model.load() }
|
||||||
|
.onDisappear { model.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(_ snapshot: WeatherSnapshot, isStale: Bool) -> some View {
|
||||||
|
if size == .small {
|
||||||
|
CompactWeather(snapshot: snapshot, isStale: isStale)
|
||||||
|
} else {
|
||||||
|
WideWeather(snapshot: snapshot, isStale: isStale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Notice: View {
|
||||||
|
let symbol: String
|
||||||
|
let text: LocalizedStringKey
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Text(text, bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CompactWeather: View {
|
||||||
|
let snapshot: WeatherSnapshot
|
||||||
|
let isStale: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
.symbolRenderingMode(.hierarchical)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(snapshot.temperature.onyxFormatted)
|
||||||
|
.font(Onyx.Font.metric)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
Text(snapshot.placeName)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.opacity(isStale ? 0.55 : 1)
|
||||||
|
.help(helpText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var helpText: Text {
|
||||||
|
// Apples Namensnennung ist Bedingung der Nutzung. In der 1×1-Kachel ist
|
||||||
|
// dafür kein Platz — sie steht hier und zusätzlich in den Einstellungen.
|
||||||
|
Text(verbatim: [snapshot.placeName,
|
||||||
|
snapshot.attribution.map { "Wetterdaten: \($0.name)" }]
|
||||||
|
.compactMap { $0 }.joined(separator: " · "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WideWeather: View {
|
||||||
|
let snapshot: WeatherSnapshot
|
||||||
|
let isStale: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.symbolRenderingMode(.hierarchical)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(snapshot.temperature.onyxFormatted)
|
||||||
|
.font(Onyx.Font.metric)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
Text(snapshot.placeName)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if let high = snapshot.high, let low = snapshot.low {
|
||||||
|
VStack(alignment: .trailing, spacing: 1) {
|
||||||
|
Text("↑ \(high.onyxFormatted)")
|
||||||
|
Text("↓ \(low.onyxFormatted)")
|
||||||
|
}
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !snapshot.hourly.isEmpty {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(snapshot.hourly.prefix(5)) { point in
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(point.date.formatted(.dateTime.hour()))
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Image(systemName: point.condition.symbolName(isDaylight: true))
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
Text(point.temperature.onyxFormatted)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if isStale {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(Onyx.Color.warning)
|
||||||
|
}
|
||||||
|
Text(stampText)
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if let attribution = snapshot.attribution {
|
||||||
|
// Pflichtangabe, klickbar auf Apples Rechtshinweise.
|
||||||
|
Link(attribution.name, destination: attribution.legalPageURL)
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stampText: String {
|
||||||
|
let time = snapshot.asOf.formatted(date: .omitted, time: .shortened)
|
||||||
|
return String(format: String(localized: "widget.weather.asOf", bundle: .module), time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Measurement where UnitType == UnitTemperature {
|
||||||
|
/// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt.
|
||||||
|
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
|
||||||
|
/// die die Vorhersage nicht hat.
|
||||||
|
var onyxFormatted: String {
|
||||||
|
formatted(.measurement(width: .narrow,
|
||||||
|
usage: .weather,
|
||||||
|
numberFormatStyle: .number.precision(.fractionLength(0))))
|
||||||
|
}
|
||||||
|
}
|
||||||
181
Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift
Normal file
181
Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import MediaProvider
|
||||||
|
|
||||||
|
@Suite("Wiedergabe-Fortschritt")
|
||||||
|
struct NowPlayingProgressTests {
|
||||||
|
|
||||||
|
private let t0 = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
|
||||||
|
private func playing(elapsed: TimeInterval = 30, duration: TimeInterval? = 200,
|
||||||
|
rate: Double = 1) -> NowPlaying {
|
||||||
|
NowPlaying(title: "Titel", isPlaying: true, duration: duration,
|
||||||
|
elapsed: elapsed, playbackRate: rate, asOf: t0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Während der Wiedergabe zählt die Position zwischen den Ereignissen weiter")
|
||||||
|
func elapsedAdvancesWhilePlaying() {
|
||||||
|
// Der Adapter liefert `elapsedTime` nicht laufend nach. Ohne dieses
|
||||||
|
// Weiterzählen steht der Scrubber still, obwohl der Titel läuft.
|
||||||
|
let state = playing(elapsed: 30)
|
||||||
|
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Im pausierten Zustand bleibt die Position stehen")
|
||||||
|
func elapsedFreezesWhenPaused() {
|
||||||
|
var state = playing(elapsed: 30)
|
||||||
|
state.isPlaying = false
|
||||||
|
#expect(state.elapsed(at: t0.addingTimeInterval(60)) == 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Doppelte Geschwindigkeit zählt doppelt so schnell")
|
||||||
|
func playbackRateIsHonoured() {
|
||||||
|
let state = playing(elapsed: 0, rate: 2)
|
||||||
|
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Position läuft nie über die Länge hinaus")
|
||||||
|
func elapsedNeverExceedsDuration() {
|
||||||
|
// Sonst zeigt die Anzeige nach dem Ende eines Titels weiter steigende
|
||||||
|
// Zahlen, solange kein neues Ereignis kommt.
|
||||||
|
let state = playing(elapsed: 190, duration: 200)
|
||||||
|
#expect(state.elapsed(at: t0.addingTimeInterval(3600)) == 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne bekannte Länge zählt sie einfach weiter — Livestreams haben kein Ende")
|
||||||
|
func liveStreamKeepsCounting() {
|
||||||
|
let state = playing(elapsed: 10, duration: nil)
|
||||||
|
#expect(state.elapsed(at: t0.addingTimeInterval(100)) == 110)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine negative Position wird auf null gezogen")
|
||||||
|
func elapsedNeverGoesNegative() {
|
||||||
|
var state = playing(elapsed: 5)
|
||||||
|
state.isPlaying = false
|
||||||
|
state.elapsed = -3
|
||||||
|
#expect(state.elapsed(at: t0) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Fortschritt liegt zwischen 0 und 1")
|
||||||
|
func progressIsNormalised() {
|
||||||
|
let state = playing(elapsed: 50, duration: 200)
|
||||||
|
#expect(state.progress(at: t0) == 0.25)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Länge gibt es keinen Fortschritt statt eines falschen Wertes")
|
||||||
|
func progressIsNilWithoutDuration() {
|
||||||
|
#expect(playing(duration: nil).progress(at: t0) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Länge null ergibt keinen Fortschritt statt einer Division durch null")
|
||||||
|
func progressIsNilForZeroDuration() {
|
||||||
|
#expect(playing(duration: 0).progress(at: t0) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Adapter-Payload")
|
||||||
|
struct AdapterPayloadTests {
|
||||||
|
|
||||||
|
private func line(_ json: String) -> String { json }
|
||||||
|
|
||||||
|
@Test("Vollmeldung setzt alle Felder")
|
||||||
|
func fullPayloadPopulates() {
|
||||||
|
let state = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","album":"LP",\
|
||||||
|
"playing":true,"duration":210.5,"elapsedTime":42.0,"playbackRate":1,\
|
||||||
|
"bundleIdentifier":"com.google.Chrome"}}
|
||||||
|
""", to: nil)
|
||||||
|
|
||||||
|
#expect(state?.title == "Song")
|
||||||
|
#expect(state?.artist == "Band")
|
||||||
|
#expect(state?.isPlaying == true)
|
||||||
|
#expect(state?.duration == 210.5)
|
||||||
|
#expect(state?.elapsed == 42)
|
||||||
|
#expect(state?.bundleIdentifier == "com.google.Chrome")
|
||||||
|
#expect(state?.origin == .adapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Teilmeldung ergänzt den bisherigen Stand, statt ihn zu ersetzen")
|
||||||
|
func diffPayloadMerges() {
|
||||||
|
// Der eigentliche Grund für diese Unterscheidung: eine Teilmeldung mit
|
||||||
|
// nur der Position würde sonst Titel, Interpret und Cover löschen.
|
||||||
|
let full = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","playing":true}}
|
||||||
|
""", to: nil)
|
||||||
|
|
||||||
|
let updated = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":true,"payload":{"elapsedTime":99.0}}
|
||||||
|
""", to: full)
|
||||||
|
|
||||||
|
#expect(updated?.title == "Song")
|
||||||
|
#expect(updated?.artist == "Band")
|
||||||
|
#expect(updated?.elapsed == 99)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leere Vollmeldung heißt: es läuft nichts")
|
||||||
|
func emptyFullPayloadMeansNothingPlaying() {
|
||||||
|
#expect(AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":false,"payload":{}}
|
||||||
|
""", to: nil) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leere Teilmeldung lässt den Stand unangetastet")
|
||||||
|
func emptyDiffKeepsState() {
|
||||||
|
let previous = NowPlaying(title: "Song")
|
||||||
|
let result = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":true,"payload":{}}
|
||||||
|
""", to: previous)
|
||||||
|
|
||||||
|
#expect(result?.title == "Song")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Zeitstempel der Quelle wird übernommen, nicht der Empfangszeitpunkt")
|
||||||
|
func timestampComesFromPayload() {
|
||||||
|
// Sonst springt der Scrubber bei jedem Ereignis, weil die Position auf
|
||||||
|
// einen falschen Bezugszeitpunkt gerechnet wird.
|
||||||
|
let state = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":false,"payload":{"title":"S","elapsedTime":10.0,\
|
||||||
|
"timestamp":"2026-08-10T14:22:16Z"}}
|
||||||
|
""", to: nil)
|
||||||
|
|
||||||
|
let expected = ISO8601DateFormatter().date(from: "2026-08-10T14:22:16Z")
|
||||||
|
#expect(state?.asOf == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cover kommt als base64 an")
|
||||||
|
func artworkIsDecoded() {
|
||||||
|
let raw = Data([0xFF, 0xD8, 0xFF]).base64EncodedString()
|
||||||
|
let state = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":false,"payload":{"title":"S","artworkData":"\(raw)"}}
|
||||||
|
""", to: nil)
|
||||||
|
|
||||||
|
#expect(state?.artwork == Data([0xFF, 0xD8, 0xFF]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Länge null wird zu 'unbekannt' statt zu einer Null-Länge")
|
||||||
|
func zeroDurationBecomesNil() {
|
||||||
|
let state = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":false,"payload":{"title":"Stream","duration":0}}
|
||||||
|
""", to: nil)
|
||||||
|
|
||||||
|
#expect(state?.duration == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Kaputte Zeilen werden verworfen, nicht interpretiert")
|
||||||
|
func garbageIsIgnored() {
|
||||||
|
for junk in ["", "kein json", "{}", "{\"type\":\"log\"}", "null"] {
|
||||||
|
#expect(AdapterPayload.apply(line: junk, to: nil) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("`playing: true` wird nicht als Zahl gelesen")
|
||||||
|
func boolIsNotMistakenForNumber() {
|
||||||
|
// Reihenfolge in AnyDecodable: Bool vor Double. Andernfalls wird `true`
|
||||||
|
// zu 1 und `playing` bliebe für immer falsch.
|
||||||
|
let state = AdapterPayload.apply(line: """
|
||||||
|
{"type":"data","diff":false,"payload":{"title":"S","playing":true}}
|
||||||
|
""", to: nil)
|
||||||
|
|
||||||
|
#expect(state?.isPlaying == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import WeatherProvider
|
||||||
|
|
||||||
|
// WeatherKit rechnet nach Abrufen ab (500.000 im Monat inklusive). Ein Widget,
|
||||||
|
// das bei jedem Öffnen des Panels neu lädt, verbrennt das Kontingent für Daten,
|
||||||
|
// die sich stündlich ändern. Der Zwischenspeicher ist deshalb kein Feinschliff,
|
||||||
|
// sondern Teil der Funktion.
|
||||||
|
|
||||||
|
@Suite("Wetter-Zwischenspeicher")
|
||||||
|
struct WeatherCacheTests {
|
||||||
|
|
||||||
|
private let place = WeatherPlace.fixed(name: "Lemgo", latitude: 52.02, longitude: 8.9)
|
||||||
|
private let other = WeatherPlace.fixed(name: "Berlin", latitude: 52.52, longitude: 13.40)
|
||||||
|
|
||||||
|
private func snapshot(at date: Date) -> WeatherSnapshot {
|
||||||
|
WeatherSnapshot(placeName: "Lemgo",
|
||||||
|
temperature: .init(value: 18, unit: .celsius),
|
||||||
|
apparentTemperature: .init(value: 17, unit: .celsius),
|
||||||
|
condition: .partlyCloudy, isDaylight: true, humidity: 0.6,
|
||||||
|
windSpeed: .init(value: 10, unit: .kilometersPerHour),
|
||||||
|
high: nil, low: nil, hourly: [], asOf: date, attribution: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frischer Eintrag wird wiederverwendet")
|
||||||
|
func freshEntryIsReused() {
|
||||||
|
let now = Date()
|
||||||
|
var cache = WeatherCache()
|
||||||
|
cache.store(snapshot(at: now), for: place)
|
||||||
|
|
||||||
|
#expect(cache.valid(for: place, now: now.addingTimeInterval(60)) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nach Ablauf der Frist wird neu geladen")
|
||||||
|
func staleEntryIsDiscarded() {
|
||||||
|
let now = Date()
|
||||||
|
var cache = WeatherCache()
|
||||||
|
cache.store(snapshot(at: now), for: place)
|
||||||
|
|
||||||
|
let later = now.addingTimeInterval(WeatherCache.maxAge + 1)
|
||||||
|
#expect(cache.valid(for: place, now: later) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Genau auf der Frist gilt der Eintrag noch")
|
||||||
|
func exactlyAtMaxAgeIsStillValid() {
|
||||||
|
let now = Date()
|
||||||
|
var cache = WeatherCache()
|
||||||
|
cache.store(snapshot(at: now), for: place)
|
||||||
|
|
||||||
|
#expect(cache.valid(for: place, now: now.addingTimeInterval(WeatherCache.maxAge)) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein anderer Ort benutzt nicht den Eintrag des ersten")
|
||||||
|
func differentPlaceMisses() {
|
||||||
|
// Sonst zeigt das Widget nach dem Umstellen des Ortes weiter das alte
|
||||||
|
// Wetter — und zwar überzeugend, weil Zahlen dastehen.
|
||||||
|
let now = Date()
|
||||||
|
var cache = WeatherCache()
|
||||||
|
cache.store(snapshot(at: now), for: place)
|
||||||
|
|
||||||
|
#expect(cache.valid(for: other, now: now) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der aktuelle Standort ist ein eigener Eintrag")
|
||||||
|
func currentLocationIsItsOwnEntry() {
|
||||||
|
let now = Date()
|
||||||
|
var cache = WeatherCache()
|
||||||
|
cache.store(snapshot(at: now), for: .current)
|
||||||
|
|
||||||
|
#expect(cache.valid(for: .current, now: now) != nil)
|
||||||
|
#expect(cache.valid(for: place, now: now) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leerer Zwischenspeicher liefert nichts")
|
||||||
|
func emptyCacheMisses() {
|
||||||
|
#expect(WeatherCache().valid(for: place, now: Date()) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein abgelaufener Eintrag bleibt als letzter bekannter Stand erhalten")
|
||||||
|
func staleEntryRemainsAsLastKnown() {
|
||||||
|
// Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel
|
||||||
|
// deutlich besser als ein leeres Feld.
|
||||||
|
let now = Date()
|
||||||
|
var cache = WeatherCache()
|
||||||
|
cache.store(snapshot(at: now), for: place)
|
||||||
|
|
||||||
|
let later = now.addingTimeInterval(WeatherCache.maxAge * 10)
|
||||||
|
#expect(cache.valid(for: place, now: later) == nil)
|
||||||
|
#expect(cache.lastKnown(for: place) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein neuer Eintrag ersetzt den alten desselben Ortes")
|
||||||
|
func storingReplaces() {
|
||||||
|
let now = Date()
|
||||||
|
var cache = WeatherCache()
|
||||||
|
cache.store(snapshot(at: now.addingTimeInterval(-1000)), for: place)
|
||||||
|
cache.store(snapshot(at: now), for: place)
|
||||||
|
|
||||||
|
#expect(cache.lastKnown(for: place)?.asOf == now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Frist entspricht dem Plan: 15 Minuten")
|
||||||
|
func maxAgeMatchesPlan() {
|
||||||
|
#expect(WeatherCache.maxAge == 15 * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Wettersymbole")
|
||||||
|
struct WeatherSymbolTests {
|
||||||
|
|
||||||
|
@Test("Klarer Himmel zeigt tagsüber die Sonne, nachts den Mond")
|
||||||
|
func clearDependsOnDaylight() {
|
||||||
|
#expect(WeatherCondition.clear.symbolName(isDaylight: true) == "sun.max.fill")
|
||||||
|
#expect(WeatherCondition.clear.symbolName(isDaylight: false) == "moon.stars.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Teilweise bewölkt unterscheidet ebenfalls Tag und Nacht")
|
||||||
|
func partlyCloudyDependsOnDaylight() {
|
||||||
|
#expect(WeatherCondition.partlyCloudy.symbolName(isDaylight: true) !=
|
||||||
|
WeatherCondition.partlyCloudy.symbolName(isDaylight: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Regen sieht nachts aus wie tagsüber")
|
||||||
|
func rainIgnoresDaylight() {
|
||||||
|
// Nicht jede Lage hat eine sinnvolle Nachtvariante. Eine erzwungene
|
||||||
|
// Unterscheidung erzeugt nur Symbole, die niemand wiedererkennt.
|
||||||
|
#expect(WeatherCondition.rain.symbolName(isDaylight: true) ==
|
||||||
|
WeatherCondition.rain.symbolName(isDaylight: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Jede Lage liefert ein Symbol — auch die unbekannte")
|
||||||
|
func everyConditionHasASymbol() {
|
||||||
|
for condition in WeatherCondition.allCases {
|
||||||
|
#expect(!condition.symbolName(isDaylight: true).isEmpty)
|
||||||
|
#expect(!condition.symbolName(isDaylight: false).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
README.md
Normal file
87
README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Onyx
|
||||||
|
|
||||||
|
**Notch-Erweiterung für macOS.** Beim Überfahren der Notch fährt ein Panel mit
|
||||||
|
eigenen Widgets aus — Kalender, Wetter, Medien, Hardware, Netzwerk, Audio-Mixer,
|
||||||
|
Ablage und AirDrop.
|
||||||
|
|
||||||
|
Zielgerät: MacBook Pro mit Apple Silicon, macOS 26+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodegen generate # erzeugt Onyx.xcodeproj aus project.yml
|
||||||
|
open Onyx.xcodeproj # dann ⌘R
|
||||||
|
```
|
||||||
|
|
||||||
|
Voraussetzungen: Xcode 26, [XcodeGen](https://github.com/yonaskolb/XcodeGen),
|
||||||
|
Signierung mit Team `PP34X97WS3`.
|
||||||
|
|
||||||
|
Tests laufen ohne Xcode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Packages/OnyxKit && swift test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
Die Projektdefinition liegt als `project.yml` vor, nicht als handgepflegte
|
||||||
|
`.xcodeproj` — so ist im Diff lesbar, was sich an den Build-Einstellungen ändert.
|
||||||
|
|
||||||
|
```
|
||||||
|
Onyx/ App-Target (nicht sandboxed, Hardened Runtime)
|
||||||
|
Packages/OnyxKit/ Alle Module als lokales Swift-Package
|
||||||
|
OnyxCore Einzelinstanz-Sicherung
|
||||||
|
OnyxDesign Farben, Maße, Bewegung — einzige Quelle
|
||||||
|
OnyxNotch Panel-Fenster, Geometrie, Zustandsmaschine
|
||||||
|
OnyxWidgetKit Widget-Protokoll, Layout-Engine, Persistenz
|
||||||
|
OnyxMenuBar Modul-Protokoll für Menüleisten-Elemente
|
||||||
|
CalendarProvider EventKit (und später Calendarr)
|
||||||
|
WeatherProvider WeatherKit + CoreLocation
|
||||||
|
MediaProvider MediaRemote-Adapter, AppleScript, Medientasten
|
||||||
|
Vendor/ Fremdcode im Quellzustand (siehe unten)
|
||||||
|
Spikes/ Wegwerf-Werkzeuge zur API-Verifikation
|
||||||
|
docs/spikes/ Was dabei herauskam — auch die Sackgassen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidungen, die man sonst für Fehler hält
|
||||||
|
|
||||||
|
**Keine App-Sandbox.** Sie ist mit dem privilegierten Helper, mit IOKit und mit
|
||||||
|
dem MediaRemote-Adapter unvereinbar. Der App-Group-Container für Calendarr
|
||||||
|
bleibt trotzdem erreichbar — auf macOS ist er von der Sandbox unabhängig.
|
||||||
|
Nachgemessen in [docs/spikes/D-appgroup.md](docs/spikes/D-appgroup.md).
|
||||||
|
|
||||||
|
**TCC-Entitlements trotz fehlender Sandbox.** Bei aktivierter Hardened Runtime
|
||||||
|
verlangt macOS `com.apple.security.personal-information.*` auch ohne Sandbox.
|
||||||
|
Fehlt eine davon, erscheint der Berechtigungsdialog gar nicht erst — ohne
|
||||||
|
Fehlermeldung. Das hat einen halben Abend gekostet:
|
||||||
|
[docs/spikes/E-tcc-hardened-runtime.md](docs/spikes/E-tcc-hardened-runtime.md).
|
||||||
|
|
||||||
|
**`ENABLE_DEBUG_DYLIB = NO`.** Xcode lagert im Debug-Build den Programmcode in
|
||||||
|
eine eigene dylib aus. TCC ordnet Berechtigungsanfragen dann dem verbleibenden
|
||||||
|
Rumpf zu, der weder Info.plist noch Entitlements trägt.
|
||||||
|
|
||||||
|
**Eigenes Einstellungsfenster.** SwiftUIs `Settings`-Szene öffnet über einen
|
||||||
|
privaten Selektor, der bei Apps ohne Menüleiste unzuverlässig ankommt.
|
||||||
|
|
||||||
|
**Der MediaRemote-Adapter wird nicht gelinkt.** Er wird von `/usr/bin/perl`
|
||||||
|
geladen, einem berechtigten Systemprogramm — im eigenen Prozess greift Apples
|
||||||
|
Entitlement-Prüfung und es käme nichts an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fremdcode
|
||||||
|
|
||||||
|
`Vendor/mediaremote-adapter` — [ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter),
|
||||||
|
BSD-3-Clause. Als Quellcode eingebettet und beim Bauen kompiliert, nicht als
|
||||||
|
fertige Binary: so ist nachvollziehbar, was läuft. Stand siehe
|
||||||
|
`ONYX-VERSION.txt`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
© 2026 Scarriffleservices
|
||||||
81
Scripts/build-mediaremote-adapter.sh
Executable file
81
Scripts/build-mediaremote-adapter.sh
Executable file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Baut den mediaremote-adapter aus dem mitgelieferten Quellcode.
|
||||||
|
#
|
||||||
|
# Wird als Build-Schritt des App-Targets aufgerufen. Repliziert das
|
||||||
|
# CMakeLists.txt des Projekts mit clang — cmake ist keine Voraussetzung, die
|
||||||
|
# man einem Mitentwickler zumuten will.
|
||||||
|
#
|
||||||
|
# Der Umweg über /usr/bin/perl ist kein Trick um seiner selbst willen: seit
|
||||||
|
# macOS 15.4 prüft mediaremoted Entitlements, und Fremdprogramme bekommen keine
|
||||||
|
# Now-Playing-Daten mehr. Perl ist ein berechtigtes Systemprogramm und lädt das
|
||||||
|
# hier gebaute Framework.
|
||||||
|
#
|
||||||
|
# Quelle: https://github.com/ungive/mediaremote-adapter (BSD-3-Clause),
|
||||||
|
# eingebettet unter Vendor/ — siehe ONYX-VERSION.txt für den Stand.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||||
|
|
||||||
|
SRC="${SRC:-$(cd "$(dirname "$0")/.." && pwd)/Vendor/mediaremote-adapter}"
|
||||||
|
OUT="${OUT:?Zielverzeichnis fehlt}"
|
||||||
|
NAME=MediaRemoteAdapter
|
||||||
|
FW="$OUT/$NAME.framework"
|
||||||
|
|
||||||
|
[[ -d "$SRC" ]] || { echo "Quellen fehlen unter $SRC" >&2; exit 1; }
|
||||||
|
|
||||||
|
rm -rf "$OUT"
|
||||||
|
mkdir -p "$FW/Versions/A/Headers" "$FW/Versions/A/Resources"
|
||||||
|
|
||||||
|
SOURCES=(
|
||||||
|
"$SRC"/src/adapter/{env,get,globals,keys,now_playing,repeat,seek,send,shuffle,speed,stream,test}.m
|
||||||
|
"$SRC"/src/private/MediaRemote.m
|
||||||
|
"$SRC"/src/utility/{Debounce,helpers}.m
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "→ Framework-Binary"
|
||||||
|
xcrun clang -dynamiclib -fobjc-arc -fvisibility=default -O2 \
|
||||||
|
-arch arm64 -mmacosx-version-min=26.0 \
|
||||||
|
-I"$SRC/include" -I"$SRC/src" \
|
||||||
|
-framework Foundation -framework AppKit -framework UniformTypeIdentifiers \
|
||||||
|
-install_name "@rpath/$NAME.framework/Versions/A/$NAME" \
|
||||||
|
-o "$FW/Versions/A/$NAME" \
|
||||||
|
"${SOURCES[@]}"
|
||||||
|
|
||||||
|
cp "$SRC/include/$NAME.h" "$FW/Versions/A/Headers/"
|
||||||
|
|
||||||
|
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
|
||||||
|
<?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>CFBundleExecutable</key><string>$NAME</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>com.vandenbe.$NAME</string>
|
||||||
|
<key>CFBundleName</key><string>$NAME</string>
|
||||||
|
<key>CFBundlePackageType</key><string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>0.1</string>
|
||||||
|
<key>CFBundleVersion</key><string>0.1.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
ln -sf A "$FW/Versions/Current"
|
||||||
|
ln -sf "Versions/Current/$NAME" "$FW/$NAME"
|
||||||
|
ln -sf Versions/Current/Headers "$FW/Headers"
|
||||||
|
ln -sf Versions/Current/Resources "$FW/Resources"
|
||||||
|
|
||||||
|
echo "→ Test-Client"
|
||||||
|
xcrun clang -fobjc-arc -O2 -arch arm64 \
|
||||||
|
-I"$SRC/src/test" \
|
||||||
|
-framework Foundation -framework MediaPlayer \
|
||||||
|
-o "$OUT/${NAME}TestClient" \
|
||||||
|
"$SRC"/src/test/{main,NowPlayingTest}.m
|
||||||
|
|
||||||
|
echo "→ ad-hoc signieren"
|
||||||
|
codesign --force --deep --sign - "$FW"
|
||||||
|
codesign --force --sign - "$OUT/${NAME}TestClient"
|
||||||
|
|
||||||
|
cp "$SRC/bin/mediaremote-adapter.pl" "$OUT/"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Fertig: $OUT"
|
||||||
|
ls -la "$OUT"
|
||||||
49
Vendor/mediaremote-adapter/.clang-format
vendored
Normal file
49
Vendor/mediaremote-adapter/.clang-format
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
BasedOnStyle: LLVM
|
||||||
|
IndentWidth: 4
|
||||||
|
---
|
||||||
|
Language: Cpp
|
||||||
|
AccessModifierOffset: -4
|
||||||
|
PointerAlignment: Left
|
||||||
|
BreakBeforeBraces: Custom
|
||||||
|
BraceWrapping:
|
||||||
|
AfterCaseLabel: false
|
||||||
|
AfterClass: true
|
||||||
|
AfterControlStatement: Never
|
||||||
|
AfterEnum: true
|
||||||
|
AfterFunction: true
|
||||||
|
AfterNamespace: true
|
||||||
|
AfterStruct: true
|
||||||
|
AfterUnion: true
|
||||||
|
AfterExternBlock: false
|
||||||
|
BeforeCatch: true
|
||||||
|
AlignAfterOpenBracket: DontAlign
|
||||||
|
Cpp11BracedListStyle: false
|
||||||
|
IncludeBlocks: Regroup
|
||||||
|
IncludeCategories:
|
||||||
|
- Regex: "<httplib\\.h>"
|
||||||
|
Priority: 3
|
||||||
|
- Regex: "<[^Q][[:alnum:]_.]+>"
|
||||||
|
Priority: 1
|
||||||
|
- Regex: "<Q.+>"
|
||||||
|
Priority: 2
|
||||||
|
- Regex: "<[^Q].+>"
|
||||||
|
Priority: 3
|
||||||
|
- Regex: ".*"
|
||||||
|
Priority: 4
|
||||||
|
SeparateDefinitionBlocks: Always
|
||||||
|
AllowShortLambdasOnASingleLine: Empty
|
||||||
|
AlwaysBreakTemplateDeclarations: true
|
||||||
|
AlignOperands: DontAlign
|
||||||
|
AlignEscapedNewlines: Left
|
||||||
|
BinPackParameters: true
|
||||||
|
AlignTrailingComments:
|
||||||
|
Kind: Never
|
||||||
|
---
|
||||||
|
Language: JavaScript
|
||||||
|
SpacesInContainerLiterals: false
|
||||||
|
JavaScriptQuotes: Single
|
||||||
|
JavaScriptWrapImports: true
|
||||||
|
---
|
||||||
|
# Notes:
|
||||||
|
# Designated initializer list formatting: Write a ',' after the last item.
|
||||||
10
Vendor/mediaremote-adapter/.cmake-format
vendored
Normal file
10
Vendor/mediaremote-adapter/.cmake-format
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
line_width = 80
|
||||||
|
tab_size = 4
|
||||||
|
separate_ctrl_name_with_space = False
|
||||||
|
separate_fn_name_with_space = False
|
||||||
|
dangle_parens = False
|
||||||
|
fractional_tab_policy = 'use-space'
|
||||||
|
max_subgroups_hwrap = 3
|
||||||
|
command_case = 'canonical'
|
||||||
|
keyword_case = 'upper'
|
||||||
|
literal_comment_pattern = '.*'
|
||||||
8
Vendor/mediaremote-adapter/.gitignore
vendored
Normal file
8
Vendor/mediaremote-adapter/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
build
|
||||||
|
.DS_Store
|
||||||
|
notes
|
||||||
|
CMakePresets.json
|
||||||
|
.cache
|
||||||
|
todo*.txt
|
||||||
|
dist
|
||||||
|
perltidy.ERR
|
||||||
0
Vendor/mediaremote-adapter/.gitmodules
vendored
Normal file
0
Vendor/mediaremote-adapter/.gitmodules
vendored
Normal file
4
Vendor/mediaremote-adapter/.perltidyrc
vendored
Normal file
4
Vendor/mediaremote-adapter/.perltidyrc
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# https://perltidy.sourceforge.net/perltidy.html
|
||||||
|
|
||||||
|
-naws
|
||||||
|
--indent-columns=2
|
||||||
92
Vendor/mediaremote-adapter/CMakeLists.txt
vendored
Normal file
92
Vendor/mediaremote-adapter/CMakeLists.txt
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.15)
|
||||||
|
project(MediaRemoteAdapter LANGUAGES OBJC)
|
||||||
|
|
||||||
|
set(ADAPTER_VERSION_MAJOR 0)
|
||||||
|
set(ADAPTER_VERSION_MINOR 1)
|
||||||
|
set(ADAPTER_VERSION_PATCH 0)
|
||||||
|
set(ADAPTER_VERSION_SHORT "${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}")
|
||||||
|
set(ADAPTER_VERSION
|
||||||
|
"${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}.${ADAPTER_VERSION_PATCH}"
|
||||||
|
)
|
||||||
|
|
||||||
|
set(MEDIAREMOTEADAPTER_FRAMEWORK_NAME "MediaRemoteAdapter"
|
||||||
|
CACHE STRING "The output name of the adapter framework")
|
||||||
|
set(MEDIAREMOTEADAPTER_TEST_CLIENT_NAME "MediaRemoteAdapterTestClient"
|
||||||
|
CACHE STRING "The output name of the adapter test client")
|
||||||
|
|
||||||
|
set(ADAPTER_SOURCES
|
||||||
|
src/adapter/env.m
|
||||||
|
src/adapter/get.m
|
||||||
|
src/adapter/globals.m
|
||||||
|
src/adapter/keys.m
|
||||||
|
src/adapter/now_playing.m
|
||||||
|
src/adapter/repeat.m
|
||||||
|
src/adapter/seek.m
|
||||||
|
src/adapter/send.m
|
||||||
|
src/adapter/shuffle.m
|
||||||
|
src/adapter/speed.m
|
||||||
|
src/adapter/stream.m
|
||||||
|
src/adapter/test.m
|
||||||
|
src/private/MediaRemote.m
|
||||||
|
src/utility/Debounce.m
|
||||||
|
src/utility/helpers.m)
|
||||||
|
set(HEADERS include/MediaRemoteAdapter.h)
|
||||||
|
|
||||||
|
set(CMAKE_OSX_ARCHITECTURES "x86_64;arm64")
|
||||||
|
|
||||||
|
add_library(MediaRemoteAdapter SHARED ${ADAPTER_SOURCES})
|
||||||
|
|
||||||
|
set_target_properties(
|
||||||
|
MediaRemoteAdapter
|
||||||
|
PROPERTIES FRAMEWORK TRUE
|
||||||
|
FRAMEWORK_VERSION A
|
||||||
|
MACOSX_FRAMEWORK_IDENTIFIER
|
||||||
|
"com.vandenbe.${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}"
|
||||||
|
MACOSX_FRAMEWORK_SHORT_VERSION_STRING "${ADAPTER_VERSION_SHORT}"
|
||||||
|
MACOSX_FRAMEWORK_BUNDLE_VERSION "${ADAPTER_VERSION}"
|
||||||
|
MACOSX_FRAMEWORK_BUNDLE_NAME
|
||||||
|
"${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}"
|
||||||
|
PUBLIC_HEADER "${HEADERS}"
|
||||||
|
OUTPUT_NAME "${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}")
|
||||||
|
|
||||||
|
target_link_libraries(MediaRemoteAdapter "-framework Foundation"
|
||||||
|
"-framework AppKit")
|
||||||
|
|
||||||
|
find_library(UT_FRAMEWORK UniformTypeIdentifiers)
|
||||||
|
if(UT_FRAMEWORK)
|
||||||
|
target_link_libraries(MediaRemoteAdapter ${UT_FRAMEWORK})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
target_include_directories(MediaRemoteAdapter
|
||||||
|
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
|
||||||
|
target_include_directories(MediaRemoteAdapter
|
||||||
|
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||||
|
|
||||||
|
target_compile_options(MediaRemoteAdapter PRIVATE "-fobjc-arc")
|
||||||
|
|
||||||
|
# Ensure that symbols are visible, otherwise the Perl script can't call into
|
||||||
|
# functions that are exported by the framework.
|
||||||
|
target_compile_options(MediaRemoteAdapter PRIVATE -fvisibility=default)
|
||||||
|
|
||||||
|
add_custom_command(
|
||||||
|
TARGET MediaRemoteAdapter
|
||||||
|
POST_BUILD
|
||||||
|
COMMAND codesign --force --deep --sign -
|
||||||
|
$<TARGET_BUNDLE_DIR:MediaRemoteAdapter>
|
||||||
|
COMMENT "Ad-hoc signing ${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}.framework")
|
||||||
|
|
||||||
|
add_executable(MediaRemoteAdapterTestClient src/test/main.m
|
||||||
|
src/test/NowPlayingTest.m)
|
||||||
|
target_link_libraries(MediaRemoteAdapterTestClient
|
||||||
|
PRIVATE "-framework Foundation" "-framework MediaPlayer")
|
||||||
|
target_include_directories(MediaRemoteAdapterTestClient
|
||||||
|
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/test)
|
||||||
|
set_target_properties(MediaRemoteAdapterTestClient
|
||||||
|
PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
|
||||||
|
set_source_files_properties(src/test/main.m PROPERTIES COMPILE_FLAGS
|
||||||
|
"-fobjc-arc")
|
||||||
|
set_source_files_properties(src/test/NowPlayingTest.m PROPERTIES COMPILE_FLAGS
|
||||||
|
"-fobjc-arc")
|
||||||
|
set_target_properties(
|
||||||
|
MediaRemoteAdapterTestClient
|
||||||
|
PROPERTIES OUTPUT_NAME "${MEDIAREMOTEADAPTER_TEST_CLIENT_NAME}")
|
||||||
28
Vendor/mediaremote-adapter/LICENSE
vendored
Normal file
28
Vendor/mediaremote-adapter/LICENSE
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2025, Jonas van den Berg and contributors
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
10
Vendor/mediaremote-adapter/Makefile
vendored
Normal file
10
Vendor/mediaremote-adapter/Makefile
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.PHONY: all version
|
||||||
|
|
||||||
|
all:
|
||||||
|
|
||||||
|
version:
|
||||||
|
@system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p'
|
||||||
|
@date
|
||||||
|
|
||||||
|
update-readme-badges:
|
||||||
|
python3 ./scripts/update-readme-badges.py
|
||||||
1
Vendor/mediaremote-adapter/ONYX-VERSION.txt
vendored
Normal file
1
Vendor/mediaremote-adapter/ONYX-VERSION.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3ac3d4bdf862c7b5399b4fba4df5689f5c38609a 2026-05-11 16:52:03 +0200
|
||||||
529
Vendor/mediaremote-adapter/README.md
vendored
Normal file
529
Vendor/mediaremote-adapter/README.md
vendored
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
> **@Apple** *Before breaking this,
|
||||||
|
> please consider giving Mac users the option
|
||||||
|
> to share actively playing media with the apps they use
|
||||||
|
> and to control media playback.
|
||||||
|
> Perhaps by introducing a new entitlement
|
||||||
|
> that can be granted to apps by users in the system settings.
|
||||||
|
> There are
|
||||||
|
> [many](https://musicpresence.app)
|
||||||
|
> [use](https://folivora.ai)
|
||||||
|
> [cases](https://lyricfever.com)
|
||||||
|
> [for](https://theboring.name)
|
||||||
|
> [this](https://github.com/kirtan-shah/nowplaying-cli).*
|
||||||
|
|
||||||
|
> **@Developers** *Please **star**
|
||||||
|
> this repository to show Apple that we care.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BADGES BEGIN -->
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
<!-- BADGES END -->
|
||||||
|
|
||||||
|
# MediaRemote Adapter
|
||||||
|
|
||||||
|
Get now playing information using the MediaRemote framework
|
||||||
|
on all macOS versions, including 15.4 and above.
|
||||||
|
|
||||||
|
This works by using a system binary – `/usr/bin/perl` in this case –
|
||||||
|
which is entitled to use the MediaRemote framework
|
||||||
|
and by dynamically loading a custom helper framework
|
||||||
|
that prints real-time updates to stdout.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Install the [media-control](https://github.com/ungive/media-control)
|
||||||
|
CLI tool to see this project in action. Works on all macOS versions:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ brew tap ungive/media-control
|
||||||
|
$ brew install media-control
|
||||||
|
$ media-control stream
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
This project provides a Perl script
|
||||||
|
with a well-defined CLI interface that you can invoke from your app
|
||||||
|
in order to read now playing information and control media players.
|
||||||
|
The [mediaremote-adapter.pl](./bin/mediaremote-adapter.pl) script
|
||||||
|
needs to be bundled with your app,
|
||||||
|
alongside the `MediaRemoteAdapter.framework`
|
||||||
|
and optionally the `MediaRemoteAdapterTestClient`
|
||||||
|
which are exposed as CMake targets in [CMakeLists.txt](./CMakeLists.txt).
|
||||||
|
You can find instructions to build the framework in the next section.
|
||||||
|
|
||||||
|
The script must then be invoked like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework COMMAND
|
||||||
|
```
|
||||||
|
|
||||||
|
`COMMAND` is a placeholder for one of the commands documented below.
|
||||||
|
|
||||||
|
For the `test` command the `NowPlayingTestCMediaRemoteAdapterTestClientlient` must be passed
|
||||||
|
as an additional argument:
|
||||||
|
|
||||||
|
```
|
||||||
|
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test
|
||||||
|
```
|
||||||
|
|
||||||
|
For ease of use you can also always pass the path to the test client:
|
||||||
|
|
||||||
|
```
|
||||||
|
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient COMMAND
|
||||||
|
```
|
||||||
|
|
||||||
|
For more help on available commands read below or omit the `COMMAND` argument.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This project is still in development
|
||||||
|
> and the API may experience breaking changes across minor revisions.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> For a maintained Swift package look at this excellent fork:
|
||||||
|
> https://github.com/ejbills/mediaremote-adapter
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/ungive/mediaremote-adapter.git
|
||||||
|
$ cd mediaremote-adapter
|
||||||
|
$ mkdir build && cd build
|
||||||
|
$ cmake ..
|
||||||
|
$ cmake --build .
|
||||||
|
$ cd ..
|
||||||
|
$ FRAMEWORK_PATH=$(realpath ./build/MediaRemoteAdapter.framework)
|
||||||
|
$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" stream
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `MediaRemoteAdapter.framework` in the build directory,
|
||||||
|
which must be *bundled* with your app, but *not linked against*.
|
||||||
|
The framework is only used by the script
|
||||||
|
and must merely be passed as a script argument.
|
||||||
|
|
||||||
|
If you want to be able to test whether the adapter still works,
|
||||||
|
which can be useful to e.g. automatically fall back to AppleScript,
|
||||||
|
you need to also bundle the `MediaRemoteAdapterTestClient` executable with your app
|
||||||
|
and pass it as an additional argument:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ HELPER_PATH=$(realpath ./build/MediaRemoteAdapterTestClient)
|
||||||
|
$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" "$HELPER_PATH" test
|
||||||
|
```
|
||||||
|
|
||||||
|
An exit code of `0` then means the adapter is functional and safe to use.
|
||||||
|
|
||||||
|
The framework and test executable are built for the following architectures:
|
||||||
|
`x86_64` `arm64`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- [get](#get)
|
||||||
|
- [stream](#stream)
|
||||||
|
- [send COMMAND](#send-command)
|
||||||
|
- [seek POSITION](#seek-position)
|
||||||
|
- [shuffle MODE](#shuffle-mode)
|
||||||
|
- [repeat MODE](#repeat-mode)
|
||||||
|
- [speed SPEED](#speed-speed)
|
||||||
|
- [test](#test)
|
||||||
|
|
||||||
|
### get
|
||||||
|
|
||||||
|
Prints now playing information once with all available metadata.
|
||||||
|
|
||||||
|
Output is encoded as JSON and characterized by either `null`
|
||||||
|
or a dictionary with any of the following keys:
|
||||||
|
|
||||||
|
> `bundleIdentifier`
|
||||||
|
`parentApplicationBundleIdentifier`
|
||||||
|
`playing`
|
||||||
|
`title`
|
||||||
|
`artist`
|
||||||
|
`album`
|
||||||
|
`duration`
|
||||||
|
`elapsedTime`
|
||||||
|
`timestamp`
|
||||||
|
`artworkMimeType`
|
||||||
|
`artworkData`
|
||||||
|
`chapterNumber`
|
||||||
|
`composer`
|
||||||
|
`genre`
|
||||||
|
`isAdvertisement`
|
||||||
|
`isBanned`
|
||||||
|
`isInWishList`
|
||||||
|
`isLiked`
|
||||||
|
`isMusicApp`
|
||||||
|
`playbackRate`
|
||||||
|
`prohibitsSkip`
|
||||||
|
`queueIndex`
|
||||||
|
`radioStationIdentifier`
|
||||||
|
`repeatMode`
|
||||||
|
`shuffleMode`
|
||||||
|
`startTime`
|
||||||
|
`supportsFastForward15Seconds`
|
||||||
|
`supportsIsBanned`
|
||||||
|
`supportsIsLiked`
|
||||||
|
`supportsRewind15Seconds`
|
||||||
|
`totalChapterCount`
|
||||||
|
`totalDiscCount`
|
||||||
|
`totalQueueCount`
|
||||||
|
`totalTrackCount`
|
||||||
|
`trackNumber`
|
||||||
|
`uniqueIdentifier`
|
||||||
|
`contentItemIdentifier`
|
||||||
|
`radioStationHash`
|
||||||
|
`mediaType`
|
||||||
|
|
||||||
|
The following mandatory keys never have a null value:
|
||||||
|
`bundleIdentifier`
|
||||||
|
`playing`
|
||||||
|
`title`.
|
||||||
|
If any of the mandatory keys cannot be determined,
|
||||||
|
the command prints `null`.
|
||||||
|
Media without a title is considered invalid.
|
||||||
|
|
||||||
|
The `mediaType` may contain one of the following values:
|
||||||
|
- `MRMediaRemoteMediaTypeMusic`
|
||||||
|
- `kMRMediaRemoteNowPlayingInfoTypeAudio`
|
||||||
|
- Possibly others, this key is not very well documented
|
||||||
|
|
||||||
|
**Caveats**
|
||||||
|
|
||||||
|
Metadata such as `artworkData` and `artworkMimeType`
|
||||||
|
often takes a bit of time to load
|
||||||
|
and may not appear in the output in all cases.
|
||||||
|
Do not rely on this key to be present reliably.
|
||||||
|
Either use the `stream` command or poll `get` regularly,
|
||||||
|
to ensure you get the artwork data *eventually*.
|
||||||
|
|
||||||
|
**Options**
|
||||||
|
|
||||||
|
`--now` Adds an `elapsedTimeNow` key with an estimation of the current
|
||||||
|
elapsed playback time. This estimation may be off by up to a second.
|
||||||
|
To determine a more accurate time without polling `get` continuously,
|
||||||
|
calculate it using the `elapsedTime` and `timestamp` keys. `elapsedTime`
|
||||||
|
contains the elapsed time at the time that is stored in `timestamp`.
|
||||||
|
|
||||||
|
`--micros` Replaces the following keys with microsecond equivalents:
|
||||||
|
|
||||||
|
| Original key | Converted key name | Comment |
|
||||||
|
|------------------|------------------------|---------------------------|
|
||||||
|
| `duration` | `durationMicros` | - |
|
||||||
|
| `elapsedTime` | `elapsedTimeMicros` | - |
|
||||||
|
| `elapsedTimeNow` | `elapsedTimeNowMicros` | Only present with `--now` |
|
||||||
|
| `timestamp` | `timestampEpochMicros` | Converted to epoch time |
|
||||||
|
|
||||||
|
`--no-artwork` Omits the `artworkData` and `artworkMimeType` keys
|
||||||
|
from the payload. Useful for consumers that do not render artwork,
|
||||||
|
since this avoids emitting several hundred kilobytes of base64-encoded
|
||||||
|
image data per update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### stream
|
||||||
|
|
||||||
|
Streams now playing information updates in real-time
|
||||||
|
until the script receives a SIGTERM signal.
|
||||||
|
|
||||||
|
Output is encoded as JSON and characterized by
|
||||||
|
a dictionary with the following keys:
|
||||||
|
|
||||||
|
> `type`
|
||||||
|
`diff`
|
||||||
|
`payload`
|
||||||
|
|
||||||
|
`type` is always a string with the value `"data"`.
|
||||||
|
|
||||||
|
`payload` contains the now playing information and is a dictionary
|
||||||
|
that is structurally identical to the output of the `get` command,
|
||||||
|
with the same keys. The dictionary itself is never `null`.
|
||||||
|
No keys are set at all,
|
||||||
|
when no media player is reporting now playing information.
|
||||||
|
Some keys may have a `null` value,
|
||||||
|
when the media player reports `null` for them (this happens rarely, if ever).
|
||||||
|
Any key may be `null`, when `diff` is set to true and the key vanishes.
|
||||||
|
|
||||||
|
`diff` is a boolean that indicates whether the `payload`
|
||||||
|
contains only fields whose values have been updated.
|
||||||
|
When set to `false`,
|
||||||
|
the payload is to be considered the current now playing state
|
||||||
|
with all available keys and their values,
|
||||||
|
regardless of any payloads that have been sent in the past.
|
||||||
|
When set to `true` on the other hand,
|
||||||
|
the last sent non-diff payload must be updated with these new values,
|
||||||
|
in order to have a representation of the the current now playing state.
|
||||||
|
When a key is not present anymore, it's set to `null` in the payload
|
||||||
|
and its previous value should be removed.
|
||||||
|
**Diffing is enabled by default, but can be disabled with a command line flag.**
|
||||||
|
|
||||||
|
**Options**
|
||||||
|
|
||||||
|
`--no-diff` Disables diffing. `diff` is always `false`
|
||||||
|
and `payload` always contains all current information.
|
||||||
|
|
||||||
|
`--debounce=N` Adds a debounce delay in milliseconds
|
||||||
|
between the point where changes are detected
|
||||||
|
and when they are printed.
|
||||||
|
If a new update comes in during delaying,
|
||||||
|
the delay is restarted and all updates are merged.
|
||||||
|
This is useful to prevent bursts of smaller updates.
|
||||||
|
The default is 0.
|
||||||
|
|
||||||
|
`--micros` Identical to the `--micros` option of the `get` command.
|
||||||
|
|
||||||
|
`--no-artwork` Identical to the `--no-artwork` option of the `get`
|
||||||
|
command. Particularly useful with `stream` with the `--no-diff` parameter,
|
||||||
|
since it otherwise re-emits the full artwork payload on every update.
|
||||||
|
|
||||||
|
**Experimental options**
|
||||||
|
|
||||||
|
`--experimental-peculiar-debounce:BUNDLE_ID=N` 
|
||||||
|
Adds a debounce delay in milliseconds for the case when the media player
|
||||||
|
with the given bundle identifier (`BUNDLE_ID`) reports metadata that
|
||||||
|
contains parts of the previous track and parts of the next track,
|
||||||
|
but doesn't contain the full metadata of the next track.
|
||||||
|
Whenever the track title changes,
|
||||||
|
the update for it is either delayed for the given debounce delay
|
||||||
|
or the update is printed when all other metadata updated as well,
|
||||||
|
whichever happens earlier.
|
||||||
|
Currently only `com.tidal.desktop` can be passed for `BUNDLE_ID`,
|
||||||
|
since it is the only media player that is known to have this issue.
|
||||||
|
A value of `1000` for `N` is recommended for TIDAL specifically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### send COMMAND
|
||||||
|
|
||||||
|
Sends a MediaRemote command to the now playing application.
|
||||||
|
|
||||||
|
The value for `COMMAND` must be a valid ID from the table below.
|
||||||
|
|
||||||
|
| ID | MediaRemote key | Description |
|
||||||
|
|:--:|-------------------------|-------------------------------|
|
||||||
|
| 0 | kMRPlay | Start playback |
|
||||||
|
| 1 | kMRPause | Pause playback |
|
||||||
|
| 2 | kMRTogglePlayPause | Toggle between play and pause |
|
||||||
|
| 3 | kMRStop | Stop playback |
|
||||||
|
| 4 | kMRNextTrack | Skip to the next track |
|
||||||
|
| 5 | kMRPreviousTrack | Return to the previous track |
|
||||||
|
| 6 | kMRToggleShuffle | Toggle shuffle mode |
|
||||||
|
| 7 | kMRToggleRepeat | Toggle repeat mode |
|
||||||
|
| 8 | kMRStartForwardSeek | Start seeking forward |
|
||||||
|
| 9 | kMREndForwardSeek | Stop seeking forward |
|
||||||
|
| 10 | kMRStartBackwardSeek | Start seeking backward |
|
||||||
|
| 11 | kMREndBackwardSeek | Stop seeking backward |
|
||||||
|
| 12 | kMRGoBackFifteenSeconds | Go back 15 seconds |
|
||||||
|
| 13 | kMRSkipFifteenSeconds | Skip ahead 15 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### seek POSITION
|
||||||
|
|
||||||
|
Seeks to a specific timeline position with the now playing application.
|
||||||
|
|
||||||
|
The value for `POSITION` must a valid positive integer.
|
||||||
|
The unit is microseconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### shuffle MODE
|
||||||
|
|
||||||
|
Sets the shuffle mode.
|
||||||
|
|
||||||
|
The value for `MODE` must be a valid ID from the table below.
|
||||||
|
|
||||||
|
| ID | Description |
|
||||||
|
|:--:|----------------|
|
||||||
|
| 1 | Disable |
|
||||||
|
| 2 | Shuffle albums |
|
||||||
|
| 3 | Shuffle tracks |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### repeat MODE
|
||||||
|
|
||||||
|
Sets the repeat mode.
|
||||||
|
|
||||||
|
The value for `MODE` must be a valid ID from the table below.
|
||||||
|
|
||||||
|
| ID | Description |
|
||||||
|
|:--:|-----------------|
|
||||||
|
| 1 | Disable |
|
||||||
|
| 2 | Repeat track |
|
||||||
|
| 3 | Repeat playlist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### speed SPEED
|
||||||
|
|
||||||
|
Sets the playback speed.
|
||||||
|
|
||||||
|
The value for `SPEED` must be a valid positive integer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### test
|
||||||
|
|
||||||
|
Tests if the adapter is entitled to use the MediaRemote framework
|
||||||
|
and if it is able to execute any of the supported commands without failure.
|
||||||
|
An exit code of 0 means the adapter is functional and safe to use.
|
||||||
|
|
||||||
|
This can be integrated into your app
|
||||||
|
to help confirm that the adapter is still functional and if not,
|
||||||
|
fall back to other methods for media detection (e.g. AppleScript),
|
||||||
|
since future macOS updates may break MediaRemote access again.
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the `test` command requires the absolute path
|
||||||
|
to the `MediaRemoteAdapterTestClient` executable after the framework path.
|
||||||
|
For ease of use you can always pass the path to the test client executable,
|
||||||
|
even when using other commands, like `get` or `stream`.
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
An exit code of `0` indicates that the adapter is still functional
|
||||||
|
and can safely be used to detect media.
|
||||||
|
Any other exit indicates that the adapter is likely broken.
|
||||||
|
|
||||||
|
If you ever get an exit code other than `0`,
|
||||||
|
please [report this](https://github.com/ungive/mediaremote-adapter/issues). Thank you!
|
||||||
|
|
||||||
|
#### How this works
|
||||||
|
|
||||||
|
1. Now playing information is attempted to be read normally using `get`
|
||||||
|
2. If no media is detected, the `MediaRemoteAdapterTestClient` helper process is launched to simulate media playback
|
||||||
|
3. While the helper process is running, now playing information is attempted to be read again using `get`
|
||||||
|
4. Afterwards the helper process is terminated
|
||||||
|
5. If any of the `get` attempts yielded media information, the command exits with an exit code of `0`
|
||||||
|
6. Otherwise the command exits with an exit code of `1`
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **May interfere with other apps using MediaRemote**
|
||||||
|
> The test can create a fake media entry that will briefly appear
|
||||||
|
as the now playing application.
|
||||||
|
This only happens when no other media is playing.
|
||||||
|
Since the helper process has no bundle identifier,
|
||||||
|
it is mostly ignored by the `stream` and `get` commands —
|
||||||
|
`stream` won't update, and `get` will print `null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built-in fixes
|
||||||
|
|
||||||
|
This library has some fixes built-in
|
||||||
|
to accomodate for inconsistencies within the MediaRemote framework:
|
||||||
|
|
||||||
|
- Artwork data sometimes unloads for a brief moment,
|
||||||
|
e.g. when changing the current timeline position of a track.
|
||||||
|
To combat this, artwork data is reused when the track has not changed,
|
||||||
|
the track had artwork data before and the artwork data has disappeared.
|
||||||
|
This fix is applied when using the `stream` command
|
||||||
|
|
||||||
|
If you need a way to disable any or all of these fixes,
|
||||||
|
please open an issue or create a pull request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- Consider `NSJSONSerialization` for JSON deserialization.
|
||||||
|
This is what is used for encoding
|
||||||
|
- You can use `NSData`'s `initWithBase64EncodedString`
|
||||||
|
for decoding of base64 data
|
||||||
|
- Every line printed to stderr is an error message.
|
||||||
|
If the script did not exit with a non-zero exit code,
|
||||||
|
then any of these errors are non-fatal and can be safely ignored
|
||||||
|
- Other apps using MediaRemote Adapter may run `test` which should not interfere with the `stream` and `get` commands, but will generate a missing bundle identifier error message, which can be ignored. See the `test` command section for more information.
|
||||||
|
- You should not reinvoke the script when a fatal error occurs
|
||||||
|
(non-zero exit code)
|
||||||
|
- Make sure to pass the absolute path of the bundled framework and helper executable
|
||||||
|
as arguments and not a relative path
|
||||||
|
|
||||||
|
## Why this works
|
||||||
|
|
||||||
|
According to the findings by [@My-Iris](https://github.com/Mx-Iris) in
|
||||||
|
[this comment](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419)
|
||||||
|
processes with a bundle identifier starting with `com.apple.`
|
||||||
|
are granted permission to access the MediaRemote framework.
|
||||||
|
The Perl platform binary `/usr/bin/perl`
|
||||||
|
is reported as having the bundle identifier `com.apple.perl` (or a variation).
|
||||||
|
|
||||||
|
You can confirm this by streaming log messages using the Console.app
|
||||||
|
whilst running the script:
|
||||||
|
|
||||||
|
`default 14:44:55.871495+0200 mediaremoted Adding client <MRDMediaRemoteClient 0x15820b1a0, bundleIdentifier = com.apple.perl5, pid = 86889>`
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
This project was created due to the MediaRemote framework
|
||||||
|
being completely non-functional when being loaded directly from within an app,
|
||||||
|
starting with macOS 15.4 (see the numerous issues linked below).
|
||||||
|
|
||||||
|
The aim of this project is to provide a tool (and perhaps soon a full library)
|
||||||
|
that serves as a fully functional alternative to using MediaRemote directly
|
||||||
|
and perhaps to inspire Apple to give us a public API
|
||||||
|
to read now playing information and control media playback on the device
|
||||||
|
(see the note at the top of this file).
|
||||||
|
|
||||||
|
## Projects that use this library
|
||||||
|
|
||||||
|
- [Now Playing Display](https://www.gxlabs.co/now-playing/): A macOS "now playing" hardware display for the Seeed XIAO ESP32-C6 paired with the Seeed Round Display for XIAO. Shows album art, track info, progress, and playback controls on a 240x240 circular TFT — connected over USB. Source code: https://github.com/gxlabs/now-playing-device
|
||||||
|
- [Music Presence](https://musicpresence.app) is a cross-platform desktop application
|
||||||
|
for showing what you are listening to in your Discord status.
|
||||||
|
It uses this library since version [2.3.1](https://github.com/ungive/discord-music-presence/releases/tag/v2.3.1)
|
||||||
|
to detect media from all media players again. Website: https://musicpresence.app
|
||||||
|
- [media-control](https://github.com/ungive/media-control)
|
||||||
|
is a CLI tool to control and observe media playback on any macOS version.
|
||||||
|
You can install it directly via brew: `$ brew tap ungive/media-control && brew install media-control`. Source code: https://github.com/ungive/media-control
|
||||||
|
|
||||||
|
*If you use this library in your project, please
|
||||||
|
[let me know](https://github.com/ungive/mediaremote-adapter/issues) and I'll add it to the list!*
|
||||||
|
|
||||||
|
## Useful links
|
||||||
|
|
||||||
|
- Issues regarding MediaRemote breaking since macOS 15.4
|
||||||
|
- https://github.com/vincentneo/LosslessSwitcher/issues/161
|
||||||
|
- https://github.com/aviwad/LyricFever/issues/94
|
||||||
|
- https://github.com/TheBoredTeam/boring.notch/issues/417
|
||||||
|
- https://community.folivora.ai/t/now-playing-is-no-longer-working-on-macos-15-4/42802/11
|
||||||
|
- https://github.com/ungive/discord-music-presence/issues/165
|
||||||
|
- https://github.com/ungive/discord-music-presence/issues/245
|
||||||
|
- https://github.com/kirtan-shah/nowplaying-cli/issues/28
|
||||||
|
- https://github.com/FelixKratz/SketchyBar/issues/708
|
||||||
|
- Getting now playing information using `osascript` and `MRNowPlayingRequest`.
|
||||||
|
Note that this is unable to load the song artwork
|
||||||
|
and it is impossible to get real-time updates with this solution.
|
||||||
|
It is much simpler to implement though
|
||||||
|
- https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800
|
||||||
|
- https://github.com/fastfetch-cli/fastfetch/commit/1557f0c5564a8288604824e55db47508f65e82c9
|
||||||
|
- https://gist.github.com/SKaplanOfficial/f9f5bdd6455436203d0d318c078358de
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Thank you [@Alexander5015](https://github.com/Alexander5015) for implementing the `test` command,
|
||||||
|
so we're able to detect when the adapter stops working!
|
||||||
|
|
||||||
|
Thank you [@EinTim23](https://github.com/EinTim23) for bringing
|
||||||
|
a [similar workaround](https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800) to my attention!
|
||||||
|
Without your hint I most likely would not have dug into this anytime soon
|
||||||
|
and my app [Music Presence](https://musicpresence.app)
|
||||||
|
would still only work with AppleScript automation.
|
||||||
|
|
||||||
|
Thank you [@My-Iris](https://github.com/Mx-Iris)
|
||||||
|
for providing insight into the changes made since macOS 15.4:
|
||||||
|
[aviwad/LyricFever#94](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the BSD 3-Clause License.
|
||||||
|
See [LICENSE](./LICENSE) for details.
|
||||||
|
|
||||||
|
Copyright (c) 2025 Jonas van den Berg and contributors
|
||||||
280
Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl
vendored
Executable file
280
Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl
vendored
Executable file
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/perl
|
||||||
|
# Copyright (c) 2025 Jonas van den Berg
|
||||||
|
# This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
# For usage information read below or run the script without arguments.
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use DynaLoader;
|
||||||
|
use File::Spec;
|
||||||
|
use File::Basename;
|
||||||
|
|
||||||
|
sub print_help() {
|
||||||
|
print <<'HELP';
|
||||||
|
Usage:
|
||||||
|
mediaremote-adapter.pl FRAMEWORK_PATH [TEST_CLIENT_PATH]
|
||||||
|
[FUNCTION [PARAMS|OPTIONS...]]
|
||||||
|
|
||||||
|
FRAMEWORK_PATH:
|
||||||
|
Absolute path to the MediaRemoteAdapter.framework directory
|
||||||
|
|
||||||
|
TEST_CLIENT_PATH: (optional)
|
||||||
|
Absolute path to the MediaRemoteAdapterTestClient executable. Only for "test"
|
||||||
|
|
||||||
|
FUNCTION:
|
||||||
|
stream Streams now playing information (as diff by default)
|
||||||
|
get Prints now playing information once with all available metadata
|
||||||
|
send Sends a command to the now playing application
|
||||||
|
seek Seeks to a specific timeline position
|
||||||
|
shuffle Sets the shuffle mode
|
||||||
|
repeat Sets the repeat mode
|
||||||
|
speed Sets the playback speed
|
||||||
|
test Tests if the adapter is entitled to use the MediaRemote framework.
|
||||||
|
An exit code other than 0 indicates the adapter is non-functional
|
||||||
|
|
||||||
|
PARAMS:
|
||||||
|
send(command)
|
||||||
|
command: The MRCommand ID as a number (e.g. kMRPlay = 0)
|
||||||
|
seek(position)
|
||||||
|
position: The timeline position in microseconds
|
||||||
|
shuffle(mode)
|
||||||
|
mode: The shuffle mode
|
||||||
|
repeat(mode)
|
||||||
|
mode: The repeat mode
|
||||||
|
speed(speed)
|
||||||
|
speed: The playback speed
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
get
|
||||||
|
--now: Adds an "elapsedTimeNow" key with an estimation of the current
|
||||||
|
elapsed playback time. This estimation may be off by up to a second.
|
||||||
|
To determine a more accurate time without polling "get" continuously,
|
||||||
|
calculate it using the "elapsedTime" and "timestamp" keys. "elapsedTime"
|
||||||
|
contains the elapsed time at the time that is stored in "timestamp".
|
||||||
|
stream
|
||||||
|
--no-diff: Disable diffing and always dump all metadata
|
||||||
|
--debounce=N: Delay in milliseconds to prevent spam (0 by default)
|
||||||
|
get, stream
|
||||||
|
--micros: Replaces the following time keys with microsecond equivalents:
|
||||||
|
"duration" -> "durationMicros"
|
||||||
|
"elapsedTime" -> "elapsedTimeMicros"
|
||||||
|
"elapsedTimeNow" -> "elapsedTimeNowMicros"
|
||||||
|
"timestamp" -> "timestampEpochMicros" (converted to epoch time)
|
||||||
|
--no-artwork: Omits "artworkData" and "artworkMimeType" from the payload.
|
||||||
|
Useful for consumers that do not render artwork, since this avoids
|
||||||
|
emitting several hundred kilobytes of base64 data per update.
|
||||||
|
--human-readable, -h: Makes values human-readable. Use only for debugging.
|
||||||
|
The JSON output is pretty-printed and the following keys are adapted:
|
||||||
|
"artworkData" -> Binary data is truncated to a shorter representation
|
||||||
|
|
||||||
|
Examples (script name and framework path omitted):
|
||||||
|
stream --no-diff --debounce=100
|
||||||
|
send 2 # Toggles play/pause in the media player (kMRATogglePlayPause)
|
||||||
|
repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist)
|
||||||
|
|
||||||
|
HELP
|
||||||
|
exit 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined $ARGV[1]) {
|
||||||
|
print_help();
|
||||||
|
}
|
||||||
|
|
||||||
|
sub fail {
|
||||||
|
my ($error) = @_;
|
||||||
|
print STDERR "$error\n";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail "Framework path not provided" unless @ARGV >= 1;
|
||||||
|
|
||||||
|
my $framework_path = shift @ARGV;
|
||||||
|
|
||||||
|
# Optionally accept MEDIAREMOTEADAPTER_TEST_CLIENT_PATH path as second argument
|
||||||
|
my $maybe_helper_path = $ARGV[0] // '';
|
||||||
|
if ($maybe_helper_path =~ m{/}){
|
||||||
|
my $helper_path = shift @ARGV;
|
||||||
|
$ENV{MEDIAREMOTEADAPTER_TEST_CLIENT_PATH} = $helper_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined $ARGV[0]) {
|
||||||
|
print_help();
|
||||||
|
}
|
||||||
|
|
||||||
|
my $framework_basename = File::Basename::basename($framework_path);
|
||||||
|
fail "Provided path is not a framework: $framework_path"
|
||||||
|
unless $framework_basename =~ s/\.framework$//;
|
||||||
|
|
||||||
|
my $framework = File::Spec->catfile($framework_path, $framework_basename);
|
||||||
|
fail "Framework not found at $framework" unless -e $framework;
|
||||||
|
|
||||||
|
my $handle = DynaLoader::dl_load_file($framework, 0)
|
||||||
|
or fail "Failed to load framework: $framework";
|
||||||
|
my $function_name = shift @ARGV or fail "Missing function name";
|
||||||
|
fail "Invalid function name: '$function_name'"
|
||||||
|
unless $function_name eq "stream"
|
||||||
|
|| $function_name eq "get"
|
||||||
|
|| $function_name eq "send"
|
||||||
|
|| $function_name eq "seek"
|
||||||
|
|| $function_name eq "shuffle"
|
||||||
|
|| $function_name eq "repeat"
|
||||||
|
|| $function_name eq "speed"
|
||||||
|
|| $function_name eq "test";
|
||||||
|
|
||||||
|
sub parse_options {
|
||||||
|
my ($start_index) = @_;
|
||||||
|
my %arg_map;
|
||||||
|
my $i = $start_index;
|
||||||
|
while ($i <= $#ARGV) {
|
||||||
|
my $arg = $ARGV[$i];
|
||||||
|
if ($arg =~ /^--([a-z:\.\\-]+)(?:=(.*))?$/) {
|
||||||
|
my $key = $1;
|
||||||
|
my $value = defined $2 ? $2 : undef;
|
||||||
|
$arg_map{$key} = $value;
|
||||||
|
splice @ARGV, $i, 1;
|
||||||
|
}
|
||||||
|
elsif ($arg =~ /^-([a-zA-Z]+)$/) {
|
||||||
|
my @flags = split //, $1;
|
||||||
|
$arg_map{$_} = undef for @flags;
|
||||||
|
splice @ARGV, $i, 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return \%arg_map;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub env_func {
|
||||||
|
my $symbol_name = shift;
|
||||||
|
return "${symbol_name}_env";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_param {
|
||||||
|
my ($func, $index, $name, $value) = @_;
|
||||||
|
$ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_option_unsafe {
|
||||||
|
my ($name, $value) = @_;
|
||||||
|
$name =~ s/-/_/g;
|
||||||
|
$ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_option {
|
||||||
|
my ($options, $key) = @_;
|
||||||
|
my $value = $options->{$key};
|
||||||
|
if (defined $value) {
|
||||||
|
fail "Unexpected value for option '$key'";
|
||||||
|
}
|
||||||
|
set_env_option_unsafe($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_option_value {
|
||||||
|
my ($options, $key) = @_;
|
||||||
|
my $value = $options->{$key};
|
||||||
|
if (!defined $value) {
|
||||||
|
fail "Missing value for option '$key'";
|
||||||
|
}
|
||||||
|
set_env_option_unsafe($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
my $symbol_name = "adapter_$function_name";
|
||||||
|
if ($function_name eq "send") {
|
||||||
|
my $id = shift @ARGV;
|
||||||
|
fail "Missing ID for '$function_name' command" unless defined $id;
|
||||||
|
set_env_param($symbol_name, 0, "command", "$id");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "stream") {
|
||||||
|
my $options = parse_options(0);
|
||||||
|
foreach my $key (keys %{$options}) {
|
||||||
|
if ($key eq "no-diff") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "debounce") {
|
||||||
|
set_env_option_value($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "micros") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "no-artwork") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "human-readable" || $key eq "h") {
|
||||||
|
set_env_option($options, "human-readable");
|
||||||
|
}
|
||||||
|
elsif ($key eq "experimental-peculiar-debounce:com.tidal.desktop") {
|
||||||
|
set_env_option_value($options, $key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fail "Unrecognized option '$key'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "get") {
|
||||||
|
my $options = parse_options(0);
|
||||||
|
foreach my $key (keys %{$options}) {
|
||||||
|
if ($key eq "micros") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "no-artwork") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "human-readable" || $key eq "h") {
|
||||||
|
set_env_option($options, "human-readable");
|
||||||
|
}
|
||||||
|
elsif ($key eq "now") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fail "Unrecognized option '$key'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "seek") {
|
||||||
|
my $position = shift @ARGV;
|
||||||
|
fail "Missing position for '$function_name' command" unless defined $position;
|
||||||
|
set_env_param($symbol_name, 0, "position", "$position");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "shuffle") {
|
||||||
|
my $mode = shift @ARGV;
|
||||||
|
fail "Missing mode for '$function_name' command" unless defined $mode;
|
||||||
|
set_env_param($symbol_name, 0, "mode", "$mode");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "repeat") {
|
||||||
|
my $mode = shift @ARGV;
|
||||||
|
fail "Missing mode for '$function_name' command" unless defined $mode;
|
||||||
|
set_env_param($symbol_name, 0, "mode", "$mode");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "speed") {
|
||||||
|
my $speed = shift @ARGV;
|
||||||
|
fail "Missing speed for '$function_name' command" unless defined $speed;
|
||||||
|
set_env_param($symbol_name, 0, "speed", "$speed");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "test") {
|
||||||
|
$symbol_name = "adapter_test";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defined shift @ARGV) {
|
||||||
|
fail "Too many arguments";
|
||||||
|
}
|
||||||
|
|
||||||
|
my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name")
|
||||||
|
or fail "Symbol '$symbol_name' not found in $framework";
|
||||||
|
DynaLoader::dl_install_xsub("main::$function_name", $symbol);
|
||||||
|
|
||||||
|
eval {
|
||||||
|
no strict "refs";
|
||||||
|
&{"main::$function_name"}();
|
||||||
|
};
|
||||||
|
if ($@) {
|
||||||
|
fail "Error executing $function_name: $@";
|
||||||
|
}
|
||||||
126
Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h
vendored
Normal file
126
Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h
vendored
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_ADAPTER_H
|
||||||
|
#define MEDIAREMOTEADAPTER_ADAPTER_H
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
// Methods suffixed with "_env" read its parameters from the environment.
|
||||||
|
// Parameters must have the format:
|
||||||
|
// MEDIAREMOTEADAPTER_<FUNC_NAME>_<PARAM_INDEX>_<PARAM_NAME>
|
||||||
|
// Example: MEDIAREMOTEADAPTER_adapter_send_0_command
|
||||||
|
|
||||||
|
extern NSString *kMRAProcessIdentifier;
|
||||||
|
extern NSString *kMRABundleIdentifier;
|
||||||
|
extern NSString *kMRAParentApplicationBundleIdentifier;
|
||||||
|
extern NSString *kMRAPlaying;
|
||||||
|
|
||||||
|
extern NSString *kMRADurationMicros;
|
||||||
|
extern NSString *kMRAElapsedTimeMicros;
|
||||||
|
extern NSString *kMRATimestampEpochMicros;
|
||||||
|
|
||||||
|
extern NSString *kMRAElapsedTimeNow;
|
||||||
|
extern NSString *kMRAElapsedTimeNowMicros;
|
||||||
|
|
||||||
|
extern NSString *kMRATitle;
|
||||||
|
extern NSString *kMRAArtist;
|
||||||
|
extern NSString *kMRAAlbum;
|
||||||
|
extern NSString *kMRADuration;
|
||||||
|
extern NSString *kMRAElapsedTime;
|
||||||
|
extern NSString *kMRATimestamp;
|
||||||
|
extern NSString *kMRAArtworkMimeType;
|
||||||
|
extern NSString *kMRAArtworkData;
|
||||||
|
|
||||||
|
extern NSString *kMRAChapterNumber;
|
||||||
|
extern NSString *kMRAComposer;
|
||||||
|
extern NSString *kMRAGenre;
|
||||||
|
extern NSString *kMRAIsAdvertisement;
|
||||||
|
extern NSString *kMRAIsBanned;
|
||||||
|
extern NSString *kMRAIsInWishList;
|
||||||
|
extern NSString *kMRAIsLiked;
|
||||||
|
extern NSString *kMRAIsMusicApp;
|
||||||
|
extern NSString *kMRAPlaybackRate;
|
||||||
|
extern NSString *kMRAProhibitsSkip;
|
||||||
|
extern NSString *kMRAQueueIndex;
|
||||||
|
extern NSString *kMRARadioStationIdentifier;
|
||||||
|
extern NSString *kMRARepeatMode;
|
||||||
|
extern NSString *kMRAShuffleMode;
|
||||||
|
extern NSString *kMRAStartTime;
|
||||||
|
extern NSString *kMRASupportsFastForward15Seconds;
|
||||||
|
extern NSString *kMRASupportsIsBanned;
|
||||||
|
extern NSString *kMRASupportsIsLiked;
|
||||||
|
extern NSString *kMRASupportsRewind15Seconds;
|
||||||
|
extern NSString *kMRATotalChapterCount;
|
||||||
|
extern NSString *kMRATotalDiscCount;
|
||||||
|
extern NSString *kMRATotalQueueCount;
|
||||||
|
extern NSString *kMRATotalTrackCount;
|
||||||
|
extern NSString *kMRATrackNumber;
|
||||||
|
extern NSString *kMRAUniqueIdentifier;
|
||||||
|
extern NSString *kMRAContentItemIdentifier;
|
||||||
|
extern NSString *kMRARadioStationHash;
|
||||||
|
extern NSString *kMRAMediaType;
|
||||||
|
|
||||||
|
// Prints the current MediaRemote now playing information to stdout.
|
||||||
|
// Data is encoded as a JSON dictionary or "null" when there is no information.
|
||||||
|
extern void adapter_get();
|
||||||
|
extern void adapter_get_env();
|
||||||
|
|
||||||
|
// Streams MediaRemote now playing updates to stdout.
|
||||||
|
// Each update is printed on a separate lined, encoded as a JSON dictionary.
|
||||||
|
// Exits when the process receives a SIGTERM signal.
|
||||||
|
extern void adapter_stream();
|
||||||
|
extern void adapter_stream_env();
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
kMRAPlay = 0,
|
||||||
|
kMRAPause = 1,
|
||||||
|
kMRATogglePlayPause = 2,
|
||||||
|
kMRAStop = 3,
|
||||||
|
kMRANextTrack = 4,
|
||||||
|
kMRAPreviousTrack = 5,
|
||||||
|
kMRAToggleShuffle = 6,
|
||||||
|
kMRAToggleRepeat = 7,
|
||||||
|
kMRAStartForwardSeek = 8,
|
||||||
|
kMRAEndForwardSeek = 9,
|
||||||
|
kMRAStartBackwardSeek = 10,
|
||||||
|
kMRAEndBackwardSeek = 11,
|
||||||
|
kMRAGoBackFifteenSeconds = 12,
|
||||||
|
kMRASkipFifteenSeconds = 13,
|
||||||
|
} MRACommand;
|
||||||
|
|
||||||
|
// Sends the given MediaRemote command to the current now playing application.
|
||||||
|
extern void adapter_send(MRACommand command);
|
||||||
|
extern void adapter_send_env();
|
||||||
|
|
||||||
|
// Seeks the timeline of the nowplaying application to the given position.
|
||||||
|
// The position must be given in microseconds.
|
||||||
|
extern void adapter_seek(long position);
|
||||||
|
extern void adapter_seek_env();
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
kMRAShuffleDisabled = 1,
|
||||||
|
kMRAShuffleAlbums = 2,
|
||||||
|
kMRAShuffleTracks = 3,
|
||||||
|
} MRAShuffleMode;
|
||||||
|
|
||||||
|
extern void adapter_shuffle(MRAShuffleMode mode);
|
||||||
|
extern void adapter_shuffle_env();
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
kMRARepeatDisabled = 1,
|
||||||
|
kMRARepeatTrack = 2,
|
||||||
|
kMRARepeatPlaylist = 3,
|
||||||
|
} MRARepeatMode;
|
||||||
|
|
||||||
|
extern void adapter_repeat(MRARepeatMode mode);
|
||||||
|
extern void adapter_repeat_env();
|
||||||
|
|
||||||
|
extern void adapter_speed(int speed);
|
||||||
|
extern void adapter_speed_env();
|
||||||
|
|
||||||
|
// Tests whether the process is entitled to use the MediaRemote framework.
|
||||||
|
// Exits with exit code 0, if it is. Any other exit code means it is not.
|
||||||
|
extern void adapter_test();
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_ADAPTER_H
|
||||||
62
Vendor/mediaremote-adapter/scripts/update-readme-badges.py
vendored
Executable file
62
Vendor/mediaremote-adapter/scripts/update-readme-badges.py
vendored
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import urllib.parse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
STATIC_BADGES = """
|
||||||
|

|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_output(command):
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_whitespace(text):
|
||||||
|
return re.sub(r'\s+', ' ', text.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def generate_badges():
|
||||||
|
system_version = get_output(
|
||||||
|
"system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p'"
|
||||||
|
)
|
||||||
|
date_output = normalize_whitespace(get_output("date"))
|
||||||
|
encoded_system_version = urllib.parse.quote(system_version)
|
||||||
|
encoded_date = urllib.parse.quote(date_output)
|
||||||
|
badge1 = f""
|
||||||
|
badge2 = f""
|
||||||
|
return f"{STATIC_BADGES.strip()}\n{badge1}\n{badge2}"
|
||||||
|
|
||||||
|
|
||||||
|
def update_readme(script_dir, new_badges):
|
||||||
|
readme_path = os.path.join(script_dir, "..", "README.md")
|
||||||
|
with open(readme_path, "r") as file:
|
||||||
|
content = file.read()
|
||||||
|
new_content = re.sub(
|
||||||
|
r"<!-- BADGES BEGIN -->.*?<!-- BADGES END -->",
|
||||||
|
f"<!-- BADGES BEGIN -->\n{new_badges}\n<!-- BADGES END -->",
|
||||||
|
content,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
with open(readme_path, "w") as file:
|
||||||
|
file.write(new_content)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
badges = generate_badges()
|
||||||
|
update_readme(script_dir, badges)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
25
Vendor/mediaremote-adapter/src/adapter/env.h
vendored
Normal file
25
Vendor/mediaremote-adapter/src/adapter/env.h
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_ADAPTER_ENV_H
|
||||||
|
#define MEDIAREMOTEADAPTER_ADAPTER_ENV_H
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NSString *getEnvValue(NSString *name);
|
||||||
|
|
||||||
|
NSString *getEnvFuncParam(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name);
|
||||||
|
NSString *getEnvFuncParamSafe(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name);
|
||||||
|
NSNumber *getEnvFuncParamInt(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name);
|
||||||
|
long getEnvFuncParamLongSafe(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name);
|
||||||
|
int getEnvFuncParamIntSafe(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name);
|
||||||
|
|
||||||
|
NSString *getEnvOption(NSString *option_name);
|
||||||
|
NSNumber *getEnvOptionInt(NSString *option_name);
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_ADAPTER_ENV_H
|
||||||
105
Vendor/mediaremote-adapter/src/adapter/env.m
vendored
Normal file
105
Vendor/mediaremote-adapter/src/adapter/env.m
vendored
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import "env.h"
|
||||||
|
|
||||||
|
#include <limits.h>
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
static NSNumber *parseIntegerOrNil(NSString *str) {
|
||||||
|
if (str == nil)
|
||||||
|
return nil;
|
||||||
|
NSScanner *scanner = [NSScanner scannerWithString:str];
|
||||||
|
NSInteger value;
|
||||||
|
if ([scanner scanInteger:&value] && [scanner isAtEnd]) {
|
||||||
|
return @(value);
|
||||||
|
} else {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *getEnvValue(NSString *name) {
|
||||||
|
NSDictionary *env = [[NSProcessInfo processInfo] environment];
|
||||||
|
return env[[name stringByReplacingOccurrencesOfString:@"-"
|
||||||
|
withString:@"_"]];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *getEnvFuncParam(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name) {
|
||||||
|
NSString *envVarName =
|
||||||
|
[NSString stringWithFormat:@"MEDIAREMOTEADAPTER_PARAM_%@_%d_%@",
|
||||||
|
func_name, param_pos, param_name];
|
||||||
|
return getEnvValue(envVarName);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *getEnvFuncParamSafe(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name) {
|
||||||
|
NSString *result = getEnvFuncParam(func_name, param_pos, param_name);
|
||||||
|
if (result == nil) {
|
||||||
|
failf(@"Missing parameter '%@' for "
|
||||||
|
@"function '%@' at position %d",
|
||||||
|
param_name, func_name, param_pos);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSNumber *getEnvFuncParamInt(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name) {
|
||||||
|
return parseIntegerOrNil(getEnvFuncParam(func_name, param_pos, param_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
long getEnvFuncParamLongSafe(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name) {
|
||||||
|
|
||||||
|
NSString *raw = getEnvFuncParam(func_name, param_pos, param_name);
|
||||||
|
if (raw == nil) {
|
||||||
|
failf(@"Missing parameter '%@' for "
|
||||||
|
@"function '%@' at position %d",
|
||||||
|
param_name, func_name, param_pos);
|
||||||
|
}
|
||||||
|
NSNumber *result = parseIntegerOrNil(raw);
|
||||||
|
if (result == nil) {
|
||||||
|
failf(@"Parameter '%@' for "
|
||||||
|
@"function '%@' at position %d is not an integer: '%@'",
|
||||||
|
param_name, func_name, param_pos, raw);
|
||||||
|
}
|
||||||
|
if ([result longLongValue] > [result longValue]) {
|
||||||
|
failf(@"Parameter '%@' for "
|
||||||
|
@"function '%@' at position %d is too large to fit into a "
|
||||||
|
@"long integer: %@",
|
||||||
|
param_name, func_name, param_pos, raw);
|
||||||
|
}
|
||||||
|
return [result longValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
int getEnvFuncParamIntSafe(NSString *func_name, int param_pos,
|
||||||
|
NSString *param_name) {
|
||||||
|
|
||||||
|
long value = getEnvFuncParamLongSafe(func_name, param_pos, param_name);
|
||||||
|
if (value > INT_MAX) {
|
||||||
|
failf(@"Parameter '%@' for "
|
||||||
|
@"function '%@' at position %d is too large to fit into an "
|
||||||
|
@"integer: %ld",
|
||||||
|
param_name, func_name, param_pos, value);
|
||||||
|
}
|
||||||
|
if (value < INT_MIN) {
|
||||||
|
failf(@"Parameter '%@' for "
|
||||||
|
@"function '%@' at position %d is too small to fit into an "
|
||||||
|
@"integer: %ld",
|
||||||
|
param_name, func_name, param_pos, value);
|
||||||
|
}
|
||||||
|
return (int)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *getEnvOption(NSString *option_name) {
|
||||||
|
NSString *envVarName = [NSString
|
||||||
|
stringWithFormat:@"MEDIAREMOTEADAPTER_OPTION_%@", option_name];
|
||||||
|
return getEnvValue(envVarName);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSNumber *getEnvOptionInt(NSString *option_name) {
|
||||||
|
return parseIntegerOrNil(getEnvOption(option_name));
|
||||||
|
}
|
||||||
11
Vendor/mediaremote-adapter/src/adapter/get.h
vendored
Normal file
11
Vendor/mediaremote-adapter/src/adapter/get.h
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_ADAPTER_GET_H
|
||||||
|
#define MEDIAREMOTEADAPTER_ADAPTER_GET_H
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NSDictionary *internal_get(BOOL isTestMode);
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_ADAPTER_GET_H
|
||||||
146
Vendor/mediaremote-adapter/src/adapter/get.m
vendored
Normal file
146
Vendor/mediaremote-adapter/src/adapter/get.m
vendored
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#include <Foundation/Foundation.h>
|
||||||
|
#import <dispatch/dispatch.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/env.h"
|
||||||
|
#import "adapter/get.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/keys.h"
|
||||||
|
#import "adapter/now_playing.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
#define GET_TIMEOUT_MILLIS 2000
|
||||||
|
#define JSON_NULL @"null"
|
||||||
|
|
||||||
|
NSDictionary *internal_get(BOOL isTestMode) {
|
||||||
|
NSString *micros_option = getEnvOption(@"micros");
|
||||||
|
__block const bool convert_micros = micros_option != nil;
|
||||||
|
|
||||||
|
NSString *human_readable_option = getEnvOption(@"human-readable");
|
||||||
|
__block const bool human_readable = human_readable_option != nil;
|
||||||
|
|
||||||
|
NSString *now_option = getEnvOption(@"now");
|
||||||
|
__block const bool calculate_now = now_option != nil;
|
||||||
|
|
||||||
|
NSString *no_artwork_option = getEnvOption(@"no-artwork");
|
||||||
|
const bool no_artwork = no_artwork_option != nil;
|
||||||
|
|
||||||
|
__block NSMutableDictionary *liveData = [NSMutableDictionary dictionary];
|
||||||
|
__block BOOL isFromTestClient = NO;
|
||||||
|
|
||||||
|
dispatch_group_t group = dispatch_group_create();
|
||||||
|
|
||||||
|
// PID and Bundle Identifier
|
||||||
|
dispatch_group_enter(group);
|
||||||
|
g_mediaRemote.getNowPlayingApplicationPID(
|
||||||
|
g_serialdispatchQueue, ^(int pid) {
|
||||||
|
if (pid != 0) {
|
||||||
|
liveData[kMRAProcessIdentifier] = @(pid);
|
||||||
|
bool ok = appForPID(pid, ^(NSRunningApplication *process) {
|
||||||
|
if (process.bundleIdentifier != nil) {
|
||||||
|
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
|
||||||
|
}
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now Playing Client
|
||||||
|
dispatch_group_enter(group);
|
||||||
|
g_mediaRemote.getNowPlayingClient(g_serialdispatchQueue, ^(id client) {
|
||||||
|
NSString *parentAppBundleID = nil;
|
||||||
|
if (client && [client respondsToSelector:@selector
|
||||||
|
(parentApplicationBundleIdentifier)]) {
|
||||||
|
parentAppBundleID = [client
|
||||||
|
performSelector:@selector(parentApplicationBundleIdentifier)];
|
||||||
|
}
|
||||||
|
if (parentAppBundleID) {
|
||||||
|
liveData[kMRAParentApplicationBundleIdentifier] = parentAppBundleID;
|
||||||
|
}
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Is Playing
|
||||||
|
dispatch_group_enter(group);
|
||||||
|
g_mediaRemote.getNowPlayingApplicationIsPlaying(
|
||||||
|
g_serialdispatchQueue, ^(bool isPlaying) {
|
||||||
|
liveData[kMRAPlaying] = @(isPlaying);
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch_group_enter(group);
|
||||||
|
g_mediaRemote.getNowPlayingInfo(g_serialdispatchQueue, ^(
|
||||||
|
NSDictionary *information) {
|
||||||
|
NSString *serviceIdentifier =
|
||||||
|
information[kMRMediaRemoteNowPlayingInfoServiceIdentifier];
|
||||||
|
if (!isTestMode &&
|
||||||
|
[serviceIdentifier
|
||||||
|
isEqualToString:@"com.vandenbe.MediaRemoteAdapter.TestClient"]) {
|
||||||
|
isFromTestClient = YES;
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSDictionary *converted = convertNowPlayingInformation(
|
||||||
|
information, convert_micros, calculate_now, no_artwork);
|
||||||
|
[liveData addEntriesFromDictionary:converted];
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all async callbacks or timeout
|
||||||
|
dispatch_time_t timeout =
|
||||||
|
dispatch_time(DISPATCH_TIME_NOW, GET_TIMEOUT_MILLIS * NSEC_PER_MSEC);
|
||||||
|
long result = dispatch_group_wait(group, timeout);
|
||||||
|
|
||||||
|
if (result != 0) {
|
||||||
|
printErrf(
|
||||||
|
@"Reading now playing information timed out after %d milliseconds",
|
||||||
|
GET_TIMEOUT_MILLIS);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFromTestClient) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (human_readable) {
|
||||||
|
makePayloadHumanReadable(liveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allMandatoryPayloadKeysSet(liveData)) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
return liveData;
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_get() {
|
||||||
|
NSDictionary *liveData = internal_get(NO);
|
||||||
|
|
||||||
|
NSString *micros_option = getEnvOption(@"micros");
|
||||||
|
const bool convert_micros = micros_option != nil;
|
||||||
|
|
||||||
|
NSString *human_readable_option = getEnvOption(@"human-readable");
|
||||||
|
const bool human_readable = human_readable_option != nil;
|
||||||
|
|
||||||
|
NSString *resultStr = nil;
|
||||||
|
if (!liveData) {
|
||||||
|
resultStr = JSON_NULL;
|
||||||
|
} else {
|
||||||
|
resultStr = serializeJsonDictionarySafe(liveData, human_readable);
|
||||||
|
if (!resultStr) {
|
||||||
|
fail(@"Failed to serialize now playing information");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printOut(resultStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern void adapter_get_env() { adapter_get(); }
|
||||||
15
Vendor/mediaremote-adapter/src/adapter/globals.h
vendored
Normal file
15
Vendor/mediaremote-adapter/src/adapter/globals.h
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H
|
||||||
|
#define MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H
|
||||||
|
|
||||||
|
#import <CoreFoundation/CoreFoundation.h>
|
||||||
|
#import <dispatch/dispatch.h>
|
||||||
|
|
||||||
|
#import "private/MediaRemote.h"
|
||||||
|
|
||||||
|
extern MediaRemote* g_mediaRemote;
|
||||||
|
extern dispatch_queue_t g_serialdispatchQueue;
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H
|
||||||
19
Vendor/mediaremote-adapter/src/adapter/globals.m
vendored
Normal file
19
Vendor/mediaremote-adapter/src/adapter/globals.m
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import "globals.h"
|
||||||
|
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
MediaRemote *g_mediaRemote = NULL;
|
||||||
|
dispatch_queue_t g_serialdispatchQueue;
|
||||||
|
|
||||||
|
__attribute__((constructor)) static void initGlobals() {
|
||||||
|
g_mediaRemote = [[MediaRemote alloc] init];
|
||||||
|
if (!g_mediaRemote) {
|
||||||
|
fail(@"Failed to initialize MediaRemote Framework");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_serialdispatchQueue = dispatch_queue_create(
|
||||||
|
"mediaremote-adapter.serial-dispatch-queue", DISPATCH_QUEUE_SERIAL);
|
||||||
|
}
|
||||||
19
Vendor/mediaremote-adapter/src/adapter/keys.h
vendored
Normal file
19
Vendor/mediaremote-adapter/src/adapter/keys.h
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_ADAPTER_KEYS_H
|
||||||
|
#define MEDIAREMOTEADAPTER_ADAPTER_KEYS_H
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
// These keys are mandatory and must never be null, empty or missing.
|
||||||
|
NSArray<NSString *> *mandatoryPayloadKeys(void);
|
||||||
|
|
||||||
|
// Checks whether all mandatory payload keys returned by mandatoryPayloadKeys()
|
||||||
|
// are present in the given payload dictionary and have a non-null value.
|
||||||
|
bool allMandatoryPayloadKeysSet(NSDictionary *data);
|
||||||
|
|
||||||
|
// These keys identify a now playing item uniquely.
|
||||||
|
NSArray<NSString *> *identifyingPayloadKeys(void);
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_ADAPTER_KEYS_H
|
||||||
83
Vendor/mediaremote-adapter/src/adapter/keys.m
vendored
Normal file
83
Vendor/mediaremote-adapter/src/adapter/keys.m
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import "keys.h"
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
|
||||||
|
NSString *kMRAProcessIdentifier = @"processIdentifier";
|
||||||
|
NSString *kMRABundleIdentifier = @"bundleIdentifier";
|
||||||
|
NSString *kMRAParentApplicationBundleIdentifier =
|
||||||
|
@"parentApplicationBundleIdentifier";
|
||||||
|
NSString *kMRAPlaying = @"playing";
|
||||||
|
|
||||||
|
NSString *kMRADurationMicros = @"durationMicros";
|
||||||
|
NSString *kMRAElapsedTimeMicros = @"elapsedTimeMicros";
|
||||||
|
NSString *kMRATimestampEpochMicros = @"timestampEpochMicros";
|
||||||
|
|
||||||
|
NSString *kMRAElapsedTimeNow = @"elapsedTimeNow";
|
||||||
|
NSString *kMRAElapsedTimeNowMicros = @"elapsedTimeNowMicros";
|
||||||
|
|
||||||
|
NSString *kMRATitle = @"title";
|
||||||
|
NSString *kMRAArtist = @"artist";
|
||||||
|
NSString *kMRAAlbum = @"album";
|
||||||
|
NSString *kMRADuration = @"duration";
|
||||||
|
NSString *kMRAElapsedTime = @"elapsedTime";
|
||||||
|
NSString *kMRATimestamp = @"timestamp";
|
||||||
|
NSString *kMRAArtworkMimeType = @"artworkMimeType";
|
||||||
|
NSString *kMRAArtworkData = @"artworkData";
|
||||||
|
|
||||||
|
NSString *kMRAChapterNumber = @"chapterNumber";
|
||||||
|
NSString *kMRAComposer = @"composer";
|
||||||
|
NSString *kMRAGenre = @"genre";
|
||||||
|
NSString *kMRAIsAdvertisement = @"isAdvertisement";
|
||||||
|
NSString *kMRAIsBanned = @"isBanned";
|
||||||
|
NSString *kMRAIsInWishList = @"isInWishList";
|
||||||
|
NSString *kMRAIsLiked = @"isLiked";
|
||||||
|
NSString *kMRAIsMusicApp = @"isMusicApp";
|
||||||
|
NSString *kMRAPlaybackRate = @"playbackRate";
|
||||||
|
NSString *kMRAProhibitsSkip = @"prohibitsSkip";
|
||||||
|
NSString *kMRAQueueIndex = @"queueIndex";
|
||||||
|
NSString *kMRARadioStationIdentifier = @"radioStationIdentifier";
|
||||||
|
NSString *kMRARepeatMode = @"repeatMode";
|
||||||
|
NSString *kMRAShuffleMode = @"shuffleMode";
|
||||||
|
NSString *kMRAStartTime = @"startTime";
|
||||||
|
NSString *kMRASupportsFastForward15Seconds = @"supportsFastForward15Seconds";
|
||||||
|
NSString *kMRASupportsIsBanned = @"supportsIsBanned";
|
||||||
|
NSString *kMRASupportsIsLiked = @"supportsIsLiked";
|
||||||
|
NSString *kMRASupportsRewind15Seconds = @"supportsRewind15Seconds";
|
||||||
|
NSString *kMRATotalChapterCount = @"totalChapterCount";
|
||||||
|
NSString *kMRATotalDiscCount = @"totalDiscCount";
|
||||||
|
NSString *kMRATotalQueueCount = @"totalQueueCount";
|
||||||
|
NSString *kMRATotalTrackCount = @"totalTrackCount";
|
||||||
|
NSString *kMRATrackNumber = @"trackNumber";
|
||||||
|
NSString *kMRAUniqueIdentifier = @"uniqueIdentifier";
|
||||||
|
NSString *kMRAContentItemIdentifier = @"contentItemIdentifier";
|
||||||
|
NSString *kMRARadioStationHash = @"radioStationHash";
|
||||||
|
NSString *kMRAMediaType = @"mediaType";
|
||||||
|
|
||||||
|
NSArray<NSString *> *mandatoryPayloadKeys(void) {
|
||||||
|
return @[ kMRAProcessIdentifier, kMRATitle, kMRAPlaying ];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allMandatoryPayloadKeysSet(NSDictionary *data) {
|
||||||
|
NSArray<NSString *> *keys = mandatoryPayloadKeys();
|
||||||
|
for (NSString *key in keys) {
|
||||||
|
if (data[key] == nil || data[key] == [NSNull null]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
id value = data[key];
|
||||||
|
if ([value isKindOfClass:[NSString class]] &&
|
||||||
|
[(NSString *)value length] == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray<NSString *> *identifyingPayloadKeys(void) {
|
||||||
|
return @[
|
||||||
|
kMRAProcessIdentifier, kMRABundleIdentifier,
|
||||||
|
kMRAParentApplicationBundleIdentifier, kMRATitle, kMRAArtist, kMRAAlbum
|
||||||
|
];
|
||||||
|
}
|
||||||
20
Vendor/mediaremote-adapter/src/adapter/now_playing.h
vendored
Normal file
20
Vendor/mediaremote-adapter/src/adapter/now_playing.h
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H
|
||||||
|
#define MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
// Requests information once so that the process runs long enough for the
|
||||||
|
// MediaRemote command to actually be sent to the now playing application.
|
||||||
|
void waitForCommandCompletion();
|
||||||
|
|
||||||
|
// Converts raw MediaRemote now playing information to adapter keys.
|
||||||
|
// Optionally replaces keys with time values with microseconds equivalents.
|
||||||
|
NSMutableDictionary *convertNowPlayingInformation(NSDictionary *information,
|
||||||
|
bool convertMicros,
|
||||||
|
bool calculateNow,
|
||||||
|
bool withoutArtwork);
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H
|
||||||
201
Vendor/mediaremote-adapter/src/adapter/now_playing.m
vendored
Normal file
201
Vendor/mediaremote-adapter/src/adapter/now_playing.m
vendored
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import "now_playing.h"
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/keys.h"
|
||||||
|
#import "private/MediaRemote.h"
|
||||||
|
|
||||||
|
#define WAIT_TIMEOUT_MILLIS 2000
|
||||||
|
|
||||||
|
void waitForCommandCompletion() {
|
||||||
|
id semaphore = dispatch_semaphore_create(0);
|
||||||
|
|
||||||
|
g_mediaRemote.getNowPlayingApplicationPID(
|
||||||
|
g_serialdispatchQueue, ^(int pid) {
|
||||||
|
dispatch_semaphore_signal(semaphore);
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch_time_t timeout =
|
||||||
|
dispatch_time(DISPATCH_TIME_NOW, WAIT_TIMEOUT_MILLIS * NSEC_PER_MSEC);
|
||||||
|
dispatch_semaphore_wait(semaphore, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSNumber *getElapsedTimeNow(NSDictionary *information) {
|
||||||
|
id elapsed = information[kMRMediaRemoteNowPlayingInfoElapsedTime];
|
||||||
|
if (![elapsed isKindOfClass:[NSNumber class]]) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
id timestamp = information[kMRMediaRemoteNowPlayingInfoTimestamp];
|
||||||
|
if (![timestamp isKindOfClass:[NSDate class]]) {
|
||||||
|
return elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSTimeInterval timestampEpoch = [(NSDate *)timestamp timeIntervalSince1970];
|
||||||
|
NSTimeInterval currentEpoch = [[NSDate date] timeIntervalSince1970];
|
||||||
|
NSTimeInterval timeDiff = currentEpoch - timestampEpoch;
|
||||||
|
|
||||||
|
double playbackRate = 0;
|
||||||
|
id playbackRateVal = information[kMRMediaRemoteNowPlayingInfoPlaybackRate];
|
||||||
|
if ([playbackRateVal isKindOfClass:[NSNumber class]]) {
|
||||||
|
playbackRate = [(NSNumber *)playbackRateVal doubleValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
double realElapsed = [(NSNumber *)elapsed doubleValue];
|
||||||
|
if (playbackRate >= 0) {
|
||||||
|
realElapsed += timeDiff * playbackRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return @(realElapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableDictionary *convertNowPlayingInformation(NSDictionary *information,
|
||||||
|
bool convertMicros,
|
||||||
|
bool calculateNow,
|
||||||
|
bool withoutArtwork) {
|
||||||
|
NSMutableDictionary *data = [NSMutableDictionary dictionary];
|
||||||
|
|
||||||
|
void (^setKey)(id, id) = ^(id key, id fromKey) {
|
||||||
|
id value = nil;
|
||||||
|
if (information != nil) {
|
||||||
|
id result = information[fromKey];
|
||||||
|
if (result != nil) {
|
||||||
|
value = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value != nil) {
|
||||||
|
[data setObject:value forKey:key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^setValue)(id key, id (^)(void)) = ^(id key, id (^evaluate)(void)) {
|
||||||
|
id value = nil;
|
||||||
|
if (information != nil) {
|
||||||
|
value = evaluate();
|
||||||
|
}
|
||||||
|
if (value != nil) {
|
||||||
|
[data setObject:value forKey:key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setKey(kMRATitle, kMRMediaRemoteNowPlayingInfoTitle);
|
||||||
|
setKey(kMRAArtist, kMRMediaRemoteNowPlayingInfoArtist);
|
||||||
|
setKey(kMRAAlbum, kMRMediaRemoteNowPlayingInfoAlbum);
|
||||||
|
if (!withoutArtwork) {
|
||||||
|
setKey(kMRAArtworkMimeType,
|
||||||
|
kMRMediaRemoteNowPlayingInfoArtworkMIMEType);
|
||||||
|
setKey(kMRAArtworkData, kMRMediaRemoteNowPlayingInfoArtworkData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!convertMicros) {
|
||||||
|
setKey(kMRADuration, kMRMediaRemoteNowPlayingInfoDuration);
|
||||||
|
setKey(kMRAElapsedTime, kMRMediaRemoteNowPlayingInfoElapsedTime);
|
||||||
|
setKey(kMRATimestamp, kMRMediaRemoteNowPlayingInfoTimestamp);
|
||||||
|
if (calculateNow) {
|
||||||
|
// This key is added and does not replace the original because it
|
||||||
|
// is just a rough estimation, meant to be used for convenience.
|
||||||
|
setValue(kMRAElapsedTimeNow, ^id {
|
||||||
|
id elapsedTime =
|
||||||
|
information[kMRMediaRemoteNowPlayingInfoElapsedTime];
|
||||||
|
id nowValue = getElapsedTimeNow(information);
|
||||||
|
if (nowValue != nil) {
|
||||||
|
elapsedTime = nowValue;
|
||||||
|
}
|
||||||
|
return elapsedTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// These keys replace their original counterparts because semantics
|
||||||
|
// don't change and no accuracy is lost, merely the time unit changes.
|
||||||
|
setValue(kMRADurationMicros, ^id {
|
||||||
|
id duration = information[kMRMediaRemoteNowPlayingInfoDuration];
|
||||||
|
if (duration != nil && [duration isKindOfClass:[NSNumber class]]) {
|
||||||
|
NSTimeInterval durationMicros =
|
||||||
|
[duration doubleValue] * 1000 * 1000;
|
||||||
|
return @(floor(durationMicros));
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
});
|
||||||
|
setValue(kMRAElapsedTimeMicros, ^id {
|
||||||
|
id elapsedTime = information[kMRMediaRemoteNowPlayingInfoElapsedTime];
|
||||||
|
if (elapsedTime != nil &&
|
||||||
|
[elapsedTime isKindOfClass:[NSNumber class]]) {
|
||||||
|
NSTimeInterval elapsedTimeMicros =
|
||||||
|
[elapsedTime doubleValue] * 1000 * 1000;
|
||||||
|
return @(floor(elapsedTimeMicros));
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
});
|
||||||
|
if (calculateNow) {
|
||||||
|
// This key is added and does not replace the original because it
|
||||||
|
// is just a rough estimation, meant to be used for convenience.
|
||||||
|
setValue(kMRAElapsedTimeNowMicros, ^id {
|
||||||
|
id elapsedTime =
|
||||||
|
information[kMRMediaRemoteNowPlayingInfoElapsedTime];
|
||||||
|
if (calculateNow) {
|
||||||
|
elapsedTime = getElapsedTimeNow(information);
|
||||||
|
}
|
||||||
|
if (elapsedTime != nil &&
|
||||||
|
[elapsedTime isKindOfClass:[NSNumber class]]) {
|
||||||
|
NSTimeInterval elapsedTimeMicros =
|
||||||
|
[elapsedTime doubleValue] * 1000 * 1000;
|
||||||
|
return @(floor(elapsedTimeMicros));
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setValue(kMRATimestampEpochMicros, ^id {
|
||||||
|
id timestamp = information[kMRMediaRemoteNowPlayingInfoTimestamp];
|
||||||
|
if (timestamp != nil && [timestamp isKindOfClass:[NSDate class]]) {
|
||||||
|
NSTimeInterval timestampEpoch = [timestamp timeIntervalSince1970];
|
||||||
|
NSTimeInterval timestampEpochMicro = timestampEpoch * 1000 * 1000;
|
||||||
|
return @(floor(timestampEpochMicro));
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some of the following keys might fail due to not being convertible
|
||||||
|
// to JSON automatically. This is difficult to test because most media
|
||||||
|
// players do not even set these keys and the data types are not documented
|
||||||
|
// anywhere. Still, JSON serialization of the resulting dictionary deletes
|
||||||
|
// any invalid keys, converts or deletes invalid values and prints error
|
||||||
|
// messages whenever any dictionary entry has been removed. Users should
|
||||||
|
// report whenever they encounter such an error with these keys.
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
setKey(kMRAChapterNumber, kMRMediaRemoteNowPlayingInfoChapterNumber);
|
||||||
|
setKey(kMRAComposer, kMRMediaRemoteNowPlayingInfoComposer);
|
||||||
|
setKey(kMRAGenre, kMRMediaRemoteNowPlayingInfoGenre);
|
||||||
|
setKey(kMRAIsAdvertisement, kMRMediaRemoteNowPlayingInfoIsAdvertisement);
|
||||||
|
setKey(kMRAIsBanned, kMRMediaRemoteNowPlayingInfoIsBanned);
|
||||||
|
setKey(kMRAIsInWishList, kMRMediaRemoteNowPlayingInfoIsInWishList);
|
||||||
|
setKey(kMRAIsLiked, kMRMediaRemoteNowPlayingInfoIsLiked);
|
||||||
|
setKey(kMRAIsMusicApp, kMRMediaRemoteNowPlayingInfoIsMusicApp);
|
||||||
|
setKey(kMRAPlaybackRate, kMRMediaRemoteNowPlayingInfoPlaybackRate);
|
||||||
|
setKey(kMRAProhibitsSkip, kMRMediaRemoteNowPlayingInfoProhibitsSkip);
|
||||||
|
setKey(kMRAQueueIndex, kMRMediaRemoteNowPlayingInfoQueueIndex);
|
||||||
|
setKey(kMRARadioStationIdentifier, kMRMediaRemoteNowPlayingInfoRadioStationIdentifier);
|
||||||
|
setKey(kMRARepeatMode, kMRMediaRemoteNowPlayingInfoRepeatMode);
|
||||||
|
setKey(kMRAShuffleMode, kMRMediaRemoteNowPlayingInfoShuffleMode);
|
||||||
|
setKey(kMRAStartTime, kMRMediaRemoteNowPlayingInfoStartTime);
|
||||||
|
setKey(kMRASupportsFastForward15Seconds, kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds);
|
||||||
|
setKey(kMRASupportsIsBanned, kMRMediaRemoteNowPlayingInfoSupportsIsBanned);
|
||||||
|
setKey(kMRASupportsIsLiked, kMRMediaRemoteNowPlayingInfoSupportsIsLiked);
|
||||||
|
setKey(kMRASupportsRewind15Seconds, kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds);
|
||||||
|
setKey(kMRATotalChapterCount, kMRMediaRemoteNowPlayingInfoTotalChapterCount);
|
||||||
|
setKey(kMRATotalDiscCount, kMRMediaRemoteNowPlayingInfoTotalDiscCount);
|
||||||
|
setKey(kMRATotalQueueCount, kMRMediaRemoteNowPlayingInfoTotalQueueCount);
|
||||||
|
setKey(kMRATotalTrackCount, kMRMediaRemoteNowPlayingInfoTotalTrackCount);
|
||||||
|
setKey(kMRATrackNumber, kMRMediaRemoteNowPlayingInfoTrackNumber);
|
||||||
|
setKey(kMRAUniqueIdentifier, kMRMediaRemoteNowPlayingInfoUniqueIdentifier);
|
||||||
|
setKey(kMRAContentItemIdentifier, kMRMediaRemoteNowPlayingInfoContentItemIdentifier);
|
||||||
|
setKey(kMRARadioStationHash, kMRMediaRemoteNowPlayingInfoRadioStationHash);
|
||||||
|
setKey(kMRAMediaType, kMRMediaRemoteNowPlayingInfoMediaType);
|
||||||
|
// clang-format on
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
44
Vendor/mediaremote-adapter/src/adapter/repeat.m
vendored
Normal file
44
Vendor/mediaremote-adapter/src/adapter/repeat.m
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#include "private/MediaRemote.h"
|
||||||
|
#include <limits.h>
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/env.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/now_playing.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
static NSArray<NSNumber *> *acceptedModes;
|
||||||
|
|
||||||
|
__attribute__((constructor)) static void init() {
|
||||||
|
acceptedModes = @[
|
||||||
|
@(kMRARepeatDisabled),
|
||||||
|
@(kMRARepeatTrack),
|
||||||
|
@(kMRARepeatPlaylist),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isModeAccepted(int mode) {
|
||||||
|
return [acceptedModes containsObject:@(mode)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_repeat(MRARepeatMode mode) {
|
||||||
|
|
||||||
|
if (!isModeAccepted((int)mode)) {
|
||||||
|
failf(@"Invalid repeat mode: %d", (int)mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_mediaRemote.setRepeatMode((int)mode);
|
||||||
|
|
||||||
|
waitForCommandCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline int repeat_0_mode() {
|
||||||
|
return getEnvFuncParamIntSafe(@"adapter_repeat", 0, @"mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_repeat_env() { adapter_repeat((MRARepeatMode)repeat_0_mode()); }
|
||||||
30
Vendor/mediaremote-adapter/src/adapter/seek.m
vendored
Normal file
30
Vendor/mediaremote-adapter/src/adapter/seek.m
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#include "private/MediaRemote.h"
|
||||||
|
#include <limits.h>
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/env.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/now_playing.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
void adapter_seek(long position) {
|
||||||
|
|
||||||
|
if (position < 0) {
|
||||||
|
failf(@"Negative values are not allowed: %d", position);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_mediaRemote.setElapsedTime(position / 1000000.0);
|
||||||
|
|
||||||
|
waitForCommandCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline long seek_0_position() {
|
||||||
|
return getEnvFuncParamLongSafe(@"adapter_seek", 0, @"position");
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_seek_env() { adapter_seek(seek_0_position()); }
|
||||||
69
Vendor/mediaremote-adapter/src/adapter/send.m
vendored
Normal file
69
Vendor/mediaremote-adapter/src/adapter/send.m
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#include "private/MediaRemote.h"
|
||||||
|
#include <limits.h>
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/env.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/now_playing.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
static NSArray<NSNumber *> *acceptedCommands;
|
||||||
|
|
||||||
|
__attribute__((constructor)) static void init() {
|
||||||
|
acceptedCommands = @[
|
||||||
|
@(kMRAPlay),
|
||||||
|
@(kMRAPause),
|
||||||
|
@(kMRATogglePlayPause),
|
||||||
|
@(kMRAStop),
|
||||||
|
@(kMRANextTrack),
|
||||||
|
@(kMRAPreviousTrack),
|
||||||
|
@(kMRAToggleShuffle),
|
||||||
|
@(kMRAToggleRepeat),
|
||||||
|
@(kMRAStartForwardSeek),
|
||||||
|
@(kMRAEndForwardSeek),
|
||||||
|
@(kMRAStartBackwardSeek),
|
||||||
|
@(kMRAEndBackwardSeek),
|
||||||
|
@(kMRAGoBackFifteenSeconds),
|
||||||
|
@(kMRASkipFifteenSeconds),
|
||||||
|
];
|
||||||
|
// TODO like/unlike tracks by reading now playing information first,
|
||||||
|
// getting the track ID, station ID and station hash
|
||||||
|
// and then sending the respective MRCommand.
|
||||||
|
// does "ban" mean "remove like" here?
|
||||||
|
}
|
||||||
|
|
||||||
|
static MRCommand findCommand(int command, bool *found) {
|
||||||
|
if ([acceptedCommands containsObject:@(command)]) {
|
||||||
|
*found = true;
|
||||||
|
return (MRCommand)command;
|
||||||
|
}
|
||||||
|
*found = false;
|
||||||
|
return (MRCommand)0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_send(MRACommand command) {
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
MRCommand commandValue = findCommand((int)command, &ok);
|
||||||
|
if (!ok) {
|
||||||
|
failf(@"Invalid command: %d", command);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool result = g_mediaRemote.sendCommand(commandValue, nil);
|
||||||
|
if (!result) {
|
||||||
|
failf(@"Failed to send command %d", command);
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForCommandCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline int send_0_command() {
|
||||||
|
return getEnvFuncParamIntSafe(@"adapter_send", 0, @"command");
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_send_env() { adapter_send((MRACommand)send_0_command()); }
|
||||||
46
Vendor/mediaremote-adapter/src/adapter/shuffle.m
vendored
Normal file
46
Vendor/mediaremote-adapter/src/adapter/shuffle.m
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#include "private/MediaRemote.h"
|
||||||
|
#include <limits.h>
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/env.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/now_playing.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
static NSArray<NSNumber *> *acceptedModes;
|
||||||
|
|
||||||
|
__attribute__((constructor)) static void init() {
|
||||||
|
acceptedModes = @[
|
||||||
|
@(kMRAShuffleDisabled),
|
||||||
|
@(kMRAShuffleAlbums),
|
||||||
|
@(kMRAShuffleTracks),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isModeAccepted(int mode) {
|
||||||
|
return [acceptedModes containsObject:@(mode)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_shuffle(MRAShuffleMode mode) {
|
||||||
|
|
||||||
|
if (!isModeAccepted((int)mode)) {
|
||||||
|
failf(@"Invalid shuffle mode: %d", (int)mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_mediaRemote.setShuffleMode((int)mode);
|
||||||
|
|
||||||
|
waitForCommandCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline int shuffle_0_mode() {
|
||||||
|
return getEnvFuncParamIntSafe(@"adapter_shuffle", 0, @"mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_shuffle_env() {
|
||||||
|
adapter_shuffle((MRAShuffleMode)shuffle_0_mode());
|
||||||
|
}
|
||||||
30
Vendor/mediaremote-adapter/src/adapter/speed.m
vendored
Normal file
30
Vendor/mediaremote-adapter/src/adapter/speed.m
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#include "private/MediaRemote.h"
|
||||||
|
#include <limits.h>
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/env.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/now_playing.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
void adapter_speed(int speed) {
|
||||||
|
|
||||||
|
if (speed < 0) {
|
||||||
|
failf(@"Negative values are not allowed: %d", speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_mediaRemote.setPlaybackSpeed(speed);
|
||||||
|
|
||||||
|
waitForCommandCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline int speed_0_speed() {
|
||||||
|
return getEnvFuncParamIntSafe(@"adapter_speed", 0, @"speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
void adapter_speed_env() { adapter_speed(speed_0_speed()); }
|
||||||
523
Vendor/mediaremote-adapter/src/adapter/stream.m
vendored
Normal file
523
Vendor/mediaremote-adapter/src/adapter/stream.m
vendored
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#import <CoreFoundation/CoreFoundation.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/env.h"
|
||||||
|
#import "adapter/globals.h"
|
||||||
|
#import "adapter/keys.h"
|
||||||
|
#import "adapter/now_playing.h"
|
||||||
|
#import "private/MediaRemote.h"
|
||||||
|
#import "utility/Debounce.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
#ifndef DEBOUNCE_DELAY_MILLIS
|
||||||
|
#define DEBOUNCE_DELAY_MILLIS 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static CFRunLoopRef g_runLoop = NULL;
|
||||||
|
|
||||||
|
static NSString *serializeData(NSDictionary *data, BOOL diff, BOOL pretty) {
|
||||||
|
return serializeJsonDictionarySafe(
|
||||||
|
@{
|
||||||
|
@"type" : @"data",
|
||||||
|
@"diff" : @(diff),
|
||||||
|
@"payload" : data ?: @{},
|
||||||
|
},
|
||||||
|
pretty);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary *createDiff(NSDictionary *a, NSDictionary *b) {
|
||||||
|
NSMutableDictionary *diff = [NSMutableDictionary dictionary];
|
||||||
|
NSMutableSet *allKeys = [NSMutableSet setWithArray:a.allKeys];
|
||||||
|
[allKeys addObjectsFromArray:b.allKeys];
|
||||||
|
for (id key in allKeys) {
|
||||||
|
id oldValue = a[key];
|
||||||
|
id newValue = b[key];
|
||||||
|
BOOL valuesDiffer = NO;
|
||||||
|
if (oldValue == nil && newValue != nil) {
|
||||||
|
valuesDiffer = YES;
|
||||||
|
} else if (oldValue != nil && newValue == nil) {
|
||||||
|
valuesDiffer = YES;
|
||||||
|
} else if (![oldValue isEqual:newValue]) {
|
||||||
|
valuesDiffer = YES;
|
||||||
|
}
|
||||||
|
if (valuesDiffer) {
|
||||||
|
diff[key] = newValue ?: [NSNull null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [diff copy];
|
||||||
|
}
|
||||||
|
|
||||||
|
static BOOL isSameItemIdentity(NSDictionary *a, NSDictionary *b) {
|
||||||
|
NSArray<NSString *> *keys = identifyingPayloadKeys();
|
||||||
|
for (NSString *key in keys) {
|
||||||
|
id aValue = a[key];
|
||||||
|
id bValue = b[key];
|
||||||
|
if (aValue == nil && bValue == nil) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (aValue == nil || bValue == nil) {
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
if (![aValue isEqual:bValue]) {
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary *previousData = nil;
|
||||||
|
|
||||||
|
static void printData(NSDictionary *data, BOOL diff, BOOL pretty) {
|
||||||
|
NSString *serialized = nil;
|
||||||
|
if (diff && previousData != nil && isSameItemIdentity(previousData, data)) {
|
||||||
|
NSDictionary *result = createDiff(previousData, data);
|
||||||
|
if ([result count] == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serialized = serializeData(result, YES, pretty);
|
||||||
|
} else {
|
||||||
|
serialized = serializeData(data, NO, pretty);
|
||||||
|
}
|
||||||
|
if (serialized != nil) {
|
||||||
|
if (diff) {
|
||||||
|
previousData = [data copy];
|
||||||
|
}
|
||||||
|
// Print the serialized data without duplicates. Note that while this
|
||||||
|
// can fail when the key order in the serialized JSON output changes,
|
||||||
|
// it practically won't because if it did, there would also be a change
|
||||||
|
// in values that needs to be reported.
|
||||||
|
printOutUnique(serialized);
|
||||||
|
}
|
||||||
|
if (!diff) {
|
||||||
|
previousData = nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appForNotification(NSNotification *notification,
|
||||||
|
void (^block)(NSRunningApplication *)) {
|
||||||
|
NSDictionary *userInfo = notification.userInfo;
|
||||||
|
id pidValue = userInfo[kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey];
|
||||||
|
if (pidValue != nil) {
|
||||||
|
int pid = [pidValue intValue];
|
||||||
|
appForPID(pid, block);
|
||||||
|
} else {
|
||||||
|
block(nil);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef struct MetadataStats {
|
||||||
|
BOOL trackTitleChanged;
|
||||||
|
int identifyingTrackKeysIdentical;
|
||||||
|
int identifyingTrackKeysChanged;
|
||||||
|
} MetadataStats;
|
||||||
|
|
||||||
|
static MetadataStats createMetadataStats() {
|
||||||
|
MetadataStats stats = {
|
||||||
|
.trackTitleChanged = NO,
|
||||||
|
.identifyingTrackKeysIdentical = 0,
|
||||||
|
.identifyingTrackKeysChanged = 0,
|
||||||
|
};
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MetadataStats compareIdentifyingTrackKeys(NSDictionary *prev,
|
||||||
|
NSDictionary *next) {
|
||||||
|
MetadataStats stats = createMetadataStats();
|
||||||
|
for (NSString *key in @[ kMRATitle, kMRAArtist, kMRAAlbum ]) {
|
||||||
|
id a = prev[key], b = next[key];
|
||||||
|
if (a == nil || b == nil)
|
||||||
|
continue;
|
||||||
|
if ([a isEqual:b]) {
|
||||||
|
stats.identifyingTrackKeysIdentical++;
|
||||||
|
} else {
|
||||||
|
stats.identifyingTrackKeysChanged++;
|
||||||
|
if ([key isEqualToString:kMRATitle]) {
|
||||||
|
stats.trackTitleChanged = YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern void adapter_stream() {
|
||||||
|
|
||||||
|
// Get ADAPTER_TEST_MODE as a boolean and set BOOL isTestMode
|
||||||
|
BOOL isTestMode = NO;
|
||||||
|
char *testModeEnv = getenv("ADAPTER_TEST_MODE");
|
||||||
|
if (testModeEnv && strcmp(testModeEnv, "0") != 0 &&
|
||||||
|
strlen(testModeEnv) > 0) {
|
||||||
|
isTestMode = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
int debounce_delay_millis = 0;
|
||||||
|
NSNumber *debounce_option = getEnvOptionInt(@"debounce");
|
||||||
|
if (debounce_option != nil) {
|
||||||
|
debounce_delay_millis = [debounce_option intValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *no_diff_option = getEnvOption(@"no_diff");
|
||||||
|
NSString *no_artwork_option = getEnvOption(@"no-artwork");
|
||||||
|
NSString *micros_option = getEnvOption(@"micros");
|
||||||
|
NSString *human_readable_option = getEnvOption(@"human-readable");
|
||||||
|
|
||||||
|
// This option is needed for media players which, when changing tracks,
|
||||||
|
// update the artist and/or other fields later than e.g. the title, the
|
||||||
|
// invalid in-between metadata therefore representing "peculiar" media. The
|
||||||
|
// only known player that does this is the TIDAL desktop player with the
|
||||||
|
// bundle ID "com.tidal.desktop". This is easy to reproduce when playing
|
||||||
|
// media from a playlist with tracks from different artists.
|
||||||
|
// FIXME Implement this for any bundle ID, should other players need it.
|
||||||
|
// In that case parse any "experimental-peculiar-debounce:*" option.
|
||||||
|
NSNumber *peculiar_debounce_option =
|
||||||
|
getEnvOptionInt(@"experimental-peculiar-debounce:com.tidal.desktop");
|
||||||
|
__block NSString *peculiar_bundle_id = nil;
|
||||||
|
__block Debounce *peculiar_debounce = nil;
|
||||||
|
__block BOOL did_peculiar_debounce = NO;
|
||||||
|
if (peculiar_debounce_option != nil) {
|
||||||
|
peculiar_bundle_id = @"com.tidal.desktop";
|
||||||
|
int debounce_millis = [peculiar_debounce_option intValue];
|
||||||
|
peculiar_debounce =
|
||||||
|
[[Debounce alloc] initWithDelay:(debounce_millis / 1000.0)
|
||||||
|
queue:g_serialdispatchQueue];
|
||||||
|
}
|
||||||
|
|
||||||
|
__block NSMutableDictionary *liveData = [NSMutableDictionary dictionary];
|
||||||
|
__block MetadataStats liveDataStats = createMetadataStats();
|
||||||
|
__block const Debounce *const debounce =
|
||||||
|
[[Debounce alloc] initWithDelay:(debounce_delay_millis / 1000.0)
|
||||||
|
queue:g_serialdispatchQueue];
|
||||||
|
__block const BOOL no_diff = (no_diff_option != nil);
|
||||||
|
__block const BOOL no_artwork = (no_artwork_option != nil);
|
||||||
|
__block const BOOL convert_micros = (micros_option != nil);
|
||||||
|
__block const bool human_readable = (human_readable_option != nil);
|
||||||
|
|
||||||
|
void (^localPrintData)(NSDictionary *) = ^(NSDictionary *data) {
|
||||||
|
printData(data, !no_diff, human_readable);
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^directHandle)() = ^() {
|
||||||
|
if (allMandatoryPayloadKeysSet(liveData)) {
|
||||||
|
if (human_readable) {
|
||||||
|
NSMutableDictionary *shallowClone =
|
||||||
|
[NSMutableDictionary dictionaryWithDictionary:liveData];
|
||||||
|
makePayloadHumanReadable(shallowClone);
|
||||||
|
localPrintData(shallowClone);
|
||||||
|
} else {
|
||||||
|
localPrintData(liveData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localPrintData(nil);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^internalHandle)(bool) = ^(bool updatedStats) {
|
||||||
|
if (peculiar_debounce == nil ||
|
||||||
|
![peculiar_bundle_id isEqual:liveData[kMRABundleIdentifier]]) {
|
||||||
|
directHandle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updatedStats && liveDataStats.trackTitleChanged &&
|
||||||
|
liveDataStats.identifyingTrackKeysIdentical > 0) {
|
||||||
|
did_peculiar_debounce = true;
|
||||||
|
[peculiar_debounce call:^{
|
||||||
|
did_peculiar_debounce = false;
|
||||||
|
directHandle();
|
||||||
|
}];
|
||||||
|
} else if (did_peculiar_debounce &&
|
||||||
|
(!updatedStats ||
|
||||||
|
liveDataStats.identifyingTrackKeysChanged == 0)) {
|
||||||
|
// Ignore this handle call, since there is an active debounce call.
|
||||||
|
} else {
|
||||||
|
[peculiar_debounce cancel];
|
||||||
|
did_peculiar_debounce = false;
|
||||||
|
directHandle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^handle)() = ^() {
|
||||||
|
internalHandle(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^handleWithUpdatedStats)() = ^() {
|
||||||
|
internalHandle(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^requestNowPlayingApplicationPID)() = ^{
|
||||||
|
g_mediaRemote.getNowPlayingApplicationPID(
|
||||||
|
g_serialdispatchQueue, ^(int pid) {
|
||||||
|
if (pid == 0) {
|
||||||
|
liveData[kMRAProcessIdentifier] = nil;
|
||||||
|
handle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
liveData[kMRAProcessIdentifier] = @(pid);
|
||||||
|
bool ok = appForPID(pid, ^(NSRunningApplication *process) {
|
||||||
|
if (process.bundleIdentifier != nil) {
|
||||||
|
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
|
||||||
|
}
|
||||||
|
handle();
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
handle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^requestNowPlayingParentApplicationBundleIdentifier)() = ^{
|
||||||
|
g_mediaRemote.getNowPlayingClient(g_serialdispatchQueue, ^(id client) {
|
||||||
|
NSString *parentAppBundleID = nil;
|
||||||
|
if (client && [client respondsToSelector:@selector
|
||||||
|
(parentApplicationBundleIdentifier)]) {
|
||||||
|
id result = [client
|
||||||
|
performSelector:@selector(parentApplicationBundleIdentifier)];
|
||||||
|
if ([result isKindOfClass:[NSString class]]) {
|
||||||
|
parentAppBundleID = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parentAppBundleID) {
|
||||||
|
liveData[kMRAParentApplicationBundleIdentifier] = parentAppBundleID;
|
||||||
|
} else {
|
||||||
|
[liveData removeObjectForKey:kMRAParentApplicationBundleIdentifier];
|
||||||
|
}
|
||||||
|
handle();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^requestNowPlayingApplicationIsPlaying)() = ^{
|
||||||
|
g_mediaRemote.getNowPlayingApplicationIsPlaying(
|
||||||
|
g_serialdispatchQueue, ^(bool isPlaying) {
|
||||||
|
liveData[kMRAPlaying] = @(isPlaying);
|
||||||
|
handle();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^requestNowPlayingInfo)() = ^{
|
||||||
|
g_mediaRemote.getNowPlayingInfo(g_serialdispatchQueue, ^(
|
||||||
|
NSDictionary *information) {
|
||||||
|
NSString *serviceIdentifier =
|
||||||
|
information[kMRMediaRemoteNowPlayingInfoServiceIdentifier];
|
||||||
|
if (!isTestMode &&
|
||||||
|
[serviceIdentifier
|
||||||
|
isEqualToString:
|
||||||
|
@"com.vandenbe.MediaRemoteAdapter.TestClient"]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSMutableDictionary *converted = convertNowPlayingInformation(
|
||||||
|
information, convert_micros, false, no_artwork);
|
||||||
|
// Transfer anything over from the existing live data.
|
||||||
|
if (liveData[kMRAProcessIdentifier] != nil) {
|
||||||
|
converted[kMRAProcessIdentifier] = liveData[kMRAProcessIdentifier];
|
||||||
|
}
|
||||||
|
if (liveData[kMRABundleIdentifier] != nil) {
|
||||||
|
converted[kMRABundleIdentifier] = liveData[kMRABundleIdentifier];
|
||||||
|
}
|
||||||
|
if (liveData[kMRAParentApplicationBundleIdentifier] != nil) {
|
||||||
|
converted[kMRAParentApplicationBundleIdentifier] =
|
||||||
|
liveData[kMRAParentApplicationBundleIdentifier];
|
||||||
|
}
|
||||||
|
if (liveData[kMRAPlaying] != nil) {
|
||||||
|
converted[kMRAPlaying] = liveData[kMRAPlaying];
|
||||||
|
}
|
||||||
|
// Use the old artwork data, since often the MediaRemote framework
|
||||||
|
// unloads the artwork and then loads it again shortly after.
|
||||||
|
// Only do this when the items have the same identity.
|
||||||
|
if (isSameItemIdentity(liveData, converted) &&
|
||||||
|
liveData[kMRAArtworkData] != nil &&
|
||||||
|
liveData[kMRAArtworkData] != [NSNull null] &&
|
||||||
|
converted[kMRAArtworkData] == nil) {
|
||||||
|
converted[kMRAArtworkData] = liveData[kMRAArtworkData];
|
||||||
|
if (liveData[kMRAArtworkMimeType] != nil &&
|
||||||
|
liveData[kMRAArtworkMimeType] != [NSNull null] &&
|
||||||
|
converted[kMRAArtworkMimeType] == nil) {
|
||||||
|
converted[kMRAArtworkMimeType] = liveData[kMRAArtworkMimeType];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
liveDataStats = compareIdentifyingTrackKeys(liveData, converted);
|
||||||
|
[liveData setDictionary:converted];
|
||||||
|
handleWithUpdatedStats();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^requestAll)() = ^{
|
||||||
|
requestNowPlayingApplicationPID();
|
||||||
|
requestNowPlayingParentApplicationBundleIdentifier();
|
||||||
|
requestNowPlayingApplicationIsPlaying();
|
||||||
|
requestNowPlayingInfo();
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^resetAll)() = ^{
|
||||||
|
[liveData removeAllObjects];
|
||||||
|
};
|
||||||
|
|
||||||
|
void (^refreshAll)() = ^{
|
||||||
|
resetAll();
|
||||||
|
requestAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME Is this foolproof? This continues and registers observers
|
||||||
|
// which might intervene with the initial requests.
|
||||||
|
requestAll();
|
||||||
|
|
||||||
|
NSNotificationCenter *default_center = [NSNotificationCenter defaultCenter];
|
||||||
|
NSNotificationCenter *shared_workscape_notification_center =
|
||||||
|
[[NSWorkspace sharedWorkspace] notificationCenter];
|
||||||
|
|
||||||
|
// TODO Refactor the below two callbacks. They share a lot of code.
|
||||||
|
|
||||||
|
id is_playing_change_observer = [default_center
|
||||||
|
addObserverForName:
|
||||||
|
kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification
|
||||||
|
object:nil
|
||||||
|
queue:nil
|
||||||
|
usingBlock:^(NSNotification *notification) {
|
||||||
|
dispatch_async(g_serialdispatchQueue, ^() {
|
||||||
|
appForNotification(notification, ^(
|
||||||
|
NSRunningApplication *process) {
|
||||||
|
if (process == nil) {
|
||||||
|
// The process for this notification could not be
|
||||||
|
// determined. Assume that there is no now playing
|
||||||
|
// application anymore.
|
||||||
|
resetAll();
|
||||||
|
handle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
id isPlayingValue =
|
||||||
|
notification.userInfo
|
||||||
|
[kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey];
|
||||||
|
if (isPlayingValue == nil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (liveData[kMRABundleIdentifier] != nil &&
|
||||||
|
process.bundleIdentifier != nil &&
|
||||||
|
![liveData[kMRABundleIdentifier]
|
||||||
|
isEqual:process.bundleIdentifier]) {
|
||||||
|
// This is a different process, reset all data.
|
||||||
|
resetAll();
|
||||||
|
}
|
||||||
|
if (liveData[kMRAProcessIdentifier] != nil &&
|
||||||
|
![liveData[kMRAProcessIdentifier]
|
||||||
|
isEqual:@(process.processIdentifier)]) {
|
||||||
|
// This is a different process, reset all data.
|
||||||
|
resetAll();
|
||||||
|
}
|
||||||
|
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
|
||||||
|
requestNowPlayingParentApplicationBundleIdentifier();
|
||||||
|
liveData[kMRAPlaying] = @([isPlayingValue boolValue]);
|
||||||
|
if (liveData[kMRATitle] == nil) {
|
||||||
|
requestNowPlayingInfo();
|
||||||
|
} else {
|
||||||
|
handle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
|
||||||
|
id info_change_observer = [default_center
|
||||||
|
addObserverForName:kMRMediaRemoteNowPlayingInfoDidChangeNotification
|
||||||
|
object:nil
|
||||||
|
queue:nil
|
||||||
|
usingBlock:^(NSNotification *notification) {
|
||||||
|
[debounce call:^{
|
||||||
|
appForNotification(notification, ^(
|
||||||
|
NSRunningApplication *process) {
|
||||||
|
if (process == nil) {
|
||||||
|
// The process for this notification could not be
|
||||||
|
// determined. Assume that there is no now playing
|
||||||
|
// application anymore.
|
||||||
|
resetAll();
|
||||||
|
handle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (liveData[kMRABundleIdentifier] != nil &&
|
||||||
|
process.bundleIdentifier != nil &&
|
||||||
|
![liveData[kMRABundleIdentifier]
|
||||||
|
isEqual:process.bundleIdentifier]) {
|
||||||
|
// This is a different process, reset all data.
|
||||||
|
resetAll();
|
||||||
|
}
|
||||||
|
if (liveData[kMRAProcessIdentifier] != nil &&
|
||||||
|
![liveData[kMRAProcessIdentifier]
|
||||||
|
isEqual:@(process.processIdentifier)]) {
|
||||||
|
// This is a different process, reset all data.
|
||||||
|
resetAll();
|
||||||
|
}
|
||||||
|
if (liveData[kMRAProcessIdentifier] == nil) {
|
||||||
|
requestNowPlayingApplicationPID();
|
||||||
|
}
|
||||||
|
if (liveData[kMRAParentApplicationBundleIdentifier] ==
|
||||||
|
nil) {
|
||||||
|
requestNowPlayingParentApplicationBundleIdentifier();
|
||||||
|
}
|
||||||
|
if (liveData[kMRAPlaying] == nil) {
|
||||||
|
requestNowPlayingApplicationIsPlaying();
|
||||||
|
}
|
||||||
|
requestNowPlayingInfo();
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Register notifications for when applications are closed.
|
||||||
|
id app_termination_observer = [shared_workscape_notification_center
|
||||||
|
addObserverForName:NSWorkspaceDidTerminateApplicationNotification
|
||||||
|
object:nil
|
||||||
|
queue:nil
|
||||||
|
usingBlock:^(NSNotification *notification) {
|
||||||
|
dispatch_async(g_serialdispatchQueue, ^() {
|
||||||
|
NSDictionary *userInfo = [notification userInfo];
|
||||||
|
id bundleIdentifier =
|
||||||
|
userInfo[@"NSApplicationBundleIdentifier"];
|
||||||
|
if (bundleIdentifier != nil &&
|
||||||
|
[bundleIdentifier
|
||||||
|
isEqual:liveData[kMRABundleIdentifier]]) {
|
||||||
|
// Refresh all data, since the application terminated.
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
|
||||||
|
g_mediaRemote.registerForNowPlayingNotifications(g_serialdispatchQueue);
|
||||||
|
|
||||||
|
CFRunLoopRun();
|
||||||
|
|
||||||
|
g_mediaRemote.unregisterForNowPlayingNotifications();
|
||||||
|
|
||||||
|
[default_center removeObserver:is_playing_change_observer];
|
||||||
|
[default_center removeObserver:info_change_observer];
|
||||||
|
[shared_workscape_notification_center
|
||||||
|
removeObserver:app_termination_observer];
|
||||||
|
}
|
||||||
|
|
||||||
|
extern void adapter_stream_env() { adapter_stream(); }
|
||||||
|
|
||||||
|
extern void _adapter_stream_cancel() {
|
||||||
|
if (g_runLoop) {
|
||||||
|
CFRunLoopStop(g_runLoop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleSignal(int signal) {
|
||||||
|
if (signal == SIGINT || signal == SIGTERM) {
|
||||||
|
_adapter_stream_cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__attribute__((constructor)) static void init() {
|
||||||
|
g_runLoop = CFRunLoopGetCurrent();
|
||||||
|
signal(SIGINT, handleSignal);
|
||||||
|
signal(SIGTERM, handleSignal);
|
||||||
|
}
|
||||||
|
__attribute__((destructor)) static void teardown() { _adapter_stream_cancel(); }
|
||||||
|
|
||||||
|
// FIXME Fix "peculiar media" (artist is updated later than title). Example:
|
||||||
|
/*
|
||||||
|
35.558 Thirteen by Big Star on Camping Songs
|
||||||
|
36.091 Good Vibrations (Remastered 2001) by Big Star on Camping Songs
|
||||||
|
36.204 Good Vibrations (Remastered 2001) by Big Star on Camping Songs (+image)
|
||||||
|
36.624 Good Vibrations (Remastered 2001) by The Beach Boys on Camping Songs
|
||||||
|
*/
|
||||||
217
Vendor/mediaremote-adapter/src/adapter/test.m
vendored
Normal file
217
Vendor/mediaremote-adapter/src/adapter/test.m
vendored
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// Copyright (c) 2025 Alexander5015
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#include <signal.h>
|
||||||
|
|
||||||
|
#import "MediaRemoteAdapter.h"
|
||||||
|
#import "adapter/get.h"
|
||||||
|
#import "test/NowPlayingTest.h"
|
||||||
|
#import "utility/helpers.h"
|
||||||
|
|
||||||
|
static NSTask *nowPlayingClientHelperTask = nil;
|
||||||
|
static NSFileHandle *helperInput = nil;
|
||||||
|
static NSFileHandle *helperOutput = nil;
|
||||||
|
|
||||||
|
void cleanup_helper() {
|
||||||
|
if (nowPlayingClientHelperTask && helperInput && helperOutput) {
|
||||||
|
@try {
|
||||||
|
[helperInput writeData:[@"cleanup\n"
|
||||||
|
dataUsingEncoding:NSUTF8StringEncoding]];
|
||||||
|
[helperInput closeFile];
|
||||||
|
} @catch (NSException *exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown with timeout
|
||||||
|
NSTimeInterval timeout = 2.0;
|
||||||
|
NSDate *cleanupDeadline = [NSDate dateWithTimeIntervalSinceNow:timeout];
|
||||||
|
while (nowPlayingClientHelperTask.isRunning &&
|
||||||
|
[cleanupDeadline timeIntervalSinceNow] > 0) {
|
||||||
|
[NSThread sleepForTimeInterval:0.1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowPlayingClientHelperTask.isRunning) {
|
||||||
|
@try {
|
||||||
|
[nowPlayingClientHelperTask terminate];
|
||||||
|
} @catch (NSException *exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDate *terminationDeadline =
|
||||||
|
[NSDate dateWithTimeIntervalSinceNow:1.0];
|
||||||
|
while (nowPlayingClientHelperTask.isRunning &&
|
||||||
|
[terminationDeadline timeIntervalSinceNow] > 0) {
|
||||||
|
[NSThread sleepForTimeInterval:0.1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowPlayingClientHelperTask.isRunning) {
|
||||||
|
// Force kill as last resort
|
||||||
|
kill(nowPlayingClientHelperTask.processIdentifier, SIGKILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@try {
|
||||||
|
if (helperOutput.readabilityHandler) {
|
||||||
|
helperOutput.readabilityHandler = nil;
|
||||||
|
}
|
||||||
|
[helperOutput closeFile];
|
||||||
|
} @catch (__unused NSException *exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@
|
||||||
|
try {
|
||||||
|
[nowPlayingClientHelperTask waitUntilExit];
|
||||||
|
} @catch (__unused NSException *exception) {
|
||||||
|
}
|
||||||
|
} else if (nowPlayingClientHelperTask) {
|
||||||
|
@try {
|
||||||
|
[nowPlayingClientHelperTask terminate];
|
||||||
|
[nowPlayingClientHelperTask waitUntilExit];
|
||||||
|
} @catch (__unused NSException *exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nowPlayingClientHelperTask = nil;
|
||||||
|
helperInput = nil;
|
||||||
|
helperOutput = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanup_and_exit() {
|
||||||
|
cleanup_helper();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleSignal(int signal) {
|
||||||
|
if (signal == SIGINT || signal == SIGTERM)
|
||||||
|
cleanup_and_exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern void adapter_test(void) {
|
||||||
|
@autoreleasepool {
|
||||||
|
signal(SIGINT, handleSignal);
|
||||||
|
signal(SIGTERM, handleSignal);
|
||||||
|
signal(SIGPIPE, SIG_IGN);
|
||||||
|
|
||||||
|
// If adapterOutput is not null, we know the adapter is working
|
||||||
|
// correctly
|
||||||
|
NSDictionary *result = internal_get(YES);
|
||||||
|
if (result != nil) {
|
||||||
|
cleanup_helper();
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantiate helper to ensure MediaRemote has data
|
||||||
|
// We only do this if adapterOutput is null to minimize the impact on
|
||||||
|
// other apps using the adapter
|
||||||
|
NSString *helperPath =
|
||||||
|
NSProcessInfo.processInfo
|
||||||
|
.environment[@"MEDIAREMOTEADAPTER_TEST_CLIENT_PATH"];
|
||||||
|
if (helperPath.length == 0) {
|
||||||
|
printErrf(@"Test client path is missing");
|
||||||
|
cleanup_helper();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up pipes for communication with the helper process
|
||||||
|
NSPipe *inputPipe = [NSPipe pipe];
|
||||||
|
NSPipe *outputPipe = [NSPipe pipe];
|
||||||
|
|
||||||
|
nowPlayingClientHelperTask = [[NSTask alloc] init];
|
||||||
|
nowPlayingClientHelperTask.launchPath = helperPath;
|
||||||
|
nowPlayingClientHelperTask.standardInput = inputPipe;
|
||||||
|
nowPlayingClientHelperTask.standardOutput = outputPipe;
|
||||||
|
|
||||||
|
@try {
|
||||||
|
[nowPlayingClientHelperTask launch];
|
||||||
|
} @catch (NSException *exception) {
|
||||||
|
printErrf(
|
||||||
|
@"Exeption while trying to launch test client task: %@: %@",
|
||||||
|
exception.name, exception.reason);
|
||||||
|
cleanup_helper();
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
helperInput = inputPipe.fileHandleForWriting;
|
||||||
|
helperOutput = outputPipe.fileHandleForReading;
|
||||||
|
|
||||||
|
dispatch_semaphore_t setupSem = dispatch_semaphore_create(0);
|
||||||
|
NSMutableString *lineBuffer = [[NSMutableString alloc] init];
|
||||||
|
helperOutput.readabilityHandler = ^(NSFileHandle *fh) {
|
||||||
|
@autoreleasepool {
|
||||||
|
NSData *chunk = [fh availableData];
|
||||||
|
if (chunk.length == 0) {
|
||||||
|
fh.readabilityHandler = nil;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate UTF-8 encoding with graceful degradation
|
||||||
|
NSString *chunkStr =
|
||||||
|
[[NSString alloc] initWithData:chunk
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
if (!chunkStr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[lineBuffer appendString:chunkStr];
|
||||||
|
|
||||||
|
NSUInteger bufferLength = [lineBuffer length];
|
||||||
|
NSUInteger searchStart = 0;
|
||||||
|
|
||||||
|
while (searchStart < bufferLength) {
|
||||||
|
NSRange remainingRange =
|
||||||
|
NSMakeRange(searchStart, bufferLength - searchStart);
|
||||||
|
NSRange nlRange = [lineBuffer rangeOfString:@"\n"
|
||||||
|
options:0
|
||||||
|
range:remainingRange];
|
||||||
|
|
||||||
|
if (nlRange.location == NSNotFound) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSUInteger lineLength = nlRange.location - searchStart;
|
||||||
|
NSString *line = [lineBuffer
|
||||||
|
substringWithRange:NSMakeRange(searchStart, lineLength)];
|
||||||
|
|
||||||
|
if ([line isEqualToString:@"setup_done"]) {
|
||||||
|
fh.readabilityHandler = nil;
|
||||||
|
dispatch_semaphore_signal(setupSem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchStart = nlRange.location + nlRange.length;
|
||||||
|
}
|
||||||
|
if (searchStart > 0) {
|
||||||
|
[lineBuffer
|
||||||
|
deleteCharactersInRange:NSMakeRange(0, searchStart)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Wait for setup_done or timeout
|
||||||
|
NSTimeInterval setupTimeout = 3.0;
|
||||||
|
dispatch_time_t timeout = dispatch_time(
|
||||||
|
DISPATCH_TIME_NOW, (int64_t)(setupTimeout * NSEC_PER_SEC));
|
||||||
|
long result_wait = dispatch_semaphore_wait(setupSem, timeout);
|
||||||
|
|
||||||
|
if (helperOutput.readabilityHandler) {
|
||||||
|
helperOutput.readabilityHandler = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result_wait != 0) {
|
||||||
|
printErrf(@"The test client did not signal setup_done within %.1fs",
|
||||||
|
setupTimeout);
|
||||||
|
cleanup_helper();
|
||||||
|
exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure new data is available, for some reason the
|
||||||
|
// first call to adapter_get slows down MediaRemote?
|
||||||
|
[NSThread sleepForTimeInterval:0.01];
|
||||||
|
|
||||||
|
result = internal_get(YES);
|
||||||
|
if (result != nil) {
|
||||||
|
cleanup_helper();
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_helper();
|
||||||
|
exit(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
Vendor/mediaremote-adapter/src/private/MediaRemote.h
vendored
Normal file
163
Vendor/mediaremote-adapter/src/private/MediaRemote.h
vendored
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// clang-format off
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTE_PRIVATE_H_
|
||||||
|
#define MEDIAREMOTE_PRIVATE_H_
|
||||||
|
|
||||||
|
#include <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#pragma mark Notifications
|
||||||
|
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoDidChangeNotification;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification;
|
||||||
|
extern NSString *kMRMediaRemotePickableRoutesDidChangeNotification;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingApplicationDidChangeNotification;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification;
|
||||||
|
extern NSString *kMRMediaRemoteRouteStatusDidChangeNotification;
|
||||||
|
|
||||||
|
#pragma mark Keys
|
||||||
|
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoAlbum;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoArtist;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoArtworkData;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoArtworkMIMEType;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoChapterNumber;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoComposer;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoDuration;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoElapsedTime;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoGenre;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoIsAdvertisement;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoIsBanned;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoIsInWishList;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoIsLiked;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoIsMusicApp;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoPlaybackRate;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoProhibitsSkip;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoQueueIndex;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoRadioStationIdentifier;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoRepeatMode;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoShuffleMode;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoStartTime;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsIsBanned;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsIsLiked;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoTimestamp;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoTitle;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoTotalChapterCount;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoTotalDiscCount;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoTotalQueueCount;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoTotalTrackCount;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoTrackNumber;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoUniqueIdentifier;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoContentItemIdentifier;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoRadioStationHash;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoMediaType;
|
||||||
|
extern NSString *kMRMediaRemoteNowPlayingInfoServiceIdentifier;
|
||||||
|
extern NSString *kMRMediaRemoteOptionMediaType;
|
||||||
|
extern NSString *kMRMediaRemoteOptionSourceID;
|
||||||
|
extern NSString *kMRMediaRemoteOptionTrackID;
|
||||||
|
extern NSString *kMRMediaRemoteOptionStationID;
|
||||||
|
extern NSString *kMRMediaRemoteOptionStationHash;
|
||||||
|
extern NSString *kMRMediaRemoteRouteDescriptionUserInfoKey;
|
||||||
|
extern NSString *kMRMediaRemoteRouteStatusUserInfoKey;
|
||||||
|
|
||||||
|
#pragma mark API
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
/*
|
||||||
|
* Use nil for userInfo.
|
||||||
|
*/
|
||||||
|
kMRPlay = 0,
|
||||||
|
kMRPause = 1,
|
||||||
|
kMRTogglePlayPause = 2,
|
||||||
|
kMRStop = 3,
|
||||||
|
kMRNextTrack = 4,
|
||||||
|
kMRPreviousTrack = 5,
|
||||||
|
kMRToggleShuffle = 6,
|
||||||
|
kMRToggleRepeat = 7,
|
||||||
|
kMRStartForwardSeek = 8,
|
||||||
|
kMREndForwardSeek = 9,
|
||||||
|
kMRStartBackwardSeek = 10,
|
||||||
|
kMREndBackwardSeek = 11,
|
||||||
|
kMRGoBackFifteenSeconds = 12,
|
||||||
|
kMRSkipFifteenSeconds = 13,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use a NSDictionary for userInfo, which contains three keys:
|
||||||
|
* kMRMediaRemoteOptionTrackID
|
||||||
|
* kMRMediaRemoteOptionStationID
|
||||||
|
* kMRMediaRemoteOptionStationHash
|
||||||
|
*/
|
||||||
|
kMRLikeTrack = 0x6A,
|
||||||
|
kMRBanTrack = 0x6B,
|
||||||
|
kMRAddTrackToWishList = 0x6C,
|
||||||
|
kMRRemoveTrackFromWishList = 0x6D
|
||||||
|
} MRCommand;
|
||||||
|
|
||||||
|
extern CFStringRef MRMediaRemoteSendCommand;
|
||||||
|
typedef bool (*MRMediaRemoteSendCommand_t)(MRCommand command, id userInfo);
|
||||||
|
|
||||||
|
extern CFStringRef MRMediaRemoteSetPlaybackSpeed;
|
||||||
|
extern CFStringRef MRMediaRemoteSetElapsedTime;
|
||||||
|
extern CFStringRef MRMediaRemoteSetShuffleMode;
|
||||||
|
extern CFStringRef MRMediaRemoteSetRepeatMode;
|
||||||
|
typedef void (*MRMediaRemoteSetPlaybackSpeed_t)(int speed);
|
||||||
|
typedef void (*MRMediaRemoteSetElapsedTime_t)(double elapsedTime);
|
||||||
|
typedef void (*MRMediaRemoteSetShuffleMode_t)(int mode);
|
||||||
|
typedef void (*MRMediaRemoteSetRepeatMode_t)(int mode);
|
||||||
|
|
||||||
|
extern CFStringRef MRMediaRemoteRegisterForNowPlayingNotifications;
|
||||||
|
extern CFStringRef MRMediaRemoteUnregisterForNowPlayingNotifications;
|
||||||
|
extern CFStringRef MRMediaRemoteGetNowPlayingApplicationPID;
|
||||||
|
extern CFStringRef MRMediaRemoteGetNowPlayingClient;
|
||||||
|
extern CFStringRef MRMediaRemoteGetNowPlayingInfo;
|
||||||
|
extern CFStringRef MRMediaRemoteGetNowPlayingApplicationIsPlaying;
|
||||||
|
|
||||||
|
typedef void (*MRMediaRemoteRegisterForNowPlayingNotifications_t)(dispatch_queue_t queue);
|
||||||
|
typedef void (*MRMediaRemoteUnregisterForNowPlayingNotifications_t)();
|
||||||
|
|
||||||
|
typedef void (^MRMediaRemoteGetNowPlayingInfoCompletion_t)(NSDictionary *information);
|
||||||
|
typedef void (^MRMediaRemoteGetNowPlayingApplicationPIDCompletion_t)(int PID);
|
||||||
|
typedef void (^MRMediaRemoteGetNowPlayingClientCompletion_t)(id clientObj);
|
||||||
|
typedef void (^MRMediaRemoteGetNowPlayingApplicationIsPlayingCompletion_t)(bool isPlaying);
|
||||||
|
|
||||||
|
typedef void (*MRMediaRemoteGetNowPlayingApplicationPID_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingApplicationPIDCompletion_t completion);
|
||||||
|
typedef void (*MRMediaRemoteGetNowPlayingClient_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingClientCompletion_t completion);
|
||||||
|
typedef void (*MRMediaRemoteGetNowPlayingInfo_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingInfoCompletion_t completion);
|
||||||
|
typedef void (*MRMediaRemoteGetNowPlayingApplicationIsPlaying_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingApplicationIsPlayingCompletion_t completion);
|
||||||
|
|
||||||
|
#pragma mark Miscellaneous
|
||||||
|
|
||||||
|
extern NSString *kMRNowPlayingClientUserInfoKey;
|
||||||
|
|
||||||
|
// Accessed with the kMRNowPlayingClientUserInfoKey
|
||||||
|
// on the userInfo dictionary of an NSNotification.
|
||||||
|
@interface MRClient : NSObject {}
|
||||||
|
-(NSString *)parentApplicationBundleIdentifier;
|
||||||
|
-(NSString *)bundleIdentifier;
|
||||||
|
-(NSString *)displayName;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface MediaRemote : NSObject
|
||||||
|
// Commands
|
||||||
|
@property(readonly) MRMediaRemoteSendCommand_t sendCommand;
|
||||||
|
// Other controls
|
||||||
|
@property(readonly) MRMediaRemoteSetPlaybackSpeed_t setPlaybackSpeed;
|
||||||
|
@property(readonly) MRMediaRemoteSetElapsedTime_t setElapsedTime;
|
||||||
|
@property(readonly) MRMediaRemoteSetShuffleMode_t setShuffleMode;
|
||||||
|
@property(readonly) MRMediaRemoteSetRepeatMode_t setRepeatMode;
|
||||||
|
// Observers
|
||||||
|
@property(readonly) MRMediaRemoteRegisterForNowPlayingNotifications_t registerForNowPlayingNotifications;
|
||||||
|
@property(readonly) MRMediaRemoteUnregisterForNowPlayingNotifications_t unregisterForNowPlayingNotifications;
|
||||||
|
// Metadata
|
||||||
|
@property(readonly) MRMediaRemoteGetNowPlayingApplicationPID_t getNowPlayingApplicationPID;
|
||||||
|
@property(readonly) MRMediaRemoteGetNowPlayingClient_t getNowPlayingClient;
|
||||||
|
@property(readonly) MRMediaRemoteGetNowPlayingInfo_t getNowPlayingInfo;
|
||||||
|
@property(readonly) MRMediaRemoteGetNowPlayingApplicationIsPlaying_t getNowPlayingApplicationIsPlaying;
|
||||||
|
// Constructor
|
||||||
|
-(id)init;
|
||||||
|
@end
|
||||||
|
|
||||||
|
#endif /* MEDIAREMOTE_PRIVATE_H_ */
|
||||||
114
Vendor/mediaremote-adapter/src/private/MediaRemote.m
vendored
Normal file
114
Vendor/mediaremote-adapter/src/private/MediaRemote.m
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// clang-format off
|
||||||
|
|
||||||
|
#include <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#include "MediaRemote.h"
|
||||||
|
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoDidChangeNotification = @"kMRMediaRemoteNowPlayingInfoDidChangeNotification";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification = @"kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification";
|
||||||
|
NSString *kMRMediaRemotePickableRoutesDidChangeNotification = @"kMRMediaRemotePickableRoutesDidChangeNotification";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingApplicationDidChangeNotification = @"kMRMediaRemoteNowPlayingApplicationDidChangeNotification";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification = @"kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification";
|
||||||
|
|
||||||
|
NSString *kMRMediaRemoteRouteStatusDidChangeNotification = @"kMRMediaRemoteRouteStatusDidChangeNotification";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey = @"kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey = @"kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoAlbum = @"kMRMediaRemoteNowPlayingInfoAlbum";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoArtist = @"kMRMediaRemoteNowPlayingInfoArtist";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoArtworkData = @"kMRMediaRemoteNowPlayingInfoArtworkData";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoArtworkMIMEType = @"kMRMediaRemoteNowPlayingInfoArtworkMIMEType";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoChapterNumber = @"kMRMediaRemoteNowPlayingInfoChapterNumber";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoComposer = @"kMRMediaRemoteNowPlayingInfoComposer";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoDuration = @"kMRMediaRemoteNowPlayingInfoDuration";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoElapsedTime = @"kMRMediaRemoteNowPlayingInfoElapsedTime";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoGenre = @"kMRMediaRemoteNowPlayingInfoGenre";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoIsAdvertisement = @"kMRMediaRemoteNowPlayingInfoIsAdvertisement";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoIsBanned = @"kMRMediaRemoteNowPlayingInfoIsBanned";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoIsInWishList = @"kMRMediaRemoteNowPlayingInfoIsInWishList";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoIsLiked = @"kMRMediaRemoteNowPlayingInfoIsLiked";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoIsMusicApp = @"kMRMediaRemoteNowPlayingInfoIsMusicApp";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoPlaybackRate = @"kMRMediaRemoteNowPlayingInfoPlaybackRate";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoProhibitsSkip = @"kMRMediaRemoteNowPlayingInfoProhibitsSkip";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoQueueIndex = @"kMRMediaRemoteNowPlayingInfoQueueIndex";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoRadioStationIdentifier = @"kMRMediaRemoteNowPlayingInfoRadioStationIdentifier";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoRepeatMode = @"kMRMediaRemoteNowPlayingInfoRepeatMode";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoShuffleMode = @"kMRMediaRemoteNowPlayingInfoShuffleMode";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoStartTime = @"kMRMediaRemoteNowPlayingInfoStartTime";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds = @"kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoSupportsIsBanned = @"kMRMediaRemoteNowPlayingInfoSupportsIsBanned";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoSupportsIsLiked = @"kMRMediaRemoteNowPlayingInfoSupportsIsLiked";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds = @"kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoTimestamp = @"kMRMediaRemoteNowPlayingInfoTimestamp";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoTitle = @"kMRMediaRemoteNowPlayingInfoTitle";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoTotalChapterCount = @"kMRMediaRemoteNowPlayingInfoTotalChapterCount";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoTotalDiscCount = @"kMRMediaRemoteNowPlayingInfoTotalDiscCount";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoTotalQueueCount = @"kMRMediaRemoteNowPlayingInfoTotalQueueCount";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoTotalTrackCount = @"kMRMediaRemoteNowPlayingInfoTotalTrackCount";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoTrackNumber = @"kMRMediaRemoteNowPlayingInfoTrackNumber";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoUniqueIdentifier = @"kMRMediaRemoteNowPlayingInfoUniqueIdentifier";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoContentItemIdentifier = @"kMRMediaRemoteNowPlayingInfoContentItemIdentifier";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoRadioStationHash = @"kMRMediaRemoteNowPlayingInfoRadioStationHash";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoMediaType = @"kMRMediaRemoteNowPlayingInfoMediaType";
|
||||||
|
NSString *kMRMediaRemoteNowPlayingInfoServiceIdentifier = @"kMRMediaRemoteNowPlayingInfoServiceIdentifier";
|
||||||
|
NSString *kMRMediaRemoteOptionMediaType = @"kMRMediaRemoteOptionMediaType";
|
||||||
|
NSString *kMRMediaRemoteOptionSourceID = @"kMRMediaRemoteOptionSourceID";
|
||||||
|
NSString *kMRMediaRemoteOptionTrackID = @"kMRMediaRemoteOptionTrackID";
|
||||||
|
NSString *kMRMediaRemoteOptionStationID = @"kMRMediaRemoteOptionStationID";
|
||||||
|
NSString *kMRMediaRemoteOptionStationHash = @"kMRMediaRemoteOptionStationHash";
|
||||||
|
NSString *kMRMediaRemoteRouteDescriptionUserInfoKey = @"kMRMediaRemoteRouteDescriptionUserInfoKey";
|
||||||
|
NSString *kMRMediaRemoteRouteStatusUserInfoKey = @"kMRMediaRemoteRouteStatusUserInfoKey";
|
||||||
|
|
||||||
|
CFStringRef MRMediaRemoteSendCommand = CFSTR("MRMediaRemoteSendCommand");
|
||||||
|
|
||||||
|
CFStringRef MRMediaRemoteSetPlaybackSpeed = CFSTR("MRMediaRemoteSetPlaybackSpeed");
|
||||||
|
CFStringRef MRMediaRemoteSetElapsedTime = CFSTR("MRMediaRemoteSetElapsedTime");
|
||||||
|
CFStringRef MRMediaRemoteSetShuffleMode = CFSTR("MRMediaRemoteSetShuffleMode");
|
||||||
|
CFStringRef MRMediaRemoteSetRepeatMode = CFSTR("MRMediaRemoteSetRepeatMode");
|
||||||
|
|
||||||
|
CFStringRef MRMediaRemoteRegisterForNowPlayingNotifications = CFSTR("MRMediaRemoteRegisterForNowPlayingNotifications");
|
||||||
|
CFStringRef MRMediaRemoteUnregisterForNowPlayingNotifications = CFSTR("MRMediaRemoteUnregisterForNowPlayingNotifications");
|
||||||
|
CFStringRef MRMediaRemoteGetNowPlayingApplicationPID = CFSTR("MRMediaRemoteGetNowPlayingApplicationPID");
|
||||||
|
CFStringRef MRMediaRemoteGetNowPlayingClient = CFSTR("MRMediaRemoteGetNowPlayingClient");
|
||||||
|
CFStringRef MRMediaRemoteGetNowPlayingInfo = CFSTR("MRMediaRemoteGetNowPlayingInfo");
|
||||||
|
CFStringRef MRMediaRemoteGetNowPlayingApplicationIsPlaying = CFSTR("MRMediaRemoteGetNowPlayingApplicationIsPlaying");
|
||||||
|
|
||||||
|
NSString *kMRNowPlayingClientUserInfoKey = @"kMRNowPlayingClientUserInfoKey";
|
||||||
|
|
||||||
|
static NSString *MediaRemoteFrameworkBundleURL = @"/System/Library/PrivateFrameworks/MediaRemote.framework";
|
||||||
|
|
||||||
|
@implementation MediaRemote
|
||||||
|
@synthesize sendCommand;
|
||||||
|
@synthesize setPlaybackSpeed;
|
||||||
|
@synthesize setElapsedTime;
|
||||||
|
@synthesize setShuffleMode;
|
||||||
|
@synthesize setRepeatMode;
|
||||||
|
@synthesize registerForNowPlayingNotifications;
|
||||||
|
@synthesize unregisterForNowPlayingNotifications;
|
||||||
|
@synthesize getNowPlayingApplicationPID;
|
||||||
|
@synthesize getNowPlayingClient;
|
||||||
|
@synthesize getNowPlayingInfo;
|
||||||
|
@synthesize getNowPlayingApplicationIsPlaying;
|
||||||
|
-(id)init
|
||||||
|
{
|
||||||
|
if (!(self = [super init])) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
CFURLRef bundleURL = (__bridge CFURLRef)[NSURL fileURLWithPath:MediaRemoteFrameworkBundleURL];
|
||||||
|
CFBundleRef bundle = CFBundleCreate(kCFAllocatorDefault, bundleURL);
|
||||||
|
if (!bundle) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
sendCommand = (MRMediaRemoteSendCommand_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSendCommand);
|
||||||
|
setPlaybackSpeed = (MRMediaRemoteSetPlaybackSpeed_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetPlaybackSpeed);
|
||||||
|
setElapsedTime = (MRMediaRemoteSetElapsedTime_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetElapsedTime);
|
||||||
|
setShuffleMode = (MRMediaRemoteSetShuffleMode_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetShuffleMode);
|
||||||
|
setRepeatMode = (MRMediaRemoteSetRepeatMode_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetRepeatMode);
|
||||||
|
registerForNowPlayingNotifications = (MRMediaRemoteRegisterForNowPlayingNotifications_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteRegisterForNowPlayingNotifications);
|
||||||
|
unregisterForNowPlayingNotifications = (MRMediaRemoteUnregisterForNowPlayingNotifications_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteUnregisterForNowPlayingNotifications);
|
||||||
|
getNowPlayingApplicationPID = (MRMediaRemoteGetNowPlayingApplicationPID_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingApplicationPID);
|
||||||
|
getNowPlayingClient = (MRMediaRemoteGetNowPlayingClient_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingClient);
|
||||||
|
getNowPlayingInfo = (MRMediaRemoteGetNowPlayingInfo_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingInfo);
|
||||||
|
getNowPlayingApplicationIsPlaying = (MRMediaRemoteGetNowPlayingApplicationIsPlaying_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingApplicationIsPlaying);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
@end
|
||||||
50
Vendor/mediaremote-adapter/src/test/NowPlayingTest.h
vendored
Normal file
50
Vendor/mediaremote-adapter/src/test/NowPlayingTest.h
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Copyright (c) 2025 Alexander5015
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <MediaPlayer/MediaPlayer.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol RemoteCommandCenterDelegateListener <NSObject>
|
||||||
|
- (void)didReceivePlayCommand;
|
||||||
|
- (void)didReceivePauseCommand;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface NowPlayingInfoDelegate : NSObject
|
||||||
|
|
||||||
|
@property(nonatomic, readonly) MPNowPlayingInfoCenter *center;
|
||||||
|
|
||||||
|
- (void)updateMetadataWithTitle:(NSString *)title
|
||||||
|
artist:(NSString *)artist
|
||||||
|
duration:(NSTimeInterval)duration;
|
||||||
|
- (void)setPlaybackRate:(float)rate elapsedTime:(NSTimeInterval)time;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface RemoteCommandCenterDelegate : NSObject
|
||||||
|
|
||||||
|
@property(nonatomic, weak) id<RemoteCommandCenterDelegateListener> listener;
|
||||||
|
|
||||||
|
- (instancetype)initWithListener:
|
||||||
|
(id<RemoteCommandCenterDelegateListener>)listener;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface NowPlayingPublishTest
|
||||||
|
: NSObject <RemoteCommandCenterDelegateListener>
|
||||||
|
|
||||||
|
@property(nonatomic, strong, readonly)
|
||||||
|
NowPlayingInfoDelegate *nowPlayingDelegate;
|
||||||
|
@property(nonatomic, strong, readonly)
|
||||||
|
RemoteCommandCenterDelegate *commandDelegate;
|
||||||
|
@property(nonatomic, assign, readonly) BOOL isPlaying;
|
||||||
|
@property(nonatomic, assign, readonly) NSTimeInterval elapsedTime;
|
||||||
|
@property(nonatomic, strong, nullable, readonly) NSDate *playbackStartDate;
|
||||||
|
@property(nonatomic, assign, readonly) NSTimeInterval totalDuration;
|
||||||
|
|
||||||
|
- (void)updateNowPlayingInfo;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
231
Vendor/mediaremote-adapter/src/test/NowPlayingTest.m
vendored
Normal file
231
Vendor/mediaremote-adapter/src/test/NowPlayingTest.m
vendored
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// Copyright (c) 2025 Alexander5015
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#include <MediaPlayer/MediaPlayer.h>
|
||||||
|
|
||||||
|
#import "NowPlayingTest.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
static const NSTimeInterval kDefaultTrackDuration = 10.0 * 60.0; // 10 minutes
|
||||||
|
static const float kPlayingRate = 1.0f;
|
||||||
|
static const float kPausedRate = 0.0f;
|
||||||
|
|
||||||
|
@implementation NowPlayingInfoDelegate {
|
||||||
|
MPNowPlayingInfoCenter *_center;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
if (self = [super init]) {
|
||||||
|
_center = [MPNowPlayingInfoCenter defaultCenter];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MPNowPlayingInfoCenter *)center {
|
||||||
|
return _center;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateMetadataWithTitle:(NSString *)title
|
||||||
|
artist:(NSString *)artist
|
||||||
|
duration:(NSTimeInterval)duration {
|
||||||
|
NSMutableDictionary *nowPlayingInfo = [@{
|
||||||
|
MPMediaItemPropertyTitle : title ?: @"Unknown Title",
|
||||||
|
MPMediaItemPropertyAlbumTitle : @"Unknown Album",
|
||||||
|
MPMediaItemPropertyArtist : artist ?: @"Unknown Artist",
|
||||||
|
MPMediaItemPropertyPlaybackDuration : @(duration),
|
||||||
|
MPNowPlayingInfoPropertyElapsedPlaybackTime : @0,
|
||||||
|
MPNowPlayingInfoPropertyCurrentPlaybackDate : [NSDate date],
|
||||||
|
MPNowPlayingInfoPropertyPlaybackRate : @(kPlayingRate),
|
||||||
|
MPNowPlayingInfoPropertyMediaType : @(MPNowPlayingInfoMediaTypeAudio),
|
||||||
|
MPNowPlayingInfoPropertyServiceIdentifier :
|
||||||
|
@"com.vandenbe.MediaRemoteAdapter.TestClient",
|
||||||
|
} mutableCopy];
|
||||||
|
|
||||||
|
#if defined(__MAC_15_0) && __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_15_0
|
||||||
|
if (@available(macOS 15, *)) {
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyExcludeFromSuggestions] = @YES;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
self.center.playbackState = MPNowPlayingPlaybackStatePlaying;
|
||||||
|
self.center.nowPlayingInfo = [nowPlayingInfo copy];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setPlaybackRate:(float)rate elapsedTime:(NSTimeInterval)time {
|
||||||
|
NSMutableDictionary *currentInfo = [self.center.nowPlayingInfo mutableCopy];
|
||||||
|
if (!currentInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(rate);
|
||||||
|
currentInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(time);
|
||||||
|
self.center.nowPlayingInfo = [currentInfo copy];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation RemoteCommandCenterDelegate
|
||||||
|
|
||||||
|
- (instancetype)initWithListener:
|
||||||
|
(id<RemoteCommandCenterDelegateListener>)listener {
|
||||||
|
if (self = [super init]) {
|
||||||
|
_listener = listener;
|
||||||
|
[self setupRemoteCommandHandlers];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupRemoteCommandHandlers {
|
||||||
|
MPRemoteCommandCenter *commandCenter =
|
||||||
|
[MPRemoteCommandCenter sharedCommandCenter];
|
||||||
|
|
||||||
|
[commandCenter.playCommand addTarget:self
|
||||||
|
action:@selector(handlePlayCommand:)];
|
||||||
|
[commandCenter.pauseCommand addTarget:self
|
||||||
|
action:@selector(handlePauseCommand:)];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MPRemoteCommandHandlerStatus)handlePlayCommand:
|
||||||
|
(MPRemoteCommandEvent *)event {
|
||||||
|
[self.listener didReceivePlayCommand];
|
||||||
|
return MPRemoteCommandHandlerStatusSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MPRemoteCommandHandlerStatus)handlePauseCommand:
|
||||||
|
(MPRemoteCommandEvent *)event {
|
||||||
|
[self.listener didReceivePauseCommand];
|
||||||
|
return MPRemoteCommandHandlerStatusSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface NowPlayingPublishTest ()
|
||||||
|
@property(nonatomic, strong, readwrite)
|
||||||
|
NowPlayingInfoDelegate *nowPlayingDelegate;
|
||||||
|
@property(nonatomic, strong, readwrite)
|
||||||
|
RemoteCommandCenterDelegate *commandDelegate;
|
||||||
|
@property(nonatomic, assign, readwrite) BOOL isPlaying;
|
||||||
|
@property(nonatomic, assign, readwrite) NSTimeInterval elapsedTime;
|
||||||
|
@property(nonatomic, strong, nullable, readwrite) NSDate *playbackStartDate;
|
||||||
|
@property(nonatomic, assign, readwrite) NSTimeInterval totalDuration;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation NowPlayingPublishTest
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
if (self = [super init]) {
|
||||||
|
[self setupDelegates];
|
||||||
|
[self initializePlaybackState];
|
||||||
|
[self setupInitialTrack];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupDelegates {
|
||||||
|
self.nowPlayingDelegate = [[NowPlayingInfoDelegate alloc] init];
|
||||||
|
self.commandDelegate =
|
||||||
|
[[RemoteCommandCenterDelegate alloc] initWithListener:self];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)initializePlaybackState {
|
||||||
|
self.totalDuration = kDefaultTrackDuration;
|
||||||
|
self.elapsedTime = 0.0;
|
||||||
|
self.playbackStartDate = [NSDate date];
|
||||||
|
self.isPlaying = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupInitialTrack {
|
||||||
|
[self.nowPlayingDelegate updateMetadataWithTitle:@"Is It Broken Yet?"
|
||||||
|
artist:@"Alexander5015, ungive"
|
||||||
|
duration:self.totalDuration];
|
||||||
|
[self updateNowPlayingInfo];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)didReceivePlayCommand {
|
||||||
|
if (self.isPlaying) {
|
||||||
|
return; // Already playing
|
||||||
|
}
|
||||||
|
|
||||||
|
[self startPlayback];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)didReceivePauseCommand {
|
||||||
|
if (!self.isPlaying) {
|
||||||
|
return; // Already paused
|
||||||
|
}
|
||||||
|
|
||||||
|
[self pausePlayback];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)startPlayback {
|
||||||
|
self.isPlaying = YES;
|
||||||
|
self.playbackStartDate = [NSDate date];
|
||||||
|
[self updateNowPlayingInfo];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)pausePlayback {
|
||||||
|
self.isPlaying = NO;
|
||||||
|
[self updateElapsedTimeFromPlaybackStart];
|
||||||
|
self.playbackStartDate = nil;
|
||||||
|
[self updateNowPlayingInfo];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateElapsedTimeFromPlaybackStart {
|
||||||
|
if (!self.playbackStartDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSTimeInterval playedInterval =
|
||||||
|
[[NSDate date] timeIntervalSinceDate:self.playbackStartDate];
|
||||||
|
self.elapsedTime += playedInterval;
|
||||||
|
|
||||||
|
// Ensure elapsed time doesn't exceed total duration
|
||||||
|
if (self.elapsedTime > self.totalDuration) {
|
||||||
|
self.elapsedTime = self.totalDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateNowPlayingInfo {
|
||||||
|
NSTimeInterval currentElapsedTime = [self calculateCurrentElapsedTime];
|
||||||
|
float playbackRate = [self calculatePlaybackRate:currentElapsedTime];
|
||||||
|
|
||||||
|
[self.nowPlayingDelegate setPlaybackRate:playbackRate
|
||||||
|
elapsedTime:currentElapsedTime];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSTimeInterval)calculateCurrentElapsedTime {
|
||||||
|
NSTimeInterval currentElapsed = self.elapsedTime;
|
||||||
|
|
||||||
|
if (self.isPlaying && self.playbackStartDate) {
|
||||||
|
NSTimeInterval intervalSinceStart =
|
||||||
|
[[NSDate date] timeIntervalSinceDate:self.playbackStartDate];
|
||||||
|
currentElapsed += intervalSinceStart;
|
||||||
|
|
||||||
|
// Cap at total duration
|
||||||
|
if (currentElapsed > self.totalDuration) {
|
||||||
|
currentElapsed = self.totalDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentElapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (float)calculatePlaybackRate:(NSTimeInterval)currentElapsedTime {
|
||||||
|
if (!self.isPlaying) {
|
||||||
|
return kPausedRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if track has ended
|
||||||
|
if (currentElapsedTime >= self.totalDuration) {
|
||||||
|
self.isPlaying = NO; // Auto-pause when track ends
|
||||||
|
return kPausedRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return kPlayingRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
53
Vendor/mediaremote-adapter/src/test/main.m
vendored
Normal file
53
Vendor/mediaremote-adapter/src/test/main.m
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (c) 2025 Alexander5015
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#import "NowPlayingTest.h"
|
||||||
|
|
||||||
|
static const NSTimeInterval kRunLoopInterval = 0.1;
|
||||||
|
static const size_t kInputBufferSize = 256;
|
||||||
|
int main(int argc, const char *argv[]) {
|
||||||
|
@autoreleasepool {
|
||||||
|
dup2(STDOUT_FILENO, STDERR_FILENO);
|
||||||
|
|
||||||
|
NowPlayingPublishTest *test = [[NowPlayingPublishTest alloc] init];
|
||||||
|
puts("setup_done");
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
BOOL shouldExit = NO;
|
||||||
|
while (!shouldExit) {
|
||||||
|
NSDate *waitUntil =
|
||||||
|
[NSDate dateWithTimeIntervalSinceNow:kRunLoopInterval];
|
||||||
|
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
|
||||||
|
beforeDate:waitUntil];
|
||||||
|
|
||||||
|
fd_set fds;
|
||||||
|
struct timeval tv = {0, 0};
|
||||||
|
FD_ZERO(&fds);
|
||||||
|
FD_SET(STDIN_FILENO, &fds);
|
||||||
|
int ret = select(STDIN_FILENO + 1, &fds, NULL, NULL, &tv);
|
||||||
|
if (ret > 0 && FD_ISSET(STDIN_FILENO, &fds)) {
|
||||||
|
char buf[kInputBufferSize];
|
||||||
|
if (fgets(buf, sizeof(buf), stdin)) {
|
||||||
|
NSString *command =
|
||||||
|
[[NSString alloc] initWithUTF8String:buf];
|
||||||
|
command = [command
|
||||||
|
stringByTrimmingCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
if ([command isEqualToString:@"cleanup"]) {
|
||||||
|
printf("cleanup_done\n");
|
||||||
|
fflush(stdout);
|
||||||
|
shouldExit = YES;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
|
||||||
|
puts("unknown_command");
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
24
Vendor/mediaremote-adapter/src/utility/Debounce.h
vendored
Normal file
24
Vendor/mediaremote-adapter/src/utility/Debounce.h
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H
|
||||||
|
#define MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H
|
||||||
|
|
||||||
|
#import <CoreFoundation/CoreFoundation.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface Debounce : NSObject
|
||||||
|
|
||||||
|
@property(nonatomic, assign, readonly) NSTimeInterval delay;
|
||||||
|
- (instancetype)initWithDelay:(NSTimeInterval)delay
|
||||||
|
queue:(nullable dispatch_queue_t)queue;
|
||||||
|
- (void)call:(dispatch_block_t)block;
|
||||||
|
- (void)cancel;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H
|
||||||
41
Vendor/mediaremote-adapter/src/utility/Debounce.m
vendored
Normal file
41
Vendor/mediaremote-adapter/src/utility/Debounce.m
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import "Debounce.h"
|
||||||
|
|
||||||
|
@interface Debounce ()
|
||||||
|
|
||||||
|
@property(nonatomic, strong) dispatch_queue_t queue;
|
||||||
|
@property(nonatomic, strong, nullable) dispatch_block_t pendingBlock;
|
||||||
|
@property(nonatomic, assign, readwrite) NSTimeInterval delay;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation Debounce
|
||||||
|
|
||||||
|
- (instancetype)initWithDelay:(NSTimeInterval)delay
|
||||||
|
queue:(dispatch_queue_t)queue {
|
||||||
|
self = [super init];
|
||||||
|
if (self) {
|
||||||
|
_delay = MAX(0.0, delay);
|
||||||
|
_queue = queue ?: dispatch_get_main_queue();
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)call:(dispatch_block_t)block {
|
||||||
|
[self cancel];
|
||||||
|
self.pendingBlock = dispatch_block_create(DISPATCH_BLOCK_BARRIER, block);
|
||||||
|
dispatch_after(
|
||||||
|
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.delay * NSEC_PER_SEC)),
|
||||||
|
self.queue, self.pendingBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)cancel {
|
||||||
|
if (self.pendingBlock) {
|
||||||
|
dispatch_block_cancel(self.pendingBlock);
|
||||||
|
self.pendingBlock = nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
27
Vendor/mediaremote-adapter/src/utility/helpers.h
vendored
Normal file
27
Vendor/mediaremote-adapter/src/utility/helpers.h
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#ifndef MEDIAREMOTEADAPTER_UTILITY_HELPERS_H
|
||||||
|
#define MEDIAREMOTEADAPTER_UTILITY_HELPERS_H
|
||||||
|
|
||||||
|
#include <stdarg.h>
|
||||||
|
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
void printOut(NSString *message);
|
||||||
|
void printOutUnique(NSString *message);
|
||||||
|
void printErr(NSString *message);
|
||||||
|
void printErrf(NSString *format, ...);
|
||||||
|
|
||||||
|
void fail(NSString *message);
|
||||||
|
void failf(NSString *format, ...);
|
||||||
|
|
||||||
|
NSString *formatError(NSError *error);
|
||||||
|
NSString *serializeJsonDictionarySafe(NSDictionary *any, bool prettyPrint);
|
||||||
|
|
||||||
|
bool appForPID(int pid, void (^block)(NSRunningApplication *));
|
||||||
|
|
||||||
|
void makePayloadHumanReadable(NSMutableDictionary *dict);
|
||||||
|
|
||||||
|
#endif // MEDIAREMOTEADAPTER_UTILITY_HELPERS_H
|
||||||
246
Vendor/mediaremote-adapter/src/utility/helpers.m
vendored
Normal file
246
Vendor/mediaremote-adapter/src/utility/helpers.m
vendored
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
// Copyright (c) 2025 Jonas van den Berg
|
||||||
|
// This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
#import "helpers.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#import <ImageIO/ImageIO.h>
|
||||||
|
#if __has_include(<UniformTypeIdentifiers/UniformTypeIdentifiers.h>)
|
||||||
|
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||||
|
#else
|
||||||
|
#import <CoreServices/CoreServices.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define JSON_NULL @"null";
|
||||||
|
|
||||||
|
void printOut(NSString *message) {
|
||||||
|
fprintf(stdout, "%s\n", [message UTF8String]);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void printOutUnique(NSString *message) {
|
||||||
|
static NSString *previous = nil;
|
||||||
|
if (![previous isEqualToString:message]) {
|
||||||
|
printOut(message);
|
||||||
|
previous = [message copy];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void printErr(NSString *message) {
|
||||||
|
fprintf(stderr, "%s\n", [message UTF8String]);
|
||||||
|
fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void printErrf(NSString *format, ...) {
|
||||||
|
va_list args;
|
||||||
|
va_start(args, format);
|
||||||
|
NSString *formattedMessage = [[NSString alloc] initWithFormat:format
|
||||||
|
arguments:args];
|
||||||
|
va_end(args);
|
||||||
|
fprintf(stderr, "%s\n", [formattedMessage UTF8String]);
|
||||||
|
fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fail(NSString *message) {
|
||||||
|
printErr(message);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void failf(NSString *format, ...) {
|
||||||
|
va_list args;
|
||||||
|
va_start(args, format);
|
||||||
|
NSString *formattedMessage = [[NSString alloc] initWithFormat:format
|
||||||
|
arguments:args];
|
||||||
|
va_end(args);
|
||||||
|
fail(formattedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *formatError(NSError *error) {
|
||||||
|
return
|
||||||
|
[NSString stringWithFormat:@"%@ (%@:%ld)", [error localizedDescription],
|
||||||
|
[error domain], (long)[error code]];
|
||||||
|
}
|
||||||
|
|
||||||
|
static id sanitizeValueForJsonEncoding(id value, NSString *parentKey) {
|
||||||
|
const id unsupported_type = nil; // remove silently at call site with log
|
||||||
|
if ([value isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSDictionary *dictionary = (NSDictionary *)value;
|
||||||
|
NSMutableDictionary *result = [NSMutableDictionary dictionary];
|
||||||
|
for (id key in dictionary) {
|
||||||
|
if (![key isKindOfClass:[NSString class]]) {
|
||||||
|
printErrf(@"Invalid JSON key in dictionary: %@ (%@)",
|
||||||
|
[key description], [key class]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
id raw = [dictionary objectForKey:key];
|
||||||
|
id clean = sanitizeValueForJsonEncoding(raw, key);
|
||||||
|
if (clean) {
|
||||||
|
result[key] = clean;
|
||||||
|
} else {
|
||||||
|
printErrf(@"Invalid JSON value type in dictionary for key "
|
||||||
|
@"'%@': %@ (%@)",
|
||||||
|
key, raw, [raw class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else if ([value isKindOfClass:[NSArray class]]) {
|
||||||
|
NSArray *array = (NSArray *)value;
|
||||||
|
NSMutableArray *result = [NSMutableArray array];
|
||||||
|
for (NSUInteger i = 0; i < array.count; i++) {
|
||||||
|
id elem = array[i];
|
||||||
|
id clean = sanitizeValueForJsonEncoding(elem, parentKey);
|
||||||
|
if (clean) {
|
||||||
|
[result addObject:clean];
|
||||||
|
} else if (parentKey != nil) {
|
||||||
|
printErrf(@"Invalid JSON value type in array at index %d "
|
||||||
|
@"under key '%@': %@ (%@)",
|
||||||
|
i, parentKey, elem, [elem class]);
|
||||||
|
} else {
|
||||||
|
printErrf(
|
||||||
|
@"Invalid JSON value type in array at index %d: %@ (%@)", i,
|
||||||
|
elem, [elem class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else if ([value isKindOfClass:[NSString class]] ||
|
||||||
|
[value isKindOfClass:[NSNull class]]) {
|
||||||
|
return value;
|
||||||
|
} else if ([value isKindOfClass:[NSNumber class]]) {
|
||||||
|
NSNumber *number = (NSNumber *)value;
|
||||||
|
double unwrapped = [number doubleValue];
|
||||||
|
if (isnan(unwrapped) || isinf(unwrapped)) {
|
||||||
|
return unsupported_type;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} else if ([value isKindOfClass:[NSDate class]]) {
|
||||||
|
static NSDateFormatter *formatter = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
formatter = [[NSDateFormatter alloc] init];
|
||||||
|
formatter.locale =
|
||||||
|
[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
|
||||||
|
formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"UTC"];
|
||||||
|
formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'";
|
||||||
|
});
|
||||||
|
return [formatter stringFromDate:(NSDate *)value];
|
||||||
|
} else if ([value isKindOfClass:[NSURL class]]) {
|
||||||
|
return [(NSURL *)value absoluteString];
|
||||||
|
} else if ([value isKindOfClass:[NSData class]]) {
|
||||||
|
return [(NSData *)value base64EncodedStringWithOptions:0];
|
||||||
|
} else {
|
||||||
|
return unsupported_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary *sanitizeDictionaryForJsonEncoding(NSDictionary *data) {
|
||||||
|
return sanitizeValueForJsonEncoding(data, nil);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *serializeJsonDictionarySafe(NSDictionary *any, bool prettyPrint) {
|
||||||
|
if (any == nil) {
|
||||||
|
NSCAssert(false, @"Cannot serialize nil as JSON");
|
||||||
|
return JSON_NULL;
|
||||||
|
}
|
||||||
|
any = sanitizeDictionaryForJsonEncoding(any);
|
||||||
|
if (any == nil) {
|
||||||
|
NSCAssert(false, @"Sanitized JSON dictionary is nil");
|
||||||
|
return JSON_NULL;
|
||||||
|
}
|
||||||
|
NSCAssert([NSJSONSerialization isValidJSONObject:any],
|
||||||
|
@"Sanitized JSON dictionary is not a valid JSON object");
|
||||||
|
@try {
|
||||||
|
NSError *error;
|
||||||
|
NSJSONWritingOptions options =
|
||||||
|
prettyPrint ? NSJSONWritingPrettyPrinted : 0;
|
||||||
|
NSData *serialized = [NSJSONSerialization dataWithJSONObject:any
|
||||||
|
options:options
|
||||||
|
error:&error];
|
||||||
|
if (!serialized) {
|
||||||
|
printErrf(@"Failed to serialize JSON: %@", error);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
return [[NSString alloc] initWithData:serialized
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
} @catch (NSException *exception) {
|
||||||
|
if ([exception.name isEqualToString:NSInvalidArgumentException]) {
|
||||||
|
printErrf(@"Exception during JSON serialization: %@: %@", exception,
|
||||||
|
[any class]);
|
||||||
|
} else {
|
||||||
|
printErrf(@"Exception during JSON serialization: %@", exception);
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Dictionary with invalid values to test sanitization before serialization.
|
||||||
|
any = @{
|
||||||
|
@"validString" : @"Hello",
|
||||||
|
@"validNumber" : @123,
|
||||||
|
@"invalidDate" : [NSDate date],
|
||||||
|
@"invalidURL" : [NSURL URLWithString:@"https://apple.com"],
|
||||||
|
@"invalidSet" : [NSSet setWithObjects:@"a", @"b", nil],
|
||||||
|
@"nestedDict" : @{@42 : @"badKey", @"validNestedKey" :
|
||||||
|
@"nestedValue"},
|
||||||
|
@123 : @"badKeyAtRoot",
|
||||||
|
@"binaryData" :
|
||||||
|
[@"Hello, base64!" dataUsingEncoding:NSUTF8StringEncoding],
|
||||||
|
@"invalidArray" : @[
|
||||||
|
@"okay", [@"Hello, base64!"
|
||||||
|
dataUsingEncoding:NSUTF8StringEncoding]
|
||||||
|
],
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
|
bool appForPID(int pid, void (^block)(NSRunningApplication *)) {
|
||||||
|
if (pid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
NSRunningApplication *process =
|
||||||
|
[NSRunningApplication runningApplicationWithProcessIdentifier:pid];
|
||||||
|
if (process == nil) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
block(process);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString *guessImageMimeTypeFromData(NSData *data) {
|
||||||
|
if (!data)
|
||||||
|
return nil;
|
||||||
|
CGImageSourceRef src =
|
||||||
|
CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
|
||||||
|
if (!src)
|
||||||
|
return nil;
|
||||||
|
CFStringRef uti = CGImageSourceGetType(src);
|
||||||
|
CFRelease(src);
|
||||||
|
if (!uti)
|
||||||
|
return nil;
|
||||||
|
#if __has_include(<UniformTypeIdentifiers/UniformTypeIdentifiers.h>)
|
||||||
|
UTType *type = [UTType typeWithIdentifier:(__bridge NSString *)uti];
|
||||||
|
return type.preferredMIMEType;
|
||||||
|
#else
|
||||||
|
CFStringRef mime =
|
||||||
|
UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType);
|
||||||
|
if (!mime)
|
||||||
|
return nil;
|
||||||
|
NSString *mimeType = (__bridge_transfer NSString *)mime;
|
||||||
|
return mimeType;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void makePayloadHumanReadable(NSMutableDictionary *dict) {
|
||||||
|
for (NSString *key in [dict allKeys]) {
|
||||||
|
id value = dict[key];
|
||||||
|
if ([value isKindOfClass:[NSData class]]) {
|
||||||
|
NSString *mimeType = guessImageMimeTypeFromData(value);
|
||||||
|
dict[key] = [NSString
|
||||||
|
stringWithFormat:@"<%@%@%lu bytes...>", mimeType ?: @"",
|
||||||
|
mimeType ? @" " : @"",
|
||||||
|
(unsigned long)[value length]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
bright.svg
Normal file
1
bright.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#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>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
73
docs/spikes/E-tcc-hardened-runtime.md
Normal file
73
docs/spikes/E-tcc-hardened-runtime.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Hardened Runtime verlangt eigene TCC-Entitlements
|
||||||
|
|
||||||
|
Aufgetreten am 10.08.2026 beim Kalender-Widget. Aufwendig zu finden, in einer
|
||||||
|
Zeile zu beheben — deshalb hier festgehalten, bevor es bei Standort, Apple
|
||||||
|
Events und Audio erneut zuschlägt.
|
||||||
|
|
||||||
|
## Erscheinungsbild
|
||||||
|
|
||||||
|
`EKEventStore.requestFullAccessToEvents()` kehrt nach wenigen Millisekunden mit
|
||||||
|
`false` zurück. Kein Fehler, kein Dialog. `authorizationStatus` bleibt auf
|
||||||
|
`notDetermined`. In den Systemeinstellungen taucht die App **gar nicht** auf —
|
||||||
|
und dort lässt sich auch nichts von Hand nachtragen, denn der Kalenderbereich
|
||||||
|
hat kein Pluszeichen. Einträge entstehen ausschließlich durch eine erfolgreiche
|
||||||
|
Anfrage. Man sitzt also in einer Sackgasse ohne jede Fehlermeldung.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
|
||||||
|
```
|
||||||
|
tccd: Prompting policy for hardened runtime; service: kTCCServiceCalendar
|
||||||
|
requires entitlement com.apple.security.personal-information.calendars
|
||||||
|
but it is missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements — **auch ohne
|
||||||
|
App-Sandbox.** Die verbreitete Annahme, sie gälten nur für sandboxed Apps, ist
|
||||||
|
falsch. Ohne sie zeigt macOS den Dialog nicht einmal an.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
|
||||||
|
In `Onyx.entitlements`, je nach genutztem Dienst:
|
||||||
|
|
||||||
|
| Dienst | Entitlement |
|
||||||
|
|---|---|
|
||||||
|
| Kalender | `com.apple.security.personal-information.calendars` |
|
||||||
|
| Standort | `com.apple.security.personal-information.location` |
|
||||||
|
| Apple Events (AppleScript) | `com.apple.security.automation.apple-events` |
|
||||||
|
| Audioaufnahme / Process Taps | `com.apple.security.device.audio-input` |
|
||||||
|
| Kontakte | `com.apple.security.personal-information.addressbook` |
|
||||||
|
|
||||||
|
## Wie es gefunden wurde
|
||||||
|
|
||||||
|
Nicht durch Nachdenken, sondern durch Ausschluss. Entscheidend war eine
|
||||||
|
**minimale Test-App**: normales Fenster, im Vordergrund, gleiches Zertifikat,
|
||||||
|
nichts von Onyx darin — und sie scheiterte identisch. Damit war belegt, dass es
|
||||||
|
nicht an Onyx lag, und die Suche konnte sich auf die Umgebung verlagern.
|
||||||
|
|
||||||
|
Der Weg dorthin führte über mehrere falsche Fährten, die hier stehen, damit sie
|
||||||
|
niemand zweimal geht:
|
||||||
|
|
||||||
|
- **`log show` wurde von zsh abgefangen** („too many arguments"). Weil stderr
|
||||||
|
umgeleitet war, sah die leere Ausgabe wie ein Befund aus. `/usr/bin/log`
|
||||||
|
benutzen.
|
||||||
|
- **`promptPolicy = 0`** im Protokoll gehörte zu `kTCCServiceReminders`, nicht
|
||||||
|
zum Kalender. Der bekam durchgehend `promptPolicy = 4`.
|
||||||
|
- **Bildschirmzeit** stand unter Verdacht (ein `digital_health_restrictions`-Profil
|
||||||
|
ist installiert), schränkt aber nur Game Center und die Zeitsynchronisation ein.
|
||||||
|
- **Ein verklemmter TCC-Eintrag** existierte tatsächlich und wurde mit
|
||||||
|
`tccutil reset Calendar <bundle-id>` entfernt — behob das Problem aber nicht.
|
||||||
|
|
||||||
|
Zwei Nebenbefunde, die für sich schon Fehler waren und mitbehoben wurden:
|
||||||
|
|
||||||
|
- **`ENABLE_DEBUG_DYLIB`** (Xcodes Voreinstellung im Debug-Build) lagert den
|
||||||
|
Programmcode in eine eigene dylib aus. Das Hauptprogramm ist dann nur ein
|
||||||
|
Rumpf, und die TCC-Anfrage erreichte `tccd` überhaupt nicht. Steht jetzt auf `NO`.
|
||||||
|
- **SwiftUIs `Settings`-Szene** wird über den privaten Selektor
|
||||||
|
`showSettingsWindow:` geöffnet und kommt bei `.accessory`-Apps unzuverlässig
|
||||||
|
an. Onyx führt sein Einstellungsfenster deshalb selbst.
|
||||||
|
|
||||||
|
## Merksatz
|
||||||
|
|
||||||
|
Wenn ein Berechtigungsdialog ausbleibt, ohne dass irgendwo ein Fehler steht:
|
||||||
|
zuerst `/usr/bin/log show --predicate 'process == "tccd"'` lesen. Der Grund
|
||||||
|
steht dort im Klartext — und nirgendwo sonst.
|
||||||
36
outline.svg
Normal file
36
outline.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve" width="512" height="512">
|
||||||
|
<g>
|
||||||
|
<path style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d=" 		M197.235,67.578L115.2,158.68c-1.702,1.89-2.922,4.165-3.553,6.629L56.541,380.415c-0.943,3.68,0.176,7.582,2.926,10.203 		l116.456,110.985c1.952,1.86,4.545,2.897,7.241,2.897h145.672c2.696,0,5.289-1.036,7.241-2.896l116.456-110.986 		c2.75-2.621,3.869-6.523,2.926-10.203l-55.107-215.106c-0.631-2.464-1.851-4.739-3.553-6.629l-133-147.698 		c-4.169-4.63-11.43-4.63-15.599,0l-27.346,30.366"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="256" y1="190.51" x2="256" y2="7.5"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="399.03" y1="161.9" x2="316.4" y2="242.94"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="195.57" y1="242.91" x2="112.97" y2="161.9"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="455.33" y1="386.08" x2="354.95" y2="368.57"/>
|
||||||
|
|
||||||
|
<polyline style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" points=" 		297.8,415.14 332.74,503.75 332.75,503.75 	"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="214.18" y1="415.2" x2="179.26" y2="503.75"/>
|
||||||
|
|
||||||
|
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="153.05" y1="368.51" x2="56.66" y2="386.07"/>
|
||||||
|
<path style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d=" 		M344.163,329.755l10.821,34.288c0.912,2.891-0.044,6.047-2.409,7.943l-51.664,41.433c-1.863,1.494-4.181,2.31-6.568,2.31h-76.882 		c-2.267,0-4.474-0.734-6.289-2.092l-55.56-41.575c-2.565-1.919-3.591-5.278-2.536-8.302l41.358-118.549 		c0.581-1.665,1.572-3.156,2.882-4.337l53.764-48.468c2.796-2.52,7.046-2.52,9.841,0l53.638,48.355 		c1.389,1.252,2.418,2.853,2.981,4.637l16.211,51.366"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
42
project.yml
42
project.yml
@@ -47,6 +47,45 @@ targets:
|
|||||||
product: OnyxMenuBar
|
product: OnyxMenuBar
|
||||||
- package: OnyxKit
|
- package: OnyxKit
|
||||||
product: CalendarProvider
|
product: CalendarProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: WeatherProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: MediaProvider
|
||||||
|
postBuildScripts:
|
||||||
|
# Der MediaRemote-Adapter wird aus dem mitgelieferten Quellcode gebaut,
|
||||||
|
# nicht als fertige Binary eingecheckt: so ist nachvollziehbar, was da
|
||||||
|
# läuft, und die Ad-hoc-Signatur passt zum restlichen Bundle.
|
||||||
|
#
|
||||||
|
# Er darf NICHT gelinkt werden. Geladen wird er von /usr/bin/perl, einem
|
||||||
|
# berechtigten Systemprogramm — im eigenen Prozess greift Apples
|
||||||
|
# Entitlement-Prüfung und es käme gar nichts an.
|
||||||
|
- name: MediaRemote-Adapter bauen und beilegen
|
||||||
|
script: |
|
||||||
|
OUT="$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor" \
|
||||||
|
"$SRCROOT/Scripts/build-mediaremote-adapter.sh"
|
||||||
|
inputFiles:
|
||||||
|
- $(SRCROOT)/Scripts/build-mediaremote-adapter.sh
|
||||||
|
outputFiles:
|
||||||
|
- $(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl
|
||||||
|
# Das Menüleistensymbol soll das Glyph aus dem App-Icon zeigen, nicht die
|
||||||
|
# abgerundete Kachel mit Verlauf. actool kompiliert das .icon-Bündel zu
|
||||||
|
# .icns und Assets.car und legt nichts Lesbares ins Bundle — und eine
|
||||||
|
# einzelne Datei daraus lässt sich nicht als Ressource referenzieren.
|
||||||
|
# Deshalb kopieren statt duplizieren: es bleibt bei einer Quelldatei.
|
||||||
|
- name: Glyph für die Menüleiste ins Bundle kopieren
|
||||||
|
script: |
|
||||||
|
SRC="$SRCROOT/Onyx/AppIcon.icon/Assets/quartz.svg"
|
||||||
|
DST="$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/quartz.svg"
|
||||||
|
if [ -f "$SRC" ]; then
|
||||||
|
mkdir -p "$(dirname "$DST")"
|
||||||
|
cp "$SRC" "$DST"
|
||||||
|
else
|
||||||
|
echo "warning: $SRC fehlt — Menüleiste fällt auf ein SF-Symbol zurück"
|
||||||
|
fi
|
||||||
|
inputFiles:
|
||||||
|
- $(SRCROOT)/Onyx/AppIcon.icon/Assets/quartz.svg
|
||||||
|
outputFiles:
|
||||||
|
- $(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/quartz.svg
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx
|
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx
|
||||||
@@ -59,3 +98,6 @@ targets:
|
|||||||
ENABLE_APP_SANDBOX: NO
|
ENABLE_APP_SANDBOX: NO
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||||
COMBINE_HIDPI_IMAGES: YES
|
COMBINE_HIDPI_IMAGES: YES
|
||||||
|
# Icon-Composer-Bündel (Onyx/AppIcon.icon). actool übernimmt daraus
|
||||||
|
# alle Größen und die Transluzenz-Darstellung von macOS 26.
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
|||||||
Reference in New Issue
Block a user