Compare commits
14 Commits
fcc66aa328
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f90c6a912 | |||
| a3133465f9 | |||
| b14162b401 | |||
| 63fa120194 | |||
| 4748363905 | |||
| 91e305ada5 | |||
| eaf091b1cf | |||
| e2902e6031 | |||
| 2fbf3bf859 | |||
| 1e14bf72a2 | |||
| daf9751ccc | |||
| e824a8e8eb | |||
| 43c78b2a99 | |||
| 1e02247cbe |
@@ -0,0 +1,64 @@
|
|||||||
|
name: Build MusicBridge compatible iloader
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: nab138/iloader
|
||||||
|
ref: 348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
|
||||||
|
path: build/compat/iloader
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: nab138/isideload
|
||||||
|
ref: f6a4d5dba717d72fc2af63eaba26b27ba44116be
|
||||||
|
path: build/compat/isideload
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: nab138/apple-crates
|
||||||
|
ref: 5da0b8df9b202434b1eff2700059b1ee142592ac
|
||||||
|
path: build/compat/apple-crates
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
- name: Install native build prerequisites
|
||||||
|
run: choco install strawberryperl make --no-progress -y
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
- name: Patch pinned sources
|
||||||
|
run: python tools/iloader/prepare.py build/compat
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: build/compat/iloader
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Test extension discovery, bundle IDs and signing permissions
|
||||||
|
working-directory: build/compat/iloader/src-tauri
|
||||||
|
run: |
|
||||||
|
cargo test -p isideload --lib musicbridge
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
cargo test -p apple-codesign --lib musicbridge
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
- name: Build portable GUI
|
||||||
|
working-directory: build/compat/iloader
|
||||||
|
run: bunx tauri build --no-bundle --config src-tauri/ci.conf.json
|
||||||
|
- name: Package helper with provenance
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force build/iloader-musicbridge
|
||||||
|
Copy-Item build/compat/iloader/src-tauri/target/release/iloader.exe build/iloader-musicbridge/iloader-musicbridge.exe
|
||||||
|
Copy-Item tools/iloader/README.md build/iloader-musicbridge/README.md
|
||||||
|
Get-ChildItem build/compat/iloader -Filter 'LICENSE*' | Copy-Item -Destination build/iloader-musicbridge
|
||||||
|
Get-FileHash build/iloader-musicbridge/iloader-musicbridge.exe | Format-List | Out-File build/iloader-musicbridge/SHA256.txt
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iloader-musicbridge-windows
|
||||||
|
path: build/iloader-musicbridge
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -15,7 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
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
|
||||||
|
|||||||
@@ -55,6 +55,16 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "76FB6BFFC478599567424755";
|
"fileRef" = "76FB6BFFC478599567424755";
|
||||||
};
|
};
|
||||||
|
"581AD2E9199DAED730DC0F6E" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "LiveActivityCommands.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"D6D68067600EEBE36972550E" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "581AD2E9199DAED730DC0F6E";
|
||||||
|
};
|
||||||
"EE2357BD5CA3755AFEB3171A" = {
|
"EE2357BD5CA3755AFEB3171A" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "sourcecode.swift";
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
@@ -115,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", "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";
|
||||||
@@ -141,11 +233,21 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "7C0FE298B442263CA281ABB7";
|
"fileRef" = "7C0FE298B442263CA281ABB7";
|
||||||
};
|
};
|
||||||
|
"6EDC1D9A7204CF776CE3B281" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "MusicControlIntent.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"962BED0D8E17961AD72EC24F" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "6EDC1D9A7204CF776CE3B281";
|
||||||
|
};
|
||||||
"E3C4B39D6D5013477C98CD58" = {
|
"E3C4B39D6D5013477C98CD58" = {
|
||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "Shared";
|
"path" = "Shared";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",);
|
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7", "6EDC1D9A7204CF776CE3B281",);
|
||||||
};
|
};
|
||||||
"ADFE24674E0FB119E649EC54" = {
|
"ADFE24674E0FB119E649EC54" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -171,6 +273,10 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "7C0FE298B442263CA281ABB7";
|
"fileRef" = "7C0FE298B442263CA281ABB7";
|
||||||
};
|
};
|
||||||
|
"41D7F12D1C7D61DAF319E724" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "6EDC1D9A7204CF776CE3B281";
|
||||||
|
};
|
||||||
"8BF729FFE074CAEE622C0292" = {
|
"8BF729FFE074CAEE622C0292" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "folder.assetcatalog";
|
"lastKnownFileType" = "folder.assetcatalog";
|
||||||
@@ -191,6 +297,16 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "C9F705D432CA45B6056BC572";
|
"fileRef" = "C9F705D432CA45B6056BC572";
|
||||||
};
|
};
|
||||||
|
"65CE96DA4162DE4255412BC7" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "LiveActivityCommandTests.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"E87BA6EF1948C84AC29D8EA8" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "65CE96DA4162DE4255412BC7";
|
||||||
|
};
|
||||||
"9819FF7CC21CAAD1BDB8D1B8" = {
|
"9819FF7CC21CAAD1BDB8D1B8" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "sourcecode.swift";
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
@@ -221,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";
|
||||||
@@ -235,7 +361,7 @@
|
|||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "MusicBridgeTests";
|
"path" = "MusicBridgeTests";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
||||||
};
|
};
|
||||||
"F16D05EC6B29248D2C61ADB1" = {
|
"F16D05EC6B29248D2C61ADB1" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -267,7 +393,7 @@
|
|||||||
"72B74C8A2143F64FD53D71E5" = {
|
"72B74C8A2143F64FD53D71E5" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF",);
|
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "27227986C8A227728D784957", "0D17ED02E5B8770D785508A3", "2599D4B5F13D672B723A4BD3", "804FFB386BC1AF4252EDA177", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"734C4650C7A5604092763C32" = {
|
"734C4650C7A5604092763C32" = {
|
||||||
@@ -289,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;
|
||||||
@@ -298,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";
|
||||||
@@ -310,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";
|
||||||
@@ -324,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" = "6";
|
"CURRENT_PROJECT_VERSION" = "8";
|
||||||
"MARKETING_VERSION" = "0.5.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";
|
||||||
@@ -346,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" = "6";
|
"CURRENT_PROJECT_VERSION" = "8";
|
||||||
"MARKETING_VERSION" = "0.5.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";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -365,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";
|
||||||
@@ -379,7 +535,7 @@
|
|||||||
"AB453A3F0E1490D48374A69A" = {
|
"AB453A3F0E1490D48374A69A" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
|
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"9AA2A4C344B5C474AF5D336C" = {
|
"9AA2A4C344B5C474AF5D336C" = {
|
||||||
@@ -471,7 +627,7 @@
|
|||||||
"FF6BDEA70DDE9A3C7EEA9DCF" = {
|
"FF6BDEA70DDE9A3C7EEA9DCF" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E",);
|
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E", "41D7F12D1C7D61DAF319E724",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"49C806A88B3E70B1004AB10D" = {
|
"49C806A88B3E70B1004AB10D" = {
|
||||||
@@ -500,14 +656,14 @@
|
|||||||
"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" = "6";
|
"CURRENT_PROJECT_VERSION" = "8";
|
||||||
"MARKETING_VERSION" = "0.5.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";
|
||||||
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
||||||
"ENABLE_TESTABILITY" = "YES";
|
"ENABLE_TESTABILITY" = "YES";
|
||||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG MUSICBRIDGE_WIDGET";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
"6A281BFCCFF96276806F5934" = {
|
"6A281BFCCFF96276806F5934" = {
|
||||||
@@ -524,12 +680,13 @@
|
|||||||
"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" = "6";
|
"CURRENT_PROJECT_VERSION" = "8";
|
||||||
"MARKETING_VERSION" = "0.5.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";
|
||||||
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = " MUSICBRIDGE_WIDGET";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
"5DCAC255CDC0CA72231320F4" = {
|
"5DCAC255CDC0CA72231320F4" = {
|
||||||
@@ -549,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";
|
||||||
@@ -600,7 +847,7 @@
|
|||||||
"productRefGroup" = "0A3E27B8CA818264D75C8D81";
|
"productRefGroup" = "0A3E27B8CA818264D75C8D81";
|
||||||
"projectDirPath" = "";
|
"projectDirPath" = "";
|
||||||
"projectRoot" = "";
|
"projectRoot" = "";
|
||||||
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",);
|
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC", "714DB4A269580A6F5127F5CC",);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
"rootObject" = "244210E48437B6556980A702";
|
"rootObject" = "244210E48437B6556980A702";
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -59,11 +59,11 @@ final class BridgeClient {
|
|||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private var socket: URLSessionWebSocketTask?
|
private var socket: URLSessionWebSocketTask?
|
||||||
|
|
||||||
init(_ connection: Connection) {
|
init(_ connection: Connection, timeout: TimeInterval = 10) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
let config = URLSessionConfiguration.ephemeral
|
let config = URLSessionConfiguration.ephemeral
|
||||||
config.timeoutIntervalForRequest = 10
|
config.timeoutIntervalForRequest = timeout
|
||||||
config.timeoutIntervalForResource = 15
|
config.timeoutIntervalForResource = timeout + 5
|
||||||
config.urlCache = nil
|
config.urlCache = nil
|
||||||
config.httpCookieStorage = nil
|
config.httpCookieStorage = nil
|
||||||
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
|
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
|
||||||
|
|||||||
@@ -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,14 +21,33 @@ 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() }
|
||||||
else if let state = store.state { liveActivity.start(state) }
|
else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) }
|
||||||
} label: {
|
} label: {
|
||||||
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
|
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
|
||||||
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
|
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
|
||||||
Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.")
|
Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
|
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
|
||||||
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
|
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
|
||||||
|
|||||||
@@ -72,5 +72,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>MusicBridgeSharedKeychainGroup</key>
|
||||||
|
<string>ru.yukinoki.musicbridge.remote-control</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ enum KeychainStore {
|
|||||||
|
|
||||||
static func save(_ connection: Connection) throws {
|
static func save(_ connection: Connection) throws {
|
||||||
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||||
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||||
if status == errSecItemNotFound {
|
if status == errSecItemNotFound {
|
||||||
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum LiveActivityCommands {
|
||||||
|
private static var busy = false
|
||||||
|
static let bindingKey = "MusicBridge.liveActivityPC"
|
||||||
|
|
||||||
|
static func perform(_ action: MusicRemoteAction, activityID: String) async {
|
||||||
|
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
|
||||||
|
activity.activityState == .active || activity.activityState == .stale else { return }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
guard let saved = try KeychainStore.load(),
|
||||||
|
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
|
||||||
|
throw BridgeError.message("Откройте приложение и включите карточку заново.")
|
||||||
|
}
|
||||||
|
let client = BridgeClient(saved, timeout: 5)
|
||||||
|
defer { client.close() }
|
||||||
|
let fresh = try await execute(action, client: client) {
|
||||||
|
(activity.activityState == .active || activity.activityState == .stale) &&
|
||||||
|
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
|
||||||
|
}
|
||||||
|
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
|
||||||
|
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
|
||||||
|
} catch {
|
||||||
|
if case BridgeError.unauthorized = error {
|
||||||
|
await activity.end(nil, dismissalPolicy: .immediate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var value = activity.content.state
|
||||||
|
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
|
||||||
|
await activity.update(ActivityContent(state: value, staleDate: Date()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
|
||||||
|
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
|
||||||
|
stillValid: () -> Bool = { true }) async throws -> MediaState {
|
||||||
|
let before = try await client.state()
|
||||||
|
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
|
||||||
|
if action == .refresh { return before }
|
||||||
|
guard before.hasSession else { return before }
|
||||||
|
let command = try command(action, state: before)
|
||||||
|
do { try await client.command(Command(type: command, value: nil)) }
|
||||||
|
catch {
|
||||||
|
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
do { return try await client.state() }
|
||||||
|
catch {
|
||||||
|
if case BridgeError.unauthorized = error { throw error }
|
||||||
|
throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String {
|
||||||
|
switch action {
|
||||||
|
case .previous where state.capabilities.previous: return "previous"
|
||||||
|
case .next where state.capabilities.next: return "next"
|
||||||
|
case .toggle where state.capabilities.toggle: return "toggle"
|
||||||
|
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
|
||||||
|
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
|
||||||
|
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,18 +32,22 @@ final class LiveActivityManager: ObservableObject {
|
|||||||
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
|
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func start(_ state: MediaState) {
|
func start(_ state: MediaState, connection: Connection) {
|
||||||
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
|
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
|
||||||
|
guard connection.token != nil else { error = "Сначала выполните сопряжение с ПК."; return }
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||||
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
|
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
|
||||||
|
try KeychainStore.save(connection)
|
||||||
let value = Self.content(state)
|
let value = Self.content(state)
|
||||||
let new = try Activity.request(attributes: MusicActivityAttributes(),
|
let new = try Activity.request(attributes: MusicActivityAttributes(),
|
||||||
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
|
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
|
||||||
pushType: nil)
|
pushType: nil)
|
||||||
activity = new
|
activity = new
|
||||||
|
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
|
||||||
last = value
|
last = value
|
||||||
running = true
|
running = true
|
||||||
error = nil
|
error = nil
|
||||||
@@ -72,6 +76,7 @@ final class LiveActivityManager: ObservableObject {
|
|||||||
|
|
||||||
func end() {
|
func end() {
|
||||||
guard let activity else { return }
|
guard let activity else { return }
|
||||||
|
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
|
||||||
self.activity = nil
|
self.activity = nil
|
||||||
running = false
|
running = false
|
||||||
last = nil
|
last = nil
|
||||||
|
|||||||
@@ -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,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) } }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -257,7 +260,6 @@ final class PlayerStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func stop() {
|
private func stop() {
|
||||||
liveActivity?.unavailable()
|
|
||||||
generation = UUID()
|
generation = UUID()
|
||||||
runner?.cancel()
|
runner?.cancel()
|
||||||
runner = nil
|
runner = nil
|
||||||
@@ -274,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 = "Нужно повторное сопряжение"
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import NowPlaying
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SystemPlayerManager: ObservableObject {
|
||||||
|
@Published private(set) var running = false
|
||||||
|
@Published private(set) var busy = false
|
||||||
|
@Published private(set) var error: String?
|
||||||
|
private var storage: Any?
|
||||||
|
private var id: String?
|
||||||
|
private var generation = UUID()
|
||||||
|
private var updates: Task<Void, Never>?
|
||||||
|
private var lastUpdate = Date.distantPast
|
||||||
|
private var lastTrack = ""
|
||||||
|
private var lastPlaying = false
|
||||||
|
private let savedIDKey = "MusicBridge.systemPlayerSession"
|
||||||
|
|
||||||
|
func start(state: MediaState, connection: Connection) async {
|
||||||
|
guard !busy, !running, state.hasSession, connection.token != nil,
|
||||||
|
UIApplication.shared.applicationState == .active else { return }
|
||||||
|
guard #available(iOS 27.0, *) else { error = "Системный пульт требует iOS 27."; return }
|
||||||
|
busy = true; error = nil
|
||||||
|
let operation = generation
|
||||||
|
let newID = UUID().uuidString
|
||||||
|
id = newID
|
||||||
|
defer { if generation == operation { busy = false } }
|
||||||
|
do {
|
||||||
|
try SystemPlayerCredentials.save(connection, id: newID)
|
||||||
|
_ = try SystemPlayerCredentials.load(newID)
|
||||||
|
UserDefaults.standard.set(newID, forKey: savedIDKey)
|
||||||
|
let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state))
|
||||||
|
guard generation == operation else { try? await session.end(); return }
|
||||||
|
storage = session
|
||||||
|
try await session.requestToBecomeSystemPrimary()
|
||||||
|
guard generation == operation else { try? await session.end(); return }
|
||||||
|
running = true
|
||||||
|
UserDefaults.standard.set(newID, forKey: savedIDKey)
|
||||||
|
} catch {
|
||||||
|
guard generation == operation else { return }
|
||||||
|
self.error = "Не удалось включить системный пульт: " + BridgeError.describe(error)
|
||||||
|
end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(connection: Connection?) async {
|
||||||
|
guard #available(iOS 27.0, *), id == nil, !busy,
|
||||||
|
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
|
||||||
|
busy = true
|
||||||
|
id = savedID
|
||||||
|
let operation = generation
|
||||||
|
defer { if generation == operation { busy = false } }
|
||||||
|
do {
|
||||||
|
let saved = try SystemPlayerCredentials.load(savedID)
|
||||||
|
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
|
||||||
|
try SystemPlayerCredentials.delete(savedID)
|
||||||
|
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||||
|
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
|
||||||
|
if generation == operation { id = nil }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
|
||||||
|
guard generation == operation else { return }
|
||||||
|
storage = session; id = savedID; running = true
|
||||||
|
} else {
|
||||||
|
guard generation == operation else { return }
|
||||||
|
try SystemPlayerCredentials.delete(savedID)
|
||||||
|
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||||
|
id = nil
|
||||||
|
}
|
||||||
|
} catch { if generation == operation { self.error = BridgeError.describe(error); id = nil } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive(_ state: MediaState, connection: Connection) {
|
||||||
|
guard #available(iOS 27.0, *), running, let id,
|
||||||
|
let session = storage as? RemoteMediaSession<SystemPlayerAttributes> else { return }
|
||||||
|
guard state.hasSession else { end(); return }
|
||||||
|
let track = state.title + "\n" + state.artist
|
||||||
|
let playing = state.playbackStatus == "Playing"
|
||||||
|
guard track != lastTrack || playing != lastPlaying || Date().timeIntervalSince(lastUpdate) >= 5 else { return }
|
||||||
|
lastUpdate = Date(); lastTrack = track; lastPlaying = playing
|
||||||
|
let value = SystemPlayerAttributes(id: id, state: state)
|
||||||
|
let operation = generation
|
||||||
|
enqueue { [weak self] in
|
||||||
|
guard let self, self.generation == operation else { return }
|
||||||
|
do {
|
||||||
|
try SystemPlayerCredentials.save(connection, id: id)
|
||||||
|
try await session.update(value)
|
||||||
|
} catch { self.error = BridgeError.describe(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
generation = UUID()
|
||||||
|
busy = false; running = false
|
||||||
|
if let id {
|
||||||
|
do { try SystemPlayerCredentials.delete(id) }
|
||||||
|
catch { self.error = BridgeError.describe(error) }
|
||||||
|
}
|
||||||
|
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||||
|
id = nil
|
||||||
|
if #available(iOS 27.0, *), let session = storage as? RemoteMediaSession<SystemPlayerAttributes> {
|
||||||
|
enqueue { [weak self] in
|
||||||
|
do { try await session.end() }
|
||||||
|
catch { self?.error = BridgeError.describe(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueue(_ action: @escaping @MainActor () async -> Void) {
|
||||||
|
let previous = updates
|
||||||
|
updates = Task { await previous?.value; await action() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>ru</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>MusicBridge</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>EXAppExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>EXExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.nowplaying.remote-media</string>
|
||||||
|
</dict>
|
||||||
|
<key>MusicBridgeSharedKeychainGroup</key>
|
||||||
|
<string>ru.yukinoki.musicbridge.remote-control</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>10.0.0.0/8</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>172.16.0.0/12</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>192.168.0.0/16</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import ExtensionFoundation
|
||||||
|
import NowPlaying
|
||||||
|
import Observation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MusicBridgeRemote: RemoteMediaSessionExtension {
|
||||||
|
var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
|
||||||
|
RemoteMediaSessionExtensionConfiguration(extension: self)
|
||||||
|
}
|
||||||
|
func session(_ attributes: SystemPlayerAttributes) async throws -> PCMediaSession {
|
||||||
|
_ = try SystemPlayerCredentials.load(attributes.id)
|
||||||
|
return PCMediaSession(attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class PCMediaSession: RemoteMediaSessionRepresentable {
|
||||||
|
let id: String
|
||||||
|
var attributes: SystemPlayerAttributes
|
||||||
|
private let transport = SystemPlayerTransport()
|
||||||
|
init(_ attributes: SystemPlayerAttributes) { id = attributes.id; self.attributes = attributes }
|
||||||
|
|
||||||
|
func update(_ attributes: SystemPlayerAttributes) { self.attributes = attributes }
|
||||||
|
|
||||||
|
var content: (any MediaContentRepresentable)? {
|
||||||
|
guard attributes.hasSession else { return nil }
|
||||||
|
let artwork = attributes.artworkID.map { artworkID in
|
||||||
|
Artwork(id: artworkID) { _ in
|
||||||
|
let saved = try SystemPlayerCredentials.load(self.id)
|
||||||
|
let client = BridgeClient(saved, timeout: 5)
|
||||||
|
defer { client.close() }
|
||||||
|
return try ArtworkRepresentation(data: await client.artwork(artworkID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MusicContent(id: attributes.source + "/" + attributes.title + "/" + attributes.artist,
|
||||||
|
songTitle: attributes.title.isEmpty ? "Без названия" : attributes.title,
|
||||||
|
artistName: attributes.artist, albumName: "", type: .audio,
|
||||||
|
duration: attributes.duration > 0 ? .finite(attributes.duration) : nil, artwork: artwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
var playbackSnapshot: MediaPlaybackSnapshot? {
|
||||||
|
MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: Float(attributes.rate)) : .paused,
|
||||||
|
elapsedTime: attributes.position, timestamp: attributes.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands: [MediaCommand] {
|
||||||
|
guard attributes.hasSession else { return [] }
|
||||||
|
return [
|
||||||
|
.play { try await self.send(.play) }.enabled(attributes.capabilities.play),
|
||||||
|
.pause { try await self.send(.pause) }.enabled(attributes.capabilities.pause),
|
||||||
|
.next { try await self.send(.next) }.enabled(attributes.capabilities.next),
|
||||||
|
.previous { try await self.send(.previous) }.enabled(attributes.capabilities.previous),
|
||||||
|
.seekToPosition { try await self.send(.seek, value: $0) }.enabled(attributes.capabilities.seek)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices: [MediaDevice] {
|
||||||
|
let capabilities: [MediaDevice.Capability] = attributes.volume.map { volume in
|
||||||
|
[.absoluteVolume(Float(volume)) { try await self.send(.volume, value: Double($0)) }]
|
||||||
|
} ?? []
|
||||||
|
return [MediaDevice(id: id, name: "MusicBridge · ПК", type: .speaker, capabilities: capabilities)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ action: SystemPlayerAction, value: Double? = nil) async throws {
|
||||||
|
do {
|
||||||
|
let state = try await transport.send(action, value: value, sessionID: id)
|
||||||
|
attributes = SystemPlayerAttributes(id: id, state: state)
|
||||||
|
} catch BridgeError.unauthorized {
|
||||||
|
try SystemPlayerCredentials.delete(id)
|
||||||
|
attributes.hasSession = false
|
||||||
|
attributes.playing = false
|
||||||
|
attributes.volume = nil
|
||||||
|
throw BridgeError.unauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MusicBridge
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveActivityCommandTests: XCTestCase {
|
||||||
|
private func client(_ token: String) throws -> BridgeClient {
|
||||||
|
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
|
||||||
|
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
|
||||||
|
let f = try XCTUnwrap(fixtures["valid"])
|
||||||
|
return BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNextUsesOneCommandAndReturnsUpdatedTrack() async throws {
|
||||||
|
let client = try client("live-next"); defer { client.close() }
|
||||||
|
let result = try await LiveActivityCommands.execute(.next, client: client)
|
||||||
|
XCTAssertEqual(result.title, "Track 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRefreshDoesNotSendCommand() async throws {
|
||||||
|
let client = try client("live-refresh"); defer { client.close() }
|
||||||
|
let result = try await LiveActivityCommands.execute(.refresh, client: client)
|
||||||
|
XCTAssertEqual(result.title, "Track 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClosedActivityDoesNotSendCommand() async throws {
|
||||||
|
let client = try client("live-closed"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client, stillValid: { false }); XCTFail("Must reject") }
|
||||||
|
catch { }
|
||||||
|
let state = try await client.state()
|
||||||
|
XCTAssertEqual(state.title, "Track 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnsupportedCommandIsNotSent() async throws {
|
||||||
|
let client = try client("live-unsupported"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
|
||||||
|
catch { }
|
||||||
|
let state = try await client.state()
|
||||||
|
XCTAssertEqual(state.title, "Track 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRejectedCommandIsNotRetried() async throws {
|
||||||
|
let client = try client("live-reject"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
|
||||||
|
catch { }
|
||||||
|
let state = try await client.state()
|
||||||
|
XCTAssertEqual(state.title, "Track 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedReadAfterSuccessReportsCommandCompleted() async throws {
|
||||||
|
let client = try client("live-refresh-fail"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must report refresh failure") }
|
||||||
|
catch { XCTAssertTrue(error.localizedDescription.contains("Команда выполнена")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ struct MusicBridgeWidgets: WidgetBundle {
|
|||||||
struct MusicBridgeLiveActivity: Widget {
|
struct MusicBridgeLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: MusicActivityAttributes.self) { context in
|
ActivityConfiguration(for: MusicActivityAttributes.self) { context in
|
||||||
MusicActivityCard(state: context.state, stale: context.isStale)
|
MusicActivityCard(state: context.state, stale: context.isStale, activityID: context.activityID)
|
||||||
.activityBackgroundTint(Color(.secondarySystemBackground))
|
.activityBackgroundTint(Color(.secondarySystemBackground))
|
||||||
.activitySystemActionForegroundColor(.indigo)
|
.activitySystemActionForegroundColor(.indigo)
|
||||||
.widgetURL(URL(string: "musicbridge://player"))
|
.widgetURL(URL(string: "musicbridge://player"))
|
||||||
@@ -27,8 +27,7 @@ struct MusicBridgeLiveActivity: Widget {
|
|||||||
TrackLabels(state: context.state)
|
TrackLabels(state: context.state)
|
||||||
ProgressView(value: context.state.progress).tint(.cyan)
|
ProgressView(value: context.state.progress).tint(.cyan)
|
||||||
ActivityStatus(state: context.state, stale: context.isStale)
|
ActivityStatus(state: context.state, stale: context.isStale)
|
||||||
Link("Открыть плеер", destination: URL(string: "musicbridge://player")!)
|
MusicActivityControls(activityID: context.activityID).foregroundStyle(.cyan)
|
||||||
.font(.caption.weight(.semibold)).foregroundStyle(.cyan)
|
|
||||||
}.padding(.bottom, 4)
|
}.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
|
|||||||
@@ -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; не менять серверный протокол молча.
|
||||||
@@ -46,7 +123,10 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
|
|||||||
- [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
|
- [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
|
||||||
- [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
|
- [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
|
||||||
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
|
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
|
||||||
- [ ] App Intents: прямые действия из Live Activity и команды Siri.
|
- [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0.
|
||||||
|
- [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone.
|
||||||
|
- [ ] Команды Siri.
|
||||||
|
- [x] NowPlaying RemoteMediaSession (iOS 27+): SDK 27 и remote-media extension реализованы в 0.7.0; установка и физическая проверка — в текущем этапе выше.
|
||||||
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
|
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
|
||||||
|
|
||||||
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
|
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
|
||||||
@@ -143,13 +223,51 @@ Bonjour, если доступны объявления того же агент
|
|||||||
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
|
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
|
||||||
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
|
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
|
||||||
Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
|
Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
|
||||||
3. **Системные интеграции — 0.5.0 (build 6).** Локальная Live Activity и Dynamic Island
|
3. **Системные интеграции — 0.6.0 (build 7).** Добавлены прямые действия LiveActivityIntent.
|
||||||
реализованы; 26 XCTest и Release archive прошли. Карточка показывает последний снимок
|
Убрана ошибка устаревания при обычном сворачивании; каждое нажатие перечитывает ПК.
|
||||||
с ПК, открывает плеер, помечает устаревшие данные. App Intents и APNs не реализованы.
|
32 XCTest и Release archive прошли. APNs и встроенный системный музыкальный пульт ещё не реализованы.
|
||||||
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
|
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
|
||||||
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
|
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
|
||||||
|
|
||||||
Изменения третьего этапа — Live Activities 0.5.0:
|
Продолжение третьего этапа — 0.6.0:
|
||||||
|
|
||||||
|
- Итоговый `43c78b2`: [Actions 34748340408](https://github.com/polskikh13/musicbridgeios/actions/runs/34748340408)
|
||||||
|
— 32 XCTest без отказов, успешный Release archive. IPA 0.6.0 (7) скачана в
|
||||||
|
`build/iPhone-0.6.0/MusicBridge-iPhone.ipa`; проверены ARM64 iPhoneOS приложения
|
||||||
|
и расширения, версии, метаданные интентов. Просмотрены светлая и тёмная карточки.
|
||||||
|
Работа системного запуска LiveActivityIntent и Keychain после блокировки требует
|
||||||
|
проверки на телефоне: обновить IPA, выключить/включить карточку, заблокировать,
|
||||||
|
проверить next/previous/toggle/refresh; затем повторить после перезагрузки и первого unlock.
|
||||||
|
- Пользователь сообщил, что карточка постоянно пишет об устаревших данных.
|
||||||
|
Причина: `PlayerStore.stop()` вызывал `unavailable()` при каждом уходе в фон.
|
||||||
|
Удалён этот вызов; реальные сетевые отказы продолжают отмечаться отдельно.
|
||||||
|
Карточка показывает время последнего снимка и отдельные ошибки запросов.
|
||||||
|
- Shared `MusicControlIntent` соответствует LiveActivityIntent. WidgetKit содержит
|
||||||
|
определение интента, выполнение сети компилируется только в основной app target;
|
||||||
|
iOS запускает процесс приложения без открытия UI. На компактном/минимальном Island
|
||||||
|
кнопок нет: нужно раскрыть долгим нажатием. Кнопки не отключаются из-за старого снимка.
|
||||||
|
- `LiveActivityCommands`: одно действие одновременно, проверка живой Activity и связи
|
||||||
|
её ID с fingerprint текущего сопряжения. После чтения состояния повторно проверяется
|
||||||
|
актуальность карточки, затем проверяются capabilities и отправляется максимум одна
|
||||||
|
команда. Refresh вообще не отправляет команду. Повторов команд нет.
|
||||||
|
- После успеха перечитывается трек и обновляется Activity. 401 завершает её;
|
||||||
|
неизвестный результат и неудача чтения после успеха имеют разные сообщения.
|
||||||
|
Нет бесконечного WSS/Bonjour в фоне; после смены IP сначала открыть приложение.
|
||||||
|
- Keychain: AfterFirstUnlockThisDeviceOnly вместо WhenUnlockedThisDeviceOnly,
|
||||||
|
чтобы команды работали при заблокированном iPhone. Существующий токен мигрирует
|
||||||
|
при повторном включении карточки. Он не покидает Keychain и не попадает в payload.
|
||||||
|
В UserDefaults сохраняется только соответствие ID активности и публичного pin.
|
||||||
|
После обновления с 0.5.0 карточку выключить и включить заново в приложении.
|
||||||
|
- HTTPS-тесты проверяют одну отправку next и новый трек, refresh без команды,
|
||||||
|
закрытую карточку, неподдерживаемую команду, отсутствие повторов отказавшей команды,
|
||||||
|
ошибку чтения после успешной команды. Физический запуск интента проверяется отдельно.
|
||||||
|
- Ответ на вопрос пользователя о встроенном музыкальном центре: официальный
|
||||||
|
[NowPlaying RemoteMediaSession](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions)
|
||||||
|
предназначен для внешнего воспроизведения. Metadata Apple указывает iOS 27.0.
|
||||||
|
Текущий runner собирает Xcode 16.4: следующий этап — SDK/Xcode 27 и отдельное
|
||||||
|
`com.apple.nowplaying.remote-media` расширение. Интеграция ещё не реализована.
|
||||||
|
|
||||||
|
История третьего этапа — Live Activities 0.5.0 (поведение фона изменено в 0.6.0):
|
||||||
|
|
||||||
- Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034)
|
- Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034)
|
||||||
— 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive.
|
— 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive.
|
||||||
|
|||||||
@@ -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 подключён:
|
||||||
@@ -68,8 +88,8 @@ Apple ID и сертификаты для этой проверки не тре
|
|||||||
## Установка на iPhone через Sideloadly
|
## Установка на iPhone через Sideloadly
|
||||||
|
|
||||||
Пользователь также подтвердил установку через iloader. Для повторной установки
|
Пользователь также подтвердил установку через iloader. Для повторной установки
|
||||||
с Live Activity используйте версию **0.5.0 (build 6)**:
|
с Live Activity используйте версию **0.6.0 (build 7)**:
|
||||||
`build/iPhone-0.5.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, раскрываемые ручные поля, крупная
|
||||||
@@ -106,34 +126,45 @@ Sideloadly (в том числе его функцией автоматичес
|
|||||||
[Sideloadly FAQ](https://sideloadly.io/faq) ·
|
[Sideloadly FAQ](https://sideloadly.io/faq) ·
|
||||||
[Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device)
|
[Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device)
|
||||||
|
|
||||||
## Live Activity и Dynamic Island
|
## Live Activity и Dynamic Island — 0.6.0
|
||||||
|
|
||||||
После подключения к ПК и появления трека нажмите под плеером
|
После обновления с 0.5.0 уберите старую карточку кнопкой в приложении и нажмите
|
||||||
«Показать на экране блокировки». Та же карточка появится в Dynamic Island
|
«Показать на экране блокировки» заново. Это связывает кнопки с текущим сопряжением
|
||||||
на моделях, где он доступен. Долгое нажатие на Island раскрывает представление;
|
и переводит запись Keychain в режим доступа после первого разблокирования.
|
||||||
нажатие на карточку или «Открыть плеер» возвращает в приложение.
|
Токен остаётся только на этом устройстве, не копируется в виджет или UserDefaults.
|
||||||
Кнопка «Убрать с экрана блокировки» завершает активность. Она также завершается,
|
|
||||||
когда ПК сообщает об отсутствии медиасессии, доступ отозван или компьютер забыт.
|
|
||||||
Удалённая пользователем карточка не создаётся снова автоматически.
|
|
||||||
|
|
||||||
Это **последний снимок состояния ПК**: название, исполнитель, состояние и прогресс.
|
На экране блокировки и в развёрнутом Dynamic Island доступны предыдущий трек,
|
||||||
В обычном режиме позиция обновляется не чаще раза в пять секунд; смена трека
|
play/pause, следующий трек и обновление. Компактный Island открывает приложение;
|
||||||
и play/pause отправляются сразу. Прогресс не выдумывает движение без связи с ПК.
|
для кнопок удерживайте Island. Каждое действие выполняет отдельные HTTPS-запросы
|
||||||
При уходе приложения в фон или потере связи отображается отметка устаревших данных;
|
с прежней проверкой сертификата. Оно работает через LiveActivityIntent в процессе
|
||||||
резервный `staleDate` — 15 секунд, если приложение было внезапно остановлено.
|
приложения, без открытия интерфейса; постоянного сетевого потока в фоне нет.
|
||||||
Для обновления нажмите карточку. Прямых кнопок управления внутри неё пока нет.
|
|
||||||
|
|
||||||
Live Activity не поддерживает постоянный сетевой поток сама по себе. Для обновления
|
Перед командой считываются возможности плеера, затем команда отправляется один раз
|
||||||
при приостановленном приложении нужен отдельный механизм ActivityKit push через
|
и перечитывается состояние. Повторы команд отключены; при неизвестном результате
|
||||||
APNs. В этой версии нет push-токенов, сервера APNs, фонового аудио и App Intents.
|
сначала используйте обновление. Одновременно выполняется одно действие карточки.
|
||||||
[Ограничения и жизненный цикл ActivityKit](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities).
|
После закрытия карточки или удаления её связи с ПК новые команды не отправляются.
|
||||||
|
Для нового адреса ПК сначала откройте приложение: Bonjour-перенос адреса остаётся
|
||||||
|
в основном приложении. При отзыве доступа карточка завершается.
|
||||||
|
|
||||||
В IPA включено расширение `MusicBridgeWidgets.appex`; при установке iloader должен
|
При обычном сворачивании показывается время последнего обновления, а не ошибка
|
||||||
сохранить и подписать его вместе с приложением. Если Live Activity не появилась,
|
«Данные устарели». Прогресс соответствует этому снимку. Реальная ошибка сети или
|
||||||
проверьте её разрешение в настройках MusicBridge и наличие расширения в логе установки.
|
команды выводится на карточке. Изменения, сделанные непосредственно на ПК, при
|
||||||
Автоматические снимки `live-card-*-screen.png` показывают общую SwiftUI-разметку
|
приостановленном приложении не появятся сами: нажмите обновление или откройте плеер.
|
||||||
карточки в Debug-предпросмотре: это не доказательство её регистрации на реальном
|
Для постоянных фоновых обновлений остаётся отдельная задача APNs.
|
||||||
экране блокировки или системного отображения Dynamic Island.
|
|
||||||
|
В IPA есть расширение `MusicBridgeWidgets.appex`: iloader должен сохранить и подписать
|
||||||
|
его вместе с приложением. Проверка работы интентов в заблокированном состоянии и
|
||||||
|
системного отображения Island выполняется на iPhone; CI проверяет сборку, сетевую
|
||||||
|
логику команд и разметку. После перезагрузки сначала один раз разблокируйте iPhone.
|
||||||
|
|
||||||
|
### Встроенный музыкальный пульт iOS
|
||||||
|
|
||||||
|
Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+)
|
||||||
|
для отображения музыки внешнего устройства на экране блокировки и в Control Center.
|
||||||
|
Это подходит архитектуре MusicBridge. Нужно отдельное расширение `com.apple.nowplaying.remote-media`. Сборка переведена на Xcode 27; 32 теста и Release archive прошли.
|
||||||
|
Эта интеграция исследована, но ещё не реализована. Для фоновых обновлений удалённых
|
||||||
|
сессий Apple также предоставляет push-механизм.
|
||||||
|
[Документация remote media sessions](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions).
|
||||||
|
|
||||||
## Источники
|
## Источники
|
||||||
|
|
||||||
@@ -143,3 +174,18 @@ APNs. В этой версии нет push-токенов, сервера APNs,
|
|||||||
- [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions)
|
- [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.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import NowPlaying
|
||||||
|
|
||||||
|
@available(iOS 27.0, *)
|
||||||
|
struct SystemPlayerAttributes: RemoteMediaSessionAttributes {
|
||||||
|
let id: String
|
||||||
|
var title: String
|
||||||
|
var artist: String
|
||||||
|
var source: String
|
||||||
|
var artworkID: String?
|
||||||
|
var playing: Bool
|
||||||
|
var rate: Double
|
||||||
|
var position: Double
|
||||||
|
var duration: Double
|
||||||
|
var timestamp: Date
|
||||||
|
var capabilities: Capabilities
|
||||||
|
var volume: Double?
|
||||||
|
var hasSession: Bool
|
||||||
|
|
||||||
|
init(id: String, state: MediaState, now: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
title = String(state.title.prefix(256)); artist = String(state.artist.prefix(256))
|
||||||
|
source = String(state.source.prefix(128)); artworkID = state.artworkId
|
||||||
|
playing = state.playbackStatus == "Playing"; rate = state.playbackRate
|
||||||
|
position = max(0, state.position(elapsed: 0) - state.startSeconds)
|
||||||
|
duration = max(0, state.endSeconds - state.startSeconds)
|
||||||
|
timestamp = now; capabilities = state.capabilities; volume = state.volume; hasSession = state.hasSession
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum SystemPlayerCredentials {
|
||||||
|
// Derive the signing prefix from our own default Keychain group. Info.plist
|
||||||
|
// build substitutions cannot know the team that later re-signs an IPA.
|
||||||
|
private static func signingPrefix() throws -> String {
|
||||||
|
let marker: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: "MusicBridge.signing-prefix.v1", kSecAttrAccount as String: "prefix"]
|
||||||
|
let status = SecItemAdd(marker.merging([kSecValueData as String: Data(),
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]) { _, v in v } as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess || status == errSecDuplicateItem else { throw failure(status) }
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let read = SecItemCopyMatching(marker.merging([kSecReturnAttributes as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne]) { _, v in v } as CFDictionary, &result)
|
||||||
|
guard read == errSecSuccess else { throw failure(read) }
|
||||||
|
guard let attributes = result as? [String: Any],
|
||||||
|
let group = attributes[kSecAttrAccessGroup as String] as? String,
|
||||||
|
let separator = group.firstIndex(of: "."), separator != group.startIndex else {
|
||||||
|
throw failure(errSecMissingEntitlement)
|
||||||
|
}
|
||||||
|
return String(group[...separator])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func query(_ id: String) throws -> [String: Any] {
|
||||||
|
guard let suffix = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
|
||||||
|
suffix == "ru.yukinoki.musicbridge.remote-control" else {
|
||||||
|
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
|
||||||
|
}
|
||||||
|
return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1",
|
||||||
|
kSecAttrAccount as String: id, kSecAttrAccessGroup as String: try signingPrefix() + suffix]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ connection: Connection, id: String) throws {
|
||||||
|
let query = try query(id)
|
||||||
|
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||||
|
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||||
|
if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, v in v } as CFDictionary, nil) }
|
||||||
|
guard status == errSecSuccess else { throw failure(status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(_ id: String) throws -> Connection {
|
||||||
|
var query = try query(id)
|
||||||
|
query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var item: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
|
||||||
|
let saved = try JSONDecoder().decode(Connection.self, from: data)
|
||||||
|
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.unauthorized }
|
||||||
|
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(_ id: String) throws {
|
||||||
|
let status = SecItemDelete(try query(id) as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func failure(_ status: OSStatus) -> BridgeError {
|
||||||
|
.message("Системный пульт не получил доступ к Keychain (\(status)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SystemPlayerAction { case play, pause, next, previous, seek, volume }
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SystemPlayerTransport {
|
||||||
|
private var busy = false
|
||||||
|
|
||||||
|
func send(_ action: SystemPlayerAction, value: Double? = nil, sessionID: String) async throws -> MediaState {
|
||||||
|
guard !busy else { throw BridgeError.message("Предыдущая команда ещё выполняется.") }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
let saved = try SystemPlayerCredentials.load(sessionID)
|
||||||
|
let client = BridgeClient(saved, timeout: 5)
|
||||||
|
defer { client.close() }
|
||||||
|
return try await Self.execute(action, value: value, client: client) {
|
||||||
|
let current = try SystemPlayerCredentials.load(sessionID)
|
||||||
|
guard current.endpoint == saved.endpoint, current.fingerprint == saved.fingerprint, current.token == saved.token else {
|
||||||
|
throw BridgeError.unauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func execute(_ action: SystemPlayerAction, value: Double? = nil, client: BridgeClient,
|
||||||
|
validate: () throws -> Void = {}) async throws -> MediaState {
|
||||||
|
let state = try await client.state()
|
||||||
|
try validate()
|
||||||
|
let command = try command(action, value: value, state: state)
|
||||||
|
do { try await client.command(command) }
|
||||||
|
catch {
|
||||||
|
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Откройте плеер перед повтором.") }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
do { return try await client.state() }
|
||||||
|
catch {
|
||||||
|
if case BridgeError.unauthorized = error { throw error }
|
||||||
|
throw BridgeError.message("Команда выполнена, но новый трек не получен. Откройте приложение для обновления.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func command(_ action: SystemPlayerAction, value: Double?, state: MediaState) throws -> Command {
|
||||||
|
switch action {
|
||||||
|
case .play where state.hasSession && state.capabilities.play: return Command(type: "play", value: nil)
|
||||||
|
case .pause where state.hasSession && state.capabilities.pause: return Command(type: "pause", value: nil)
|
||||||
|
case .next where state.hasSession && state.capabilities.next: return Command(type: "next", value: nil)
|
||||||
|
case .previous where state.hasSession && state.capabilities.previous: return Command(type: "previous", value: nil)
|
||||||
|
case .seek where state.hasSession && state.capabilities.seek && state.maxSeekSeconds > state.minSeekSeconds:
|
||||||
|
guard let value, value.isFinite else { throw BridgeError.message("Некорректная позиция трека.") }
|
||||||
|
return Command(type: "seek", value: min(state.maxSeekSeconds, max(state.minSeekSeconds, state.startSeconds + value)))
|
||||||
|
case .volume where state.volume != nil:
|
||||||
|
guard let value, value.isFinite else { throw BridgeError.message("Некорректная громкость.") }
|
||||||
|
return Command(type: "volume", value: min(1, max(0, value)))
|
||||||
|
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ struct MusicActivityAttributes: ActivityAttributes {
|
|||||||
var duration: Double
|
var duration: Double
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
var connected: Bool
|
var connected: Bool
|
||||||
|
var message: String? = nil
|
||||||
|
|
||||||
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
|
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
|
||||||
static func bounded(_ text: String) -> String {
|
static func bounded(_ text: String) -> String {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
struct MusicActivityCard: View {
|
struct MusicActivityCard: View {
|
||||||
let state: MusicActivityAttributes.ContentState
|
let state: MusicActivityAttributes.ContentState
|
||||||
let stale: Bool
|
let stale: Bool
|
||||||
|
var activityID: String = ""
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "waveform").font(.title).foregroundStyle(.indigo)
|
Image(systemName: "waveform").font(.title).foregroundStyle(.indigo)
|
||||||
TrackLabels(state: state)
|
TrackLabels(state: state)
|
||||||
@@ -14,8 +16,8 @@ struct MusicActivityCard: View {
|
|||||||
}
|
}
|
||||||
ProgressView(value: state.progress).tint(.indigo)
|
ProgressView(value: state.progress).tint(.indigo)
|
||||||
ActivityStatus(state: state, stale: stale)
|
ActivityStatus(state: state, stale: stale)
|
||||||
Label("Открыть плеер", systemImage: "arrow.up.forward.app").font(.caption.weight(.semibold))
|
MusicActivityControls(activityID: activityID)
|
||||||
}.padding(16)
|
}.padding(12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,13 +36,35 @@ struct ActivityStatus: View {
|
|||||||
let state: MusicActivityAttributes.ContentState
|
let state: MusicActivityAttributes.ContentState
|
||||||
let stale: Bool
|
let stale: Bool
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if stale || !state.connected {
|
if let message = state.message {
|
||||||
Text("Данные устарели · откройте плеер").font(.caption).foregroundStyle(.secondary)
|
Text(message).font(.caption).lineLimit(2)
|
||||||
|
} else if !state.connected {
|
||||||
|
Text("Нет связи с ПК · нажмите обновление").font(.caption).foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("Снимок с ПК в")
|
Text("Обновлено в")
|
||||||
Text(state.updatedAt, style: .time)
|
Text(state.updatedAt, style: .time)
|
||||||
}.font(.caption).foregroundStyle(.secondary)
|
}.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MusicActivityControls: View {
|
||||||
|
let activityID: String
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
control(.previous, icon: "backward.end.fill", label: "Предыдущий трек")
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
control(.toggle, icon: "playpause.fill", label: "Воспроизведение или пауза")
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
control(.next, icon: "forward.end.fill", label: "Следующий трек")
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
control(.refresh, icon: "arrow.clockwise", label: "Обновить трек")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func control(_ action: MusicRemoteAction, icon: String, label: String) -> some View {
|
||||||
|
Button(intent: MusicControlIntent(action, activityID: activityID)) {
|
||||||
|
Image(systemName: icon).font(.title3).frame(minWidth: 44, minHeight: 44)
|
||||||
|
}.buttonStyle(.plain).accessibilityLabel(label).disabled(activityID.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
enum MusicRemoteAction: String, AppEnum {
|
||||||
|
case previous, toggle, next, refresh
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Команда плеера"
|
||||||
|
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||||
|
.previous: "Предыдущий трек", .toggle: "Воспроизведение / пауза", .next: "Следующий трек", .refresh: "Обновить"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MusicControlIntent: LiveActivityIntent {
|
||||||
|
static var title: LocalizedStringResource = "Управлять музыкой на ПК"
|
||||||
|
static var openAppWhenRun: Bool = false
|
||||||
|
static var isDiscoverable: Bool = false
|
||||||
|
static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
|
||||||
|
@Parameter(title: "Команда") var action: MusicRemoteAction
|
||||||
|
@Parameter(title: "Активность") var activityID: String
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(_ action: MusicRemoteAction, activityID: String) { self.action = action; self.activityID = activityID }
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
#if !MUSICBRIDGE_WIDGET
|
||||||
|
await LiveActivityCommands.perform(action, activityID: activityID)
|
||||||
|
#endif
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-11
@@ -22,6 +22,8 @@ def configurations(name, settings):
|
|||||||
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
|
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
|
||||||
if mode == "Debug":
|
if mode == "Debug":
|
||||||
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
|
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
|
||||||
|
if name == "MusicBridgeWidgets":
|
||||||
|
values["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = values.get("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "") + " MUSICBRIDGE_WIDGET"
|
||||||
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
|
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
|
||||||
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
|
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
|
||||||
defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
|
defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
|
||||||
@@ -35,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
|
||||||
@@ -52,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 = []
|
||||||
@@ -69,24 +80,32 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
|
|||||||
settings.update(TEST_HOST="$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge", BUNDLE_LOADER="$(TEST_HOST)")
|
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="6", MARKETING_VERSION="0.5.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="6", MARKETING_VERSION="0.5.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon")
|
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon", CODE_SIGN_ENTITLEMENTS="MusicBridge/App.entitlements")
|
||||||
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
|
embed = 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"],
|
||||||
@@ -142,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.")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# iloader: совместимость с MusicBridge 0.7.0
|
||||||
|
|
||||||
|
Это отдельная неофициальная portable-сборка iloader для Windows. Исходники:
|
||||||
|
- https://github.com/nab138/iloader/tree/348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
|
||||||
|
- https://github.com/nab138/isideload/tree/f6a4d5dba717d72fc2af63eaba26b27ba44116be
|
||||||
|
- https://github.com/nab138/apple-crates/tree/5da0b8df9b202434b1eff2700059b1ee142592ac
|
||||||
|
|
||||||
|
Патчи хранятся в репозитории MusicBridge в tools/iloader. Workflow
|
||||||
|
iloader-compat.yml запускается вручную, не использует Apple Account/сертификаты.
|
||||||
|
Apple-авторизация и подпись происходят только локально на компьютере пользователя.
|
||||||
|
|
||||||
|
Исправления: обнаружение Extensions вместе с PlugIns, переименование и получение
|
||||||
|
профиля каждого расширения, вложенная подпись ExtensionKit и общий Keychain для
|
||||||
|
MusicBridge только в пределах разрешений настоящего provisioning profile Apple.
|
||||||
|
Регрессионные Rust-тесты проверяют обе папки расширений, сохранение новых Bundle ID,
|
||||||
|
обработку вложенной подписи и отказ при недопустимой Keychain group.
|
||||||
|
|
||||||
|
Использование после успешной сборки:
|
||||||
|
1. Закрыть обычный iloader. Удалять его и установленный MusicBridge не требуется.
|
||||||
|
2. Запустить iloader-musicbridge.exe. Заголовок окна содержит compatibility build.
|
||||||
|
3. Использовать прежний Apple Account; выбрать исходную MusicBridge 0.7.0 IPA.
|
||||||
|
4. Подписать и установить заново. Все расширения нужно сохранить.
|
||||||
|
5. Проверить обычное подключение, затем кнопку системного пульта и Пункт управления.
|
||||||
|
|
||||||
|
Вариант сохраняет идентификатор/хранилище обычного iloader для локального входа,
|
||||||
|
поэтому одновременно запускать обе версии не нужно. Обновления из upstream в
|
||||||
|
этой сборке отключены. Совместимость нового extension point с бесплатным профилем
|
||||||
|
Apple окончательно подтверждается только установкой на устройстве.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
diff --git a/apple-codesign-quick/src/bundle.rs b/apple-codesign-quick/src/bundle.rs
|
||||||
|
index 040fd07..2bfa359 100644
|
||||||
|
--- a/apple-codesign-quick/src/bundle.rs
|
||||||
|
+++ b/apple-codesign-quick/src/bundle.rs
|
||||||
|
@@ -16,6 +16,23 @@ const FAIRPLAY_DIR: &str = "SC_Info";
|
||||||
|
const CODE_SIGNATURE_DIR: &str = "_CodeSignature";
|
||||||
|
const CODE_RESOURCES_FILE: &str = "CodeResources";
|
||||||
|
|
||||||
|
+#[cfg(test)]
|
||||||
|
+mod musicbridge_extensionkit_tests {
|
||||||
|
+ use super::*;
|
||||||
|
+ #[test]
|
||||||
|
+ fn musicbridge_extensionkit_is_signed_as_nested_bundle() {
|
||||||
|
+ let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||||
|
+ let root = std::env::temp_dir().join(format!("musicbridge-codesign-test-{unique}"));
|
||||||
|
+ let extension = root.join("Extensions/Remote.appex");
|
||||||
|
+ std::fs::create_dir_all(&extension).unwrap();
|
||||||
|
+ std::fs::write(extension.join("Info.plist"), b"fixture").unwrap();
|
||||||
|
+ let bundle = Bundle { path: root, app_info: Dictionary::new() };
|
||||||
|
+ assert_eq!(bundle.sub_bundles().unwrap(), vec![extension]);
|
||||||
|
+ assert!(!should_hash_resource("Extensions/Remote.appex/Remote", "Main"));
|
||||||
|
+ assert!(should_hash_resource("Assets.car", "Main"));
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
pub struct BundleSigningSettings<'a> {
|
||||||
|
pub team_id: String,
|
||||||
|
pub main_entitlements: Dictionary,
|
||||||
|
@@ -202,7 +219,7 @@ impl Bundle {
|
||||||
|
|
||||||
|
fn sub_bundles(&self) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut bundles = Vec::new();
|
||||||
|
- for folder in ["Frameworks", "PlugIns"] {
|
||||||
|
+ for folder in ["Frameworks", "PlugIns", "Extensions"] {
|
||||||
|
let dir = self.path.join(folder);
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
if !isideload_vfs::fs::metadata(&dir)
|
||||||
|
@@ -463,7 +480,7 @@ fn should_hash_resource(relative: &str, executable: &str) -> bool {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
- for nested_root in ["Frameworks/", "PlugIns/"] {
|
||||||
|
+ for nested_root in ["Frameworks/", "PlugIns/", "Extensions/"] {
|
||||||
|
if let Some(rest) = relative.strip_prefix(nested_root)
|
||||||
|
&& rest.contains('/')
|
||||||
|
{
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
diff --git a/isideload/src/sideload/application.rs b/isideload/src/sideload/application.rs
|
||||||
|
index 4e0ccd0..be80af4 100644
|
||||||
|
--- a/isideload/src/sideload/application.rs
|
||||||
|
+++ b/isideload/src/sideload/application.rs
|
||||||
|
@@ -20,6 +20,33 @@ pub struct Application {
|
||||||
|
//pub temp_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
+#[cfg(test)]
|
||||||
|
+mod musicbridge_tests {
|
||||||
|
+ use super::*;
|
||||||
|
+ #[test]
|
||||||
|
+ fn musicbridge_extensionkit_is_renamed_and_persisted() {
|
||||||
|
+ let root = std::env::temp_dir().join(format!("musicbridge-sign-test-{}", uuid::Uuid::new_v4()));
|
||||||
|
+ for (directory, id) in [("", "ru.yukinoki.musicbridge"),
|
||||||
|
+ ("PlugIns/Widget.appex", "ru.yukinoki.musicbridge.widgets"),
|
||||||
|
+ ("Extensions/Remote.appex", "ru.yukinoki.musicbridge.remote")] {
|
||||||
|
+ let path = root.join(directory);
|
||||||
|
+ std::fs::create_dir_all(&path).unwrap();
|
||||||
|
+ let mut info = plist::Dictionary::new();
|
||||||
|
+ info.insert("CFBundleIdentifier".into(), id.into());
|
||||||
|
+ plist::to_file_xml(path.join("Info.plist"), &info).unwrap();
|
||||||
|
+ }
|
||||||
|
+ let mut app = Application::new(root.clone()).unwrap();
|
||||||
|
+ assert_eq!(app.bundle.app_extensions().len(), 2);
|
||||||
|
+ app.update_bundle_id("ru.yukinoki.musicbridge", "ru.yukinoki.musicbridge.TEAM").unwrap();
|
||||||
|
+ app.bundle.write_info().unwrap();
|
||||||
|
+ for extension in app.bundle.app_extensions_mut() { extension.write_info().unwrap(); }
|
||||||
|
+ let saved = Application::new(root).unwrap();
|
||||||
|
+ for extension in saved.bundle.app_extensions() {
|
||||||
|
+ assert!(extension.bundle_identifier().unwrap().starts_with("ru.yukinoki.musicbridge.TEAM."));
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
impl Application {
|
||||||
|
pub fn new(path: PathBuf) -> Result<Self, Report> {
|
||||||
|
if !isideload_vfs::fs::metadata(&path).is_ok() {
|
||||||
|
diff --git a/isideload/src/sideload/bundle.rs b/isideload/src/sideload/bundle.rs
|
||||||
|
index 67a947d..2cc2282 100644
|
||||||
|
--- a/isideload/src/sideload/bundle.rs
|
||||||
|
+++ b/isideload/src/sideload/bundle.rs
|
||||||
|
@@ -45,23 +45,19 @@ impl Bundle {
|
||||||
|
"Failed to parse Info.plist".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
- // Load app extensions from PlugIns directory
|
||||||
|
- let plug_ins_dir = bundle_path.join("PlugIns");
|
||||||
|
- let app_extensions = if isideload_vfs::fs::metadata(&plug_ins_dir).is_ok() {
|
||||||
|
- isideload_vfs::fs::read_dir(&plug_ins_dir)
|
||||||
|
- .context(SideloadError::InvalidBundle(
|
||||||
|
- "Failed to read PlugIns directory".to_string(),
|
||||||
|
- ))?
|
||||||
|
- .filter_map(|entry| entry.ok())
|
||||||
|
- .filter(|entry| {
|
||||||
|
- entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
|
||||||
|
- && isideload_vfs::fs::metadata(&entry.path().join("Info.plist")).is_ok()
|
||||||
|
- })
|
||||||
|
- .filter_map(|entry| Bundle::new(entry.path()).ok())
|
||||||
|
- .collect()
|
||||||
|
- } else {
|
||||||
|
- Vec::new()
|
||||||
|
- };
|
||||||
|
+ // ExtensionKit uses Extensions; WidgetKit and older extensions use PlugIns.
|
||||||
|
+ // Invalid nested bundles must fail signing, never be silently skipped.
|
||||||
|
+ let mut app_extensions = Vec::new();
|
||||||
|
+ for folder in ["PlugIns", "Extensions"] {
|
||||||
|
+ let directory = bundle_path.join(folder);
|
||||||
|
+ if !isideload_vfs::fs::metadata(&directory).is_ok() { continue; }
|
||||||
|
+ for entry in isideload_vfs::fs::read_dir(&directory)? {
|
||||||
|
+ let entry = entry?;
|
||||||
|
+ if entry.file_type()?.is_dir() {
|
||||||
|
+ app_extensions.push(Bundle::new(entry.path())?);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Load frameworks from Frameworks directory
|
||||||
|
let frameworks_dir = bundle_path.join("Frameworks");
|
||||||
|
diff --git a/isideload/src/sideload/sign.rs b/isideload/src/sideload/sign.rs
|
||||||
|
index a3d9799..c7e6154 100644
|
||||||
|
--- a/isideload/src/sideload/sign.rs
|
||||||
|
+++ b/isideload/src/sideload/sign.rs
|
||||||
|
@@ -5,6 +5,7 @@ use apple_codesign::{
|
||||||
|
};
|
||||||
|
|
||||||
|
use plist::Dictionary;
|
||||||
|
+use rootcause::option_ext::OptionExt;
|
||||||
|
use rootcause::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
@@ -63,6 +64,10 @@ where
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
+ let musicbridge = app.main_bundle_id()? == format!("ru.yukinoki.musicbridge.{}", team.team_id)
|
||||||
|
+ && app.bundle.app_info.get("MusicBridgeSharedKeychainGroup").and_then(plist::Value::as_string)
|
||||||
|
+ == Some("ru.yukinoki.musicbridge.remote-control");
|
||||||
|
+ if musicbridge { entitlements = musicbridge_keychain(&entitlements)?; }
|
||||||
|
let mut settings = BundleSigningSettings::new(&team.team_id, entitlements, Some(&signer));
|
||||||
|
settings.embedded_mobileprovision = Some(main_provisioning_profile.encoded_profile.as_ref());
|
||||||
|
|
||||||
|
@@ -75,9 +80,56 @@ where
|
||||||
|
.map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
+ if musicbridge {
|
||||||
|
+ let remote_id = format!("{}.remote", app.main_bundle_id()?);
|
||||||
|
+ let remote = settings.entitlements_by_bundle_id.get(&remote_id)
|
||||||
|
+ .ok_or_report().context("MusicBridge remote extension has no provisioning profile")?;
|
||||||
|
+ let remote = musicbridge_keychain(remote)?;
|
||||||
|
+ settings.entitlements_by_bundle_id.insert(remote_id, remote);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
if let Some(callback) = &progress_callback {
|
||||||
|
callback(0.5).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(sign_bundle(&app.bundle.bundle_dir, &settings)?)
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+// Request only a group explicitly permitted by Apple's actual profile.
|
||||||
|
+fn musicbridge_keychain(profile: &Dictionary) -> Result<Dictionary, Report> {
|
||||||
|
+ let application = profile.get("application-identifier").and_then(plist::Value::as_string)
|
||||||
|
+ .ok_or_report().context("Profile has no application-identifier")?;
|
||||||
|
+ let prefix = application.split_once('.').ok_or_report().context("Invalid app identifier")?.0;
|
||||||
|
+ let shared = format!("{}.ru.yukinoki.musicbridge.remote-control", prefix);
|
||||||
|
+ let allowed = profile.get("keychain-access-groups").and_then(plist::Value::as_array)
|
||||||
|
+ .ok_or_report().context("Profile has no Keychain groups")?;
|
||||||
|
+ if !allowed.iter().filter_map(plist::Value::as_string).any(|group|
|
||||||
|
+ group == shared || group == format!("{}.*", prefix)) {
|
||||||
|
+ bail!("Apple's provisioning profile does not permit the MusicBridge shared Keychain group");
|
||||||
|
+ }
|
||||||
|
+ let mut result = profile.clone();
|
||||||
|
+ // A concrete per-app default group comes first, shared group second.
|
||||||
|
+ result.insert("keychain-access-groups".into(), plist::Value::Array(vec![
|
||||||
|
+ plist::Value::String(application.into()), plist::Value::String(shared)]));
|
||||||
|
+ Ok(result)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+#[cfg(test)]
|
||||||
|
+mod musicbridge_tests {
|
||||||
|
+ use super::*;
|
||||||
|
+ fn profile(group: &str) -> Dictionary {
|
||||||
|
+ let mut p = Dictionary::new();
|
||||||
|
+ p.insert("application-identifier".into(), "TEAM.ru.yukinoki.musicbridge.TEAM".into());
|
||||||
|
+ p.insert("keychain-access-groups".into(), plist::Value::Array(vec![group.into()]));
|
||||||
|
+ p
|
||||||
|
+ }
|
||||||
|
+ #[test]
|
||||||
|
+ fn musicbridge_sharing_respects_profile() {
|
||||||
|
+ let value = musicbridge_keychain(&profile("TEAM.*")).unwrap();
|
||||||
|
+ let groups = value["keychain-access-groups"].as_array().unwrap();
|
||||||
|
+ assert_eq!(groups[0].as_string(), Some("TEAM.ru.yukinoki.musicbridge.TEAM"));
|
||||||
|
+ assert_eq!(groups[1].as_string(), Some("TEAM.ru.yukinoki.musicbridge.remote-control"));
|
||||||
|
+ assert!(musicbridge_keychain(&profile("OTHER.*")).is_err());
|
||||||
|
+ assert!(musicbridge_keychain(&profile("TEAM.unrelated")).is_err());
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Apply reviewed patches to pinned upstream checkouts. No Apple credentials involved."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = Path(sys.argv[1]).resolve()
|
||||||
|
patches = Path(__file__).resolve().parent
|
||||||
|
versions = {
|
||||||
|
"iloader": "348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0",
|
||||||
|
"isideload": "f6a4d5dba717d72fc2af63eaba26b27ba44116be",
|
||||||
|
"apple-crates": "5da0b8df9b202434b1eff2700059b1ee142592ac",
|
||||||
|
}
|
||||||
|
for name, commit in versions.items():
|
||||||
|
path = root / name
|
||||||
|
actual = subprocess.check_output(["git", "-C", str(path), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
if actual != commit:
|
||||||
|
raise RuntimeError(f"Unexpected {name} revision: {actual}")
|
||||||
|
for name, patch in [("isideload", "isideload.patch"), ("apple-crates", "apple-codesign.patch")]:
|
||||||
|
subprocess.run(["git", "-C", str(root/name), "apply", "--check", str(patches/patch)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(root/name), "apply", str(patches/patch)], check=True)
|
||||||
|
|
||||||
|
manifest = root / "iloader/src-tauri/Cargo.toml"
|
||||||
|
with manifest.open("a", encoding="utf-8") as file:
|
||||||
|
file.write('\n[patch."https://github.com/nab138/isideload"]\nisideload = { path = "../../isideload/isideload" }\n')
|
||||||
|
file.write('\n[patch."https://github.com/nab138/apple-crates"]\napple-codesign = { path = "../../apple-crates/apple-codesign-quick" }\n')
|
||||||
|
# Portable helper: preserve upstream identifier/storage so existing Apple login can
|
||||||
|
# be reused locally. Use a distinct title and disable upstream update replacement.
|
||||||
|
config = root / "iloader/src-tauri/ci.conf.json"
|
||||||
|
value = json.loads(config.read_text(encoding="utf-8"))
|
||||||
|
value["app"] = {"windows": [{"title": "iloader — MusicBridge compatibility build", "width": 800, "height": 600}]}
|
||||||
|
config.write_text(json.dumps(value, indent=2), encoding="utf-8")
|
||||||
|
print("Patched pinned iloader, extension discovery, nested signing and profile-checked Keychain sharing.")
|
||||||
@@ -13,6 +13,9 @@ import subprocess
|
|||||||
import threading
|
import 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"
|
||||||
@@ -67,11 +70,25 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def snapshot(self, token):
|
def snapshot(self, token):
|
||||||
result = dict(state)
|
result = dict(state)
|
||||||
|
if token.startswith("live-"):
|
||||||
|
with counts_lock:
|
||||||
|
commands = counts.get((token, "command"), 0)
|
||||||
|
result["title"] = "Track " + str(commands)
|
||||||
|
if token == "live-unsupported":
|
||||||
|
result["capabilities"] = {key: False for key in result["capabilities"]}
|
||||||
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
|
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
|
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
|
||||||
|
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||||
|
if token.startswith("live-"):
|
||||||
|
count(token, "command")
|
||||||
|
if token == "live-reject":
|
||||||
|
self.reply(200, {"success": False, "code": "unsupported", "message": "Rejected"})
|
||||||
|
else:
|
||||||
|
self.reply(200, {"success": True, "code": "ok", "message": "OK"})
|
||||||
|
return
|
||||||
if self.path == "/v1/pair":
|
if self.path == "/v1/pair":
|
||||||
code = body.get("code")
|
code = body.get("code")
|
||||||
if code == "33333333":
|
if code == "33333333":
|
||||||
@@ -86,6 +103,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||||
number = count(token, self.path)
|
number = count(token, self.path)
|
||||||
|
if token == "live-refresh-fail" and self.path == "/v1/state":
|
||||||
|
with counts_lock:
|
||||||
|
commands = counts.get((token, "command"), 0)
|
||||||
|
if commands:
|
||||||
|
self.reply(503, {})
|
||||||
|
return
|
||||||
if token == "revoked":
|
if token == "revoked":
|
||||||
self.reply(401, {})
|
self.reply(401, {})
|
||||||
return
|
return
|
||||||
@@ -147,6 +170,18 @@ 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)
|
||||||
|
if name == "expired":
|
||||||
|
private_key = serialization.load_pem_private_key(key.read_bytes(), password=None)
|
||||||
|
template = x509.load_pem_x509_certificate((work / "valid.pem").read_bytes())
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
builder = (x509.CertificateBuilder().subject_name(template.subject).issuer_name(template.issuer)
|
||||||
|
.public_key(private_key.public_key()).serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(days=2))
|
||||||
|
.not_valid_after(now - datetime.timedelta(days=1)))
|
||||||
|
for extension in template.extensions:
|
||||||
|
builder = builder.add_extension(extension.value, extension.critical)
|
||||||
|
cert.write_bytes(builder.sign(private_key, hashes.SHA256()).public_bytes(serialization.Encoding.PEM))
|
||||||
|
else:
|
||||||
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256",
|
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256",
|
||||||
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)],
|
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)],
|
||||||
check=True, capture_output=True, timeout=15)
|
check=True, capture_output=True, timeout=15)
|
||||||
|
|||||||
@@ -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
@@ -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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user