Compare commits

..

11 Commits

27 changed files with 1266 additions and 47 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: jobs:
simulator: simulator:
runs-on: macos-15 runs-on: xcode-27
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -24,6 +24,7 @@ jobs:
- name: Inspect toolchain and project - name: Inspect toolchain and project
run: | run: |
xcodebuild -version xcodebuild -version
xcodebuild -version | head -n 1 | grep -E "^Xcode 27([.]|$)"
swift --version swift --version
python3 tools/validate_project.py python3 tools/validate_project.py
xcodebuild -list -project MusicBridge.xcodeproj xcodebuild -list -project MusicBridge.xcodeproj
@@ -36,7 +37,9 @@ jobs:
run: | run: |
set -o pipefail set -o pipefail
mkdir -p build 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=$! TLS_PID=$!
trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT
for attempt in {1..30}; do for attempt in {1..30}; do
@@ -94,7 +97,7 @@ jobs:
sleep 1 sleep 1
xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-light-screen.png 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)))') 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 bootstatus "$COMPACT_ID" -b
xcrun simctl install "$COMPACT_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app xcrun simctl install "$COMPACT_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app
xcrun simctl launch "$COMPACT_ID" ru.yukinoki.musicbridge --preview-player xcrun simctl launch "$COMPACT_ID" ru.yukinoki.musicbridge --preview-player
+229 -17
View File
@@ -125,11 +125,93 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "7148B52E915554F67E875C26"; "fileRef" = "7148B52E915554F67E875C26";
}; };
"088AC623B345717D980B3156" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerManager.swift";
"sourceTree" = "<group>";
};
"27227986C8A227728D784957" = {
"isa" = "PBXBuildFile";
"fileRef" = "088AC623B345717D980B3156";
};
"026F39792BBA0E5032536CED" = { "026F39792BBA0E5032536CED" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridge"; "path" = "MusicBridge";
"sourceTree" = "<group>"; "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" = { "7C5E653CCD928B143AB68F09" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -255,6 +337,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "464F6C65BC2FAB6BCCC77F46"; "fileRef" = "464F6C65BC2FAB6BCCC77F46";
}; };
"7749A66489642A16382E8E42" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerTests.swift";
"sourceTree" = "<group>";
};
"340F55A9E43E8FFA66453825" = {
"isa" = "PBXBuildFile";
"fileRef" = "7749A66489642A16382E8E42";
};
"8BED860F1BD59C500D215E8D" = { "8BED860F1BD59C500D215E8D" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -269,7 +361,7 @@
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridgeTests"; "path" = "MusicBridgeTests";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); "children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
}; };
"F16D05EC6B29248D2C61ADB1" = { "F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -301,7 +393,7 @@
"72B74C8A2143F64FD53D71E5" = { "72B74C8A2143F64FD53D71E5" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "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; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"734C4650C7A5604092763C32" = { "734C4650C7A5604092763C32" = {
@@ -323,6 +415,13 @@
"ATTRIBUTES" = ("RemoveHeadersOnCopy",); "ATTRIBUTES" = ("RemoveHeadersOnCopy",);
}; };
}; };
"013486AD4616563D96D6E9AC" = {
"isa" = "PBXBuildFile";
"fileRef" = "F1BB1F45195BEB71A550539C";
"settings" = {
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
};
};
"AFC32963E7959E7A34264826" = { "AFC32963E7959E7A34264826" = {
"isa" = "PBXCopyFilesBuildPhase"; "isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
@@ -332,6 +431,15 @@
"name" = "Embed App Extensions"; "name" = "Embed App Extensions";
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"B2EC54BE7DEB4C17571E7035" = {
"isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647;
"dstPath" = "$(EXTENSIONS_FOLDER_PATH)";
"dstSubfolderSpec" = 16;
"files" = ("013486AD4616563D96D6E9AC",);
"name" = "Embed ExtensionKit Extensions";
"runOnlyForDeploymentPostprocessing" = 0;
};
"01E77404E10F254C43E64C55" = { "01E77404E10F254C43E64C55" = {
"isa" = "PBXContainerItemProxy"; "isa" = "PBXContainerItemProxy";
"containerPortal" = "244210E48437B6556980A702"; "containerPortal" = "244210E48437B6556980A702";
@@ -344,6 +452,18 @@
"target" = "BFE9A9DC8C194372006C3ACC"; "target" = "BFE9A9DC8C194372006C3ACC";
"targetProxy" = "01E77404E10F254C43E64C55"; "targetProxy" = "01E77404E10F254C43E64C55";
}; };
"E73C0469E1928499B9BE1AC7" = {
"isa" = "PBXContainerItemProxy";
"containerPortal" = "244210E48437B6556980A702";
"proxyType" = 1;
"remoteGlobalIDString" = "714DB4A269580A6F5127F5CC";
"remoteInfo" = "MusicBridgeRemote";
};
"F76CBC167D5B68BDC0376A94" = {
"isa" = "PBXTargetDependency";
"target" = "714DB4A269580A6F5127F5CC";
"targetProxy" = "E73C0469E1928499B9BE1AC7";
};
"E3322BA79DBD9A84650B2CAE" = { "E3322BA79DBD9A84650B2CAE" = {
"isa" = "XCBuildConfiguration"; "isa" = "XCBuildConfiguration";
"name" = "Debug"; "name" = "Debug";
@@ -358,9 +478,10 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "7"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.6.0"; "MARKETING_VERSION" = "0.7.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES"; "ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
@@ -380,9 +501,10 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "7"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.6.0"; "MARKETING_VERSION" = "0.7.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
}; };
}; };
@@ -399,9 +521,9 @@
"productReference" = "7C35A0276A762371ABAE5596"; "productReference" = "7C35A0276A762371ABAE5596";
"productType" = "com.apple.product-type.application"; "productType" = "com.apple.product-type.application";
"buildConfigurationList" = "C6336ED5908EABEBF60AE675"; "buildConfigurationList" = "C6336ED5908EABEBF60AE675";
"buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826",); "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826", "B2EC54BE7DEB4C17571E7035",);
"buildRules" = (); "buildRules" = ();
"dependencies" = ("A4C2D386AB0259F33DCDC427",); "dependencies" = ("A4C2D386AB0259F33DCDC427", "F76CBC167D5B68BDC0376A94",);
}; };
"5E4BE508157A7B2CB7B4C2F2" = { "5E4BE508157A7B2CB7B4C2F2" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -413,7 +535,7 @@
"AB453A3F0E1490D48374A69A" = { "AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); "files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"9AA2A4C344B5C474AF5D336C" = { "9AA2A4C344B5C474AF5D336C" = {
@@ -534,8 +656,8 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "7"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.6.0"; "MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES"; "APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES"; "SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
@@ -558,8 +680,8 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "7"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.6.0"; "MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES"; "APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES"; "SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
@@ -584,16 +706,106 @@
"buildRules" = (); "buildRules" = ();
"dependencies" = (); "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" = { "0A3E27B8CA818264D75C8D81" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"name" = "Products"; "name" = "Products";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F",); "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F", "F1BB1F45195BEB71A550539C",);
}; };
"0D6E4079E36703EBD37C0072" = { "0D6E4079E36703EBD37C0072" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); "children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "D2DF40467DEC980974060FD5", "E2D7CC5D9CE02F4A7B8243D8", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",);
}; };
"73EBF6644258C120893E685D" = { "73EBF6644258C120893E685D" = {
"isa" = "XCBuildConfiguration"; "isa" = "XCBuildConfiguration";
@@ -635,7 +847,7 @@
"productRefGroup" = "0A3E27B8CA818264D75C8D81"; "productRefGroup" = "0A3E27B8CA818264D75C8D81";
"projectDirPath" = ""; "projectDirPath" = "";
"projectRoot" = ""; "projectRoot" = "";
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",); "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC", "714DB4A269580A6F5127F5CC",);
}; };
}; };
"rootObject" = "244210E48437B6556980A702"; "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>
+20
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
@ObservedObject var liveActivity: LiveActivityManager @ObservedObject var liveActivity: LiveActivityManager
@ObservedObject var systemPlayer: SystemPlayerManager
@State private var showForget = false @State private var showForget = false
var body: some View { var body: some View {
@@ -20,6 +21,25 @@ struct ContentView: View {
} else { } else {
PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30)))) 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) { VStack(alignment: .leading, spacing: 10) {
Button { Button {
if liveActivity.running { liveActivity.end() } if liveActivity.running { liveActivity.end() }
+2
View File
@@ -72,5 +72,7 @@
</dict> </dict>
</dict> </dict>
</dict> </dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>ru.yukinoki.musicbridge.remote-control</string>
</dict> </dict>
</plist> </plist>
+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 let play, pause, toggle, next, previous, seek: Bool
} }
+4 -1
View File
@@ -4,6 +4,7 @@ import SwiftUI
struct MusicBridgeApp: App { struct MusicBridgeApp: App {
@StateObject private var store = PreviewSupport.makeStore() @StateObject private var store = PreviewSupport.makeStore()
@StateObject private var liveActivity = LiveActivityManager() @StateObject private var liveActivity = LiveActivityManager()
@StateObject private var systemPlayer = SystemPlayerManager()
@Environment(\.scenePhase) private var phase @Environment(\.scenePhase) private var phase
var body: some Scene { var body: some Scene {
@@ -20,13 +21,15 @@ struct MusicBridgeApp: App {
}.padding() }.padding()
} }
} else { } else {
ContentView(store: store, liveActivity: liveActivity) ContentView(store: store, liveActivity: liveActivity, systemPlayer: systemPlayer)
} }
} }
.dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5) .dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
.onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } } .onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } }
.task { .task {
store.liveActivity = liveActivity store.liveActivity = liveActivity
store.systemPlayer = systemPlayer
if !PreviewSupport.enabled { await systemPlayer.restore(connection: store.connection) }
if !PreviewSupport.enabled { store.setActive(phase == .active) } if !PreviewSupport.enabled { store.setActive(phase == .active) }
} }
.onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } } .onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
+4
View File
@@ -15,6 +15,7 @@ final class PlayerStore: ObservableObject {
@Published var incomingInvitation: String? @Published var incomingInvitation: String?
let discovery = DiscoveryStore() let discovery = DiscoveryStore()
var liveActivity: LiveActivityManager? var liveActivity: LiveActivityManager?
var systemPlayer: SystemPlayerManager?
private(set) var receivedAt = Date() private(set) var receivedAt = Date()
private var client: BridgeClient? private var client: BridgeClient?
private var runner: Task<Void, Never>? private var runner: Task<Void, Never>?
@@ -170,6 +171,7 @@ final class PlayerStore: ObservableObject {
connected = true connected = true
status = "Подключено" status = "Подключено"
liveActivity?.receive(snapshot) liveActivity?.receive(snapshot)
if let connection { systemPlayer?.receive(snapshot, connection: connection) }
guard artworkID != snapshot.artworkId else { return } guard artworkID != snapshot.artworkId else { return }
artworkID = snapshot.artworkId artworkID = snapshot.artworkId
artworkTask?.cancel() artworkTask?.cancel()
@@ -248,6 +250,7 @@ final class PlayerStore: ObservableObject {
do { try storage.delete() } do { try storage.delete() }
catch { self.error = error.localizedDescription; return } catch { self.error = error.localizedDescription; return }
liveActivity?.end() liveActivity?.end()
systemPlayer?.end()
stop() stop()
cancelPairing() cancelPairing()
needsPairing = false needsPairing = false
@@ -273,6 +276,7 @@ final class PlayerStore: ObservableObject {
private func revokeAccess() { private func revokeAccess() {
liveActivity?.end() liveActivity?.end()
systemPlayer?.end()
stop() stop()
needsPairing = true needsPairing = true
status = "Нужно повторное сопряжение" 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>
+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")
}
}
+80 -3
View File
@@ -1,10 +1,87 @@
# MusicBridge iOS — план # MusicBridge iOS — план
Обновлено: 2026-09-13. Пользователь разрешил начать нативное iOS-приложение. Обновлено: 2026-09-14. Пользователь разрешил начать нативное iOS-приложение.
Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios. Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios.
Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`. 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. - SwiftUI, без веб-клиента. Музыка играет на Windows.
- Сверять контракт с Windows PROTOCOL.md; не менять серверный протокол молча. - Сверять контракт с Windows PROTOCOL.md; не менять серверный протокол молча.
@@ -49,7 +126,7 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0. - [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0.
- [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone. - [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone.
- [ ] Команды Siri. - [ ] Команды 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: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне. - [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
+39 -5
View File
@@ -23,7 +23,27 @@ Windows-агентом и подключение по QR. В 0.4.0 добавл
## Сборка на Mac ## Сборка на 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 запускает приложение, 2. Выбрать схему MusicBridge и симулятор iPhone. Run запускает приложение,
Test выполняет `MusicBridgeTests`. Test выполняет `MusicBridgeTests`.
3. Для физического iPhone выбрать свою Team в Signing & Capabilities. Сертификаты 3. Для физического iPhone выбрать свою Team в Signing & Capabilities. Сертификаты
@@ -38,7 +58,7 @@ Windows-агентом и подключение по QR. В 0.4.0 добавл
## GitHub Actions ## GitHub Actions
`.github/workflows/ios.yml` собирает приложение и выполняет XCTest на `macos-15` `.github/workflows/ios.yml` собирает приложение и выполняет XCTest на `xcode-27`
при push в main, pull request или ручном запуске. Сохраняет лог, `.xcresult` при push в main, pull request или ручном запуске. Сохраняет лог, `.xcresult`
и ZIP приложения **для симулятора**, который нельзя установить на физический iPhone. и ZIP приложения **для симулятора**, который нельзя установить на физический iPhone.
Apple ID и сертификаты для этой проверки не требуются. GitHub remote подключён: Apple ID и сертификаты для этой проверки не требуются. GitHub remote подключён:
@@ -69,7 +89,7 @@ Apple ID и сертификаты для этой проверки не тре
Пользователь также подтвердил установку через iloader. Для повторной установки Пользователь также подтвердил установку через iloader. Для повторной установки
с Live Activity используйте версию **0.6.0 (build 7)**: с 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, которым установлена первая версия. подпишите тем же Apple Account, которым установлена первая версия.
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
@@ -141,8 +161,7 @@ play/pause, следующий трек и обновление. Компакт
Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+) Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+)
для отображения музыки внешнего устройства на экране блокировки и в Control Center. для отображения музыки внешнего устройства на экране блокировки и в Control Center.
Это подходит архитектуре MusicBridge. Нужны SDK/Xcode 27 и отдельное расширение Это подходит архитектуре MusicBridge. Нужно отдельное расширение `com.apple.nowplaying.remote-media`. Сборка переведена на Xcode 27; 32 теста и Release archive прошли.
`com.apple.nowplaying.remote-media`; текущая сборка использует Xcode 16.4.
Эта интеграция исследована, но ещё не реализована. Для фоновых обновлений удалённых Эта интеграция исследована, но ещё не реализована. Для фоновых обновлений удалённых
сессий Apple также предоставляет push-механизм. сессий Apple также предоставляет push-механизм.
[Документация remote media sessions](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions). [Документация 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) - [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions)
План и ограничения — в `PLAN.md`. Копия протокола агента — в `PROTOCOL.md`. План и ограничения — в `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("Плеер на ПК не поддерживает эту команду.")
}
}
}
+40 -11
View File
@@ -37,6 +37,13 @@ def source_group(folder):
return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds
app_group, app_sources = source_group("MusicBridge") 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") shared_group, shared_sources = source_group("Shared")
widget_group, widget_sources = source_group("MusicBridgeWidgets") widget_group, widget_sources = source_group("MusicBridgeWidgets")
app_sources += shared_sources 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) tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture)
products = [] products = []
targets = [] 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" 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", is_remote = name == "MusicBridgeRemote"
includeInIndex=0, path=name + (".appex" if is_widget else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR") 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) products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0), phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], 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)] 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" + (".widgets" if is_widget else ".tests" if is_test else ""), 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="17.0", SDKROOT="iphoneos", 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", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
GENERATE_INFOPLIST_FILE="YES" if is_test else "NO") GENERATE_INFOPLIST_FILE="YES" if is_test else "NO")
deps = [] 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)") 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") 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)] deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
elif is_widget: elif is_extension:
settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0", settings.update(INFOPLIST_FILE=name + "/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0",
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES", APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks") LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
if is_remote:
settings.update(CODE_SIGN_ENTITLEMENTS="MusicBridgeRemote/Remote.entitlements")
else: 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 = 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, phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0)) 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, proxy = add("widget-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets") remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets")
deps = [add("widget-dependency", "PBXTargetDependency", target=uid("MusicBridgeWidgetstarget"), targetProxy=proxy)] 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, 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)) buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products) 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"), project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"),
buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")), buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")),
compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"], 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)", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension")) NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension"))
(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False)) (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.") 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.")
+18 -3
View File
@@ -13,6 +13,9 @@ import subprocess
import threading import threading
import time import time
import zlib import zlib
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
work = root / "build/tls" work = root / "build/tls"
@@ -167,9 +170,21 @@ for name, days in (("valid", "1825"), ("expired", "-1")):
key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem")) key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem"))
subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", str(config), 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) "-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", if name == "expired":
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)], private_key = serialization.load_pem_private_key(key.read_bytes(), password=None)
check=True, capture_output=True, timeout=15) 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 = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2 context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(cert, key) 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] root = pathlib.Path(__file__).resolve().parents[1]
generated = [root / "MusicBridge.xcodeproj/project.pbxproj", generated = [root / "MusicBridge.xcodeproj/project.pbxproj",
root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme", 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] before = [p.read_bytes() for p in generated]
subprocess.run([sys.executable, str(root / "tools/generate_project.py")], check=True) 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." 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") 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"): for source in (root / folder).glob("*.swift"):
key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper() key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper()
assert key in project, f"Missing source: {source.name}" 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]) platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
offset += size offset += size
assert platforms == [2], "Widget is not built for iOS devices" 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(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("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.")