Compare commits
11 Commits
e824a8e8eb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f90c6a912 | |||
| a3133465f9 | |||
| b14162b401 | |||
| 63fa120194 | |||
| 4748363905 | |||
| 91e305ada5 | |||
| eaf091b1cf | |||
| e2902e6031 | |||
| 2fbf3bf859 | |||
| 1e14bf72a2 | |||
| daf9751ccc |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -125,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", "581AD2E9199DAED730DC0F6E", "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";
|
||||
@@ -255,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";
|
||||
@@ -269,7 +361,7 @@
|
||||
"isa" = "PBXGroup";
|
||||
"path" = "MusicBridgeTests";
|
||||
"sourceTree" = "<group>";
|
||||
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
||||
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
||||
};
|
||||
"F16D05EC6B29248D2C61ADB1" = {
|
||||
"isa" = "PBXFileReference";
|
||||
@@ -301,7 +393,7 @@
|
||||
"72B74C8A2143F64FD53D71E5" = {
|
||||
"isa" = "PBXSourcesBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",);
|
||||
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "27227986C8A227728D784957", "0D17ED02E5B8770D785508A3", "2599D4B5F13D672B723A4BD3", "804FFB386BC1AF4252EDA177", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",);
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"734C4650C7A5604092763C32" = {
|
||||
@@ -323,6 +415,13 @@
|
||||
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
|
||||
};
|
||||
};
|
||||
"013486AD4616563D96D6E9AC" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "F1BB1F45195BEB71A550539C";
|
||||
"settings" = {
|
||||
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
|
||||
};
|
||||
};
|
||||
"AFC32963E7959E7A34264826" = {
|
||||
"isa" = "PBXCopyFilesBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
@@ -332,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";
|
||||
@@ -344,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";
|
||||
@@ -358,9 +478,10 @@
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
||||
"CURRENT_PROJECT_VERSION" = "7";
|
||||
"MARKETING_VERSION" = "0.6.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";
|
||||
@@ -380,9 +501,10 @@
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
||||
"CURRENT_PROJECT_VERSION" = "7";
|
||||
"MARKETING_VERSION" = "0.6.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";
|
||||
};
|
||||
};
|
||||
@@ -399,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";
|
||||
@@ -413,7 +535,7 @@
|
||||
"AB453A3F0E1490D48374A69A" = {
|
||||
"isa" = "PBXSourcesBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
|
||||
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",);
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"9AA2A4C344B5C474AF5D336C" = {
|
||||
@@ -534,8 +656,8 @@
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
|
||||
"CURRENT_PROJECT_VERSION" = "7";
|
||||
"MARKETING_VERSION" = "0.6.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";
|
||||
@@ -558,8 +680,8 @@
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
|
||||
"CURRENT_PROJECT_VERSION" = "7";
|
||||
"MARKETING_VERSION" = "0.6.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";
|
||||
@@ -584,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";
|
||||
@@ -635,7 +847,7 @@
|
||||
"productRefGroup" = "0A3E27B8CA818264D75C8D81";
|
||||
"projectDirPath" = "";
|
||||
"projectRoot" = "";
|
||||
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",);
|
||||
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC", "714DB4A269580A6F5127F5CC",);
|
||||
};
|
||||
};
|
||||
"rootObject" = "244210E48437B6556980A702";
|
||||
|
||||
@@ -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,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,6 +21,25 @@ 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() }
|
||||
|
||||
@@ -72,5 +72,7 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>MusicBridgeSharedKeychainGroup</key>
|
||||
<string>ru.yukinoki.musicbridge.remote-control</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -119,7 +119,7 @@ struct Invitation: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct Capabilities: Decodable {
|
||||
struct Capabilities: Codable {
|
||||
let play, pause, toggle, next, previous, seek: Bool
|
||||
}
|
||||
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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
|
||||
@@ -273,6 +276,7 @@ final class PlayerStore: ObservableObject {
|
||||
|
||||
private func revokeAccess() {
|
||||
liveActivity?.end()
|
||||
systemPlayer?.end()
|
||||
stop()
|
||||
needsPairing = true
|
||||
status = "Нужно повторное сопряжение"
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 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")
|
||||
}
|
||||
}
|
||||
@@ -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; не менять серверный протокол молча.
|
||||
@@ -49,7 +126,7 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
|
||||
- [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0.
|
||||
- [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone.
|
||||
- [ ] Команды Siri.
|
||||
- [ ] NowPlaying RemoteMediaSession (iOS 27+): перейти на SDK 27, добавить remote-media extension и проверить системный Control Center.
|
||||
- [x] NowPlaying RemoteMediaSession (iOS 27+): SDK 27 и remote-media extension реализованы в 0.7.0; установка и физическая проверка — в текущем этапе выше.
|
||||
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
|
||||
|
||||
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
|
||||
|
||||
@@ -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 подключён:
|
||||
@@ -69,7 +89,7 @@ Apple ID и сертификаты для этой проверки не тре
|
||||
|
||||
Пользователь также подтвердил установку через iloader. Для повторной установки
|
||||
с Live Activity используйте версию **0.6.0 (build 7)**:
|
||||
`build/iPhone-0.6.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
|
||||
`build/iPhone-0.6.0-xcode27/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
|
||||
подпишите тем же Apple Account, которым установлена первая версия.
|
||||
|
||||
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
|
||||
@@ -141,8 +161,7 @@ play/pause, следующий трек и обновление. Компакт
|
||||
|
||||
Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+)
|
||||
для отображения музыки внешнего устройства на экране блокировки и в Control Center.
|
||||
Это подходит архитектуре MusicBridge. Нужны SDK/Xcode 27 и отдельное расширение
|
||||
`com.apple.nowplaying.remote-media`; текущая сборка использует Xcode 16.4.
|
||||
Это подходит архитектуре 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).
|
||||
@@ -155,3 +174,18 @@ Apple предлагает `RemoteMediaSession` в новом NowPlaying framewo
|
||||
- [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.
|
||||
|
||||
@@ -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)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.")
|
||||
}
|
||||
}
|
||||
@@ -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("Плеер на ПК не поддерживает эту команду.")
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-11
@@ -37,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
|
||||
@@ -54,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 = []
|
||||
@@ -71,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="7", MARKETING_VERSION="0.6.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="7", MARKETING_VERSION="0.6.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"],
|
||||
@@ -144,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.")
|
||||
|
||||
@@ -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 окончательно подтверждается только установкой на устройстве.
|
||||
@@ -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('/')
|
||||
{
|
||||
@@ -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());
|
||||
+ }
|
||||
+}
|
||||
@@ -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.")
|
||||
@@ -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"
|
||||
@@ -167,6 +170,18 @@ 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)
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user