Compare commits

...

14 Commits

Author SHA1 Message Date
ros 7f90c6a912 Record observed iloader placeholder failure and compatibility build [skip ci] 2026-09-14 01:10:28 +03:00
ros a3133465f9 Import optional profile validation trait [skip ci] 2026-09-14 01:09:34 +03:00
ros b14162b401 Include upstream Windows build prerequisites [skip ci] 2026-09-14 01:08:43 +03:00
ros 63fa120194 Build patched iloader for ExtensionKit IDs, profiles and Keychain [skip ci] 2026-09-14 01:08:19 +03:00
ros 4748363905 Record verified remote player build and remaining device checks [skip ci] 2026-09-14 00:59:14 +03:00
ros 91e305ada5 Close revoked remote sessions and document installation requirements
iOS build and tests / simulator (push) Has been cancelled
2026-09-14 00:51:20 +03:00
ros eaf091b1cf Fix remote player SDK types and signing prefix; test remote commands 2026-09-14 00:48:25 +03:00
ros e2902e6031 Add iOS 27 remote media session and system player extension 2026-09-14 00:17:07 +03:00
ros 2fbf3bf859 Record SSH access and verified Xcode 27 build [skip ci] 2026-09-13 18:13:07 +03:00
ros 1e14bf72a2 Generate deterministic expired TLS fixtures on modern OpenSSL 2026-09-13 18:04:50 +03:00
ros daf9751ccc Move CI to Xcode 27 and select an available secondary simulator 2026-09-13 18:03:22 +03:00
ros e824a8e8eb Document interactive activity release and NowPlaying research [skip ci] 2026-09-13 11:47:51 +03:00
ros 43c78b2a99 Keep lock screen controls within a compact card 2026-09-13 11:41:24 +03:00
ros 1e02247cbe Control PC playback through interactive Live Activity intents 2026-09-13 11:40:23 +03:00
36 changed files with 1601 additions and 94 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Build MusicBridge compatible iloader
on:
workflow_dispatch:
permissions:
contents: read
jobs:
windows:
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/iloader
ref: 348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
path: build/compat/iloader
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/isideload
ref: f6a4d5dba717d72fc2af63eaba26b27ba44116be
path: build/compat/isideload
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/apple-crates
ref: 5da0b8df9b202434b1eff2700059b1ee142592ac
path: build/compat/apple-crates
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: ilammy/msvc-dev-cmd@v1
- name: Install native build prerequisites
run: choco install strawberryperl make --no-progress -y
- uses: oven-sh/setup-bun@v2
- name: Patch pinned sources
run: python tools/iloader/prepare.py build/compat
- name: Install frontend dependencies
working-directory: build/compat/iloader
run: bun install --frozen-lockfile
- name: Test extension discovery, bundle IDs and signing permissions
working-directory: build/compat/iloader/src-tauri
run: |
cargo test -p isideload --lib musicbridge
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo test -p apple-codesign --lib musicbridge
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build portable GUI
working-directory: build/compat/iloader
run: bunx tauri build --no-bundle --config src-tauri/ci.conf.json
- name: Package helper with provenance
run: |
New-Item -ItemType Directory -Force build/iloader-musicbridge
Copy-Item build/compat/iloader/src-tauri/target/release/iloader.exe build/iloader-musicbridge/iloader-musicbridge.exe
Copy-Item tools/iloader/README.md build/iloader-musicbridge/README.md
Get-ChildItem build/compat/iloader -Filter 'LICENSE*' | Copy-Item -Destination build/iloader-musicbridge
Get-FileHash build/iloader-musicbridge/iloader-musicbridge.exe | Format-List | Out-File build/iloader-musicbridge/SHA256.txt
- uses: actions/upload-artifact@v4
with:
name: iloader-musicbridge-windows
path: build/iloader-musicbridge
if-no-files-found: error
retention-days: 7
+6 -3
View File
@@ -15,7 +15,7 @@ concurrency:
jobs:
simulator:
runs-on: macos-15
runs-on: xcode-27
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
@@ -24,6 +24,7 @@ jobs:
- name: Inspect toolchain and project
run: |
xcodebuild -version
xcodebuild -version | head -n 1 | grep -E "^Xcode 27([.]|$)"
swift --version
python3 tools/validate_project.py
xcodebuild -list -project MusicBridge.xcodeproj
@@ -36,7 +37,9 @@ jobs:
run: |
set -o pipefail
mkdir -p build
python3 -u tools/tls_fixture.py > build/tls-fixture.log 2>&1 &
python3 -m venv build/test-tools
build/test-tools/bin/pip install cryptography==46.0.3
build/test-tools/bin/python -u tools/tls_fixture.py > build/tls-fixture.log 2>&1 &
TLS_PID=$!
trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT
for attempt in {1..30}; do
@@ -94,7 +97,7 @@ jobs:
sleep 1
xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-light-screen.png
RUNTIME=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); print(next(k for k,v in d["devices"].items() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)))')
COMPACT_ID=$(xcrun simctl create MusicBridge-Compact com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation "$RUNTIME")
COMPACT_ID=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); v=next(v for v in d["devices"].values() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)); candidates=[x for x in v if x.get("isAvailable") and x.get("name", "").startswith("iPhone") and x["udid"]!=os.environ["SIMULATOR_ID"]]; print(min(candidates,key=lambda x:("Max" in x["name"] or "Plus" in x["name"], "Pro" in x["name"], x["name"]))["udid"])')
xcrun simctl bootstatus "$COMPACT_ID" -b
xcrun simctl install "$COMPACT_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app
xcrun simctl launch "$COMPACT_ID" ru.yukinoki.musicbridge --preview-player
+267 -20
View File
@@ -55,6 +55,16 @@
"isa" = "PBXBuildFile";
"fileRef" = "76FB6BFFC478599567424755";
};
"581AD2E9199DAED730DC0F6E" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityCommands.swift";
"sourceTree" = "<group>";
};
"D6D68067600EEBE36972550E" = {
"isa" = "PBXBuildFile";
"fileRef" = "581AD2E9199DAED730DC0F6E";
};
"EE2357BD5CA3755AFEB3171A" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
@@ -115,11 +125,93 @@
"isa" = "PBXBuildFile";
"fileRef" = "7148B52E915554F67E875C26";
};
"088AC623B345717D980B3156" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerManager.swift";
"sourceTree" = "<group>";
};
"27227986C8A227728D784957" = {
"isa" = "PBXBuildFile";
"fileRef" = "088AC623B345717D980B3156";
};
"026F39792BBA0E5032536CED" = {
"isa" = "PBXGroup";
"path" = "MusicBridge";
"sourceTree" = "<group>";
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",);
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "088AC623B345717D980B3156", "8BF729FFE074CAEE622C0292",);
};
"1C6CBD84758D03C1B40D19C7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerAttributes.swift";
"sourceTree" = "<group>";
};
"0D17ED02E5B8770D785508A3" = {
"isa" = "PBXBuildFile";
"fileRef" = "1C6CBD84758D03C1B40D19C7";
};
"77372299BB61F0ED174508F0" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerCredentials.swift";
"sourceTree" = "<group>";
};
"2599D4B5F13D672B723A4BD3" = {
"isa" = "PBXBuildFile";
"fileRef" = "77372299BB61F0ED174508F0";
};
"897E8908B2E9AD60A31E6897" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerTransport.swift";
"sourceTree" = "<group>";
};
"804FFB386BC1AF4252EDA177" = {
"isa" = "PBXBuildFile";
"fileRef" = "897E8908B2E9AD60A31E6897";
};
"D2DF40467DEC980974060FD5" = {
"isa" = "PBXGroup";
"path" = "RemoteShared";
"sourceTree" = "<group>";
"children" = ("1C6CBD84758D03C1B40D19C7", "77372299BB61F0ED174508F0", "897E8908B2E9AD60A31E6897",);
};
"54D4A2CB4EFD85CA136E4B32" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicBridgeRemote.swift";
"sourceTree" = "<group>";
};
"ED075C376D7772FEBD3EC117" = {
"isa" = "PBXBuildFile";
"fileRef" = "54D4A2CB4EFD85CA136E4B32";
};
"E2D7CC5D9CE02F4A7B8243D8" = {
"isa" = "PBXGroup";
"path" = "MusicBridgeRemote";
"sourceTree" = "<group>";
"children" = ("54D4A2CB4EFD85CA136E4B32",);
};
"511889FA962E9B3AC1BC92DE" = {
"isa" = "PBXBuildFile";
"fileRef" = "1C6CBD84758D03C1B40D19C7";
};
"A3C91023F16219D79F80723C" = {
"isa" = "PBXBuildFile";
"fileRef" = "77372299BB61F0ED174508F0";
};
"823EB4050AFBFC9AE72A3C03" = {
"isa" = "PBXBuildFile";
"fileRef" = "897E8908B2E9AD60A31E6897";
};
"8EE58DCF843B86D38CF45CC0" = {
"isa" = "PBXBuildFile";
"fileRef" = "124CF3F5AC075D1D6042DF64";
};
"4AAE356B6279CE0B686A3B9D" = {
"isa" = "PBXBuildFile";
"fileRef" = "065A9D559DC80AAF876DA757";
};
"7C5E653CCD928B143AB68F09" = {
"isa" = "PBXFileReference";
@@ -141,11 +233,21 @@
"isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7";
};
"6EDC1D9A7204CF776CE3B281" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicControlIntent.swift";
"sourceTree" = "<group>";
};
"962BED0D8E17961AD72EC24F" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"E3C4B39D6D5013477C98CD58" = {
"isa" = "PBXGroup";
"path" = "Shared";
"sourceTree" = "<group>";
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",);
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7", "6EDC1D9A7204CF776CE3B281",);
};
"ADFE24674E0FB119E649EC54" = {
"isa" = "PBXFileReference";
@@ -171,6 +273,10 @@
"isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7";
};
"41D7F12D1C7D61DAF319E724" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"8BF729FFE074CAEE622C0292" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "folder.assetcatalog";
@@ -191,6 +297,16 @@
"isa" = "PBXBuildFile";
"fileRef" = "C9F705D432CA45B6056BC572";
};
"65CE96DA4162DE4255412BC7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityCommandTests.swift";
"sourceTree" = "<group>";
};
"E87BA6EF1948C84AC29D8EA8" = {
"isa" = "PBXBuildFile";
"fileRef" = "65CE96DA4162DE4255412BC7";
};
"9819FF7CC21CAAD1BDB8D1B8" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
@@ -221,6 +337,16 @@
"isa" = "PBXBuildFile";
"fileRef" = "464F6C65BC2FAB6BCCC77F46";
};
"7749A66489642A16382E8E42" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerTests.swift";
"sourceTree" = "<group>";
};
"340F55A9E43E8FFA66453825" = {
"isa" = "PBXBuildFile";
"fileRef" = "7749A66489642A16382E8E42";
};
"8BED860F1BD59C500D215E8D" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
@@ -235,7 +361,7 @@
"isa" = "PBXGroup";
"path" = "MusicBridgeTests";
"sourceTree" = "<group>";
"children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
};
"F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference";
@@ -267,7 +393,7 @@
"72B74C8A2143F64FD53D71E5" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF",);
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "27227986C8A227728D784957", "0D17ED02E5B8770D785508A3", "2599D4B5F13D672B723A4BD3", "804FFB386BC1AF4252EDA177", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"734C4650C7A5604092763C32" = {
@@ -289,6 +415,13 @@
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
};
};
"013486AD4616563D96D6E9AC" = {
"isa" = "PBXBuildFile";
"fileRef" = "F1BB1F45195BEB71A550539C";
"settings" = {
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
};
};
"AFC32963E7959E7A34264826" = {
"isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647;
@@ -298,6 +431,15 @@
"name" = "Embed App Extensions";
"runOnlyForDeploymentPostprocessing" = 0;
};
"B2EC54BE7DEB4C17571E7035" = {
"isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647;
"dstPath" = "$(EXTENSIONS_FOLDER_PATH)";
"dstSubfolderSpec" = 16;
"files" = ("013486AD4616563D96D6E9AC",);
"name" = "Embed ExtensionKit Extensions";
"runOnlyForDeploymentPostprocessing" = 0;
};
"01E77404E10F254C43E64C55" = {
"isa" = "PBXContainerItemProxy";
"containerPortal" = "244210E48437B6556980A702";
@@ -310,6 +452,18 @@
"target" = "BFE9A9DC8C194372006C3ACC";
"targetProxy" = "01E77404E10F254C43E64C55";
};
"E73C0469E1928499B9BE1AC7" = {
"isa" = "PBXContainerItemProxy";
"containerPortal" = "244210E48437B6556980A702";
"proxyType" = 1;
"remoteGlobalIDString" = "714DB4A269580A6F5127F5CC";
"remoteInfo" = "MusicBridgeRemote";
};
"F76CBC167D5B68BDC0376A94" = {
"isa" = "PBXTargetDependency";
"target" = "714DB4A269580A6F5127F5CC";
"targetProxy" = "E73C0469E1928499B9BE1AC7";
};
"E3322BA79DBD9A84650B2CAE" = {
"isa" = "XCBuildConfiguration";
"name" = "Debug";
@@ -324,9 +478,10 @@
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "6";
"MARKETING_VERSION" = "0.5.0";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
@@ -346,9 +501,10 @@
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "6";
"MARKETING_VERSION" = "0.5.0";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
};
};
@@ -365,9 +521,9 @@
"productReference" = "7C35A0276A762371ABAE5596";
"productType" = "com.apple.product-type.application";
"buildConfigurationList" = "C6336ED5908EABEBF60AE675";
"buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826",);
"buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826", "B2EC54BE7DEB4C17571E7035",);
"buildRules" = ();
"dependencies" = ("A4C2D386AB0259F33DCDC427",);
"dependencies" = ("A4C2D386AB0259F33DCDC427", "F76CBC167D5B68BDC0376A94",);
};
"5E4BE508157A7B2CB7B4C2F2" = {
"isa" = "PBXFileReference";
@@ -379,7 +535,7 @@
"AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"9AA2A4C344B5C474AF5D336C" = {
@@ -471,7 +627,7 @@
"FF6BDEA70DDE9A3C7EEA9DCF" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E",);
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E", "41D7F12D1C7D61DAF319E724",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"49C806A88B3E70B1004AB10D" = {
@@ -500,14 +656,14 @@
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "6";
"MARKETING_VERSION" = "0.5.0";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG MUSICBRIDGE_WIDGET";
};
};
"6A281BFCCFF96276806F5934" = {
@@ -524,12 +680,13 @@
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "6";
"MARKETING_VERSION" = "0.5.0";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = " MUSICBRIDGE_WIDGET";
};
};
"5DCAC255CDC0CA72231320F4" = {
@@ -549,16 +706,106 @@
"buildRules" = ();
"dependencies" = ();
};
"F1BB1F45195BEB71A550539C" = {
"isa" = "PBXFileReference";
"explicitFileType" = "wrapper.extensionkit-extension";
"includeInIndex" = 0;
"path" = "MusicBridgeRemote.appex";
"sourceTree" = "BUILT_PRODUCTS_DIR";
};
"C2BA95929A255B0F1FC04A79" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("ED075C376D7772FEBD3EC117", "511889FA962E9B3AC1BC92DE", "A3C91023F16219D79F80723C", "823EB4050AFBFC9AE72A3C03", "8EE58DCF843B86D38CF45CC0", "4AAE356B6279CE0B686A3B9D",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"D3E3B87195B5FF1DA14795C1" = {
"isa" = "PBXFrameworksBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"9C3BF80554FD9B39BDF1988D" = {
"isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"809F689056832D3F2A4D91B6" = {
"isa" = "XCBuildConfiguration";
"name" = "Debug";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "27.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
};
};
"5E42ACAF0DE32186ED14E82F" = {
"isa" = "XCBuildConfiguration";
"name" = "Release";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "27.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
};
};
"AD0B31AD2D7D0B6DCF3ACF58" = {
"isa" = "XCConfigurationList";
"buildConfigurations" = ("809F689056832D3F2A4D91B6", "5E42ACAF0DE32186ED14E82F",);
"defaultConfigurationIsVisible" = 0;
"defaultConfigurationName" = "Release";
};
"714DB4A269580A6F5127F5CC" = {
"isa" = "PBXNativeTarget";
"name" = "MusicBridgeRemote";
"productName" = "MusicBridgeRemote";
"productReference" = "F1BB1F45195BEB71A550539C";
"productType" = "com.apple.product-type.extensionkit-extension";
"buildConfigurationList" = "AD0B31AD2D7D0B6DCF3ACF58";
"buildPhases" = ("C2BA95929A255B0F1FC04A79", "D3E3B87195B5FF1DA14795C1", "9C3BF80554FD9B39BDF1988D",);
"buildRules" = ();
"dependencies" = ();
};
"0A3E27B8CA818264D75C8D81" = {
"isa" = "PBXGroup";
"name" = "Products";
"sourceTree" = "<group>";
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F",);
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F", "F1BB1F45195BEB71A550539C",);
};
"0D6E4079E36703EBD37C0072" = {
"isa" = "PBXGroup";
"sourceTree" = "<group>";
"children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",);
"children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "D2DF40467DEC980974060FD5", "E2D7CC5D9CE02F4A7B8243D8", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",);
};
"73EBF6644258C120893E685D" = {
"isa" = "XCBuildConfiguration";
@@ -600,7 +847,7 @@
"productRefGroup" = "0A3E27B8CA818264D75C8D81";
"projectDirPath" = "";
"projectRoot" = "";
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",);
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC", "714DB4A269580A6F5127F5CC",);
};
};
"rootObject" = "244210E48437B6556980A702";
+11
View File
@@ -0,0 +1,11 @@
<?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>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</array>
</dict>
</plist>
+3 -3
View File
@@ -59,11 +59,11 @@ final class BridgeClient {
private let session: URLSession
private var socket: URLSessionWebSocketTask?
init(_ connection: Connection) {
init(_ connection: Connection, timeout: TimeInterval = 10) {
self.connection = connection
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 10
config.timeoutIntervalForResource = 15
config.timeoutIntervalForRequest = timeout
config.timeoutIntervalForResource = timeout + 5
config.urlCache = nil
config.httpCookieStorage = nil
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
+22 -2
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct ContentView: View {
@ObservedObject var store: PlayerStore
@ObservedObject var liveActivity: LiveActivityManager
@ObservedObject var systemPlayer: SystemPlayerManager
@State private var showForget = false
var body: some View {
@@ -20,14 +21,33 @@ struct ContentView: View {
} else {
PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30))))
if #available(iOS 27.0, *) {
VStack(alignment: .leading, spacing: 10) {
Button {
if systemPlayer.running { systemPlayer.end() }
else if let state = store.state, let connection = store.connection {
Task { await systemPlayer.start(state: state, connection: connection) }
}
} label: {
Label(systemPlayer.running ? "Выключить системный пульт" : "Включить системный пульт iOS", systemImage: "play.rectangle")
}.disabled(systemPlayer.busy || (!systemPlayer.running && (!store.connected || store.state?.hasSession != true)))
if systemPlayer.busy { ProgressView() }
Text("Музыка ПК в Пункте управления. Звук остаётся на компьютере.")
.font(.caption).foregroundStyle(.secondary)
Text("Без фоновых push-обновлений трек обновляется при открытом приложении или после команды пульта.")
.font(.caption).foregroundStyle(.secondary)
if let error = systemPlayer.error { Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
}
VStack(alignment: .leading, spacing: 10) {
Button {
if liveActivity.running { liveActivity.end() }
else if let state = store.state { liveActivity.start(state) }
else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) }
} label: {
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.")
Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.")
.font(.caption).foregroundStyle(.secondary)
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
+2
View File
@@ -72,5 +72,7 @@
</dict>
</dict>
</dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>ru.yukinoki.musicbridge.remote-control</string>
</dict>
</plist>
+1 -1
View File
@@ -22,7 +22,7 @@ enum KeychainStore {
static func save(_ connection: Connection) throws {
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
if status == errSecItemNotFound {
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
+68
View File
@@ -0,0 +1,68 @@
import ActivityKit
import Foundation
@MainActor
enum LiveActivityCommands {
private static var busy = false
static let bindingKey = "MusicBridge.liveActivityPC"
static func perform(_ action: MusicRemoteAction, activityID: String) async {
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
activity.activityState == .active || activity.activityState == .stale else { return }
busy = true
defer { busy = false }
do {
guard let saved = try KeychainStore.load(),
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
throw BridgeError.message("Откройте приложение и включите карточку заново.")
}
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
let fresh = try await execute(action, client: client) {
(activity.activityState == .active || activity.activityState == .stale) &&
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
}
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
} catch {
if case BridgeError.unauthorized = error {
await activity.end(nil, dismissalPolicy: .immediate)
return
}
var value = activity.content.state
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
await activity.update(ActivityContent(state: value, staleDate: Date()))
}
}
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
stillValid: () -> Bool = { true }) async throws -> MediaState {
let before = try await client.state()
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
if action == .refresh { return before }
guard before.hasSession else { return before }
let command = try command(action, state: before)
do { try await client.command(Command(type: command, value: nil)) }
catch {
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") }
throw error
}
do { return try await client.state() }
catch {
if case BridgeError.unauthorized = error { throw error }
throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.")
}
}
static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String {
switch action {
case .previous where state.capabilities.previous: return "previous"
case .next where state.capabilities.next: return "next"
case .toggle where state.capabilities.toggle: return "toggle"
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
}
}
}
+6 -1
View File
@@ -32,18 +32,22 @@ final class LiveActivityManager: ObservableObject {
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
}
func start(_ state: MediaState) {
func start(_ state: MediaState, connection: Connection) {
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
guard connection.token != nil else { error = "Сначала выполните сопряжение с ПК."; return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
return
}
do {
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
try KeychainStore.save(connection)
let value = Self.content(state)
let new = try Activity.request(attributes: MusicActivityAttributes(),
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
pushType: nil)
activity = new
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
last = value
running = true
error = nil
@@ -72,6 +76,7 @@ final class LiveActivityManager: ObservableObject {
func end() {
guard let activity else { return }
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
self.activity = nil
running = false
last = nil
+1 -1
View File
@@ -119,7 +119,7 @@ struct Invitation: Decodable {
}
}
struct Capabilities: Decodable {
struct Capabilities: Codable {
let play, pause, toggle, next, previous, seek: Bool
}
+4 -1
View File
@@ -4,6 +4,7 @@ import SwiftUI
struct MusicBridgeApp: App {
@StateObject private var store = PreviewSupport.makeStore()
@StateObject private var liveActivity = LiveActivityManager()
@StateObject private var systemPlayer = SystemPlayerManager()
@Environment(\.scenePhase) private var phase
var body: some Scene {
@@ -20,13 +21,15 @@ struct MusicBridgeApp: App {
}.padding()
}
} else {
ContentView(store: store, liveActivity: liveActivity)
ContentView(store: store, liveActivity: liveActivity, systemPlayer: systemPlayer)
}
}
.dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
.onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } }
.task {
store.liveActivity = liveActivity
store.systemPlayer = systemPlayer
if !PreviewSupport.enabled { await systemPlayer.restore(connection: store.connection) }
if !PreviewSupport.enabled { store.setActive(phase == .active) }
}
.onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
+4 -1
View File
@@ -15,6 +15,7 @@ final class PlayerStore: ObservableObject {
@Published var incomingInvitation: String?
let discovery = DiscoveryStore()
var liveActivity: LiveActivityManager?
var systemPlayer: SystemPlayerManager?
private(set) var receivedAt = Date()
private var client: BridgeClient?
private var runner: Task<Void, Never>?
@@ -170,6 +171,7 @@ final class PlayerStore: ObservableObject {
connected = true
status = "Подключено"
liveActivity?.receive(snapshot)
if let connection { systemPlayer?.receive(snapshot, connection: connection) }
guard artworkID != snapshot.artworkId else { return }
artworkID = snapshot.artworkId
artworkTask?.cancel()
@@ -248,6 +250,7 @@ final class PlayerStore: ObservableObject {
do { try storage.delete() }
catch { self.error = error.localizedDescription; return }
liveActivity?.end()
systemPlayer?.end()
stop()
cancelPairing()
needsPairing = false
@@ -257,7 +260,6 @@ final class PlayerStore: ObservableObject {
}
private func stop() {
liveActivity?.unavailable()
generation = UUID()
runner?.cancel()
runner = nil
@@ -274,6 +276,7 @@ final class PlayerStore: ObservableObject {
private func revokeAccess() {
liveActivity?.end()
systemPlayer?.end()
stop()
needsPairing = true
status = "Нужно повторное сопряжение"
+114
View File
@@ -0,0 +1,114 @@
import SwiftUI
import NowPlaying
@MainActor
final class SystemPlayerManager: ObservableObject {
@Published private(set) var running = false
@Published private(set) var busy = false
@Published private(set) var error: String?
private var storage: Any?
private var id: String?
private var generation = UUID()
private var updates: Task<Void, Never>?
private var lastUpdate = Date.distantPast
private var lastTrack = ""
private var lastPlaying = false
private let savedIDKey = "MusicBridge.systemPlayerSession"
func start(state: MediaState, connection: Connection) async {
guard !busy, !running, state.hasSession, connection.token != nil,
UIApplication.shared.applicationState == .active else { return }
guard #available(iOS 27.0, *) else { error = "Системный пульт требует iOS 27."; return }
busy = true; error = nil
let operation = generation
let newID = UUID().uuidString
id = newID
defer { if generation == operation { busy = false } }
do {
try SystemPlayerCredentials.save(connection, id: newID)
_ = try SystemPlayerCredentials.load(newID)
UserDefaults.standard.set(newID, forKey: savedIDKey)
let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state))
guard generation == operation else { try? await session.end(); return }
storage = session
try await session.requestToBecomeSystemPrimary()
guard generation == operation else { try? await session.end(); return }
running = true
UserDefaults.standard.set(newID, forKey: savedIDKey)
} catch {
guard generation == operation else { return }
self.error = "Не удалось включить системный пульт: " + BridgeError.describe(error)
end()
}
}
func restore(connection: Connection?) async {
guard #available(iOS 27.0, *), id == nil, !busy,
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
busy = true
id = savedID
let operation = generation
defer { if generation == operation { busy = false } }
do {
let saved = try SystemPlayerCredentials.load(savedID)
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
try SystemPlayerCredentials.delete(savedID)
UserDefaults.standard.removeObject(forKey: savedIDKey)
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
if generation == operation { id = nil }
return
}
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
guard generation == operation else { return }
storage = session; id = savedID; running = true
} else {
guard generation == operation else { return }
try SystemPlayerCredentials.delete(savedID)
UserDefaults.standard.removeObject(forKey: savedIDKey)
id = nil
}
} catch { if generation == operation { self.error = BridgeError.describe(error); id = nil } }
}
func receive(_ state: MediaState, connection: Connection) {
guard #available(iOS 27.0, *), running, let id,
let session = storage as? RemoteMediaSession<SystemPlayerAttributes> else { return }
guard state.hasSession else { end(); return }
let track = state.title + "\n" + state.artist
let playing = state.playbackStatus == "Playing"
guard track != lastTrack || playing != lastPlaying || Date().timeIntervalSince(lastUpdate) >= 5 else { return }
lastUpdate = Date(); lastTrack = track; lastPlaying = playing
let value = SystemPlayerAttributes(id: id, state: state)
let operation = generation
enqueue { [weak self] in
guard let self, self.generation == operation else { return }
do {
try SystemPlayerCredentials.save(connection, id: id)
try await session.update(value)
} catch { self.error = BridgeError.describe(error) }
}
}
func end() {
generation = UUID()
busy = false; running = false
if let id {
do { try SystemPlayerCredentials.delete(id) }
catch { self.error = BridgeError.describe(error) }
}
UserDefaults.standard.removeObject(forKey: savedIDKey)
id = nil
if #available(iOS 27.0, *), let session = storage as? RemoteMediaSession<SystemPlayerAttributes> {
enqueue { [weak self] in
do { try await session.end() }
catch { self?.error = BridgeError.describe(error) }
}
}
storage = nil
}
private func enqueue(_ action: @escaping @MainActor () async -> Void) {
let previous = updates
updates = Task { await previous?.value; await action() }
}
}
+56
View File
@@ -0,0 +1,56 @@
<?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>CFBundleDevelopmentRegion</key>
<string>ru</string>
<key>CFBundleDisplayName</key>
<string>MusicBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.nowplaying.remote-media</string>
</dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>ru.yukinoki.musicbridge.remote-control</string>
<key>NSLocalNetworkUsageDescription</key>
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>10.0.0.0/8</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>172.16.0.0/12</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>192.168.0.0/16</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
+77
View File
@@ -0,0 +1,77 @@
import ExtensionFoundation
import NowPlaying
import Observation
import Foundation
@main
struct MusicBridgeRemote: RemoteMediaSessionExtension {
var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
RemoteMediaSessionExtensionConfiguration(extension: self)
}
func session(_ attributes: SystemPlayerAttributes) async throws -> PCMediaSession {
_ = try SystemPlayerCredentials.load(attributes.id)
return PCMediaSession(attributes)
}
}
@MainActor @Observable
final class PCMediaSession: RemoteMediaSessionRepresentable {
let id: String
var attributes: SystemPlayerAttributes
private let transport = SystemPlayerTransport()
init(_ attributes: SystemPlayerAttributes) { id = attributes.id; self.attributes = attributes }
func update(_ attributes: SystemPlayerAttributes) { self.attributes = attributes }
var content: (any MediaContentRepresentable)? {
guard attributes.hasSession else { return nil }
let artwork = attributes.artworkID.map { artworkID in
Artwork(id: artworkID) { _ in
let saved = try SystemPlayerCredentials.load(self.id)
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
return try ArtworkRepresentation(data: await client.artwork(artworkID))
}
}
return MusicContent(id: attributes.source + "/" + attributes.title + "/" + attributes.artist,
songTitle: attributes.title.isEmpty ? "Без названия" : attributes.title,
artistName: attributes.artist, albumName: "", type: .audio,
duration: attributes.duration > 0 ? .finite(attributes.duration) : nil, artwork: artwork)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: Float(attributes.rate)) : .paused,
elapsedTime: attributes.position, timestamp: attributes.timestamp)
}
var commands: [MediaCommand] {
guard attributes.hasSession else { return [] }
return [
.play { try await self.send(.play) }.enabled(attributes.capabilities.play),
.pause { try await self.send(.pause) }.enabled(attributes.capabilities.pause),
.next { try await self.send(.next) }.enabled(attributes.capabilities.next),
.previous { try await self.send(.previous) }.enabled(attributes.capabilities.previous),
.seekToPosition { try await self.send(.seek, value: $0) }.enabled(attributes.capabilities.seek)
]
}
var devices: [MediaDevice] {
let capabilities: [MediaDevice.Capability] = attributes.volume.map { volume in
[.absoluteVolume(Float(volume)) { try await self.send(.volume, value: Double($0)) }]
} ?? []
return [MediaDevice(id: id, name: "MusicBridge · ПК", type: .speaker, capabilities: capabilities)]
}
private func send(_ action: SystemPlayerAction, value: Double? = nil) async throws {
do {
let state = try await transport.send(action, value: value, sessionID: id)
attributes = SystemPlayerAttributes(id: id, state: state)
} catch BridgeError.unauthorized {
try SystemPlayerCredentials.delete(id)
attributes.hasSession = false
attributes.playing = false
attributes.volume = nil
throw BridgeError.unauthorized
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?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>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</array>
</dict>
</plist>
@@ -0,0 +1,54 @@
import XCTest
@testable import MusicBridge
@MainActor
final class LiveActivityCommandTests: XCTestCase {
private func client(_ token: String) throws -> BridgeClient {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
let f = try XCTUnwrap(fixtures["valid"])
return BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: token))
}
func testNextUsesOneCommandAndReturnsUpdatedTrack() async throws {
let client = try client("live-next"); defer { client.close() }
let result = try await LiveActivityCommands.execute(.next, client: client)
XCTAssertEqual(result.title, "Track 1")
}
func testRefreshDoesNotSendCommand() async throws {
let client = try client("live-refresh"); defer { client.close() }
let result = try await LiveActivityCommands.execute(.refresh, client: client)
XCTAssertEqual(result.title, "Track 0")
}
func testClosedActivityDoesNotSendCommand() async throws {
let client = try client("live-closed"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client, stillValid: { false }); XCTFail("Must reject") }
catch { }
let state = try await client.state()
XCTAssertEqual(state.title, "Track 0")
}
func testUnsupportedCommandIsNotSent() async throws {
let client = try client("live-unsupported"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
catch { }
let state = try await client.state()
XCTAssertEqual(state.title, "Track 0")
}
func testRejectedCommandIsNotRetried() async throws {
let client = try client("live-reject"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
catch { }
let state = try await client.state()
XCTAssertEqual(state.title, "Track 1")
}
func testFailedReadAfterSuccessReportsCommandCompleted() async throws {
let client = try client("live-refresh-fail"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must report refresh failure") }
catch { XCTAssertTrue(error.localizedDescription.contains("Команда выполнена")) }
}
}
+54
View File
@@ -0,0 +1,54 @@
import XCTest
@testable import MusicBridge
@MainActor
final class SystemPlayerTests: XCTestCase {
private func state(_ changes: [String: Any] = [:]) throws -> MediaState {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json"))
var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any])
changes.forEach { json[$0.key] = $0.value }
return try JSONDecoder().decode(MediaState.self, from: JSONSerialization.data(withJSONObject: json))
}
func testSeekConvertsRelativePositionAndClampsToAllowedRange() throws {
let state = try state(["startSeconds": 100, "endSeconds": 300, "minSeekSeconds": 110, "maxSeekSeconds": 280])
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 30, state: state).value, 130)
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: -50, state: state).value, 110)
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 500, state: state).value, 280)
XCTAssertThrowsError(try SystemPlayerTransport.command(.seek, value: .nan, state: state))
}
func testVolumeBoundsAndUnsupportedPlayback() throws {
let state = try state()
XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: 2, state: state).value, 1)
XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: -1, state: state).value, 0)
XCTAssertThrowsError(try SystemPlayerTransport.command(.volume, value: .infinity, state: state))
XCTAssertThrowsError(try SystemPlayerTransport.command(.play, value: nil, state: state))
XCTAssertThrowsError(try SystemPlayerTransport.command(.next, value: nil, state: self.state(["hasSession": false])))
}
func testRemoteSnapshotContainsOnlyPublicPlaybackMetadata() throws {
guard #available(iOS 27.0, *) else { throw XCTSkip("Requires NowPlaying") }
let snapshot = SystemPlayerAttributes(id: "session", state: try state(["positionSeconds": 130, "startSeconds": 100, "endSeconds": 300]))
XCTAssertEqual(snapshot.position, 30)
XCTAssertEqual(snapshot.duration, 200)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any])
XCTAssertEqual(Set(json.keys), Set(["id", "title", "artist", "source", "playing", "rate", "position", "duration", "timestamp", "capabilities", "volume", "hasSession"]))
}
func testRevokedSessionDoesNotSendCommandAndNextIsSentOnce() async throws {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
let f = try XCTUnwrap(fixtures["valid"])
let client = BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: "live-system-next"))
defer { client.close() }
do {
_ = try await SystemPlayerTransport.execute(.next, client: client) { throw BridgeError.unauthorized }
XCTFail("Revoked session must be rejected")
} catch BridgeError.unauthorized { }
let unchanged = try await client.state()
XCTAssertEqual(unchanged.title, "Track 0")
let updated = try await SystemPlayerTransport.execute(.next, client: client)
XCTAssertEqual(updated.title, "Track 1")
}
}
+2 -3
View File
@@ -10,7 +10,7 @@ struct MusicBridgeWidgets: WidgetBundle {
struct MusicBridgeLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MusicActivityAttributes.self) { context in
MusicActivityCard(state: context.state, stale: context.isStale)
MusicActivityCard(state: context.state, stale: context.isStale, activityID: context.activityID)
.activityBackgroundTint(Color(.secondarySystemBackground))
.activitySystemActionForegroundColor(.indigo)
.widgetURL(URL(string: "musicbridge://player"))
@@ -27,8 +27,7 @@ struct MusicBridgeLiveActivity: Widget {
TrackLabels(state: context.state)
ProgressView(value: context.state.progress).tint(.cyan)
ActivityStatus(state: context.state, stale: context.isStale)
Link("Открыть плеер", destination: URL(string: "musicbridge://player")!)
.font(.caption.weight(.semibold)).foregroundStyle(.cyan)
MusicActivityControls(activityID: context.activityID).foregroundStyle(.cyan)
}.padding(.bottom, 4)
}
} compactLeading: {
+125 -7
View File
@@ -1,10 +1,87 @@
# MusicBridge iOS — план
Обновлено: 2026-09-13. Пользователь разрешил начать нативное iOS-приложение.
Обновлено: 2026-09-14. Пользователь разрешил начать нативное iOS-приложение.
Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios.
Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`.
## Правила проекта
## Системный пульт 0.7.0 (8) — текущий этап
- [x] Реализация NowPlaying RemoteMediaSession и отдельного ExtensionKit target для iOS 27.
- [x] Кнопка включения/выключения в приложении; музыка продолжает играть на ПК.
- [x] Метаданные, обложка, play/pause, предыдущий/следующий трек, перемотка, громкость ПК.
- [x] Перед командой проверяются свежие capabilities и действующее сопряжение; команды не повторяются автоматически.
- [x] Токен хранится в общем Keychain, не передаётся в атрибутах системной сессии.
- [x] Перемотка переводится из позиции относительно трека в диапазон Windows API.
- [x] Выключение и удаление сопряжения удаляют доступ расширения; возврат запоздавшего restore не восстанавливает сессию.
- [x] Итоговый `91e305a`: [Actions 34785082289](https://github.com/polskikh13/musicbridgeios/actions/runs/34785082289), Xcode 27, 36 XCTest без отказов, успешный Release archive. Первая попытка 34783380221 выявила Double/Float и исправлена.
- [ ] Установка с подписью обоих расширений и общим Keychain; проверка на физическом iPhone.
- [ ] Пункт управления: кнопки, перемотка, громкость; блокировка телефона, сон ПК, отзыв токена, повторная подпись.
IPA скачана в `build/iPhone-0.7.0/MusicBridge-iPhone.ipa` (без подписи).
Локально проверены ZIP, ARM64 iPhoneOS всех трёх исполняемых файлов,
совпадение версий, ExtensionKit extension point и общий Keychain suffix.
SHA-256: `a84e146a5259c0c7250dab829f2eb07093f33d5e212ff1e1880f6c3fd29d65ee`.
Логи тестов находятся в `build/iPhone-0.7.0/tests`, снимки — в `preview`.
Просмотрены светлый плеер и дополнительный iPhone: кнопка пульта видна снизу,
пояснения доступны прокруткой. Системная карточка NowPlaying на этих снимках
не проверялась: для запуска нужен подписанный общий Keychain.
**Ограничение iloader:** проверен официальный исходник `nab138/isideload`, ветка
`apple-codesign-quick`, commit `f6a4d5dba717d72fc2af63eaba26b27ba44116be`, который использует текущий iloader.
`isideload/src/sideload/bundle.rs` обнаруживает расширения только в `PlugIns`,
а NowPlaying размещается в `Extensions`. `sign.rs` берёт entitlements из profile,
добавление shared Keychain реализовано для отдельных SpecialApp, не для MusicBridge.
Поэтому обычную установку 0.7.0 через iloader нельзя считать поддержанной.
Не переносить ExtensionKit в PlugIns наугад и не передавать токен через публичные атрибуты.
Следующий этап установки: совместимый подписыватель или доработка iloader для
ExtensionKit и общего Keychain с реальными provisioning profiles. Xcode signing
всех трёх targets — вариант при наличии совместимого Mac и профилей; разрешение
нового extension point бесплатной командой ещё не подтверждено на устройстве.
2026-09-14 пользователь попробовал установку 0.7.0: IXErrorDomain 2/8,
родитель стал `ru.yukinoki.musicbridge.<TeamID>`, а remote остался
`ru.yukinoki.musicbridge.remote`. Это подтверждает пропуск Extensions при переименовании.
Дополнительно библиотека apple-codesign-quick также обходила только Frameworks/PlugIns.
В `tools/iloader` добавлены патчи обоих уровней и воспроизводимая подготовка
закреплённых upstream revisions. Отдельный ручной workflow `iloader-compat.yml`
собирает portable Windows GUI; исходное приложение iloader на ПК не заменяется.
Патч сохраняет публичные Bundle ID приложения до подписи, затем iloader сам
применяет TeamID ко всем расширениям и получает для них настоящие Apple profiles.
Shared Keychain для MusicBridge добавляется только при разрешении в profile.
- [x] Проверено чистое применение патчей к закреплённым исходникам.
- [ ] Rust-тесты на переименование/обнаружение ExtensionKit, вложенную подпись и Keychain.
- [ ] Готовый portable EXE и повторная установка исходной IPA 0.7.0 через него.
- [ ] Подтвердить регистрацию NowPlaying и общий Keychain на iPhone.
Без APNs расширение читает ПК при команде; открытое приложение обновляет сессию
при смене трека/статуса и периодически. Смена трека на самом ПК при приостановленном
приложении не гарантирует автоматического обновления карточки. Фиктивное аудио не используется.
## Инфраструктура — текущее состояние
- SSH к macOS проверен: `aresov@10.67.67.28`, macOS 26.6.2, x86_64,
около 459 ГБ свободно. Xcode не установлен. Локальный отдельный ключ:
`C:/Users/areso/.ssh/musicbridge_macos_ed25519` (закрытый ключ не включать в репо).
Использовать `C:/Windows/System32/OpenSSH/ssh.exe` с `-F NUL`,
`-o ProxyCommand=none -o ProxyJump=none -o IdentitiesOnly=yes` и этим ключом;
другая установленная версия ssh не подключалась. Host key сохранён в known_hosts.
- Xcode 27 требует Apple Silicon; эта Intel-виртуалка не подходит для SDK 27.
CI переведён на GitHub-hosted `xcode-27`: Xcode 27.0 (27A5252f), Swift 6.4, SDK iOS 27.0.
[Actions 34764552877](https://github.com/polskikh13/musicbridgeios/actions/runs/34764552877)
для `1e14bf7`: 32 XCTest, 0 отказов, успешный Release archive.
IPA: `build/iPhone-0.6.0-xcode27/MusicBridge-iPhone.ipa`; проверены SDK 27, ARM64
приложения/расширения и структура пакета. Предпросмотр карточки просмотрен.
Версия осталась 0.6.0 (7); новые функции в пересборке не добавлены.
Просроченный тестовый сертификат создаётся с явными датами через
cryptography==46.0.3 в отдельном venv, поскольку новый OpenSSL отвергает -days -1.
- Пользователь подтвердил бесплатный Apple Account через iloader.
Фоновая доставка APNs заблокирована отсутствием Push Notifications capability
и соответствующей подписи. Платная Developer Program, App ID, ключ APNs и
provisioning profile пока отсутствуют; не обещать работу push после простой
смены Xcode. После появления доступа нужны регистрация токенов, отправитель
обновлений с Windows/сервера и тестирование доставки на физическом iPhone.
## Правила реализации
- SwiftUI, без веб-клиента. Музыка играет на Windows.
- Сверять контракт с Windows PROTOCOL.md; не менять серверный протокол молча.
@@ -46,7 +123,10 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
- [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
- [ ] App Intents: прямые действия из Live Activity и команды Siri.
- [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0.
- [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone.
- [ ] Команды Siri.
- [x] NowPlaying RemoteMediaSession (iOS 27+): SDK 27 и remote-media extension реализованы в 0.7.0; установка и физическая проверка — в текущем этапе выше.
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
@@ -143,13 +223,51 @@ Bonjour, если доступны объявления того же агент
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
3. **Системные интеграции — 0.5.0 (build 6).** Локальная Live Activity и Dynamic Island
реализованы; 26 XCTest и Release archive прошли. Карточка показывает последний снимок
с ПК, открывает плеер, помечает устаревшие данные. App Intents и APNs не реализованы.
3. **Системные интеграции — 0.6.0 (build 7).** Добавлены прямые действия LiveActivityIntent.
Убрана ошибка устаревания при обычном сворачивании; каждое нажатие перечитывает ПК.
32 XCTest и Release archive прошли. APNs и встроенный системный музыкальный пульт ещё не реализованы.
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
Изменения третьего этапа — Live Activities 0.5.0:
Продолжение третьего этапа — 0.6.0:
- Итоговый `43c78b2`: [Actions 34748340408](https://github.com/polskikh13/musicbridgeios/actions/runs/34748340408)
— 32 XCTest без отказов, успешный Release archive. IPA 0.6.0 (7) скачана в
`build/iPhone-0.6.0/MusicBridge-iPhone.ipa`; проверены ARM64 iPhoneOS приложения
и расширения, версии, метаданные интентов. Просмотрены светлая и тёмная карточки.
Работа системного запуска LiveActivityIntent и Keychain после блокировки требует
проверки на телефоне: обновить IPA, выключить/включить карточку, заблокировать,
проверить next/previous/toggle/refresh; затем повторить после перезагрузки и первого unlock.
- Пользователь сообщил, что карточка постоянно пишет об устаревших данных.
Причина: `PlayerStore.stop()` вызывал `unavailable()` при каждом уходе в фон.
Удалён этот вызов; реальные сетевые отказы продолжают отмечаться отдельно.
Карточка показывает время последнего снимка и отдельные ошибки запросов.
- Shared `MusicControlIntent` соответствует LiveActivityIntent. WidgetKit содержит
определение интента, выполнение сети компилируется только в основной app target;
iOS запускает процесс приложения без открытия UI. На компактном/минимальном Island
кнопок нет: нужно раскрыть долгим нажатием. Кнопки не отключаются из-за старого снимка.
- `LiveActivityCommands`: одно действие одновременно, проверка живой Activity и связи
её ID с fingerprint текущего сопряжения. После чтения состояния повторно проверяется
актуальность карточки, затем проверяются capabilities и отправляется максимум одна
команда. Refresh вообще не отправляет команду. Повторов команд нет.
- После успеха перечитывается трек и обновляется Activity. 401 завершает её;
неизвестный результат и неудача чтения после успеха имеют разные сообщения.
Нет бесконечного WSS/Bonjour в фоне; после смены IP сначала открыть приложение.
- Keychain: AfterFirstUnlockThisDeviceOnly вместо WhenUnlockedThisDeviceOnly,
чтобы команды работали при заблокированном iPhone. Существующий токен мигрирует
при повторном включении карточки. Он не покидает Keychain и не попадает в payload.
В UserDefaults сохраняется только соответствие ID активности и публичного pin.
После обновления с 0.5.0 карточку выключить и включить заново в приложении.
- HTTPS-тесты проверяют одну отправку next и новый трек, refresh без команды,
закрытую карточку, неподдерживаемую команду, отсутствие повторов отказавшей команды,
ошибку чтения после успешной команды. Физический запуск интента проверяется отдельно.
- Ответ на вопрос пользователя о встроенном музыкальном центре: официальный
[NowPlaying RemoteMediaSession](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions)
предназначен для внешнего воспроизведения. Metadata Apple указывает iOS 27.0.
Текущий runner собирает Xcode 16.4: следующий этап — SDK/Xcode 27 и отдельное
`com.apple.nowplaying.remote-media` расширение. Интеграция ещё не реализована.
История третьего этапа — Live Activities 0.5.0 (поведение фона изменено в 0.6.0):
- Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034)
— 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive.
+74 -28
View File
@@ -23,7 +23,27 @@ Windows-агентом и подключение по QR. В 0.4.0 добавл
## Сборка на Mac
1. Открыть `MusicBridge.xcodeproj` в Xcode 16 или новее.
Версия **0.7.0 (8)** добавляет системный пульт iOS 27: в плеере нажать
«Включить системный пульт iOS», затем открыть Пункт управления. Реализованы
play/pause, следующий/предыдущий трек, перемотка и громкость ПК. Звук остаётся на ПК.
Проверка этой функции на физическом iPhone пока не завершена.
Код `91e305a` прошёл [Actions](https://github.com/polskikh13/musicbridgeios/actions/runs/34785082289):
36 XCTest без отказов и Release archive. Неподписанная IPA:
`build/iPhone-0.7.0/MusicBridge-iPhone.ipa`.
Для этой версии нужно подписать **MusicBridge, MusicBridgeWidgets и MusicBridgeRemote**.
У приложения и MusicBridgeRemote должны совпадать разрешённые Keychain Sharing
groups: `<AppIdentifierPrefix>ru.yukinoki.musicbridge.remote-control`.
Префикс определяется из подписи при запуске, без хранения Apple Account в проекте.
MusicBridgeRemote — ExtensionKit в `Extensions`, а WidgetKit — в `PlugIns`.
Текущий iloader не обрабатывает эту комбинацию полностью: обычную переподпись
0.7.0 считать поддержанной нельзя. Для прежней установки оставлена IPA 0.6.0 ниже.
Состояние работ и требования к подписывателю описаны в `PLAN.md`.
Без APNs трек обновляется из открытого приложения или после команды системного
пульта. Изменения на ПК при приостановленном приложении могут отображаться с задержкой.
1. Открыть `MusicBridge.xcodeproj` в Xcode 27.
2. Выбрать схему MusicBridge и симулятор iPhone. Run запускает приложение,
Test выполняет `MusicBridgeTests`.
3. Для физического iPhone выбрать свою Team в Signing & Capabilities. Сертификаты
@@ -38,7 +58,7 @@ Windows-агентом и подключение по QR. В 0.4.0 добавл
## GitHub Actions
`.github/workflows/ios.yml` собирает приложение и выполняет XCTest на `macos-15`
`.github/workflows/ios.yml` собирает приложение и выполняет XCTest на `xcode-27`
при push в main, pull request или ручном запуске. Сохраняет лог, `.xcresult`
и ZIP приложения **для симулятора**, который нельзя установить на физический iPhone.
Apple ID и сертификаты для этой проверки не требуются. GitHub remote подключён:
@@ -68,8 +88,8 @@ Apple ID и сертификаты для этой проверки не тре
## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки
с Live Activity используйте версию **0.5.0 (build 6)**:
`build/iPhone-0.5.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
с Live Activity используйте версию **0.6.0 (build 7)**:
`build/iPhone-0.6.0-xcode27/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия.
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
@@ -106,34 +126,45 @@ Sideloadly (в том числе его функцией автоматичес
[Sideloadly FAQ](https://sideloadly.io/faq) ·
[Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device)
## Live Activity и Dynamic Island
## Live Activity и Dynamic Island — 0.6.0
После подключения к ПК и появления трека нажмите под плеером
«Показать на экране блокировки». Та же карточка появится в Dynamic Island
на моделях, где он доступен. Долгое нажатие на Island раскрывает представление;
нажатие на карточку или «Открыть плеер» возвращает в приложение.
Кнопка «Убрать с экрана блокировки» завершает активность. Она также завершается,
когда ПК сообщает об отсутствии медиасессии, доступ отозван или компьютер забыт.
Удалённая пользователем карточка не создаётся снова автоматически.
После обновления с 0.5.0 уберите старую карточку кнопкой в приложении и нажмите
«Показать на экране блокировки» заново. Это связывает кнопки с текущим сопряжением
и переводит запись Keychain в режим доступа после первого разблокирования.
Токен остаётся только на этом устройстве, не копируется в виджет или UserDefaults.
Это **последний снимок состояния ПК**: название, исполнитель, состояние и прогресс.
В обычном режиме позиция обновляется не чаще раза в пять секунд; смена трека
и play/pause отправляются сразу. Прогресс не выдумывает движение без связи с ПК.
При уходе приложения в фон или потере связи отображается отметка устаревших данных;
резервный `staleDate` — 15 секунд, если приложение было внезапно остановлено.
Для обновления нажмите карточку. Прямых кнопок управления внутри неё пока нет.
На экране блокировки и в развёрнутом Dynamic Island доступны предыдущий трек,
play/pause, следующий трек и обновление. Компактный Island открывает приложение;
для кнопок удерживайте Island. Каждое действие выполняет отдельные HTTPS-запросы
с прежней проверкой сертификата. Оно работает через LiveActivityIntent в процессе
приложения, без открытия интерфейса; постоянного сетевого потока в фоне нет.
Live Activity не поддерживает постоянный сетевой поток сама по себе. Для обновления
при приостановленном приложении нужен отдельный механизм ActivityKit push через
APNs. В этой версии нет push-токенов, сервера APNs, фонового аудио и App Intents.
[Ограничения и жизненный цикл ActivityKit](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities).
Перед командой считываются возможности плеера, затем команда отправляется один раз
и перечитывается состояние. Повторы команд отключены; при неизвестном результате
сначала используйте обновление. Одновременно выполняется одно действие карточки.
После закрытия карточки или удаления её связи с ПК новые команды не отправляются.
Для нового адреса ПК сначала откройте приложение: Bonjour-перенос адреса остаётся
в основном приложении. При отзыве доступа карточка завершается.
В IPA включено расширение `MusicBridgeWidgets.appex`; при установке iloader должен
сохранить и подписать его вместе с приложением. Если Live Activity не появилась,
проверьте её разрешение в настройках MusicBridge и наличие расширения в логе установки.
Автоматические снимки `live-card-*-screen.png` показывают общую SwiftUI-разметку
карточки в Debug-предпросмотре: это не доказательство её регистрации на реальном
экране блокировки или системного отображения Dynamic Island.
При обычном сворачивании показывается время последнего обновления, а не ошибка
«Данные устарели». Прогресс соответствует этому снимку. Реальная ошибка сети или
команды выводится на карточке. Изменения, сделанные непосредственно на ПК, при
приостановленном приложении не появятся сами: нажмите обновление или откройте плеер.
Для постоянных фоновых обновлений остаётся отдельная задача APNs.
В IPA есть расширение `MusicBridgeWidgets.appex`: iloader должен сохранить и подписать
его вместе с приложением. Проверка работы интентов в заблокированном состоянии и
системного отображения Island выполняется на iPhone; CI проверяет сборку, сетевую
логику команд и разметку. После перезагрузки сначала один раз разблокируйте iPhone.
### Встроенный музыкальный пульт iOS
Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+)
для отображения музыки внешнего устройства на экране блокировки и в Control Center.
Это подходит архитектуре MusicBridge. Нужно отдельное расширение `com.apple.nowplaying.remote-media`. Сборка переведена на Xcode 27; 32 теста и Release archive прошли.
Эта интеграция исследована, но ещё не реализована. Для фоновых обновлений удалённых
сессий Apple также предоставляет push-механизм.
[Документация remote media sessions](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions).
## Источники
@@ -143,3 +174,18 @@ APNs. В этой версии нет push-токенов, сервера APNs,
- [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions)
План и ограничения — в `PLAN.md`. Копия протокола агента — в `PROTOCOL.md`.
## Сервер сборки и фоновые обновления
GitHub Actions использует Apple Silicon runner `xcode-27` с проверкой версии Xcode.
SSH к локальной macOS-виртуалке настроен, однако она x86_64, без Xcode и не может
запускать Xcode 27. Для SDK 27 используется GitHub Actions.
Тестовый TLS-сервер требует `cryptography==46.0.3`; CI устанавливает пакет
в отдельное Python-окружение. Это зависимость тестов, не приложения.
Пользователь использует бесплатный Apple Account. APNs требует Push Notifications
capability и подписи с соответствующим provisioning profile, недоступных Personal Team.
Поэтому автоматическая фоновая доставка ещё не подключена. Команды Live Activity
выполняют запросы при нажатии; смена трека на ПК при приостановленном приложении
не обновляет карточку без запроса или push.
+29
View File
@@ -0,0 +1,29 @@
import Foundation
import NowPlaying
@available(iOS 27.0, *)
struct SystemPlayerAttributes: RemoteMediaSessionAttributes {
let id: String
var title: String
var artist: String
var source: String
var artworkID: String?
var playing: Bool
var rate: Double
var position: Double
var duration: Double
var timestamp: Date
var capabilities: Capabilities
var volume: Double?
var hasSession: Bool
init(id: String, state: MediaState, now: Date = Date()) {
self.id = id
title = String(state.title.prefix(256)); artist = String(state.artist.prefix(256))
source = String(state.source.prefix(128)); artworkID = state.artworkId
playing = state.playbackStatus == "Playing"; rate = state.playbackRate
position = max(0, state.position(elapsed: 0) - state.startSeconds)
duration = max(0, state.endSeconds - state.startSeconds)
timestamp = now; capabilities = state.capabilities; volume = state.volume; hasSession = state.hasSession
}
}
@@ -0,0 +1,62 @@
import Foundation
import Security
enum SystemPlayerCredentials {
// Derive the signing prefix from our own default Keychain group. Info.plist
// build substitutions cannot know the team that later re-signs an IPA.
private static func signingPrefix() throws -> String {
let marker: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "MusicBridge.signing-prefix.v1", kSecAttrAccount as String: "prefix"]
let status = SecItemAdd(marker.merging([kSecValueData as String: Data(),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]) { _, v in v } as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else { throw failure(status) }
var result: CFTypeRef?
let read = SecItemCopyMatching(marker.merging([kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]) { _, v in v } as CFDictionary, &result)
guard read == errSecSuccess else { throw failure(read) }
guard let attributes = result as? [String: Any],
let group = attributes[kSecAttrAccessGroup as String] as? String,
let separator = group.firstIndex(of: "."), separator != group.startIndex else {
throw failure(errSecMissingEntitlement)
}
return String(group[...separator])
}
private static func query(_ id: String) throws -> [String: Any] {
guard let suffix = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
suffix == "ru.yukinoki.musicbridge.remote-control" else {
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
}
return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1",
kSecAttrAccount as String: id, kSecAttrAccessGroup as String: try signingPrefix() + suffix]
}
static func save(_ connection: Connection, id: String) throws {
let query = try query(id)
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, v in v } as CFDictionary, nil) }
guard status == errSecSuccess else { throw failure(status) }
}
static func load(_ id: String) throws -> Connection {
var query = try query(id)
query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
let saved = try JSONDecoder().decode(Connection.self, from: data)
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.unauthorized }
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
}
static func delete(_ id: String) throws {
let status = SecItemDelete(try query(id) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
}
private static func failure(_ status: OSStatus) -> BridgeError {
.message("Системный пульт не получил доступ к Keychain (\(status)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.")
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
enum SystemPlayerAction { case play, pause, next, previous, seek, volume }
@MainActor
final class SystemPlayerTransport {
private var busy = false
func send(_ action: SystemPlayerAction, value: Double? = nil, sessionID: String) async throws -> MediaState {
guard !busy else { throw BridgeError.message("Предыдущая команда ещё выполняется.") }
busy = true
defer { busy = false }
let saved = try SystemPlayerCredentials.load(sessionID)
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
return try await Self.execute(action, value: value, client: client) {
let current = try SystemPlayerCredentials.load(sessionID)
guard current.endpoint == saved.endpoint, current.fingerprint == saved.fingerprint, current.token == saved.token else {
throw BridgeError.unauthorized
}
}
}
static func execute(_ action: SystemPlayerAction, value: Double? = nil, client: BridgeClient,
validate: () throws -> Void = {}) async throws -> MediaState {
let state = try await client.state()
try validate()
let command = try command(action, value: value, state: state)
do { try await client.command(command) }
catch {
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Откройте плеер перед повтором.") }
throw error
}
do { return try await client.state() }
catch {
if case BridgeError.unauthorized = error { throw error }
throw BridgeError.message("Команда выполнена, но новый трек не получен. Откройте приложение для обновления.")
}
}
static func command(_ action: SystemPlayerAction, value: Double?, state: MediaState) throws -> Command {
switch action {
case .play where state.hasSession && state.capabilities.play: return Command(type: "play", value: nil)
case .pause where state.hasSession && state.capabilities.pause: return Command(type: "pause", value: nil)
case .next where state.hasSession && state.capabilities.next: return Command(type: "next", value: nil)
case .previous where state.hasSession && state.capabilities.previous: return Command(type: "previous", value: nil)
case .seek where state.hasSession && state.capabilities.seek && state.maxSeekSeconds > state.minSeekSeconds:
guard let value, value.isFinite else { throw BridgeError.message("Некорректная позиция трека.") }
return Command(type: "seek", value: min(state.maxSeekSeconds, max(state.minSeekSeconds, state.startSeconds + value)))
case .volume where state.volume != nil:
guard let value, value.isFinite else { throw BridgeError.message("Некорректная громкость.") }
return Command(type: "volume", value: min(1, max(0, value)))
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
}
}
}
+1
View File
@@ -10,6 +10,7 @@ struct MusicActivityAttributes: ActivityAttributes {
var duration: Double
var updatedAt: Date
var connected: Bool
var message: String? = nil
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
static func bounded(_ text: String) -> String {
+30 -6
View File
@@ -1,10 +1,12 @@
import SwiftUI
import AppIntents
struct MusicActivityCard: View {
let state: MusicActivityAttributes.ContentState
let stale: Bool
var activityID: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Image(systemName: "waveform").font(.title).foregroundStyle(.indigo)
TrackLabels(state: state)
@@ -14,8 +16,8 @@ struct MusicActivityCard: View {
}
ProgressView(value: state.progress).tint(.indigo)
ActivityStatus(state: state, stale: stale)
Label("Открыть плеер", systemImage: "arrow.up.forward.app").font(.caption.weight(.semibold))
}.padding(16)
MusicActivityControls(activityID: activityID)
}.padding(12)
}
}
@@ -34,13 +36,35 @@ struct ActivityStatus: View {
let state: MusicActivityAttributes.ContentState
let stale: Bool
var body: some View {
if stale || !state.connected {
Text("Данные устарели · откройте плеер").font(.caption).foregroundStyle(.secondary)
if let message = state.message {
Text(message).font(.caption).lineLimit(2)
} else if !state.connected {
Text("Нет связи с ПК · нажмите обновление").font(.caption).foregroundStyle(.secondary)
} else {
HStack(spacing: 4) {
Text("Снимок с ПК в")
Text("Обновлено в")
Text(state.updatedAt, style: .time)
}.font(.caption).foregroundStyle(.secondary)
}
}
}
struct MusicActivityControls: View {
let activityID: String
var body: some View {
HStack {
control(.previous, icon: "backward.end.fill", label: "Предыдущий трек")
Spacer(minLength: 4)
control(.toggle, icon: "playpause.fill", label: "Воспроизведение или пауза")
Spacer(minLength: 4)
control(.next, icon: "forward.end.fill", label: "Следующий трек")
Spacer(minLength: 4)
control(.refresh, icon: "arrow.clockwise", label: "Обновить трек")
}
}
private func control(_ action: MusicRemoteAction, icon: String, label: String) -> some View {
Button(intent: MusicControlIntent(action, activityID: activityID)) {
Image(systemName: icon).font(.title3).frame(minWidth: 44, minHeight: 44)
}.buttonStyle(.plain).accessibilityLabel(label).disabled(activityID.isEmpty)
}
}
+28
View File
@@ -0,0 +1,28 @@
import AppIntents
enum MusicRemoteAction: String, AppEnum {
case previous, toggle, next, refresh
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Команда плеера"
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.previous: "Предыдущий трек", .toggle: "Воспроизведение / пауза", .next: "Следующий трек", .refresh: "Обновить"
]
}
struct MusicControlIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Управлять музыкой на ПК"
static var openAppWhenRun: Bool = false
static var isDiscoverable: Bool = false
static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
@Parameter(title: "Команда") var action: MusicRemoteAction
@Parameter(title: "Активность") var activityID: String
init() {}
init(_ action: MusicRemoteAction, activityID: String) { self.action = action; self.activityID = activityID }
func perform() async throws -> some IntentResult {
#if !MUSICBRIDGE_WIDGET
await LiveActivityCommands.perform(action, activityID: activityID)
#endif
return .result()
}
}
+42 -11
View File
@@ -22,6 +22,8 @@ def configurations(name, settings):
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
if mode == "Debug":
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
if name == "MusicBridgeWidgets":
values["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = values.get("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "") + " MUSICBRIDGE_WIDGET"
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
@@ -35,6 +37,13 @@ def source_group(folder):
return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds
app_group, app_sources = source_group("MusicBridge")
remote_shared_group, remote_shared_sources = source_group("RemoteShared")
remote_group, remote_sources = source_group("MusicBridgeRemote")
remote_sources += [add("remote-shared/" + str(index), "PBXBuildFile", fileRef=objects[build]["fileRef"])
for index, build in enumerate(remote_shared_sources)]
for filename in ("Models.swift", "BridgeClient.swift"):
remote_sources.append(add("remote-core/" + filename, "PBXBuildFile", fileRef=uid("MusicBridge/" + filename)))
app_sources += remote_shared_sources
shared_group, shared_sources = source_group("Shared")
widget_group, widget_sources = source_group("MusicBridgeWidgets")
app_sources += shared_sources
@@ -52,16 +61,18 @@ objects[test_group]["children"].append(tls_fixture)
tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture)
products = []
targets = []
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False)]:
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False), ("MusicBridgeRemote", remote_sources, False)]:
is_widget = name == "MusicBridgeWidgets"
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.app-extension" if is_widget else "wrapper.cfbundle" if is_test else "wrapper.application",
includeInIndex=0, path=name + (".appex" if is_widget else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR")
is_remote = name == "MusicBridgeRemote"
is_extension = is_widget or is_remote
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.extensionkit-extension" if is_remote else "wrapper.app-extension" if is_extension else "wrapper.cfbundle" if is_test else "wrapper.application",
includeInIndex=0, path=name + (".appex" if is_extension else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR")
products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0),
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[] if is_widget else [fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)]
settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".widgets" if is_widget else ".tests" if is_test else ""),
SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos",
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[] if is_extension else [fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)]
settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".remote" if is_remote else ".widgets" if is_widget else ".tests" if is_test else ""),
SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="27.0" if is_remote else "17.0", SDKROOT="iphoneos",
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
GENERATE_INFOPLIST_FILE="YES" if is_test else "NO")
deps = []
@@ -69,24 +80,32 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
settings.update(TEST_HOST="$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge", BUNDLE_LOADER="$(TEST_HOST)")
proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
elif is_widget:
settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0",
elif is_extension:
settings.update(INFOPLIST_FILE=name + "/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0",
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
if is_remote:
settings.update(CODE_SIGN_ENTITLEMENTS="MusicBridgeRemote/Remote.entitlements")
else:
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon")
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon", CODE_SIGN_ENTITLEMENTS="MusicBridge/App.entitlements")
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
embed_remote = add("embed-remote", "PBXBuildFile", fileRef=uid("MusicBridgeRemoteproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0))
phases.append(add("embed-remote-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="$(EXTENSIONS_FOLDER_PATH)", dstSubfolderSpec=16, files=[embed_remote], name="Embed ExtensionKit Extensions", runOnlyForDeploymentPostprocessing=0))
proxy = add("widget-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets")
deps = [add("widget-dependency", "PBXTargetDependency", target=uid("MusicBridgeWidgetstarget"), targetProxy=proxy)]
remote_proxy = add("remote-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeRemotetarget"), remoteInfo="MusicBridgeRemote")
deps.append(add("remote-dependency", "PBXTargetDependency", target=uid("MusicBridgeRemotetarget"), targetProxy=remote_proxy))
targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product,
productType="com.apple.product-type.app-extension" if is_widget else "com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application",
productType="com.apple.product-type.extensionkit-extension" if is_remote else "com.apple.product-type.app-extension" if is_widget else "com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application",
buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products)
main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, shared_group, widget_group, test_group, product_group])
main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, shared_group, widget_group, remote_shared_group, remote_group, test_group, product_group])
project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"),
buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")),
compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"],
@@ -142,4 +161,16 @@ widget_info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBri
CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension"))
(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False))
shared_group_name = "$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control"
info["MusicBridgeSharedKeychainGroup"] = "ru.yukinoki.musicbridge.remote-control"
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
remote_info = {k: v for k, v in widget_info.items() if k != "NSExtension"}
remote_info.update(EXAppExtensionAttributes=dict(EXExtensionPointIdentifier="com.apple.nowplaying.remote-media"),
MusicBridgeSharedKeychainGroup=info["MusicBridgeSharedKeychainGroup"],
NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"],
NSAppTransportSecurity=info["NSAppTransportSecurity"])
(ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False))
for folder, filename in (("MusicBridge", "App.entitlements"), ("MusicBridgeRemote", "Remote.entitlements")):
groups = ["$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)", shared_group_name]
(ROOT / folder / filename).write_bytes(plistlib.dumps({"keychain-access-groups": groups}))
print("Generated Xcode project, shared scheme and Info.plist.")
+28
View File
@@ -0,0 +1,28 @@
# iloader: совместимость с MusicBridge 0.7.0
Это отдельная неофициальная portable-сборка iloader для Windows. Исходники:
- https://github.com/nab138/iloader/tree/348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
- https://github.com/nab138/isideload/tree/f6a4d5dba717d72fc2af63eaba26b27ba44116be
- https://github.com/nab138/apple-crates/tree/5da0b8df9b202434b1eff2700059b1ee142592ac
Патчи хранятся в репозитории MusicBridge в tools/iloader. Workflow
iloader-compat.yml запускается вручную, не использует Apple Account/сертификаты.
Apple-авторизация и подпись происходят только локально на компьютере пользователя.
Исправления: обнаружение Extensions вместе с PlugIns, переименование и получение
профиля каждого расширения, вложенная подпись ExtensionKit и общий Keychain для
MusicBridge только в пределах разрешений настоящего provisioning profile Apple.
Регрессионные Rust-тесты проверяют обе папки расширений, сохранение новых Bundle ID,
обработку вложенной подписи и отказ при недопустимой Keychain group.
Использование после успешной сборки:
1. Закрыть обычный iloader. Удалять его и установленный MusicBridge не требуется.
2. Запустить iloader-musicbridge.exe. Заголовок окна содержит compatibility build.
3. Использовать прежний Apple Account; выбрать исходную MusicBridge 0.7.0 IPA.
4. Подписать и установить заново. Все расширения нужно сохранить.
5. Проверить обычное подключение, затем кнопку системного пульта и Пункт управления.
Вариант сохраняет идентификатор/хранилище обычного iloader для локального входа,
поэтому одновременно запускать обе версии не нужно. Обновления из upstream в
этой сборке отключены. Совместимость нового extension point с бесплатным профилем
Apple окончательно подтверждается только установкой на устройстве.
+46
View File
@@ -0,0 +1,46 @@
diff --git a/apple-codesign-quick/src/bundle.rs b/apple-codesign-quick/src/bundle.rs
index 040fd07..2bfa359 100644
--- a/apple-codesign-quick/src/bundle.rs
+++ b/apple-codesign-quick/src/bundle.rs
@@ -16,6 +16,23 @@ const FAIRPLAY_DIR: &str = "SC_Info";
const CODE_SIGNATURE_DIR: &str = "_CodeSignature";
const CODE_RESOURCES_FILE: &str = "CodeResources";
+#[cfg(test)]
+mod musicbridge_extensionkit_tests {
+ use super::*;
+ #[test]
+ fn musicbridge_extensionkit_is_signed_as_nested_bundle() {
+ let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let root = std::env::temp_dir().join(format!("musicbridge-codesign-test-{unique}"));
+ let extension = root.join("Extensions/Remote.appex");
+ std::fs::create_dir_all(&extension).unwrap();
+ std::fs::write(extension.join("Info.plist"), b"fixture").unwrap();
+ let bundle = Bundle { path: root, app_info: Dictionary::new() };
+ assert_eq!(bundle.sub_bundles().unwrap(), vec![extension]);
+ assert!(!should_hash_resource("Extensions/Remote.appex/Remote", "Main"));
+ assert!(should_hash_resource("Assets.car", "Main"));
+ }
+}
+
pub struct BundleSigningSettings<'a> {
pub team_id: String,
pub main_entitlements: Dictionary,
@@ -202,7 +219,7 @@ impl Bundle {
fn sub_bundles(&self) -> Result<Vec<PathBuf>> {
let mut bundles = Vec::new();
- for folder in ["Frameworks", "PlugIns"] {
+ for folder in ["Frameworks", "PlugIns", "Extensions"] {
let dir = self.path.join(folder);
#[cfg(feature = "wasm")]
if !isideload_vfs::fs::metadata(&dir)
@@ -463,7 +480,7 @@ fn should_hash_resource(relative: &str, executable: &str) -> bool {
return false;
}
- for nested_root in ["Frameworks/", "PlugIns/"] {
+ for nested_root in ["Frameworks/", "PlugIns/", "Extensions/"] {
if let Some(rest) = relative.strip_prefix(nested_root)
&& rest.contains('/')
{
+159
View File
@@ -0,0 +1,159 @@
diff --git a/isideload/src/sideload/application.rs b/isideload/src/sideload/application.rs
index 4e0ccd0..be80af4 100644
--- a/isideload/src/sideload/application.rs
+++ b/isideload/src/sideload/application.rs
@@ -20,6 +20,33 @@ pub struct Application {
//pub temp_path: PathBuf,
}
+#[cfg(test)]
+mod musicbridge_tests {
+ use super::*;
+ #[test]
+ fn musicbridge_extensionkit_is_renamed_and_persisted() {
+ let root = std::env::temp_dir().join(format!("musicbridge-sign-test-{}", uuid::Uuid::new_v4()));
+ for (directory, id) in [("", "ru.yukinoki.musicbridge"),
+ ("PlugIns/Widget.appex", "ru.yukinoki.musicbridge.widgets"),
+ ("Extensions/Remote.appex", "ru.yukinoki.musicbridge.remote")] {
+ let path = root.join(directory);
+ std::fs::create_dir_all(&path).unwrap();
+ let mut info = plist::Dictionary::new();
+ info.insert("CFBundleIdentifier".into(), id.into());
+ plist::to_file_xml(path.join("Info.plist"), &info).unwrap();
+ }
+ let mut app = Application::new(root.clone()).unwrap();
+ assert_eq!(app.bundle.app_extensions().len(), 2);
+ app.update_bundle_id("ru.yukinoki.musicbridge", "ru.yukinoki.musicbridge.TEAM").unwrap();
+ app.bundle.write_info().unwrap();
+ for extension in app.bundle.app_extensions_mut() { extension.write_info().unwrap(); }
+ let saved = Application::new(root).unwrap();
+ for extension in saved.bundle.app_extensions() {
+ assert!(extension.bundle_identifier().unwrap().starts_with("ru.yukinoki.musicbridge.TEAM."));
+ }
+ }
+}
+
impl Application {
pub fn new(path: PathBuf) -> Result<Self, Report> {
if !isideload_vfs::fs::metadata(&path).is_ok() {
diff --git a/isideload/src/sideload/bundle.rs b/isideload/src/sideload/bundle.rs
index 67a947d..2cc2282 100644
--- a/isideload/src/sideload/bundle.rs
+++ b/isideload/src/sideload/bundle.rs
@@ -45,23 +45,19 @@ impl Bundle {
"Failed to parse Info.plist".to_string(),
))?;
- // Load app extensions from PlugIns directory
- let plug_ins_dir = bundle_path.join("PlugIns");
- let app_extensions = if isideload_vfs::fs::metadata(&plug_ins_dir).is_ok() {
- isideload_vfs::fs::read_dir(&plug_ins_dir)
- .context(SideloadError::InvalidBundle(
- "Failed to read PlugIns directory".to_string(),
- ))?
- .filter_map(|entry| entry.ok())
- .filter(|entry| {
- entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
- && isideload_vfs::fs::metadata(&entry.path().join("Info.plist")).is_ok()
- })
- .filter_map(|entry| Bundle::new(entry.path()).ok())
- .collect()
- } else {
- Vec::new()
- };
+ // ExtensionKit uses Extensions; WidgetKit and older extensions use PlugIns.
+ // Invalid nested bundles must fail signing, never be silently skipped.
+ let mut app_extensions = Vec::new();
+ for folder in ["PlugIns", "Extensions"] {
+ let directory = bundle_path.join(folder);
+ if !isideload_vfs::fs::metadata(&directory).is_ok() { continue; }
+ for entry in isideload_vfs::fs::read_dir(&directory)? {
+ let entry = entry?;
+ if entry.file_type()?.is_dir() {
+ app_extensions.push(Bundle::new(entry.path())?);
+ }
+ }
+ }
// Load frameworks from Frameworks directory
let frameworks_dir = bundle_path.join("Frameworks");
diff --git a/isideload/src/sideload/sign.rs b/isideload/src/sideload/sign.rs
index a3d9799..c7e6154 100644
--- a/isideload/src/sideload/sign.rs
+++ b/isideload/src/sideload/sign.rs
@@ -5,6 +5,7 @@ use apple_codesign::{
};
use plist::Dictionary;
+use rootcause::option_ext::OptionExt;
use rootcause::prelude::*;
use crate::{
@@ -63,6 +64,10 @@ where
);
}
+ let musicbridge = app.main_bundle_id()? == format!("ru.yukinoki.musicbridge.{}", team.team_id)
+ && app.bundle.app_info.get("MusicBridgeSharedKeychainGroup").and_then(plist::Value::as_string)
+ == Some("ru.yukinoki.musicbridge.remote-control");
+ if musicbridge { entitlements = musicbridge_keychain(&entitlements)?; }
let mut settings = BundleSigningSettings::new(&team.team_id, entitlements, Some(&signer));
settings.embedded_mobileprovision = Some(main_provisioning_profile.encoded_profile.as_ref());
@@ -75,9 +80,56 @@ where
.map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone()))
.collect();
+ if musicbridge {
+ let remote_id = format!("{}.remote", app.main_bundle_id()?);
+ let remote = settings.entitlements_by_bundle_id.get(&remote_id)
+ .ok_or_report().context("MusicBridge remote extension has no provisioning profile")?;
+ let remote = musicbridge_keychain(remote)?;
+ settings.entitlements_by_bundle_id.insert(remote_id, remote);
+ }
+
if let Some(callback) = &progress_callback {
callback(0.5).await;
}
Ok(sign_bundle(&app.bundle.bundle_dir, &settings)?)
}
+
+// Request only a group explicitly permitted by Apple's actual profile.
+fn musicbridge_keychain(profile: &Dictionary) -> Result<Dictionary, Report> {
+ let application = profile.get("application-identifier").and_then(plist::Value::as_string)
+ .ok_or_report().context("Profile has no application-identifier")?;
+ let prefix = application.split_once('.').ok_or_report().context("Invalid app identifier")?.0;
+ let shared = format!("{}.ru.yukinoki.musicbridge.remote-control", prefix);
+ let allowed = profile.get("keychain-access-groups").and_then(plist::Value::as_array)
+ .ok_or_report().context("Profile has no Keychain groups")?;
+ if !allowed.iter().filter_map(plist::Value::as_string).any(|group|
+ group == shared || group == format!("{}.*", prefix)) {
+ bail!("Apple's provisioning profile does not permit the MusicBridge shared Keychain group");
+ }
+ let mut result = profile.clone();
+ // A concrete per-app default group comes first, shared group second.
+ result.insert("keychain-access-groups".into(), plist::Value::Array(vec![
+ plist::Value::String(application.into()), plist::Value::String(shared)]));
+ Ok(result)
+}
+
+#[cfg(test)]
+mod musicbridge_tests {
+ use super::*;
+ fn profile(group: &str) -> Dictionary {
+ let mut p = Dictionary::new();
+ p.insert("application-identifier".into(), "TEAM.ru.yukinoki.musicbridge.TEAM".into());
+ p.insert("keychain-access-groups".into(), plist::Value::Array(vec![group.into()]));
+ p
+ }
+ #[test]
+ fn musicbridge_sharing_respects_profile() {
+ let value = musicbridge_keychain(&profile("TEAM.*")).unwrap();
+ let groups = value["keychain-access-groups"].as_array().unwrap();
+ assert_eq!(groups[0].as_string(), Some("TEAM.ru.yukinoki.musicbridge.TEAM"));
+ assert_eq!(groups[1].as_string(), Some("TEAM.ru.yukinoki.musicbridge.remote-control"));
+ assert!(musicbridge_keychain(&profile("OTHER.*")).is_err());
+ assert!(musicbridge_keychain(&profile("TEAM.unrelated")).is_err());
+ }
+}
+33
View File
@@ -0,0 +1,33 @@
"""Apply reviewed patches to pinned upstream checkouts. No Apple credentials involved."""
import json
from pathlib import Path
import subprocess
import sys
root = Path(sys.argv[1]).resolve()
patches = Path(__file__).resolve().parent
versions = {
"iloader": "348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0",
"isideload": "f6a4d5dba717d72fc2af63eaba26b27ba44116be",
"apple-crates": "5da0b8df9b202434b1eff2700059b1ee142592ac",
}
for name, commit in versions.items():
path = root / name
actual = subprocess.check_output(["git", "-C", str(path), "rev-parse", "HEAD"], text=True).strip()
if actual != commit:
raise RuntimeError(f"Unexpected {name} revision: {actual}")
for name, patch in [("isideload", "isideload.patch"), ("apple-crates", "apple-codesign.patch")]:
subprocess.run(["git", "-C", str(root/name), "apply", "--check", str(patches/patch)], check=True)
subprocess.run(["git", "-C", str(root/name), "apply", str(patches/patch)], check=True)
manifest = root / "iloader/src-tauri/Cargo.toml"
with manifest.open("a", encoding="utf-8") as file:
file.write('\n[patch."https://github.com/nab138/isideload"]\nisideload = { path = "../../isideload/isideload" }\n')
file.write('\n[patch."https://github.com/nab138/apple-crates"]\napple-codesign = { path = "../../apple-crates/apple-codesign-quick" }\n')
# Portable helper: preserve upstream identifier/storage so existing Apple login can
# be reused locally. Use a distinct title and disable upstream update replacement.
config = root / "iloader/src-tauri/ci.conf.json"
value = json.loads(config.read_text(encoding="utf-8"))
value["app"] = {"windows": [{"title": "iloader — MusicBridge compatibility build", "width": 800, "height": 600}]}
config.write_text(json.dumps(value, indent=2), encoding="utf-8")
print("Patched pinned iloader, extension discovery, nested signing and profile-checked Keychain sharing.")
+38 -3
View File
@@ -13,6 +13,9 @@ import subprocess
import threading
import time
import zlib
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
root = pathlib.Path(__file__).resolve().parents[1]
work = root / "build/tls"
@@ -67,11 +70,25 @@ class Handler(http.server.BaseHTTPRequestHandler):
def snapshot(self, token):
result = dict(state)
if token.startswith("live-"):
with counts_lock:
commands = counts.get((token, "command"), 0)
result["title"] = "Track " + str(commands)
if token == "live-unsupported":
result["capabilities"] = {key: False for key in result["capabilities"]}
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
return result
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
if token.startswith("live-"):
count(token, "command")
if token == "live-reject":
self.reply(200, {"success": False, "code": "unsupported", "message": "Rejected"})
else:
self.reply(200, {"success": True, "code": "ok", "message": "OK"})
return
if self.path == "/v1/pair":
code = body.get("code")
if code == "33333333":
@@ -86,6 +103,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
number = count(token, self.path)
if token == "live-refresh-fail" and self.path == "/v1/state":
with counts_lock:
commands = counts.get((token, "command"), 0)
if commands:
self.reply(503, {})
return
if token == "revoked":
self.reply(401, {})
return
@@ -147,9 +170,21 @@ for name, days in (("valid", "1825"), ("expired", "-1")):
key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem"))
subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", str(config),
"-keyout", str(key), "-out", str(csr)], check=True, capture_output=True, timeout=15)
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256",
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)],
check=True, capture_output=True, timeout=15)
if name == "expired":
private_key = serialization.load_pem_private_key(key.read_bytes(), password=None)
template = x509.load_pem_x509_certificate((work / "valid.pem").read_bytes())
now = datetime.datetime.now(datetime.timezone.utc)
builder = (x509.CertificateBuilder().subject_name(template.subject).issuer_name(template.issuer)
.public_key(private_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=2))
.not_valid_after(now - datetime.timedelta(days=1)))
for extension in template.extensions:
builder = builder.add_extension(extension.value, extension.critical)
cert.write_bytes(builder.sign(private_key, hashes.SHA256()).public_bytes(serialization.Encoding.PEM))
else:
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256",
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)],
check=True, capture_output=True, timeout=15)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(cert, key)
+3 -2
View File
@@ -11,12 +11,13 @@ import xml.etree.ElementTree as ET
root = pathlib.Path(__file__).resolve().parents[1]
generated = [root / "MusicBridge.xcodeproj/project.pbxproj",
root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme",
root / "MusicBridge/Info.plist", root / "MusicBridgeWidgets/Info.plist"]
root / "MusicBridge/Info.plist", root / "MusicBridgeWidgets/Info.plist",
root / "MusicBridgeRemote/Info.plist", root / "MusicBridge/App.entitlements", root / "MusicBridgeRemote/Remote.entitlements"]
before = [p.read_bytes() for p in generated]
subprocess.run([sys.executable, str(root / "tools/generate_project.py")], check=True)
assert before == [p.read_bytes() for p in generated], "Generated project is stale; review and commit regeneration."
project = generated[0].read_text(encoding="utf-8")
for folder in ("MusicBridge", "MusicBridgeTests", "Shared", "MusicBridgeWidgets"):
for folder in ("MusicBridge", "MusicBridgeTests", "Shared", "MusicBridgeWidgets", "RemoteShared", "MusicBridgeRemote"):
for source in (root / folder).glob("*.swift"):
key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper()
assert key in project, f"Missing source: {source.name}"
+20 -1
View File
@@ -47,6 +47,25 @@ with zipfile.ZipFile(path) as archive:
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
offset += size
assert platforms == [2], "Widget is not built for iOS devices"
remote_path = prefix + "Extensions/MusicBridgeRemote.appex/"
remote = plistlib.loads(archive.read(remote_path + "Info.plist"))
assert remote["CFBundleIdentifier"] == info["CFBundleIdentifier"] + ".remote"
assert remote["CFBundleShortVersionString"] == info["CFBundleShortVersionString"]
assert remote["CFBundleVersion"] == info["CFBundleVersion"]
assert remote["MinimumOSVersion"] == "27.0"
assert remote["EXAppExtensionAttributes"]["EXExtensionPointIdentifier"] == "com.apple.nowplaying.remote-media"
assert remote["MusicBridgeSharedKeychainGroup"] == info["MusicBridgeSharedKeychainGroup"] == "ru.yukinoki.musicbridge.remote-control"
binary = archive.read(remote_path + remote["CFBundleExecutable"])
assert struct.unpack_from("<II", binary) == (0xFEEDFACF, 0x0100000C)
offset, platforms = 32, []
for _ in range(struct.unpack_from("<I", binary, 16)[0]):
command, size = struct.unpack_from("<II", binary, offset)
assert size >= 8 and offset + size <= len(binary)
if command == 0x32:
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
offset += size
assert platforms == [2], "Remote extension is not built for iOS devices"
print(f"PASS: {path.name}: valid IPA, iPhoneOS ARM64, iOS 17+, ru.yukinoki.musicbridge.")
print("PASS: embedded WidgetKit extension, matching version, ARM64 iPhoneOS and Live Activities support.")
print("Requires local signing with Sideloadly before installation on an iPhone.")
print("PASS: embedded iOS 27 NowPlaying ExtensionKit extension and matching shared Keychain metadata.")
print("Requires signing all extensions and shared Keychain entitlements before installation.")