Compare commits

...

32 Commits

Author SHA1 Message Date
ros 7f90c6a912 Record observed iloader placeholder failure and compatibility build [skip ci] 2026-09-14 01:10:28 +03:00
ros a3133465f9 Import optional profile validation trait [skip ci] 2026-09-14 01:09:34 +03:00
ros b14162b401 Include upstream Windows build prerequisites [skip ci] 2026-09-14 01:08:43 +03:00
ros 63fa120194 Build patched iloader for ExtensionKit IDs, profiles and Keychain [skip ci] 2026-09-14 01:08:19 +03:00
ros 4748363905 Record verified remote player build and remaining device checks [skip ci] 2026-09-14 00:59:14 +03:00
ros 91e305ada5 Close revoked remote sessions and document installation requirements
iOS build and tests / simulator (push) Has been cancelled
2026-09-14 00:51:20 +03:00
ros eaf091b1cf Fix remote player SDK types and signing prefix; test remote commands 2026-09-14 00:48:25 +03:00
ros e2902e6031 Add iOS 27 remote media session and system player extension 2026-09-14 00:17:07 +03:00
ros 2fbf3bf859 Record SSH access and verified Xcode 27 build [skip ci] 2026-09-13 18:13:07 +03:00
ros 1e14bf72a2 Generate deterministic expired TLS fixtures on modern OpenSSL 2026-09-13 18:04:50 +03:00
ros daf9751ccc Move CI to Xcode 27 and select an available secondary simulator 2026-09-13 18:03:22 +03:00
ros e824a8e8eb Document interactive activity release and NowPlaying research [skip ci] 2026-09-13 11:47:51 +03:00
ros 43c78b2a99 Keep lock screen controls within a compact card 2026-09-13 11:41:24 +03:00
ros 1e02247cbe Control PC playback through interactive Live Activity intents 2026-09-13 11:40:23 +03:00
ros fcc66aa328 Document local Live Activity release and device validation [skip ci] 2026-09-13 11:33:42 +03:00
ros ddd44e8edb Add local Live Activity and Dynamic Island track display 2026-09-13 11:27:58 +03:00
ros 2ca6201ad6 Document UI release and remaining device checks [skip ci] 2026-09-13 11:02:44 +03:00
ros aa0b0f0d8e Fit player artwork to the available screen height 2026-09-13 10:56:43 +03:00
ros cbd3c0f6f4 Refresh native pairing and player UI and add the MusicBridge app icon 2026-09-12 21:54:23 +03:00
ros bce04f5d96 Document verified resilience stage and next iOS phases [skip ci] 2026-09-12 21:46:04 +03:00
ros bde6f7a466 Fix foreground error assignment and test persistence recovery 2026-09-12 21:42:41 +03:00
ros c461c26f7f Handle interrupted pairing, artwork retries and revoked access reliably 2026-09-12 21:41:37 +03:00
ros afa771198d Record verified 0.2.0 IPA and simulator preview [skip ci] 2026-09-12 21:31:02 +03:00
ros e384e9ae1e Restore pairing prompt when an unpaired app returns to foreground 2026-09-12 16:00:12 +03:00
ros ad695f01ae Boot and install the app before capturing the simulator screen 2026-09-12 15:57:29 +03:00
ros 6afafcf0e2 Make pairing fingerprint readable and capture simulator UI in CI 2026-09-12 15:55:33 +03:00
ros 397caf23ce Add camera QR pairing, Bonjour discovery and pinned address recovery 2026-09-12 15:52:31 +03:00
ros 5f16ec48aa Document verified TLS fix and iPhone installation build [skip ci] 2026-09-12 15:38:43 +03:00
ros 93f7c9cb4e Handle task-level server trust challenges with the same certificate pin 2026-09-12 15:35:59 +03:00
ros a17a7f8027 Allow pinned self-signed TLS for private IPv4 ranges on iOS 17+ 2026-09-12 15:33:17 +03:00
ros f8de824752 Avoid reverse DNS delays in CI TLS fixture 2026-09-12 15:30:50 +03:00
ros d886a8a523 Add real iOS HTTPS and WSS certificate regression checks 2026-09-12 15:29:11 +03:00
51 changed files with 3644 additions and 125 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Build MusicBridge compatible iloader
on:
workflow_dispatch:
permissions:
contents: read
jobs:
windows:
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/iloader
ref: 348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
path: build/compat/iloader
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/isideload
ref: f6a4d5dba717d72fc2af63eaba26b27ba44116be
path: build/compat/isideload
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/apple-crates
ref: 5da0b8df9b202434b1eff2700059b1ee142592ac
path: build/compat/apple-crates
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: ilammy/msvc-dev-cmd@v1
- name: Install native build prerequisites
run: choco install strawberryperl make --no-progress -y
- uses: oven-sh/setup-bun@v2
- name: Patch pinned sources
run: python tools/iloader/prepare.py build/compat
- name: Install frontend dependencies
working-directory: build/compat/iloader
run: bun install --frozen-lockfile
- name: Test extension discovery, bundle IDs and signing permissions
working-directory: build/compat/iloader/src-tauri
run: |
cargo test -p isideload --lib musicbridge
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo test -p apple-codesign --lib musicbridge
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build portable GUI
working-directory: build/compat/iloader
run: bunx tauri build --no-bundle --config src-tauri/ci.conf.json
- name: Package helper with provenance
run: |
New-Item -ItemType Directory -Force build/iloader-musicbridge
Copy-Item build/compat/iloader/src-tauri/target/release/iloader.exe build/iloader-musicbridge/iloader-musicbridge.exe
Copy-Item tools/iloader/README.md build/iloader-musicbridge/README.md
Get-ChildItem build/compat/iloader -Filter 'LICENSE*' | Copy-Item -Destination build/iloader-musicbridge
Get-FileHash build/iloader-musicbridge/iloader-musicbridge.exe | Format-List | Out-File build/iloader-musicbridge/SHA256.txt
- uses: actions/upload-artifact@v4
with:
name: iloader-musicbridge-windows
path: build/iloader-musicbridge
if-no-files-found: error
retention-days: 7
+52 -1
View File
@@ -15,7 +15,7 @@ concurrency:
jobs: jobs:
simulator: simulator:
runs-on: macos-15 runs-on: xcode-27
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -24,6 +24,7 @@ jobs:
- name: Inspect toolchain and project - name: Inspect toolchain and project
run: | run: |
xcodebuild -version xcodebuild -version
xcodebuild -version | head -n 1 | grep -E "^Xcode 27([.]|$)"
swift --version swift --version
python3 tools/validate_project.py python3 tools/validate_project.py
xcodebuild -list -project MusicBridge.xcodeproj xcodebuild -list -project MusicBridge.xcodeproj
@@ -36,6 +37,17 @@ jobs:
run: | run: |
set -o pipefail set -o pipefail
mkdir -p build mkdir -p build
python3 -m venv build/test-tools
build/test-tools/bin/pip install cryptography==46.0.3
build/test-tools/bin/python -u tools/tls_fixture.py > build/tls-fixture.log 2>&1 &
TLS_PID=$!
trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT
for attempt in {1..30}; do
if grep -q 'TLS fixtures ready' build/tls-fixture.log; then break; fi
if ! kill -0 "$TLS_PID" 2>/dev/null; then cat build/tls-fixture.log; exit 1; fi
sleep 1
done
grep -q 'TLS fixtures ready' build/tls-fixture.log || { cat build/tls-fixture.log; exit 1; }
xcodebuild test \ xcodebuild test \
-project MusicBridge.xcodeproj \ -project MusicBridge.xcodeproj \
-scheme MusicBridge \ -scheme MusicBridge \
@@ -53,11 +65,50 @@ jobs:
name: ios-test-results name: ios-test-results
path: | path: |
build/xcodebuild.log build/xcodebuild.log
build/tls-fixture.log
build/MusicBridge.xcresult build/MusicBridge.xcresult
if-no-files-found: warn if-no-files-found: warn
retention-days: 7 retention-days: 7
- name: Archive simulator application - name: Archive simulator application
run: ditto -c -k --sequesterRsrc --keepParent DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app build/MusicBridge-simulator.zip run: ditto -c -k --sequesterRsrc --keepParent DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app build/MusicBridge-simulator.zip
- name: Capture pairing screen
run: |
xcrun simctl bootstatus "$SIMULATOR_ID" -b
xcrun simctl install "$SIMULATOR_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge
sleep 3
xcrun simctl io "$SIMULATOR_ID" screenshot build/pairing-screen.png
xcrun simctl terminate "$SIMULATOR_ID" ru.yukinoki.musicbridge
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player
sleep 2
xcrun simctl io "$SIMULATOR_ID" screenshot build/player-light-screen.png
xcrun simctl ui "$SIMULATOR_ID" appearance dark
sleep 1
xcrun simctl io "$SIMULATOR_ID" screenshot build/player-dark-screen.png
xcrun simctl terminate "$SIMULATOR_ID" ru.yukinoki.musicbridge
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player --preview-large-text
sleep 2
xcrun simctl io "$SIMULATOR_ID" screenshot build/player-large-text-screen.png
xcrun simctl terminate "$SIMULATOR_ID" ru.yukinoki.musicbridge
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player --preview-live-card
sleep 2
xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-dark-screen.png
xcrun simctl ui "$SIMULATOR_ID" appearance light
sleep 1
xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-light-screen.png
RUNTIME=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); print(next(k for k,v in d["devices"].items() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)))')
COMPACT_ID=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); v=next(v for v in d["devices"].values() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)); candidates=[x for x in v if x.get("isAvailable") and x.get("name", "").startswith("iPhone") and x["udid"]!=os.environ["SIMULATOR_ID"]]; print(min(candidates,key=lambda x:("Max" in x["name"] or "Plus" in x["name"], "Pro" in x["name"], x["name"]))["udid"])')
xcrun simctl bootstatus "$COMPACT_ID" -b
xcrun simctl install "$COMPACT_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app
xcrun simctl launch "$COMPACT_ID" ru.yukinoki.musicbridge --preview-player
sleep 3
xcrun simctl io "$COMPACT_ID" screenshot build/player-compact-screen.png
- name: Save pairing screen
uses: actions/upload-artifact@v4
with:
name: ios-ui-preview
path: build/*-screen.png
retention-days: 7
- name: Save simulator application - name: Save simulator application
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+540 -15
View File
@@ -15,6 +15,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "065A9D559DC80AAF876DA757"; "fileRef" = "065A9D559DC80AAF876DA757";
}; };
"763FA6A137E5E94274AC56A3" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "ConnectionStorage.swift";
"sourceTree" = "<group>";
};
"40D2920E2C996D62B7190BA4" = {
"isa" = "PBXBuildFile";
"fileRef" = "763FA6A137E5E94274AC56A3";
};
"971C0AE92F167DE7AB145086" = { "971C0AE92F167DE7AB145086" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -25,6 +35,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "971C0AE92F167DE7AB145086"; "fileRef" = "971C0AE92F167DE7AB145086";
}; };
"5F6F408829AB46D3B85FB911" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "DiscoveryStore.swift";
"sourceTree" = "<group>";
};
"842E87CD23DCB5BBCA4340F6" = {
"isa" = "PBXBuildFile";
"fileRef" = "5F6F408829AB46D3B85FB911";
};
"76FB6BFFC478599567424755" = { "76FB6BFFC478599567424755" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -35,6 +55,26 @@
"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" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityManager.swift";
"sourceTree" = "<group>";
};
"181CACDC331104A1E16CFC20" = {
"isa" = "PBXBuildFile";
"fileRef" = "EE2357BD5CA3755AFEB3171A";
};
"124CF3F5AC075D1D6042DF64" = { "124CF3F5AC075D1D6042DF64" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -65,11 +105,217 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "9C432A0DA0BEC173839EBF86"; "fileRef" = "9C432A0DA0BEC173839EBF86";
}; };
"6B0F2027221CDD63E8FD4C59" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "PreviewSupport.swift";
"sourceTree" = "<group>";
};
"287044ADBD143F929035BC61" = {
"isa" = "PBXBuildFile";
"fileRef" = "6B0F2027221CDD63E8FD4C59";
};
"7148B52E915554F67E875C26" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "QRScanner.swift";
"sourceTree" = "<group>";
};
"BE808A0D13A16FE67D32B7FB" = {
"isa" = "PBXBuildFile";
"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", "971C0AE92F167DE7AB145086", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86",); "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "088AC623B345717D980B3156", "8BF729FFE074CAEE622C0292",);
};
"1C6CBD84758D03C1B40D19C7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerAttributes.swift";
"sourceTree" = "<group>";
};
"0D17ED02E5B8770D785508A3" = {
"isa" = "PBXBuildFile";
"fileRef" = "1C6CBD84758D03C1B40D19C7";
};
"77372299BB61F0ED174508F0" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerCredentials.swift";
"sourceTree" = "<group>";
};
"2599D4B5F13D672B723A4BD3" = {
"isa" = "PBXBuildFile";
"fileRef" = "77372299BB61F0ED174508F0";
};
"897E8908B2E9AD60A31E6897" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerTransport.swift";
"sourceTree" = "<group>";
};
"804FFB386BC1AF4252EDA177" = {
"isa" = "PBXBuildFile";
"fileRef" = "897E8908B2E9AD60A31E6897";
};
"D2DF40467DEC980974060FD5" = {
"isa" = "PBXGroup";
"path" = "RemoteShared";
"sourceTree" = "<group>";
"children" = ("1C6CBD84758D03C1B40D19C7", "77372299BB61F0ED174508F0", "897E8908B2E9AD60A31E6897",);
};
"54D4A2CB4EFD85CA136E4B32" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicBridgeRemote.swift";
"sourceTree" = "<group>";
};
"ED075C376D7772FEBD3EC117" = {
"isa" = "PBXBuildFile";
"fileRef" = "54D4A2CB4EFD85CA136E4B32";
};
"E2D7CC5D9CE02F4A7B8243D8" = {
"isa" = "PBXGroup";
"path" = "MusicBridgeRemote";
"sourceTree" = "<group>";
"children" = ("54D4A2CB4EFD85CA136E4B32",);
};
"511889FA962E9B3AC1BC92DE" = {
"isa" = "PBXBuildFile";
"fileRef" = "1C6CBD84758D03C1B40D19C7";
};
"A3C91023F16219D79F80723C" = {
"isa" = "PBXBuildFile";
"fileRef" = "77372299BB61F0ED174508F0";
};
"823EB4050AFBFC9AE72A3C03" = {
"isa" = "PBXBuildFile";
"fileRef" = "897E8908B2E9AD60A31E6897";
};
"8EE58DCF843B86D38CF45CC0" = {
"isa" = "PBXBuildFile";
"fileRef" = "124CF3F5AC075D1D6042DF64";
};
"4AAE356B6279CE0B686A3B9D" = {
"isa" = "PBXBuildFile";
"fileRef" = "065A9D559DC80AAF876DA757";
};
"7C5E653CCD928B143AB68F09" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicActivityAttributes.swift";
"sourceTree" = "<group>";
};
"C7506B09240305F44C61EB60" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C5E653CCD928B143AB68F09";
};
"7C0FE298B442263CA281ABB7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicActivityViews.swift";
"sourceTree" = "<group>";
};
"4FD4638045EDA8D7CCFD00AF" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7";
};
"6EDC1D9A7204CF776CE3B281" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicControlIntent.swift";
"sourceTree" = "<group>";
};
"962BED0D8E17961AD72EC24F" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"E3C4B39D6D5013477C98CD58" = {
"isa" = "PBXGroup";
"path" = "Shared";
"sourceTree" = "<group>";
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7", "6EDC1D9A7204CF776CE3B281",);
};
"ADFE24674E0FB119E649EC54" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicBridgeWidgets.swift";
"sourceTree" = "<group>";
};
"F942C4AC2D1ABF86575E779A" = {
"isa" = "PBXBuildFile";
"fileRef" = "ADFE24674E0FB119E649EC54";
};
"F932ED2EB70210651D1121CB" = {
"isa" = "PBXGroup";
"path" = "MusicBridgeWidgets";
"sourceTree" = "<group>";
"children" = ("ADFE24674E0FB119E649EC54",);
};
"D739DF07A523D6FA1D29EECD" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C5E653CCD928B143AB68F09";
};
"E41BBFD90B0D341CC92C100E" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7";
};
"41D7F12D1C7D61DAF319E724" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"8BF729FFE074CAEE622C0292" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "folder.assetcatalog";
"path" = "Assets.xcassets";
"sourceTree" = "<group>";
};
"5522B3B3B2EACC1C8F9BEAAC" = {
"isa" = "PBXBuildFile";
"fileRef" = "8BF729FFE074CAEE622C0292";
};
"C9F705D432CA45B6056BC572" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "DiscoveryTests.swift";
"sourceTree" = "<group>";
};
"1DD7BB0437E9F53586831F22" = {
"isa" = "PBXBuildFile";
"fileRef" = "C9F705D432CA45B6056BC572";
};
"65CE96DA4162DE4255412BC7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityCommandTests.swift";
"sourceTree" = "<group>";
};
"E87BA6EF1948C84AC29D8EA8" = {
"isa" = "PBXBuildFile";
"fileRef" = "65CE96DA4162DE4255412BC7";
};
"9819FF7CC21CAAD1BDB8D1B8" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityTests.swift";
"sourceTree" = "<group>";
};
"BD0B3FAC7F1259D249134283" = {
"isa" = "PBXBuildFile";
"fileRef" = "9819FF7CC21CAAD1BDB8D1B8";
}; };
"ACD0393DCDDDF044F2966340" = { "ACD0393DCDDDF044F2966340" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -81,11 +327,41 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "ACD0393DCDDDF044F2966340"; "fileRef" = "ACD0393DCDDDF044F2966340";
}; };
"464F6C65BC2FAB6BCCC77F46" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "ResilienceTests.swift";
"sourceTree" = "<group>";
};
"EFDB280E0B23F7E2A246BC89" = {
"isa" = "PBXBuildFile";
"fileRef" = "464F6C65BC2FAB6BCCC77F46";
};
"7749A66489642A16382E8E42" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerTests.swift";
"sourceTree" = "<group>";
};
"340F55A9E43E8FFA66453825" = {
"isa" = "PBXBuildFile";
"fileRef" = "7749A66489642A16382E8E42";
};
"8BED860F1BD59C500D215E8D" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "TLSTests.swift";
"sourceTree" = "<group>";
};
"DC1A5B0B94F4C98F1CA36676" = {
"isa" = "PBXBuildFile";
"fileRef" = "8BED860F1BD59C500D215E8D";
};
"E1AD768E105BF42350625991" = { "E1AD768E105BF42350625991" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridgeTests"; "path" = "MusicBridgeTests";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("ACD0393DCDDDF044F2966340", "F16D05EC6B29248D2C61ADB1",); "children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
}; };
"F16D05EC6B29248D2C61ADB1" = { "F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -97,6 +373,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "F16D05EC6B29248D2C61ADB1"; "fileRef" = "F16D05EC6B29248D2C61ADB1";
}; };
"4731785CAB9BB914B8834AE6" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "text.json";
"path" = "tls-fixture.json";
"sourceTree" = "<group>";
};
"7D079549A35384F9CAE8A5C4" = {
"isa" = "PBXBuildFile";
"fileRef" = "4731785CAB9BB914B8834AE6";
};
"7C35A0276A762371ABAE5596" = { "7C35A0276A762371ABAE5596" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"explicitFileType" = "wrapper.application"; "explicitFileType" = "wrapper.application";
@@ -107,7 +393,7 @@
"72B74C8A2143F64FD53D71E5" = { "72B74C8A2143F64FD53D71E5" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("82F1C33CE4F8B8F641FF0461", "16EA630DE1FE9F0A16580C9D", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38",); "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" = {
@@ -119,9 +405,65 @@
"69816CB820E8173702C54C37" = { "69816CB820E8173702C54C37" = {
"isa" = "PBXResourcesBuildPhase"; "isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = (); "files" = ("5522B3B3B2EACC1C8F9BEAAC",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"310BC1A83962BC738E0B4980" = {
"isa" = "PBXBuildFile";
"fileRef" = "CE9225C1B8E80F534681689F";
"settings" = {
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
};
};
"013486AD4616563D96D6E9AC" = {
"isa" = "PBXBuildFile";
"fileRef" = "F1BB1F45195BEB71A550539C";
"settings" = {
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
};
};
"AFC32963E7959E7A34264826" = {
"isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647;
"dstPath" = "";
"dstSubfolderSpec" = 13;
"files" = ("310BC1A83962BC738E0B4980",);
"name" = "Embed App Extensions";
"runOnlyForDeploymentPostprocessing" = 0;
};
"B2EC54BE7DEB4C17571E7035" = {
"isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647;
"dstPath" = "$(EXTENSIONS_FOLDER_PATH)";
"dstSubfolderSpec" = 16;
"files" = ("013486AD4616563D96D6E9AC",);
"name" = "Embed ExtensionKit Extensions";
"runOnlyForDeploymentPostprocessing" = 0;
};
"01E77404E10F254C43E64C55" = {
"isa" = "PBXContainerItemProxy";
"containerPortal" = "244210E48437B6556980A702";
"proxyType" = 1;
"remoteGlobalIDString" = "BFE9A9DC8C194372006C3ACC";
"remoteInfo" = "MusicBridgeWidgets";
};
"A4C2D386AB0259F33DCDC427" = {
"isa" = "PBXTargetDependency";
"target" = "BFE9A9DC8C194372006C3ACC";
"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";
@@ -136,8 +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" = "1"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.1.0"; "MARKETING_VERSION" = "0.7.0";
"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";
@@ -157,8 +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" = "1"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.1.0"; "MARKETING_VERSION" = "0.7.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
}; };
}; };
@@ -175,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",); "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826", "B2EC54BE7DEB4C17571E7035",);
"buildRules" = (); "buildRules" = ();
"dependencies" = (); "dependencies" = ("A4C2D386AB0259F33DCDC427", "F76CBC167D5B68BDC0376A94",);
}; };
"5E4BE508157A7B2CB7B4C2F2" = { "5E4BE508157A7B2CB7B4C2F2" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -189,7 +535,7 @@
"AB453A3F0E1490D48374A69A" = { "AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("17E248E58CB43C680B48DD51",); "files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"9AA2A4C344B5C474AF5D336C" = { "9AA2A4C344B5C474AF5D336C" = {
@@ -201,7 +547,7 @@
"5D92C2C610AA78B83DDF2133" = { "5D92C2C610AA78B83DDF2133" = {
"isa" = "PBXResourcesBuildPhase"; "isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("F8A2DE539EBA355A3807D729",); "files" = ("F8A2DE539EBA355A3807D729", "7D079549A35384F9CAE8A5C4",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"DAB01D73CBBCF1FEDE84E2D6" = { "DAB01D73CBBCF1FEDE84E2D6" = {
@@ -271,16 +617,195 @@
"buildRules" = (); "buildRules" = ();
"dependencies" = ("F9A94CEF8CF7D85CE881E703",); "dependencies" = ("F9A94CEF8CF7D85CE881E703",);
}; };
"CE9225C1B8E80F534681689F" = {
"isa" = "PBXFileReference";
"explicitFileType" = "wrapper.app-extension";
"includeInIndex" = 0;
"path" = "MusicBridgeWidgets.appex";
"sourceTree" = "BUILT_PRODUCTS_DIR";
};
"FF6BDEA70DDE9A3C7EEA9DCF" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E", "41D7F12D1C7D61DAF319E724",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"49C806A88B3E70B1004AB10D" = {
"isa" = "PBXFrameworksBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"64D7ED5162C7963DE736D0DF" = {
"isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"D30B018A8B4A6A434860DC58" = {
"isa" = "XCBuildConfiguration";
"name" = "Debug";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.widgets";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "17.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/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";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG MUSICBRIDGE_WIDGET";
};
};
"6A281BFCCFF96276806F5934" = {
"isa" = "XCBuildConfiguration";
"name" = "Release";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.widgets";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "17.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/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";
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = " MUSICBRIDGE_WIDGET";
};
};
"5DCAC255CDC0CA72231320F4" = {
"isa" = "XCConfigurationList";
"buildConfigurations" = ("D30B018A8B4A6A434860DC58", "6A281BFCCFF96276806F5934",);
"defaultConfigurationIsVisible" = 0;
"defaultConfigurationName" = "Release";
};
"BFE9A9DC8C194372006C3ACC" = {
"isa" = "PBXNativeTarget";
"name" = "MusicBridgeWidgets";
"productName" = "MusicBridgeWidgets";
"productReference" = "CE9225C1B8E80F534681689F";
"productType" = "com.apple.product-type.app-extension";
"buildConfigurationList" = "5DCAC255CDC0CA72231320F4";
"buildPhases" = ("FF6BDEA70DDE9A3C7EEA9DCF", "49C806A88B3E70B1004AB10D", "64D7ED5162C7963DE736D0DF",);
"buildRules" = ();
"dependencies" = ();
};
"F1BB1F45195BEB71A550539C" = {
"isa" = "PBXFileReference";
"explicitFileType" = "wrapper.extensionkit-extension";
"includeInIndex" = 0;
"path" = "MusicBridgeRemote.appex";
"sourceTree" = "BUILT_PRODUCTS_DIR";
};
"C2BA95929A255B0F1FC04A79" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("ED075C376D7772FEBD3EC117", "511889FA962E9B3AC1BC92DE", "A3C91023F16219D79F80723C", "823EB4050AFBFC9AE72A3C03", "8EE58DCF843B86D38CF45CC0", "4AAE356B6279CE0B686A3B9D",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"D3E3B87195B5FF1DA14795C1" = {
"isa" = "PBXFrameworksBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"9C3BF80554FD9B39BDF1988D" = {
"isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"809F689056832D3F2A4D91B6" = {
"isa" = "XCBuildConfiguration";
"name" = "Debug";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "27.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
};
};
"5E42ACAF0DE32186ED14E82F" = {
"isa" = "XCBuildConfiguration";
"name" = "Release";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "27.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
};
};
"AD0B31AD2D7D0B6DCF3ACF58" = {
"isa" = "XCConfigurationList";
"buildConfigurations" = ("809F689056832D3F2A4D91B6", "5E42ACAF0DE32186ED14E82F",);
"defaultConfigurationIsVisible" = 0;
"defaultConfigurationName" = "Release";
};
"714DB4A269580A6F5127F5CC" = {
"isa" = "PBXNativeTarget";
"name" = "MusicBridgeRemote";
"productName" = "MusicBridgeRemote";
"productReference" = "F1BB1F45195BEB71A550539C";
"productType" = "com.apple.product-type.extensionkit-extension";
"buildConfigurationList" = "AD0B31AD2D7D0B6DCF3ACF58";
"buildPhases" = ("C2BA95929A255B0F1FC04A79", "D3E3B87195B5FF1DA14795C1", "9C3BF80554FD9B39BDF1988D",);
"buildRules" = ();
"dependencies" = ();
};
"0A3E27B8CA818264D75C8D81" = { "0A3E27B8CA818264D75C8D81" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"name" = "Products"; "name" = "Products";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2",); "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F", "F1BB1F45195BEB71A550539C",);
}; };
"0D6E4079E36703EBD37C0072" = { "0D6E4079E36703EBD37C0072" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("026F39792BBA0E5032536CED", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); "children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "D2DF40467DEC980974060FD5", "E2D7CC5D9CE02F4A7B8243D8", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",);
}; };
"73EBF6644258C120893E685D" = { "73EBF6644258C120893E685D" = {
"isa" = "XCBuildConfiguration"; "isa" = "XCBuildConfiguration";
@@ -322,7 +847,7 @@
"productRefGroup" = "0A3E27B8CA818264D75C8D81"; "productRefGroup" = "0A3E27B8CA818264D75C8D81";
"projectDirPath" = ""; "projectDirPath" = "";
"projectRoot" = ""; "projectRoot" = "";
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A",); "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC", "714DB4A269580A6F5127F5CC",);
}; };
}; };
"rootObject" = "244210E48437B6556980A702"; "rootObject" = "244210E48437B6556980A702";
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</array>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}
@@ -0,0 +1 @@
{"info":{"author":"xcode","version":1}}
+29 -9
View File
@@ -9,6 +9,9 @@ final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
#if DEBUG
print("MusicBridge TLS: received server authentication challenge")
#endif
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
challenge.protectionSpace.host == connection.endpoint.host, challenge.protectionSpace.host == connection.endpoint.host,
challenge.protectionSpace.port == (connection.endpoint.port ?? 443), challenge.protectionSpace.port == (connection.endpoint.port ?? 443),
@@ -19,18 +22,31 @@ final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate
let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data) let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data)
.map { String(format: "%02X", $0) }.joined() .map { String(format: "%02X", $0) }.joined()
guard digest == connection.fingerprint else { guard digest == connection.fingerprint else {
#if DEBUG
print("MusicBridge TLS: fingerprint mismatch")
#endif
completionHandler(.cancelAuthenticationChallenge, nil); return completionHandler(.cancelAuthenticationChallenge, nil); return
} }
// Trust only this exact out-of-band certificate, while checking its validity dates. // Trust only this exact out-of-band certificate, while checking its validity dates.
SecTrustSetAnchorCertificates(trust, [leaf] as CFArray) SecTrustSetAnchorCertificates(trust, [leaf] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, true) SecTrustSetAnchorCertificatesOnly(trust, true)
SecTrustSetPolicies(trust, SecPolicyCreateBasicX509()) SecTrustSetPolicies(trust, SecPolicyCreateBasicX509())
guard SecTrustEvaluateWithError(trust, nil) else { var trustError: CFError?
guard SecTrustEvaluateWithError(trust, &trustError) else {
#if DEBUG
print("MusicBridge TLS: pinned certificate rejected: \(String(describing: trustError))")
#endif
completionHandler(.cancelAuthenticationChallenge, nil); return completionHandler(.cancelAuthenticationChallenge, nil); return
} }
completionHandler(.useCredential, URLCredential(trust: trust)) completionHandler(.useCredential, URLCredential(trust: trust))
} }
func urlSession(_ session: URLSession, task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
urlSession(session, didReceive: challenge, completionHandler: completionHandler)
}
func urlSession(_ session: URLSession, task: URLSessionTask, func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler: @escaping (URLRequest?) -> Void) {
@@ -43,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)
@@ -70,14 +86,18 @@ final class BridgeClient {
let (bytes, response) = try await session.bytes(for: request) let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") } guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") }
if response.statusCode == 401 { throw BridgeError.unauthorized } if response.statusCode == 401 { throw BridgeError.unauthorized }
guard (200...299).contains(response.statusCode) else { let success = (200...299).contains(response.statusCode)
throw BridgeError.message("ПК отклонил запрос (HTTP \(response.statusCode)). Проверьте код и состояние агента.") let bodyLimit = success ? limit : min(limit, 16 * 1024)
}
var result = Data() var result = Data()
for try await byte in bytes { for try await byte in bytes {
guard result.count < limit else { throw BridgeError.message("Ответ ПК слишком большой.") } guard result.count < bodyLimit else { throw BridgeError.message("Ответ ПК слишком большой.") }
result.append(byte) result.append(byte)
} }
guard success else {
struct Failure: Decodable { let code: String? }
let code = (try? JSONDecoder().decode(Failure.self, from: result))?.code
throw BridgeError.http(status: response.statusCode, code: code)
}
return result return result
} }
@@ -85,7 +105,7 @@ final class BridgeClient {
let body = try JSONSerialization.data(withJSONObject: ["code": code, "deviceName": "MusicBridge iPhone"]) let body = try JSONSerialization.data(withJSONObject: ["code": code, "deviceName": "MusicBridge iPhone"])
let response = try JSONDecoder().decode(PairResponse.self, from: await data(request("v1/pair", body: body, authenticated: false))) let response = try JSONDecoder().decode(PairResponse.self, from: await data(request("v1/pair", body: body, authenticated: false)))
guard response.success, let token = response.token, Connection.isFingerprint(token) else { guard response.success, let token = response.token, Connection.isFingerprint(token) else {
throw BridgeError.message("Сопряжение отклонено: \(response.code)") throw BridgeError.http(status: 403, code: response.code)
} }
return token return token
} }
+9
View File
@@ -0,0 +1,9 @@
import Foundation
// Injectable persistence keeps lifecycle tests away from the user's real Keychain.
struct ConnectionStorage {
var load: () throws -> Connection?
var save: (Connection) throws -> Void
var delete: () throws -> Void
static let keychain = Self(load: KeychainStore.load, save: KeychainStore.save, delete: KeychainStore.delete)
}
+190 -35
View File
@@ -2,33 +2,87 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
@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 {
NavigationStack { NavigationStack {
GeometryReader { viewport in
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 20) {
Label(store.status, systemImage: store.connected ? "wifi" : "wifi.slash") HStack(spacing: 8) {
.font(.subheadline).foregroundStyle(.secondary) Circle().fill(store.connected ? Color.green : Color.secondary).frame(width: 7, height: 7)
Text(store.status).font(.subheadline).foregroundStyle(.secondary)
Spacer(minLength: 0)
}.accessibilityElement(children: .combine)
if store.connection == nil { if store.connection == nil {
PairingView(store: store) PairingView(store: store, discovery: store.discovery)
} else { } else {
PlayerView(store: store) PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30))))
Text(store.connection?.endpoint.host ?? "")
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) .font(.caption).foregroundStyle(.secondary)
if !store.connected { Text("Без фоновых push-обновлений трек обновляется при открытом приложении или после команды пульта.")
.font(.caption).foregroundStyle(.secondary)
if let error = systemPlayer.error { Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
}
VStack(alignment: .leading, spacing: 10) {
Button {
if liveActivity.running { liveActivity.end() }
else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) }
} label: {
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.")
.font(.caption).foregroundStyle(.secondary)
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
if !store.connected && !store.needsPairing {
Button("Подключиться снова") { store.resume() } Button("Подключиться снова") { store.resume() }
} }
Button("Забыть компьютер", role: .destructive) { showForget = true } if store.needsPairing {
.font(.footnote) Button("Выполнить сопряжение заново") { showForget = true }
.buttonStyle(.borderedProminent).controlSize(.large)
}
} }
if let error = store.error { if let error = store.error {
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled) Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
Button("Скрыть сообщение") { store.error = nil }.font(.footnote)
} }
}.padding(24) }.padding(20)
}
.scrollDismissesKeyboard(.interactively)
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("MusicBridge") .navigationTitle("MusicBridge")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if store.connection != nil {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Text(store.connection?.endpoint.host ?? "Компьютер")
Button("Подключиться снова", systemImage: "arrow.clockwise") { store.resume() }.disabled(store.needsPairing)
Button("Забыть компьютер", systemImage: "trash", role: .destructive) { showForget = true }
} label: { Image(systemName: "desktopcomputer").frame(minWidth: 48, minHeight: 48) }
.accessibilityLabel("Настройки компьютера")
}
}
}
.confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) { .confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) {
Button("Забыть", role: .destructive) { store.forget() } Button("Забыть", role: .destructive) { store.forget() }
} message: { } message: {
@@ -38,28 +92,89 @@ struct ContentView: View {
} }
} }
private extension View {
func bridgeCard() -> some View {
padding(18).background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 24))
}
}
private struct PairingView: View { private struct PairingView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
@ObservedObject var discovery: DiscoveryStore
@State private var endpoint = "https://" @State private var endpoint = "https://"
@State private var fingerprint = "" @State private var fingerprint = ""
@State private var code = "" @State private var code = ""
@State private var invitation = "" @State private var invitation = ""
@State private var showScanner = false
@State private var showDetails = false
@State private var prepared = false
@FocusState private var editing: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Image(systemName: "desktopcomputer.and.arrow.down") VStack(spacing: 14) {
.font(.system(size: 48)).foregroundStyle(.indigo) Image(systemName: "waveform.path")
.frame(maxWidth: .infinity).accessibilityHidden(true) .font(.system(size: 40, weight: .medium)).foregroundStyle(.white)
Text("Музыка на ПК. Управление здесь.").font(.title2.bold()) .frame(width: 84, height: 84)
Text("Откройте сопряжение в MusicBridge на Windows. Подключите iPhone к той же домашней сети.") .background(LinearGradient(colors: [.indigo, .teal], startPoint: .topLeading, endPoint: .bottomTrailing), in: RoundedRectangle(cornerRadius: 25))
.foregroundStyle(.secondary) .accessibilityHidden(true)
Group { Text("Ваш ПК. Ваша музыка.").font(.title2.bold()).multilineTextAlignment(.center)
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL) Text("Откройте QR в агенте на компьютере. iPhone и ПК должны быть в одной сети.")
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint) .font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
TextField("Восьмизначный код", text: $code).keyboardType(.numberPad) Button { editing = false; showScanner = true } label: {
}.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled() Label("Сканировать QR", systemImage: "qrcode.viewfinder")
Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary) .frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
}.frame(maxWidth: .infinity).bridgeCard()
if prepared {
Label("QR прочитан · данные готовы", systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.semibold)).foregroundStyle(.teal)
Text(endpoint).font(.footnote.monospaced()).textSelection(.enabled)
Text("Если приглашение пришло не с экрана вашего ПК, сверьте отпечаток в деталях подключения.")
.font(.caption).foregroundStyle(.secondary)
}
DisclosureGroup("Найти компьютер") {
VStack(alignment: .leading, spacing: 12) {
Text(discovery.message).font(.footnote).foregroundStyle(.secondary)
ForEach(discovery.computers) { computer in
Button { Button {
endpoint = computer.endpoint
fingerprint = computer.fingerprint
code = ""
showDetails = true; prepared = false
store.error = nil
} label: {
VStack(alignment: .leading) {
Label(computer.name, systemImage: "desktopcomputer")
Text(computer.endpoint).font(.caption)
}
}
}
Text("Выберите ПК, сверьте весь отпечаток с его экраном и введите свежий код сопряжения.")
.font(.caption).foregroundStyle(.secondary)
Button("Повторить поиск") { discovery.restart() }
Button("Настройки доступа к сети") {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
}.padding(.top, 8)
}.bridgeCard().disabled(store.pairing)
DisclosureGroup(prepared ? "Детали подключения" : "Подключить вручную", isExpanded: $showDetails) {
VStack(alignment: .leading, spacing: 12) {
Group {
Text("Адрес компьютера").font(.subheadline.weight(.medium))
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL)
Text("Отпечаток сертификата").font(.subheadline.weight(.medium))
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint, axis: .vertical)
.lineLimit(2...4).font(.system(.footnote, design: .monospaced))
Text("Код сопряжения").font(.subheadline.weight(.medium))
TextField("Восьмизначный код", text: $code).keyboardType(.numberPad)
}.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled().focused($editing)
Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary)
}.padding(.top, 12)
}.bridgeCard().disabled(store.pairing)
if prepared || showDetails {
Button {
editing = false
Task { await store.pair(endpoint: endpoint.trimmingCharacters(in: .whitespacesAndNewlines), Task { await store.pair(endpoint: endpoint.trimmingCharacters(in: .whitespacesAndNewlines),
fingerprint: fingerprint.trimmingCharacters(in: .whitespacesAndNewlines), fingerprint: fingerprint.trimmingCharacters(in: .whitespacesAndNewlines),
code: code.trimmingCharacters(in: .whitespacesAndNewlines)) } code: code.trimmingCharacters(in: .whitespacesAndNewlines)) }
@@ -69,27 +184,47 @@ private struct PairingView: View {
Text(store.pairing ? "Сопряжение…" : "Подключить компьютер") Text(store.pairing ? "Сопряжение…" : "Подключить компьютер")
}.frame(maxWidth: .infinity) }.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing) }.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
DisclosureGroup("Импорт строки из QR") { }
DisclosureGroup("Вставить приглашение") {
VStack(spacing: 12) { VStack(spacing: 12) {
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical) TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled() .lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Заполнить из приглашения") { Button("Заполнить из приглашения") {
importText(invitation)
}
}.padding(.top, 12)
}.bridgeCard().disabled(store.pairing)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Готово") { editing = false } }
}
.sheet(isPresented: $showScanner) { QRScannerSheet(receive: importText) }
.onAppear { consumeIncoming() }
.onChange(of: store.incomingInvitation) { _, _ in consumeIncoming() }
}
private func consumeIncoming() {
guard let text = store.incomingInvitation else { return }
store.incomingInvitation = nil
importText(text)
}
private func importText(_ text: String) {
do { do {
let parsed = try Invitation.parse(invitation.trimmingCharacters(in: .whitespacesAndNewlines)) let parsed = try Invitation.parse(text.trimmingCharacters(in: .whitespacesAndNewlines))
endpoint = parsed.endpoint endpoint = parsed.endpoint
fingerprint = parsed.certificateSha256 fingerprint = parsed.certificateSha256
code = parsed.code code = parsed.code
prepared = true; showDetails = false; editing = false
store.error = nil store.error = nil
} catch { store.error = error.localizedDescription } } catch { store.error = error.localizedDescription }
} }
}.padding(.top, 12)
}.disabled(store.pairing)
}
}
} }
private struct PlayerView: View { private struct PlayerView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
let coverSize: CGFloat
@Environment(\.dynamicTypeSize) private var textSize
@State private var seekValue = 0.0 @State private var seekValue = 0.0
@State private var seeking = false @State private var seeking = false
@State private var volumeValue = 0.0 @State private var volumeValue = 0.0
@@ -101,15 +236,22 @@ private struct PlayerView: View {
if let artwork = store.artwork { if let artwork = store.artwork {
Image(uiImage: artwork).resizable().scaledToFit() Image(uiImage: artwork).resizable().scaledToFit()
} else { } else {
RoundedRectangle(cornerRadius: 24).fill(.indigo.opacity(0.12)) RoundedRectangle(cornerRadius: 28)
.overlay { Image(systemName: "music.note").font(.system(size: 72)).foregroundStyle(.indigo) } .fill(LinearGradient(colors: [.indigo.opacity(0.85), .teal.opacity(0.75)], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay { Image(systemName: "waveform.path").font(.system(size: 72, weight: .light)).foregroundStyle(.white.opacity(0.9)) }
} }
}.frame(height: 270).clipShape(RoundedRectangle(cornerRadius: 24)) }.frame(width: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize,
height: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize)
.clipShape(RoundedRectangle(cornerRadius: 28))
.accessibilityLabel("Обложка трека") .accessibilityLabel("Обложка трека")
if let error = store.artworkError {
Text(error).font(.footnote).foregroundStyle(.secondary)
Button("Повторить загрузку обложки") { store.retryArtwork() }.disabled(!store.connected)
}
if let state = store.state { if let state = store.state {
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК") Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
.font(.title2.bold()).multilineTextAlignment(.center) .font(.title2.bold()).multilineTextAlignment(.center)
Text(state.artist).foregroundStyle(.secondary) Text(state.artist).foregroundStyle(.secondary).multilineTextAlignment(.center)
TimelineView(.periodic(from: .now, by: 0.25)) { timeline in TimelineView(.periodic(from: .now, by: 0.25)) { timeline in
let position = state.position(elapsed: timeline.date.timeIntervalSince(store.receivedAt)) let position = state.position(elapsed: timeline.date.timeIntervalSince(store.receivedAt))
VStack { VStack {
@@ -127,14 +269,21 @@ private struct PlayerView: View {
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary) }.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
} }
} }
HStack(spacing: 36) { HStack(spacing: 28) {
control("previous", icon: "backward.end.fill", label: "Предыдущий трек", enabled: state.capabilities.previous) control("previous", icon: "backward.end.fill", label: "Предыдущий трек", enabled: state.capabilities.previous)
let playing = state.playbackStatus == "Playing" let playing = state.playbackStatus == "Playing"
let command = state.capabilities.toggle ? "toggle" : (playing ? "pause" : "play") let command = state.capabilities.toggle ? "toggle" : (playing ? "pause" : "play")
control(command, icon: playing ? "pause.circle.fill" : "play.circle.fill", label: playing ? "Пауза" : "Воспроизвести", control(command, icon: playing ? "pause.circle.fill" : "play.circle.fill", label: playing ? "Пауза" : "Воспроизвести",
enabled: state.capabilities.toggle || (playing ? state.capabilities.pause : state.capabilities.play), size: 64) enabled: state.capabilities.toggle || (playing ? state.capabilities.pause : state.capabilities.play), size: 78)
control("next", icon: "forward.end.fill", label: "Следующий трек", enabled: state.capabilities.next) control("next", icon: "forward.end.fill", label: "Следующий трек", enabled: state.capabilities.next)
} }
VStack(spacing: 8) {
HStack {
Text("Громкость ПК").font(.subheadline.weight(.semibold))
Spacer()
Text(state.muted == true ? "Без звука" : state.volume.map { "\(Int((changingVolume ? volumeValue : $0) * 100))%" } ?? "Недоступна")
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
HStack { HStack {
control("toggleMute", icon: state.muted == true ? "speaker.slash.fill" : "speaker.wave.2.fill", control("toggleMute", icon: state.muted == true ? "speaker.slash.fill" : "speaker.wave.2.fill",
label: state.muted == true ? "Включить звук ПК" : "Выключить звук ПК", enabled: state.muted != nil, size: 22) label: state.muted == true ? "Включить звук ПК" : "Выключить звук ПК", enabled: state.muted != nil, size: 22)
@@ -144,13 +293,19 @@ private struct PlayerView: View {
if !editing { Task { await store.send("volume", value: volumeValue) } } if !editing { Task { await store.send("volume", value: volumeValue) } }
}.disabled(state.volume == nil || store.busy).accessibilityLabel("Системная громкость ПК") }.disabled(state.volume == nil || store.busy).accessibilityLabel("Системная громкость ПК")
} }
}.bridgeCard()
ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) { ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) {
Text($0).font(.caption).foregroundStyle(.secondary) Text($0).font(.caption).foregroundStyle(.secondary)
} }
} else { } else {
Text("Ожидаем состояние компьютера").foregroundStyle(.secondary) VStack(spacing: 8) {
Text(store.needsPairing ? "Подключите ПК заново" : "Ждём ваш компьютер").font(.title2.bold())
Text("Проверьте, что агент запущен и устройства находятся в одной сети.")
.font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
} }
}.disabled(!store.connected) }.disabled(!store.connected)
.onChange(of: store.connected) { _, _ in seeking = false; changingVolume = false }
} }
private func control(_ type: String, icon: String, label: String, enabled: Bool, size: CGFloat = 28) -> some View { private func control(_ type: String, icon: String, label: String, enabled: Bool, size: CGFloat = 28) -> some View {
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import Combine
import Darwin
struct DiscoveredComputer: Identifiable, Equatable {
let name: String
let endpoint: String
let fingerprint: String
var id: String { fingerprint + endpoint }
static func parse(name: String, host: String, port: Int, txt: [String: Data]) -> Self? {
guard txt["v"] == Data("1".utf8), let data = txt["id"],
let pin = String(data: data, encoding: .utf8),
let connection = try? Connection(endpoint: "https://\(host):\(port)", fingerprint: pin) else { return nil }
return Self(name: String(name.prefix(80)), endpoint: connection.endpoint.absoluteString,
fingerprint: connection.fingerprint)
}
// An advertisement is a routing hint, never a replacement for saved trust.
func reconnect(_ saved: Connection) -> Connection? {
guard fingerprint == saved.fingerprint else { return nil }
return try? Connection(endpoint: endpoint, fingerprint: saved.fingerprint, token: saved.token)
}
}
// Started on the main run loop; Foundation delivers browser/service delegates there.
final class DiscoveryStore: NSObject, ObservableObject, NetServiceBrowserDelegate, NetServiceDelegate {
@Published private(set) var computers: [DiscoveredComputer] = []
@Published private(set) var message = "Поиск ПК не запущен"
private var browser: NetServiceBrowser?
private var services: [String: NetService] = [:]
private var records: [String: [DiscoveredComputer]] = [:]
private var refresh: Timer?
func start() {
guard browser == nil else { return }
message = "Ищем MusicBridge в локальной сети…"
let browser = NetServiceBrowser()
self.browser = browser
browser.delegate = self
browser.searchForServices(ofType: "_musicbridge._tcp.", inDomain: "local.")
refresh = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
guard let self else { return }
for service in self.services.values {
service.stop()
service.resolve(withTimeout: 5)
}
}
}
func stop() {
refresh?.invalidate(); refresh = nil
browser?.delegate = nil; browser?.stop(); browser = nil
for service in services.values { service.delegate = nil; service.stop() }
services.removeAll(); records.removeAll(); computers = []
message = "Поиск ПК приостановлен"
}
func restart() { stop(); start() }
private func key(_ service: NetService) -> String { service.domain + service.type + service.name }
private func publish() {
computers = Array(Set(records.values.flatMap { $0 }.map(\.id))).sorted().compactMap { id in
records.values.lazy.flatMap { $0 }.first { $0.id == id }
}
message = computers.isEmpty ? "ПК пока не найден. Проверьте Wi-Fi и LAN-режим агента." : "Найдено адресов: \(computers.count)"
}
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
guard self.browser === browser, services.count < 64, services[key(service)] == nil else { return }
services[key(service)] = service
service.delegate = self
service.resolve(withTimeout: 5)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
guard self.browser === browser else { return }
let old = services.removeValue(forKey: key(service))
old?.delegate = nil; old?.stop()
records.removeValue(forKey: key(service)); publish()
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
guard self.browser === browser else { return }
stop()
message = "Поиск недоступен. Разрешите MusicBridge доступ к локальной сети в Настройках и повторите поиск."
}
func netServiceDidResolveAddress(_ sender: NetService) {
guard services[key(sender)] === sender else { return }
let txt = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
records[key(sender)] = (sender.addresses ?? []).prefix(16).compactMap { data in
guard let host = Self.ipv4(data) else { return nil }
return DiscoveredComputer.parse(name: sender.name, host: host, port: sender.port, txt: txt)
}
publish()
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
guard services[key(sender)] === sender else { return }
records.removeValue(forKey: key(sender)); publish()
}
static func ipv4(_ data: Data) -> String? {
guard data.count >= MemoryLayout<sockaddr_in>.size else { return nil }
var address = data.withUnsafeBytes { $0.loadUnaligned(as: sockaddr_in.self) }
guard address.sin_family == sa_family_t(AF_INET) else { return nil }
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &address.sin_addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
return String(cString: buffer)
}
}
+39
View File
@@ -24,16 +24,55 @@
<true/> <true/>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
</array> </array>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string> <string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
<key>NSCameraUsageDescription</key>
<string>Камера сканирует QR в MusicBridge на ПК для безопасного сопряжения.</string>
<key>NSBonjourServices</key>
<array>
<string>_musicbridge._tcp</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ru.yukinoki.musicbridge.pair</string>
<key>CFBundleURLSchemes</key>
<array>
<string>musicbridge</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
<key>NSExceptionDomains</key>
<dict>
<key>10.0.0.0/8</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict> </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>
<key>MusicBridgeSharedKeychainGroup</key>
<string>ru.yukinoki.musicbridge.remote-control</string>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -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)
+68
View File
@@ -0,0 +1,68 @@
import ActivityKit
import Foundation
@MainActor
enum LiveActivityCommands {
private static var busy = false
static let bindingKey = "MusicBridge.liveActivityPC"
static func perform(_ action: MusicRemoteAction, activityID: String) async {
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
activity.activityState == .active || activity.activityState == .stale else { return }
busy = true
defer { busy = false }
do {
guard let saved = try KeychainStore.load(),
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
throw BridgeError.message("Откройте приложение и включите карточку заново.")
}
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
let fresh = try await execute(action, client: client) {
(activity.activityState == .active || activity.activityState == .stale) &&
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
}
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
} catch {
if case BridgeError.unauthorized = error {
await activity.end(nil, dismissalPolicy: .immediate)
return
}
var value = activity.content.state
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
await activity.update(ActivityContent(state: value, staleDate: Date()))
}
}
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
stillValid: () -> Bool = { true }) async throws -> MediaState {
let before = try await client.state()
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
if action == .refresh { return before }
guard before.hasSession else { return before }
let command = try command(action, state: before)
do { try await client.command(Command(type: command, value: nil)) }
catch {
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") }
throw error
}
do { return try await client.state() }
catch {
if case BridgeError.unauthorized = error { throw error }
throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.")
}
}
static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String {
switch action {
case .previous where state.capabilities.previous: return "previous"
case .next where state.capabilities.next: return "next"
case .toggle where state.capabilities.toggle: return "toggle"
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import ActivityKit
import SwiftUI
@MainActor
final class LiveActivityManager: ObservableObject {
@Published private(set) var running = false
@Published private(set) var error: String?
private var activity: Activity<MusicActivityAttributes>?
private var updates: Task<Void, Never>?
private var observer: Task<Void, Never>?
private var last: MusicActivityAttributes.ContentState?
init() {
let existing = Activity<MusicActivityAttributes>.activities
.filter { $0.activityState == .active || $0.activityState == .stale }
if let first = existing.first {
activity = first
last = first.content.state
running = true
observe(first)
}
for extra in existing.dropFirst() {
Task { await extra.end(nil, dismissalPolicy: .immediate) }
}
}
static func content(_ state: MediaState, now: Date = Date()) -> MusicActivityAttributes.ContentState {
.init(title: MusicActivityAttributes.ContentState.bounded(state.title.isEmpty ? "Без названия" : state.title),
artist: MusicActivityAttributes.ContentState.bounded(state.artist),
playing: state.playbackStatus == "Playing",
position: max(0, state.position(elapsed: 0) - state.startSeconds),
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
}
func start(_ state: MediaState, connection: Connection) {
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
guard connection.token != nil else { error = "Сначала выполните сопряжение с ПК."; return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
return
}
do {
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
try KeychainStore.save(connection)
let value = Self.content(state)
let new = try Activity.request(attributes: MusicActivityAttributes(),
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
pushType: nil)
activity = new
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
last = value
running = true
error = nil
observe(new)
} catch { self.error = "Не удалось включить Live Activity: " + error.localizedDescription }
}
func receive(_ state: MediaState) {
guard state.hasSession else { end(); return }
guard let activity, running else { return }
let value = Self.content(state)
// Coalesce ordinary position ticks, but publish track/playback changes immediately.
if let last, last.connected, last.title == value.title, last.artist == value.artist,
last.playing == value.playing, last.duration == value.duration,
abs(last.position - value.position) < 5, value.updatedAt.timeIntervalSince(last.updatedAt) < 5 { return }
last = value
enqueue { await activity.update(ActivityContent(state: value, staleDate: value.updatedAt.addingTimeInterval(15))) }
}
func unavailable() {
guard let activity, var value = last, value.connected else { return }
value.connected = false
last = value
enqueue { await activity.update(ActivityContent(state: value, staleDate: Date())) }
}
func end() {
guard let activity else { return }
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
self.activity = nil
running = false
last = nil
observer?.cancel()
enqueue { await activity.end(nil, dismissalPolicy: .immediate) }
}
private func enqueue(_ operation: @escaping @MainActor () async -> Void) {
let previous = updates
updates = Task { await previous?.value; await operation() }
}
private func observe(_ activity: Activity<MusicActivityAttributes>) {
observer?.cancel()
observer = Task { [weak self] in
for await state in activity.activityStateUpdates {
guard !Task.isCancelled, let self, self.activity?.id == activity.id else { return }
if state == .dismissed || state == .ended {
self.activity = nil; self.running = false; self.last = nil
return
}
}
}
}
}
+35 -2
View File
@@ -3,14 +3,45 @@ import Foundation
enum BridgeError: LocalizedError { enum BridgeError: LocalizedError {
case message(String) case message(String)
case unauthorized case unauthorized
case http(status: Int, code: String?)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .message(let text): return text case .message(let text): return text
case .unauthorized: return "Доступ отозван. Выполните сопряжение заново." case .unauthorized: return "Доступ отозван. Выполните сопряжение заново."
case .http(let status, let code):
switch code {
case "pairing_closed": return "Код истёк или сопряжение закрыто. Откройте новый QR на ПК."
case "invalid_code": return "Неверный код. Проверьте восемь цифр на экране ПК."
case "device_limit": return "На ПК уже сохранено восемь устройств. Удалите ненужное устройство в агенте."
case "storage_failed": return "ПК не смог сохранить сопряжение. Проверьте сообщение в Windows-агенте."
case "invalid_name": return "ПК отклонил имя устройства. Обновите приложение."
default:
if (300...399).contains(status) { return "ПК перенаправляет запрос на другой адрес. Проверьте адрес MusicBridge." }
if status == 404 { return "Данные на ПК уже изменились. Повторите загрузку." }
if status == 429 || status == 503 { return "ПК временно занят. Повторите попытку немного позже." }
return "ПК отклонил запрос (HTTP \(status))."
} }
} }
} }
static func describe(_ error: Error) -> String {
if let error = error as? URLError {
switch error.code {
case .timedOut: return "ПК не ответил вовремя. Проверьте, что агент запущен и компьютер не спит."
case .notConnectedToInternet, .networkConnectionLost, .cannotConnectToHost, .cannotFindHost:
return "Нет связи с ПК. Проверьте Wi-Fi, LAN-режим агента и доступ MusicBridge к локальной сети."
case .serverCertificateUntrusted, .serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
.serverCertificateNotYetValid, .secureConnectionFailed, .userCancelledAuthentication:
return "Не удалось подтвердить сертификат ПК. Сверьте отпечаток и дату на устройствах; при смене сертификата выполните сопряжение заново."
case .cancelled: return "Соединение прервано."
default: break
}
}
if error is DecodingError { return "ПК прислал данные неподдерживаемого формата. Проверьте версии агента и приложения." }
return error.localizedDescription
}
}
struct Connection: Codable { struct Connection: Codable {
let endpoint: URL let endpoint: URL
let fingerprint: String let fingerprint: String
@@ -66,7 +97,9 @@ struct Invitation: Decodable {
var base64 = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var base64 = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
guard let data = Data(base64Encoded: base64) else { throw BridgeError.message("Повреждённые данные QR.") } guard let data = Data(base64Encoded: base64) else { throw BridgeError.message("Повреждённые данные QR.") }
let invitation = try JSONDecoder().decode(Invitation.self, from: data) let invitation: Invitation
do { invitation = try JSONDecoder().decode(Invitation.self, from: data) }
catch { throw BridgeError.message("QR не содержит корректного приглашения MusicBridge. Откройте новый QR в агенте на ПК.") }
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var expiry = formatter.date(from: invitation.expiresAt) var expiry = formatter.date(from: invitation.expiresAt)
@@ -86,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
} }
+27 -4
View File
@@ -2,14 +2,37 @@ import SwiftUI
@main @main
struct MusicBridgeApp: App { struct MusicBridgeApp: App {
@StateObject private var store = PlayerStore() @StateObject private var store = PreviewSupport.makeStore()
@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 {
WindowGroup { WindowGroup {
ContentView(store: store) Group {
.task { store.setActive(phase == .active) } if PreviewSupport.liveCard, let state = store.state {
.onChange(of: phase) { _, value in store.setActive(value == .active) } ScrollView {
VStack(spacing: 24) {
Text("Live Activity · предпросмотр").font(.headline)
MusicActivityCard(state: LiveActivityManager.content(state), stale: false)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 24))
MusicActivityCard(state: LiveActivityManager.content(state), stale: true)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 24))
}.padding()
}
} else {
ContentView(store: store, liveActivity: liveActivity, systemPlayer: systemPlayer)
}
}
.dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
.onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } }
.task {
store.liveActivity = liveActivity
store.systemPlayer = systemPlayer
if !PreviewSupport.enabled { await systemPlayer.restore(connection: store.connection) }
if !PreviewSupport.enabled { store.setActive(phase == .active) }
}
.onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
} }
} }
} }
+150 -20
View File
@@ -10,6 +10,12 @@ final class PlayerStore: ObservableObject {
@Published var connected = false @Published var connected = false
@Published var busy = false @Published var busy = false
@Published var pairing = false @Published var pairing = false
@Published var needsPairing = false
@Published var artworkError: String?
@Published var incomingInvitation: String?
let discovery = DiscoveryStore()
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>?
@@ -17,50 +23,106 @@ final class PlayerStore: ObservableObject {
private var artworkID: String? private var artworkID: String?
private var generation = UUID() private var generation = UUID()
private var active = false private var active = false
private var pairingID: UUID?
private var pairingClient: BridgeClient?
private let storage: ConnectionStorage
private let retryUnit: UInt64
private let artworkRetryUnit: UInt64
private let discoveryEnabled: Bool
init() { init(storage: ConnectionStorage = .keychain, retryUnit: UInt64 = 1_000_000_000,
do { connection = try KeychainStore.load() } artworkRetryUnit: UInt64 = 2_000_000_000, discoveryEnabled: Bool = true) {
self.storage = storage
self.retryUnit = retryUnit
self.artworkRetryUnit = artworkRetryUnit
self.discoveryEnabled = discoveryEnabled
do { connection = try storage.load() }
catch { self.error = error.localizedDescription } catch { self.error = error.localizedDescription }
} }
func pair(endpoint: String, fingerprint: String, code: String) async { func pair(endpoint: String, fingerprint: String, code: String) async {
guard !pairing else { return } guard active, !pairing else { return }
let operation = UUID()
pairingID = operation
pairing = true pairing = true
defer { pairing = false } defer {
if pairingID == operation { pairing = false; pairingID = nil; pairingClient = nil }
}
error = nil error = nil
do { do {
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") } guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint) var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
let pairingClient = BridgeClient(saved) let pairingClient = BridgeClient(saved)
self.pairingClient = pairingClient
defer { pairingClient.close() } defer { pairingClient.close() }
saved.token = try await pairingClient.pair(code: code) saved.token = try await pairingClient.pair(code: code)
do { try KeychainStore.save(saved) } guard pairingID == operation, active else { return }
do { try storage.save(saved) }
catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") } catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") }
connection = saved connection = saved
needsPairing = false
resume() resume()
} catch { self.error = error.localizedDescription } } catch {
guard pairingID == operation else { return }
self.error = BridgeError.describe(error)
}
} }
func setActive(_ active: Bool) { func setActive(_ active: Bool) {
guard self.active != active else { return }
self.active = active self.active = active
if active { resume() } else { stop(); status = "Соединение приостановлено" } if active {
// Retry loading if Keychain was locked when the app was created.
if connection == nil {
do { connection = try storage.load() }
catch { self.error = BridgeError.describe(error) }
}
if discoveryEnabled { discovery.start() }
resume()
} else {
cancelPairing()
discovery.stop(); stop()
status = needsPairing ? "Нужно повторное сопряжение" : "Соединение приостановлено"
}
}
func importInvitation(_ text: String) {
guard connection == nil, !pairing else {
error = "Для подключения другого ПК сначала выберите «Забыть компьютер»."
return
}
incomingInvitation = text
} }
func resume() { func resume() {
stop() stop()
guard active, let connection else { return } guard active else { return }
guard !needsPairing else { status = "Нужно повторное сопряжение"; return }
guard let connection else { status = "Подключите компьютер"; return }
let current = generation let current = generation
runner = Task { [weak self] in runner = Task { [weak self] in
var delay: UInt64 = 1 var delay: UInt64 = 1
var attempt = 0
while !Task.isCancelled { while !Task.isCancelled {
guard let self, self.generation == current else { return } guard let self, self.generation == current else { return }
let client = BridgeClient(connection) let saved = self.connection ?? connection
let alternatives = self.discovery.computers.compactMap { $0.reconnect(saved) }
.filter { $0.endpoint != saved.endpoint }
let candidates = [saved] + alternatives
let candidate = candidates[attempt % candidates.count]
let client = BridgeClient(candidate)
self.client = client self.client = client
self.status = "Подключение к ПК…" self.status = "Подключение к ПК…"
do { do {
let initial = try await client.state() let initial = try await client.state()
try Task.checkCancellation() try Task.checkCancellation()
guard self.generation == current else { client.close(); return } guard self.generation == current else { client.close(); return }
// Persist a discovered address only after pinned, authenticated HTTPS succeeds.
if candidate.endpoint != saved.endpoint {
try self.storage.save(candidate)
self.connection = candidate
}
self.error = nil
self.accept(initial, client: client) self.accept(initial, client: client)
client.openEvents() client.openEvents()
// Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link. // Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link.
@@ -83,19 +145,21 @@ final class PlayerStore: ObservableObject {
client.close() client.close()
guard !Task.isCancelled, self.generation == current else { return } guard !Task.isCancelled, self.generation == current else { return }
self.connected = false self.connected = false
self.liveActivity?.unavailable()
self.state = nil self.state = nil
self.artworkTask?.cancel() self.artworkTask?.cancel()
self.artwork = nil self.artwork = nil
self.artworkID = nil self.artworkID = nil
self.artworkError = nil
if case BridgeError.unauthorized = error { if case BridgeError.unauthorized = error {
self.status = "Нужно повторное сопряжение" self.revokeAccess()
self.error = error.localizedDescription
return return
} }
self.status = "ПК недоступен. Повтор через \(delay) с" self.status = "ПК недоступен. Повтор через \(delay) с"
self.error = error.localizedDescription self.error = BridgeError.describe(error)
attempt += 1
} }
do { try await Task.sleep(nanoseconds: delay * 1_000_000_000) } catch { return } do { try await Task.sleep(nanoseconds: delay * self.retryUnit) } catch { return }
delay = min(delay * 2, 16) delay = min(delay * 2, 16)
} }
} }
@@ -106,19 +170,55 @@ final class PlayerStore: ObservableObject {
receivedAt = Date() receivedAt = Date()
connected = true connected = true
status = "Подключено" status = "Подключено"
error = nil 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()
artwork = nil artwork = nil
artworkError = nil
guard let id = snapshot.artworkId else { return } guard let id = snapshot.artworkId else { return }
let current = generation let current = generation
artworkTask = Task { [weak self] in artworkTask = Task { [weak self] in
guard let data = try? await client.artwork(id), !Task.isCancelled, for attempt in 0..<4 {
let self, self.generation == current, self.artworkID == id else { return } do {
self.artwork = UIImage(data: data) try Task.checkCancellation()
let data = try await client.artwork(id)
guard !Task.isCancelled, let self, self.generation == current, self.artworkID == id else { return }
guard let image = UIImage(data: data) else { throw BridgeError.message("ПК прислал повреждённую обложку.") }
self.artwork = image
self.artworkError = nil
return
} catch {
guard !Task.isCancelled, let self, self.generation == current, self.artworkID == id else { return }
if case BridgeError.unauthorized = error { self.revokeAccess(); return }
self.artworkError = attempt < 3 ? "Обложка пока недоступна. Повторяем загрузку…" : "Не удалось загрузить обложку. Можно повторить вручную."
// A 404 often means the song changed between the state and image requests.
if case BridgeError.http(status: 404, code: _) = error {
do {
let fresh = try await client.state()
guard !Task.isCancelled, self.generation == current, self.artworkID == id else { return }
self.accept(fresh, client: client)
if self.artworkID != id { return }
} catch {
guard !Task.isCancelled, self.generation == current else { return }
if case BridgeError.unauthorized = error { self.revokeAccess(); return }
} }
} }
if attempt < 3 {
do { try await Task.sleep(nanoseconds: self.artworkRetryUnit * UInt64(1 << attempt)) }
catch { return }
}
}
}
}
}
func retryArtwork() {
guard connected, let state, let client else { return }
artworkID = nil
accept(state, client: client)
}
func send(_ type: String, value: Double? = nil) async { func send(_ type: String, value: Double? = nil) async {
guard connected, !busy, let client else { return } guard connected, !busy, let client else { return }
@@ -128,20 +228,32 @@ final class PlayerStore: ObservableObject {
do { do {
// Commands are never retried: their IDs are not idempotency keys. // Commands are never retried: their IDs are not idempotency keys.
try await client.command(Command(type: type, value: value)) try await client.command(Command(type: type, value: value))
guard generation == current else { return }
self.error = nil
do {
let fresh = try await client.state() let fresh = try await client.state()
guard generation == current else { return } guard generation == current else { return }
accept(fresh, client: client) accept(fresh, client: client)
} catch { } catch {
guard generation == current else { return } guard generation == current else { return }
self.error = error.localizedDescription if case BridgeError.unauthorized = error { revokeAccess(); return }
if case BridgeError.unauthorized = error { stop(); status = "Нужно повторное сопряжение" } self.error = "Команда выполнена, но состояние пока не обновилось. " + BridgeError.describe(error)
}
} catch {
guard generation == current else { return }
if case BridgeError.unauthorized = error { revokeAccess(); return }
self.error = (error is URLError ? "Не удалось подтвердить выполнение команды. Перед повтором проверьте плеер. " : "") + BridgeError.describe(error)
} }
} }
func forget() { func forget() {
do { try KeychainStore.delete() } do { try storage.delete() }
catch { self.error = error.localizedDescription; return } catch { self.error = error.localizedDescription; return }
liveActivity?.end()
systemPlayer?.end()
stop() stop()
cancelPairing()
needsPairing = false
connection = nil connection = nil
status = "Подключите компьютер" status = "Подключите компьютер"
error = nil error = nil
@@ -159,5 +271,23 @@ final class PlayerStore: ObservableObject {
state = nil state = nil
artwork = nil artwork = nil
artworkID = nil artworkID = nil
artworkError = nil
}
private func revokeAccess() {
liveActivity?.end()
systemPlayer?.end()
stop()
needsPairing = true
status = "Нужно повторное сопряжение"
error = BridgeError.unauthorized.localizedDescription
}
private func cancelPairing() {
guard pairingID != nil else { return }
pairingID = nil
pairingClient?.close(); pairingClient = nil
pairing = false
error = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой."
} }
} }
+44
View File
@@ -0,0 +1,44 @@
import SwiftUI
// Screenshots only: this fixture and its launch switches do not exist in Release.
@MainActor
enum PreviewSupport {
static var enabled: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("--preview-player")
#else
false
#endif
}
static var largeText: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("--preview-large-text")
#else
false
#endif
}
static var liveCard: Bool {
#if DEBUG
enabled && ProcessInfo.processInfo.arguments.contains("--preview-live-card")
#else
false
#endif
}
static func makeStore() -> PlayerStore {
#if DEBUG
if enabled {
let store = PlayerStore(storage: ConnectionStorage(load: { nil }, save: { _ in }, delete: {}), discoveryEnabled: false)
store.connection = try? Connection(endpoint: "https://192.168.1.10:8765", fingerprint: String(repeating: "A", count: 64))
store.connected = true
store.status = "Предпросмотр интерфейса"
store.state = MediaState(protocolVersion: 1, hasSession: true, title: "Ночной город", artist: "MusicBridge · пример плеера", source: "",
playbackStatus: "Playing", positionSeconds: 42, startSeconds: 0, endSeconds: 214,
minSeekSeconds: 0, maxSeekSeconds: 214, playbackRate: 1,
capabilities: Capabilities(play: true, pause: true, toggle: true, next: true, previous: true, seek: true),
volume: 0.64, muted: false, mediaError: nil, volumeError: nil, artworkId: nil, artworkError: nil)
return store
}
#endif
return PlayerStore()
}
}
+135
View File
@@ -0,0 +1,135 @@
import SwiftUI
import AVFoundation
struct QRScannerSheet: View {
let receive: (String) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var phase
@State private var error: String?
var body: some View {
NavigationStack {
VStack(spacing: 18) {
Text("Наведите камеру на QR в окне MusicBridge на ПК.")
.multilineTextAlignment(.center)
if let error {
ContentUnavailableView("Камера недоступна", systemImage: "camera", description: Text(error))
Button("Открыть настройки") {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
Text("Можно закрыть сканер и ввести данные вручную.").font(.footnote)
} else {
QRScanner(receive: { text in receive(text); dismiss() }, failed: { error = $0 })
.clipShape(RoundedRectangle(cornerRadius: 20))
.accessibilityLabel("Камера для сканирования QR")
}
}.padding()
.navigationTitle("Сканировать QR")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Закрыть") { dismiss() } } }
.onChange(of: phase) { _, value in if value == .background { dismiss() } }
}
}
}
private struct QRScanner: UIViewControllerRepresentable {
let receive: (String) -> Void
let failed: (String) -> Void
func makeUIViewController(context: Context) -> QRScannerController {
QRScannerController(receive: receive, failed: failed)
}
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
static func dismantleUIViewController(_ controller: QRScannerController, coordinator: ()) { controller.stop() }
}
private final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
private let capture = AVCaptureSession()
private let queue = DispatchQueue(label: "MusicBridge.camera")
private var preview: AVCaptureVideoPreviewLayer?
private var visible = false
private var delivered = false
private var wantsRunning = false // camera queue only
private var configured = false // camera queue only
private let receive: (String) -> Void
private let failed: (String) -> Void
init(receive: @escaping (String) -> Void, failed: @escaping (String) -> Void) {
self.receive = receive; self.failed = failed
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let preview = AVCaptureVideoPreviewLayer(session: capture)
preview.videoGravity = .resizeAspectFill
view.layer.addSublayer(preview)
self.preview = preview
}
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews(); preview?.frame = view.bounds }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
visible = true
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: start()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard let self, self.visible else { return }
if granted { self.start() } else { self.failed("Разрешите доступ к камере в настройках MusicBridge.") }
}
}
default: failed("Разрешите доступ к камере в настройках MusicBridge.")
}
}
override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated); stop() }
private func start() {
queue.async { [weak self] in
guard let self else { return }
self.wantsRunning = true
do {
if !self.configured {
self.capture.beginConfiguration()
defer { self.capture.commitConfiguration() }
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
throw BridgeError.message("Задняя камера не найдена. На симуляторе сканирование недоступно.")
}
let input = try AVCaptureDeviceInput(device: camera)
let output = AVCaptureMetadataOutput()
guard self.capture.canAddInput(input), self.capture.canAddOutput(output) else {
throw BridgeError.message("Не удалось запустить камеру. Закройте сканер и повторите попытку.")
}
self.capture.addInput(input); self.capture.addOutput(output)
guard output.availableMetadataObjectTypes.contains(.qr) else { throw BridgeError.message("Сканирование QR недоступно.") }
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
self.configured = true
}
if self.wantsRunning { self.capture.startRunning() }
} catch {
DispatchQueue.main.async { [weak self] in
guard let self, self.visible else { return }
self.failed(error.localizedDescription)
}
}
}
}
func stop() {
visible = false
queue.async { [weak self] in
guard let self else { return }
self.wantsRunning = false
if self.capture.isRunning { self.capture.stopRunning() }
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard visible, !delivered, let text = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return }
delivered = true
stop()
receive(text)
}
}
+114
View File
@@ -0,0 +1,114 @@
import SwiftUI
import NowPlaying
@MainActor
final class SystemPlayerManager: ObservableObject {
@Published private(set) var running = false
@Published private(set) var busy = false
@Published private(set) var error: String?
private var storage: Any?
private var id: String?
private var generation = UUID()
private var updates: Task<Void, Never>?
private var lastUpdate = Date.distantPast
private var lastTrack = ""
private var lastPlaying = false
private let savedIDKey = "MusicBridge.systemPlayerSession"
func start(state: MediaState, connection: Connection) async {
guard !busy, !running, state.hasSession, connection.token != nil,
UIApplication.shared.applicationState == .active else { return }
guard #available(iOS 27.0, *) else { error = "Системный пульт требует iOS 27."; return }
busy = true; error = nil
let operation = generation
let newID = UUID().uuidString
id = newID
defer { if generation == operation { busy = false } }
do {
try SystemPlayerCredentials.save(connection, id: newID)
_ = try SystemPlayerCredentials.load(newID)
UserDefaults.standard.set(newID, forKey: savedIDKey)
let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state))
guard generation == operation else { try? await session.end(); return }
storage = session
try await session.requestToBecomeSystemPrimary()
guard generation == operation else { try? await session.end(); return }
running = true
UserDefaults.standard.set(newID, forKey: savedIDKey)
} catch {
guard generation == operation else { return }
self.error = "Не удалось включить системный пульт: " + BridgeError.describe(error)
end()
}
}
func restore(connection: Connection?) async {
guard #available(iOS 27.0, *), id == nil, !busy,
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
busy = true
id = savedID
let operation = generation
defer { if generation == operation { busy = false } }
do {
let saved = try SystemPlayerCredentials.load(savedID)
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
try SystemPlayerCredentials.delete(savedID)
UserDefaults.standard.removeObject(forKey: savedIDKey)
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
if generation == operation { id = nil }
return
}
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
guard generation == operation else { return }
storage = session; id = savedID; running = true
} else {
guard generation == operation else { return }
try SystemPlayerCredentials.delete(savedID)
UserDefaults.standard.removeObject(forKey: savedIDKey)
id = nil
}
} catch { if generation == operation { self.error = BridgeError.describe(error); id = nil } }
}
func receive(_ state: MediaState, connection: Connection) {
guard #available(iOS 27.0, *), running, let id,
let session = storage as? RemoteMediaSession<SystemPlayerAttributes> else { return }
guard state.hasSession else { end(); return }
let track = state.title + "\n" + state.artist
let playing = state.playbackStatus == "Playing"
guard track != lastTrack || playing != lastPlaying || Date().timeIntervalSince(lastUpdate) >= 5 else { return }
lastUpdate = Date(); lastTrack = track; lastPlaying = playing
let value = SystemPlayerAttributes(id: id, state: state)
let operation = generation
enqueue { [weak self] in
guard let self, self.generation == operation else { return }
do {
try SystemPlayerCredentials.save(connection, id: id)
try await session.update(value)
} catch { self.error = BridgeError.describe(error) }
}
}
func end() {
generation = UUID()
busy = false; running = false
if let id {
do { try SystemPlayerCredentials.delete(id) }
catch { self.error = BridgeError.describe(error) }
}
UserDefaults.standard.removeObject(forKey: savedIDKey)
id = nil
if #available(iOS 27.0, *), let session = storage as? RemoteMediaSession<SystemPlayerAttributes> {
enqueue { [weak self] in
do { try await session.end() }
catch { self?.error = BridgeError.describe(error) }
}
}
storage = nil
}
private func enqueue(_ action: @escaping @MainActor () async -> Void) {
let previous = updates
updates = Task { await previous?.value; await action() }
}
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>ru</string>
<key>CFBundleDisplayName</key>
<string>MusicBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.nowplaying.remote-media</string>
</dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>ru.yukinoki.musicbridge.remote-control</string>
<key>NSLocalNetworkUsageDescription</key>
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>10.0.0.0/8</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>172.16.0.0/12</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>192.168.0.0/16</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
+77
View File
@@ -0,0 +1,77 @@
import ExtensionFoundation
import NowPlaying
import Observation
import Foundation
@main
struct MusicBridgeRemote: RemoteMediaSessionExtension {
var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
RemoteMediaSessionExtensionConfiguration(extension: self)
}
func session(_ attributes: SystemPlayerAttributes) async throws -> PCMediaSession {
_ = try SystemPlayerCredentials.load(attributes.id)
return PCMediaSession(attributes)
}
}
@MainActor @Observable
final class PCMediaSession: RemoteMediaSessionRepresentable {
let id: String
var attributes: SystemPlayerAttributes
private let transport = SystemPlayerTransport()
init(_ attributes: SystemPlayerAttributes) { id = attributes.id; self.attributes = attributes }
func update(_ attributes: SystemPlayerAttributes) { self.attributes = attributes }
var content: (any MediaContentRepresentable)? {
guard attributes.hasSession else { return nil }
let artwork = attributes.artworkID.map { artworkID in
Artwork(id: artworkID) { _ in
let saved = try SystemPlayerCredentials.load(self.id)
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
return try ArtworkRepresentation(data: await client.artwork(artworkID))
}
}
return MusicContent(id: attributes.source + "/" + attributes.title + "/" + attributes.artist,
songTitle: attributes.title.isEmpty ? "Без названия" : attributes.title,
artistName: attributes.artist, albumName: "", type: .audio,
duration: attributes.duration > 0 ? .finite(attributes.duration) : nil, artwork: artwork)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: Float(attributes.rate)) : .paused,
elapsedTime: attributes.position, timestamp: attributes.timestamp)
}
var commands: [MediaCommand] {
guard attributes.hasSession else { return [] }
return [
.play { try await self.send(.play) }.enabled(attributes.capabilities.play),
.pause { try await self.send(.pause) }.enabled(attributes.capabilities.pause),
.next { try await self.send(.next) }.enabled(attributes.capabilities.next),
.previous { try await self.send(.previous) }.enabled(attributes.capabilities.previous),
.seekToPosition { try await self.send(.seek, value: $0) }.enabled(attributes.capabilities.seek)
]
}
var devices: [MediaDevice] {
let capabilities: [MediaDevice.Capability] = attributes.volume.map { volume in
[.absoluteVolume(Float(volume)) { try await self.send(.volume, value: Double($0)) }]
} ?? []
return [MediaDevice(id: id, name: "MusicBridge · ПК", type: .speaker, capabilities: capabilities)]
}
private func send(_ action: SystemPlayerAction, value: Double? = nil) async throws {
do {
let state = try await transport.send(action, value: value, sessionID: id)
attributes = SystemPlayerAttributes(id: id, state: state)
} catch BridgeError.unauthorized {
try SystemPlayerCredentials.delete(id)
attributes.hasSession = false
attributes.playing = false
attributes.volume = nil
throw BridgeError.unauthorized
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</array>
</dict>
</plist>
+42
View File
@@ -0,0 +1,42 @@
import XCTest
import Darwin
@testable import MusicBridge
final class DiscoveryTests: XCTestCase {
let pin = String(repeating: "A", count: 64)
func testWindowsAdvertisementAndTrustPreservingRelocation() throws {
let txt = ["v": Data("1".utf8), "id": Data(pin.lowercased().utf8)]
let pc = try XCTUnwrap(DiscoveredComputer.parse(name: "MusicBridge-AAAAAAAAAAAA", host: "192.168.1.23", port: 8765, txt: txt))
let saved = try Connection(endpoint: "https://192.168.1.2:8765", fingerprint: pin, token: "secret")
let moved = try XCTUnwrap(pc.reconnect(saved))
XCTAssertEqual(moved.endpoint.absoluteString, "https://192.168.1.23:8765")
XCTAssertEqual(moved.fingerprint, saved.fingerprint)
XCTAssertEqual(moved.token, saved.token)
let other = try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: String(repeating: "B", count: 64))
XCTAssertNil(pc.reconnect(other))
}
func testRejectsPublicAddressesUnsupportedVersionAndBadPins() {
let txt = ["v": Data("1".utf8), "id": Data(pin.utf8)]
for host in ["8.8.8.8", "127.0.0.1", "localhost", "192.168.1.2/path"] {
XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: host, port: 8765, txt: txt))
}
for port in [0, -1, 65536] {
XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: "192.168.1.2", port: port, txt: txt))
}
XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: "192.168.1.2", port: 8765, txt: ["v": Data("2".utf8), "id": Data(pin.utf8)]))
XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: "192.168.1.2", port: 8765, txt: ["v": Data("1".utf8), "id": Data("bad".utf8)]))
}
func testParsesBonjourSocketAddressWithoutAcceptingTruncatedOrIPv6Data() {
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
address.sin_family = sa_family_t(AF_INET)
_ = "192.168.1.42".withCString { inet_pton(AF_INET, $0, &address.sin_addr) }
let data = withUnsafeBytes(of: &address) { Data($0) }
XCTAssertEqual(DiscoveryStore.ipv4(data), "192.168.1.42")
XCTAssertNil(DiscoveryStore.ipv4(Data(data.prefix(4))))
address.sin_family = sa_family_t(AF_INET6)
XCTAssertNil(DiscoveryStore.ipv4(withUnsafeBytes(of: &address) { Data($0) }))
}
}
@@ -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("Команда выполнена")) }
}
}
+45
View File
@@ -0,0 +1,45 @@
import XCTest
@testable import MusicBridge
final class LiveActivityTests: XCTestCase {
@MainActor
func testSnapshotUsesTrackRelativePosition() throws {
let state = try fixture(position: 130, start: 100, end: 300)
let content = LiveActivityManager.content(state, now: Date(timeIntervalSince1970: 1000))
XCTAssertEqual(content.position, 30)
XCTAssertEqual(content.duration, 200)
XCTAssertEqual(content.progress, 0.15, accuracy: 0.001)
XCTAssertEqual(content.updatedAt, Date(timeIntervalSince1970: 1000))
}
@MainActor
func testOversizedUnicodeMetadataStaysBelowActivityKitBudget() throws {
let malicious = String(repeating: "🎵\u{301}", count: 5000)
let state = try fixture(title: malicious, artist: malicious)
let content = LiveActivityManager.content(state)
XCTAssertLessThanOrEqual(content.title.utf8.count, 256)
XCTAssertLessThanOrEqual(content.artist.utf8.count, 256)
let data = try JSONEncoder().encode(content)
XCTAssertLessThan(data.count, 2048)
XCTAssertEqual(try JSONDecoder().decode(MusicActivityAttributes.ContentState.self, from: data), content)
}
@MainActor
func testUnknownDurationAndPausedState() throws {
let state = try fixture(title: "", playback: "Paused", position: 42, start: 0, end: 0)
let content = LiveActivityManager.content(state)
XCTAssertEqual(content.title, "Без названия")
XCTAssertFalse(content.playing)
XCTAssertEqual(content.progress, 0)
XCTAssertTrue(content.connected)
}
private func fixture(title: String = "Track", artist: String = "Artist", playback: String = "Playing",
position: Double = 42, start: Double = 0, end: Double = 200) throws -> MediaState {
let url = Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json")!
var json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [String: Any]
json["title"] = title; json["artist"] = artist; json["playbackStatus"] = playback
json["positionSeconds"] = position; json["startSeconds"] = start; json["endSeconds"] = end
return try JSONDecoder().decode(MediaState.self, from: JSONSerialization.data(withJSONObject: json))
}
}
+18
View File
@@ -45,6 +45,24 @@ final class ProtocolTests: XCTestCase {
XCTAssertFalse(Invitation.validCode("12345678")) XCTAssertFalse(Invitation.validCode("12345678"))
} }
func testWindowsInvitationTimestampAndMalformedJSON() throws {
// System.Text.Json emits up to seven fractional digits and a numeric UTC offset.
let json = """
{"version":1,"endpoint":"https://192.168.1.23:8765","certificateSha256":"\(fingerprint)","code":"01234567","expiresAt":"2026-09-12T15:30:00.1234567+03:00"}
"""
func payload(_ json: String) -> String {
"musicbridge://pair?data=" + Data(json.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
let before = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-09-12T12:29:00Z"))
XCTAssertEqual(try Invitation.parse(payload(json), now: before).code, "01234567")
XCTAssertThrowsError(try Invitation.parse(payload(json), now: before.addingTimeInterval(120)))
XCTAssertThrowsError(try Invitation.parse(payload("{}"), now: before)) { error in
XCTAssertTrue(error is BridgeError)
}
XCTAssertThrowsError(try Invitation.parse("https://example.com"))
}
func testWindowsSnapshotAndTimeline() throws { func testWindowsSnapshotAndTimeline() throws {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json")) let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json"))
let state = try JSONDecoder().decode(MediaState.self, from: Data(contentsOf: url)) let state = try JSONDecoder().decode(MediaState.self, from: Data(contentsOf: url))
+142
View File
@@ -0,0 +1,142 @@
import XCTest
@testable import MusicBridge
@MainActor
final class ResilienceTests: XCTestCase {
private func connection(_ token: String) throws -> Connection {
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))
guard let fixture = fixtures["valid"] else { throw XCTSkip("Start tools/tls_fixture.py first.") }
return try Connection(endpoint: XCTUnwrap(fixture["endpoint"]), fingerprint: XCTUnwrap(fixture["fingerprint"]), token: token)
}
private func store(_ token: String) throws -> PlayerStore {
let saved = try connection(token)
return PlayerStore(storage: ConnectionStorage(load: { saved }, save: { _ in }, delete: {}),
retryUnit: 20_000_000, artworkRetryUnit: 20_000_000, discoveryEnabled: false)
}
private func eventually(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async throws {
for _ in 0..<200 {
if condition() { return }
try await Task.sleep(nanoseconds: 20_000_000)
}
XCTFail("Condition did not become true", file: file, line: line)
}
func testRecoversAfterTemporaryServerFailureAndForegroundReturn() async throws {
let store = try store("recover")
defer { store.setActive(false) }
store.setActive(true)
try await eventually { store.connected }
store.setActive(false)
XCTAssertFalse(store.connected)
XCTAssertNil(store.state)
store.setActive(true)
try await eventually { store.connected }
}
func testRevokedAccessStopsRetriesAndSurvivesForegroundReturn() async throws {
let store = try store("revoked")
defer { store.setActive(false) }
store.setActive(true)
try await eventually { store.needsPairing }
XCTAssertFalse(store.connected)
store.setActive(false); store.setActive(true)
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssertEqual(store.status, "Нужно повторное сопряжение")
XCTAssertTrue(store.needsPairing)
XCTAssertFalse(store.connected)
}
func testRetriesStorageLoadWhenForegroundBecomesAvailable() async throws {
let saved = try connection("storage-unlock")
var loads = 0
let storage = ConnectionStorage(load: {
loads += 1
if loads == 1 { throw BridgeError.message("Keychain locked") }
return saved
}, save: { _ in }, delete: {})
let store = PlayerStore(storage: storage, discoveryEnabled: false)
defer { store.setActive(false) }
XCTAssertNil(store.connection)
store.setActive(true)
try await eventually { store.connected }
XCTAssertEqual(loads, 2)
XCTAssertNil(store.error)
}
func testArtworkRetriesAfter404WithoutTrackChange() async throws {
let store = try store("artwork")
defer { store.setActive(false) }
store.setActive(true)
try await eventually { store.artwork != nil }
XCTAssertEqual(store.state?.artworkId, String(repeating: "A", count: 64))
XCTAssertNil(store.artworkError)
}
func testArtworkFailureStopsAtBoundedRetryAndAllowsManualRetry() async throws {
let store = try store("artwork-fail")
defer { store.setActive(false) }
store.setActive(true)
try await eventually { store.artworkError?.contains("вручную") == true }
XCTAssertTrue(store.connected)
store.retryArtwork()
try await eventually { store.artworkError?.contains("Повторяем") == true }
store.setActive(false)
XCTAssertNil(store.artworkError)
}
func testLateArtworkDoesNotReplaceNewTrackImage() async throws {
let store = try store("late-art")
defer { store.setActive(false) }
store.setActive(true)
try await eventually { store.artwork?.size.width == 2 }
try await Task.sleep(nanoseconds: 600_000_000)
XCTAssertEqual(store.state?.artworkId, String(repeating: "B", count: 64))
XCTAssertEqual(store.artwork?.size.width, 2)
}
func testStreamUpdatesDoNotEraseRejectedCommandMessage() async throws {
let store = try store("command-reject")
defer { store.setActive(false) }
store.setActive(true)
try await eventually { store.connected }
await store.send("next")
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(store.error, "Плеер не поддерживает эту команду.")
XCTAssertFalse(store.busy)
}
func testInterruptedPairingDoesNotSaveLateResponse() async throws {
let target = try connection("")
var saved = false
let store = PlayerStore(storage: ConnectionStorage(load: { nil }, save: { _ in saved = true }, delete: {}), discoveryEnabled: false)
store.setActive(true)
let task = Task { await store.pair(endpoint: target.endpoint.absoluteString, fingerprint: target.fingerprint, code: "33333333") }
try await eventually { store.pairing }
try await Task.sleep(nanoseconds: 80_000_000)
store.setActive(false)
await task.value
XCTAssertFalse(saved)
XCTAssertNil(store.connection)
XCTAssertFalse(store.pairing)
XCTAssertTrue(store.error?.contains("прервано") == true)
}
func testRedirectIsRejectedAndPairingServerCodesArePreserved() async throws {
let client = BridgeClient(try connection("redirect"))
defer { client.close() }
do { _ = try await client.state(); XCTFail("Followed redirect") }
catch {
guard case BridgeError.http(status: 302, code: _) = error else { return XCTFail("Unexpected error: \(error)") }
}
for (code, expected) in [("11111111", "invalid_code"), ("22222222", "pairing_closed")] {
do { _ = try await client.pair(code: code); XCTFail("Accepted invalid pairing") }
catch {
guard case BridgeError.http(status: 403, code: let actual) = error else { return XCTFail("Unexpected error: \(error)") }
XCTAssertEqual(actual, expected)
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import XCTest
@testable import MusicBridge
@MainActor
final class SystemPlayerTests: XCTestCase {
private func state(_ changes: [String: Any] = [:]) throws -> MediaState {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json"))
var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any])
changes.forEach { json[$0.key] = $0.value }
return try JSONDecoder().decode(MediaState.self, from: JSONSerialization.data(withJSONObject: json))
}
func testSeekConvertsRelativePositionAndClampsToAllowedRange() throws {
let state = try state(["startSeconds": 100, "endSeconds": 300, "minSeekSeconds": 110, "maxSeekSeconds": 280])
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 30, state: state).value, 130)
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: -50, state: state).value, 110)
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 500, state: state).value, 280)
XCTAssertThrowsError(try SystemPlayerTransport.command(.seek, value: .nan, state: state))
}
func testVolumeBoundsAndUnsupportedPlayback() throws {
let state = try state()
XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: 2, state: state).value, 1)
XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: -1, state: state).value, 0)
XCTAssertThrowsError(try SystemPlayerTransport.command(.volume, value: .infinity, state: state))
XCTAssertThrowsError(try SystemPlayerTransport.command(.play, value: nil, state: state))
XCTAssertThrowsError(try SystemPlayerTransport.command(.next, value: nil, state: self.state(["hasSession": false])))
}
func testRemoteSnapshotContainsOnlyPublicPlaybackMetadata() throws {
guard #available(iOS 27.0, *) else { throw XCTSkip("Requires NowPlaying") }
let snapshot = SystemPlayerAttributes(id: "session", state: try state(["positionSeconds": 130, "startSeconds": 100, "endSeconds": 300]))
XCTAssertEqual(snapshot.position, 30)
XCTAssertEqual(snapshot.duration, 200)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any])
XCTAssertEqual(Set(json.keys), Set(["id", "title", "artist", "source", "playing", "rate", "position", "duration", "timestamp", "capabilities", "volume", "hasSession"]))
}
func testRevokedSessionDoesNotSendCommandAndNextIsSentOnce() async throws {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
let f = try XCTUnwrap(fixtures["valid"])
let client = BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: "live-system-next"))
defer { client.close() }
do {
_ = try await SystemPlayerTransport.execute(.next, client: client) { throw BridgeError.unauthorized }
XCTFail("Revoked session must be rejected")
} catch BridgeError.unauthorized { }
let unchanged = try await client.state()
XCTAssertEqual(unchanged.title, "Track 0")
let updated = try await SystemPlayerTransport.execute(.next, client: client)
XCTAssertEqual(updated.title, "Track 1")
}
}
+36
View File
@@ -0,0 +1,36 @@
import XCTest
@testable import MusicBridge
final class TLSTests: XCTestCase {
private func connection(_ name: String, wrongPin: Bool = false) throws -> Connection {
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))
guard let fixture = fixtures[name] else { throw XCTSkip("Start tools/tls_fixture.py before building TLS tests.") }
return try Connection(endpoint: XCTUnwrap(fixture["endpoint"]),
fingerprint: wrongPin ? String(repeating: "0", count: 64) : XCTUnwrap(fixture["fingerprint"]))
}
func testPinnedSelfSignedHTTPSAndWSS() async throws {
let client = BridgeClient(try connection("valid"))
defer { client.close() }
let state = try await client.state()
XCTAssertEqual(state.protocolVersion, 1)
client.openEvents()
let event = try await client.nextState()
XCTAssertEqual(event.title, state.title)
}
func testWrongFingerprintIsRejected() async throws {
let client = BridgeClient(try connection("valid", wrongPin: true))
defer { client.close() }
do { _ = try await client.state(); XCTFail("Accepted the wrong fingerprint") }
catch { XCTAssertTrue(error is URLError) }
}
func testExpiredPinnedCertificateIsRejected() async throws {
let client = BridgeClient(try connection("expired"))
defer { client.close() }
do { _ = try await client.state(); XCTFail("Accepted an expired certificate") }
catch { XCTAssertTrue(error is URLError) }
}
}
+1
View File
@@ -0,0 +1 @@
{}
+29
View File
@@ -0,0 +1,29 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,45 @@
import ActivityKit
import SwiftUI
import WidgetKit
@main
struct MusicBridgeWidgets: WidgetBundle {
var body: some Widget { MusicBridgeLiveActivity() }
}
struct MusicBridgeLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MusicActivityAttributes.self) { context in
MusicActivityCard(state: context.state, stale: context.isStale, activityID: context.activityID)
.activityBackgroundTint(Color(.secondarySystemBackground))
.activitySystemActionForegroundColor(.indigo)
.widgetURL(URL(string: "musicbridge://player"))
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "waveform").foregroundStyle(.cyan).padding(.leading, 4)
}
DynamicIslandExpandedRegion(.trailing) {
Image(systemName: context.isStale || !context.state.connected ? "arrow.clockwise" : context.state.playing ? "play.fill" : "pause.fill")
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 8) {
TrackLabels(state: context.state)
ProgressView(value: context.state.progress).tint(.cyan)
ActivityStatus(state: context.state, stale: context.isStale)
MusicActivityControls(activityID: context.activityID).foregroundStyle(.cyan)
}.padding(.bottom, 4)
}
} compactLeading: {
Image(systemName: "waveform").foregroundStyle(.cyan)
} compactTrailing: {
Image(systemName: context.isStale || !context.state.connected ? "arrow.clockwise" : context.state.playing ? "play.fill" : "pause.fill")
.accessibilityLabel(context.isStale || !context.state.connected ? "Откройте MusicBridge для обновления" : "Музыка на ПК")
} minimal: {
Image(systemName: context.isStale || !context.state.connected ? "arrow.clockwise" : "waveform").foregroundStyle(.cyan)
}
.widgetURL(URL(string: "musicbridge://player"))
.keylineTint(.cyan)
}
}
}
+300 -12
View File
@@ -1,10 +1,87 @@
# MusicBridge iOS — план # MusicBridge iOS — план
Обновлено: 2026-09-10. Пользователь разрешил начать нативное 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; не менять серверный протокол молча.
@@ -30,21 +107,65 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] Первый успешный xcodebuild + XCTest на macOS: 7 тестов, 0 отказов. - [x] Первый успешный xcodebuild + XCTest на macOS: 7 тестов, 0 отказов.
- [x] Подключён GitHub remote polskikh13/musicbridgeios; Actions и чтение логов через gh работают. - [x] Подключён GitHub remote polskikh13/musicbridgeios; Actions и чтение логов через gh работают.
- [ ] Интеграционные тесты TLS: верный/чужой/истёкший сертификат, redirect, WSS. - [x] Интеграционные тесты TLS: верный/чужой/истёкший сертификат, HTTPS и WSS.
- [ ] Проверить 401 и отзыв устройства, Keychain после переустановки/блокировки. - [x] Пройдены интеграционные проверки redirect, 401, возврата из фона и загрузки обложки.
- [ ] Настроить подпись и установку на физический iPhone. - [ ] Проверить реальный Keychain после переустановки/блокировки и отзыв устройства на физическом iPhone.
- [ ] Проверить LAN/плеер/команды/обложку с настоящим Windows-агентом. - [x] Установить на физический iPhone: пользователь подтвердил установку через iloader 2026-09-12.
- [ ] Камера для QR, системные разрешения и обработка отказа. - [x] Первое подключение к настоящему Windows-агенту: пользователь подтвердил рабочий пилот на iPhone 2026-09-12.
- [ ] Bonjour-поиск `_musicbridge._tcp`, список ПК и обновление адреса при смене IP. - [ ] Полная ручная проверка каждого действия, обложки и устойчивости в Яндекс Музыке и SoundCloud.
- [ ] Улучшить ошибки сопряжения по серверным кодам; повтор загрузки обложки после 404. - [x] Камера для QR, запрос доступа, сообщение при отказе, остановка камеры при закрытии/уходе в фон.
- [ ] Иконка приложения, визуальная проверка разных размеров экрана и Dynamic Type. - [x] Bonjour-поиск `_musicbridge._tcp`, список адресов ПК и восстановление адреса с сохранённым сертификатом.
- [ ] Live Activities / Dynamic Island / App Intents — отдельный последующий этап. - [x] QR через камеру работает на физическом iPhone — подтверждено пользователем после установки 0.2.0.
- [ ] Отдельно проверить оба входа (встроенный сканер и стандартная камера), отказ в доступе к камере, Bonjour и смену IP на физическом iPhone.
- [x] Реализованы понятные ошибки сопряжения по кодам сервера, повтор загрузки обложки после 404 и ручной повтор.
- [x] Иконка приложения и обновлённый интерфейс подключения/плеера.
- [x] Просмотрены снимки 0.4.0: подключение, светлая/тёмная темы, iPhone SE и крупный текст.
- [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
- [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
- [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0.
- [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone.
- [ ] Команды Siri.
- [x] NowPlaying RemoteMediaSession (iOS 27+): SDK 27 и remote-media extension реализованы в 0.7.0; установка и физическая проверка — в текущем этапе выше.
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
Ограничения: хранится один ПК; при смене его IP пока нужно повторное сопряжение. Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
Bonjour, если доступны объявления того же агента. Новый адрес сохраняется только
после успешного HTTPS-запроса с прежним отпечатком и токеном.
Нет фонового воспроизведения, App Store/TestFlight и подписанной IPA. Нет фонового воспроизведения, App Store/TestFlight и подписанной IPA.
Потеря ответа на успешное сопряжение может оставить устройство на ПК: удалить его Потеря ответа на успешное сопряжение может оставить устройство на ПК: удалить его
в окне агента перед повтором. Облачный runner не имеет доступа к домашней LAN. в окне агента перед повтором. Облачный runner не имеет доступа к домашней LAN.
## Проверка на iPhone 2026-09-12
- Windows GUI запущен с `--tray --lan`, порт TCP 8765 слушает.
- На iPhone первая попытка сопряжения завершилась ошибкой недействительного
сертификата сервера. Установка приложения уже работает; это отдельная TLS-проблема.
- Ошибка воспроизведена настоящим HTTPS-запросом в iOS Simulator: старый код
возвращает NSURLErrorDomain -1202 даже с верным отпечатком. Одни ATS-исключения
её не устранили. Исправление: обрабатывать server trust также в
`URLSessionTaskDelegate.didReceive`, направляя его в общую проверку отпечатка
и срока сертификата. После этого HTTPS и WSS проходят.
- Подготовлены ATS-исключения только для RFC1918 CIDR: `10.0.0.0/8`,
`172.16.0.0/12`, `192.168.0.0/16`. Начиная с iOS 17, для IP нужны явные
исключения. Клиент продолжает требовать HTTPS, точный SHA-256 отпечаток,
действующий сертификат и запрещает redirect. Глобального отключения ATS нет.
- Добавлены настоящие HTTPS/WSS проверки в симуляторе с сертификатом того же
типа, что у агента: RSA 2048, самоподписанный, CA:FALSE, SAN localhost/127.0.0.1,
срок пять лет. Отдельно проверяются чужой отпечаток и истёкший сертификат.
`tools/tls_fixture.py` запускается в CI перед сборкой тестов; закрытые ключи
создаются временно на runner и не включаются в приложение или артефакты.
- Сообщение «непригодные данные» при импорте приглашения пока не разобрано:
пользователь попросил отложить его. Следующий этап — исправить импорт и добавить
сканер QR, затем поиск ПК и проверки устойчивости.
- `93f7c9c`: [Actions 34694138394](https://github.com/polskikh13/musicbridgeios/actions/runs/34694138394)
— 10 XCTest, 0 отказов, успешный Release archive. Версия 0.1.1 (build 2),
IPA скачана в `build/iPhone-0.1.1/MusicBridge-iPhone.ipa`; локально проверены ZIP,
iPhoneOS ARM64, версия и ATS-настройки внутри готовой IPA.
После обновления пользователь подтвердил: «всё работает в пилотном режиме».
Это подтверждает первую рабочую связку iPhone ↔ Windows; полная матрица ручных
проверок и длительная работа пока не подтверждены.
## Карта ## Карта
- `MusicBridge/Models.swift`: контракт и проверка входных данных. - `MusicBridge/Models.swift`: контракт и проверка входных данных.
@@ -52,12 +173,179 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- `KeychainStore.swift`: хранение сопряжения. - `KeychainStore.swift`: хранение сопряжения.
- `PlayerStore.swift`: состояние, команды, жизненный цикл и восстановление. - `PlayerStore.swift`: состояние, команды, жизненный цикл и восстановление.
- `ContentView.swift`: подключение и плеер. - `ContentView.swift`: подключение и плеер.
- `QRScanner.swift`: камера AVFoundation, доступ, сканирование и жизненный цикл.
- `DiscoveryStore.swift`: Bonjour, разрешение адресов, TXT и безопасный выбор адреса.
- `ConnectionStorage.swift`: доступ к Keychain и подмена хранилища для изолированных тестов жизненного цикла.
- `LiveActivityManager.swift`: запуск, обновление, устаревание и завершение одной локальной Live Activity.
- `Shared/`: ограниченный публичный снимок трека и общие SwiftUI-компоненты карточки.
- `MusicBridgeWidgets/`: расширение WidgetKit, экран блокировки и все представления Dynamic Island.
- `MusicBridgeTests/`: XCTest и пример Windows JSON. - `MusicBridgeTests/`: XCTest и пример Windows JSON.
- `tools/`: генератор проекта, структурные проверки, выбор симулятора для CI. - `tools/`: генератор проекта, структурные проверки, выбор симулятора для CI.
- `.github/workflows/ios.yml`: CI на macOS. - `.github/workflows/ios.yml`: CI на macOS.
- `tools/package_ipa.sh`: Release archive для настоящего iPhone и упаковка Payload. - `tools/package_ipa.sh`: Release archive для настоящего iPhone и упаковка Payload.
- `tools/verify_ipa.py`: проверка ZIP, Info.plist, ARM64 и платформы Mach-O (iOS, не Simulator). - `tools/verify_ipa.py`: проверка ZIP, Info.plist, ARM64 и платформы Mach-O (iOS, не Simulator).
## QR и поиск ПК — 0.2.0 (build 3)
- Добавлен сканер внутри приложения; после сканирования заполняются адрес,
отпечаток и код, подключение запускает пользователь. Также зарегистрирована
схема `musicbridge://`, приглашения принимаются через `onOpenURL`.
Посторонний QR, повреждённый JSON и истёкший код дают понятную ошибку.
- Регистрация URL-схемы устраняет отсутствие обработчика ссылки при чтении QR
стандартной камерой. Исходное сообщение «непригодные данные» ещё нужно
перепроверить на телефоне, точный источник той ошибки не был установлен.
- Поиск использует NSBonjourServices и NetServiceBrowser. Объявления версии 1
принимаются только с допустимым SHA-256 и частным IPv4. При первом выборе
найденного ПК пользователь сверяет полный отпечаток с его экраном.
- При восстановлении адреса TXT служит только подсказкой: сертификат из сети
не заменяет сохранённый. Чужой fingerprint не используется; токен отправляется
только через соединение с прежним TLS pin. После успеха адрес обновляется в Keychain.
- Поиск приостанавливается в фоне; адреса повторно разрешаются раз в 15 секунд,
список ограничен 64 сервисами и 16 адресами на сервис. Есть повторный поиск
и переход в настройки разрешений.
- `397caf2`: Actions 34694893204 — 14 XCTest, 0 отказов, Release archive успешен.
Проверены формат объявления Windows, разбор sockaddr, отказы чужим данным,
сохранение pin/token при смене адреса и .NET timestamp с семью дробными цифрами.
Камера и реальный multicast iPhone ↔ Windows не эмулируются этими тестами.
- Итоговый `e384e9a`: [Actions 34695228108](https://github.com/polskikh13/musicbridgeios/actions/runs/34695228108)
— 14 XCTest без отказов и успешный Release archive. Экран подключения просмотрен
на снимке симулятора; исправлен статус при возвращении без сохранённого ПК.
IPA скачана в `build/iPhone-0.2.0/MusicBridge-iPhone.ipa`; проверены ZIP, ARM64
iPhoneOS, версия 0.2.0 (3), разрешение камеры, Bonjour и обработчик URL в готовом пакете.
Пользователь подтвердил работу QR через камеру на iPhone. Следующий шаг:
проверить Bonjour/смену IP и устойчивость, улучшить ошибки и восстановление обложки.
## Следующие этапы — выполнять последовательно
1. **Устойчивость и ошибки — версия 0.3.0 (build 4).** Исправления и автоматические
проверки завершены. Следующий ручной шаг — проверка на iPhone.
2. **Интерфейс — версия 0.4.0 (build 5).** Добавлены иконка, компактный экран QR,
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
3. **Системные интеграции — 0.6.0 (build 7).** Добавлены прямые действия LiveActivityIntent.
Убрана ошибка устаревания при обычном сворачивании; каждое нажатие перечитывает ПК.
32 XCTest и Release archive прошли. APNs и встроенный системный музыкальный пульт ещё не реализованы.
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
Продолжение третьего этапа — 0.6.0:
- Итоговый `43c78b2`: [Actions 34748340408](https://github.com/polskikh13/musicbridgeios/actions/runs/34748340408)
— 32 XCTest без отказов, успешный Release archive. IPA 0.6.0 (7) скачана в
`build/iPhone-0.6.0/MusicBridge-iPhone.ipa`; проверены ARM64 iPhoneOS приложения
и расширения, версии, метаданные интентов. Просмотрены светлая и тёмная карточки.
Работа системного запуска LiveActivityIntent и Keychain после блокировки требует
проверки на телефоне: обновить IPA, выключить/включить карточку, заблокировать,
проверить next/previous/toggle/refresh; затем повторить после перезагрузки и первого unlock.
- Пользователь сообщил, что карточка постоянно пишет об устаревших данных.
Причина: `PlayerStore.stop()` вызывал `unavailable()` при каждом уходе в фон.
Удалён этот вызов; реальные сетевые отказы продолжают отмечаться отдельно.
Карточка показывает время последнего снимка и отдельные ошибки запросов.
- Shared `MusicControlIntent` соответствует LiveActivityIntent. WidgetKit содержит
определение интента, выполнение сети компилируется только в основной app target;
iOS запускает процесс приложения без открытия UI. На компактном/минимальном Island
кнопок нет: нужно раскрыть долгим нажатием. Кнопки не отключаются из-за старого снимка.
- `LiveActivityCommands`: одно действие одновременно, проверка живой Activity и связи
её ID с fingerprint текущего сопряжения. После чтения состояния повторно проверяется
актуальность карточки, затем проверяются capabilities и отправляется максимум одна
команда. Refresh вообще не отправляет команду. Повторов команд нет.
- После успеха перечитывается трек и обновляется Activity. 401 завершает её;
неизвестный результат и неудача чтения после успеха имеют разные сообщения.
Нет бесконечного WSS/Bonjour в фоне; после смены IP сначала открыть приложение.
- Keychain: AfterFirstUnlockThisDeviceOnly вместо WhenUnlockedThisDeviceOnly,
чтобы команды работали при заблокированном iPhone. Существующий токен мигрирует
при повторном включении карточки. Он не покидает Keychain и не попадает в payload.
В UserDefaults сохраняется только соответствие ID активности и публичного pin.
После обновления с 0.5.0 карточку выключить и включить заново в приложении.
- HTTPS-тесты проверяют одну отправку next и новый трек, refresh без команды,
закрытую карточку, неподдерживаемую команду, отсутствие повторов отказавшей команды,
ошибку чтения после успешной команды. Физический запуск интента проверяется отдельно.
- Ответ на вопрос пользователя о встроенном музыкальном центре: официальный
[NowPlaying RemoteMediaSession](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions)
предназначен для внешнего воспроизведения. Metadata Apple указывает iOS 27.0.
Текущий runner собирает Xcode 16.4: следующий этап — SDK/Xcode 27 и отдельное
`com.apple.nowplaying.remote-media` расширение. Интеграция ещё не реализована.
История третьего этапа — Live Activities 0.5.0 (поведение фона изменено в 0.6.0):
- Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034)
— 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive.
IPA скачана в `build/iPhone-0.5.0/MusicBridge-iPhone.ipa`; проверены оба ARM64
iPhoneOS исполняемых файла, структура `.appex`, версии и NSSupportsLiveActivities.
Светлый/тёмный предпросмотр карточки просмотрен: текст и отметка устаревания читаемы.
Установка/регистрация Live Activity на физическом iPhone остаются ручной проверкой.
- ActivityKit без push: старт только кнопкой пользователя при активном приложении
и медиасессии. Восстанавливается существующая активность, дубли завершаются;
после удаления пользователем автоматического пересоздания нет.
- WidgetKit-расширение встроено в приложение. В attributes/content нет адреса ПК,
токена, fingerprint или обложки; только публичные данные трека, ограниченные
по UTF-8 (по 256 байт для названия и исполнителя).
- Обычные позиции объединяются до одного обновления в пять секунд; смена трека,
длительности или состояния воспроизведения отправляется сразу. Прогресс статический,
соответствует последнему снимку. В фоне/без сети помечается устаревшим;
`staleDate` через 15 секунд страхует внезапную остановку приложения.
- Нет медиасессии, отзыв доступа, «Забыть компьютер» или ручное выключение — завершение.
Нажатие карточки открывает `musicbridge://player`, не попадает в импорт QR.
- Новые XCTest: позиция относительно startSeconds, нулевая длительность/пауза,
чрезмерные Unicode-метаданные и размер Codable payload. Проверка IPA требует
встроенную ARM64 iPhoneOS `.appex`, совпадающие версии и поддержку Live Activities.
- CI-предпросмотр общей разметки карточки проверяет светлую/тёмную темы и устаревшие
данные. Это не проверка системной регистрации ActivityKit/Island. На физическом
iPhone проверить старт, блокировку, раскрытие Island, переход в плеер, возврат,
удаление карточки, отсутствие автопересоздания, окончание сессии и отзыв доступа.
- Следующие отдельные задачи: LiveActivityIntent с безопасным выполнением команды
(проверить доступность Keychain/локальной сети при блокировке); Siri; APNs, если
требуются актуальные данные после приостановки приложения. Постоянный WSS в фоне
не реализован и не обещается. Windows-протокол не менялся.
- Основание: [Apple ActivityKit](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities)
и [WidgetKit extension](https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension).
Изменения второго этапа:
- Итоговый `aa0b0f0`: [Actions 34746477145](https://github.com/polskikh13/musicbridgeios/actions/runs/34746477145)
— 23 XCTest без отказов и успешный Release archive. Итоговая IPA скачана в
`build/iPhone-0.4.0/MusicBridge-iPhone.ipa`; проверены ZIP, iPhoneOS ARM64,
версия 0.4.0 (5), AppIcon в Info.plist и скомпилированный каталог Assets.car.
Снимки находятся в `build/iPhone-0.4.0/final-preview`. На маленьком экране
основные кнопки видны сразу; для нижней части громкости может требоваться прокрутка.
- `cbd3c0f`: добавлены оригинальная иконка 1024×1024 и каталог AppIcon,
карточка QR, раскрываемые ручные поля, меню ПК, крупная play/pause и процент громкости.
- `aa0b0f0`: после просмотра iPhone SE уменьшена обложка по доступной высоте окна;
размеры заданы явно, чтобы заглушка оставалась квадратной. Исполнитель выровнен по центру.
- В Debug есть изолированный предпросмотр с примером трека и памятью вместо Keychain;
в Release этот режим недоступен. CI сохраняет светлый/тёмный плеер, крупный текст,
компактный экран и подключение. Ошибка Keychain -34018 на снимке подключения
относится к неподписанному запуску в CI; доступ к Keychain на телефоне требует подписи.
- Ручная проверка обновления: установить IPA через iloader, проверить иконку,
сохранённое сопряжение, QR, меню ПК, перемотку и громкость; повторить с крупным
системным шрифтом и тёмной темой. Реальную камеру и домашнюю сеть CI не проверяет.
Изменения первого этапа:
- Итоговый `bde6f7a`: [Actions 34712051233](https://github.com/polskikh13/musicbridgeios/actions/runs/34712051233)
— 23 XCTest без отказов, Release archive успешен. IPA 0.3.0 (build 4) скачана
в `build/iPhone-0.3.0/MusicBridge-iPhone.ipa`; проверены ZIP, платформа iPhoneOS,
ARM64 и версия. Экран подключения просмотрен на снимке симулятора.
- Четыре попытки загрузить обложку, паузы 2/4/8 секунд, отдельное сообщение и кнопка
ручного повтора. После 404 перечитывается состояние, чтобы заметить смену трека.
Старая обложка не должна подменять новую после запоздавшего ответа.
- При уходе в фон закрываются текущие соединения и незавершённое сопряжение;
его поздний ответ не сохраняется. Если сервер уже создал запись устройства,
пользователь получает указание удалить её перед повторным сопряжением.
- Ответ 401 переводит приложение в состояние повторного сопряжения, останавливает
автоматические повторы и сохраняет этот статус при возвращении из фона.
- Понятные сообщения для invalid_code, pairing_closed, device_limit, storage_failed,
HTTP-ошибок, сетевых сбоев и TLS. Текст отказа команды не стирается обновлением
прогресса; сообщение можно скрыть. Команды не повторяются автоматически.
- Успех команды и неудача последующего чтения состояния различаются в интерфейсе.
После недоступности Keychain при запуске чтение повторяется при активации приложения.
- Реальные HTTPS/WSS тесты используют временный сервер CI с задержками, 404/401/503,
redirect, ошибками сопряжения и сменой обложки. Реальный пользовательский Keychain
в этих тестах не изменяется. Сон ПК, Wi-Fi и системную блокировку надо проверить
отдельно на физических устройствах.
## Результаты 2026-09-10 ## Результаты 2026-09-10
- `07322c2`: успешный [Actions run 34445566212](https://github.com/polskikh13/musicbridgeios/actions/runs/34445566212). - `07322c2`: успешный [Actions run 34445566212](https://github.com/polskikh13/musicbridgeios/actions/runs/34445566212).
+114 -9
View File
@@ -3,10 +3,11 @@
Нативный пульт на SwiftUI для MusicBridge.Agent на Windows. Звук играет на ПК. Нативный пульт на SwiftUI для MusicBridge.Agent на Windows. Звук играет на ПК.
Минимальная версия iOS — 17. Зависимостей Swift Package/CocoaPods нет. Минимальная версия iOS — 17. Зависимостей Swift Package/CocoaPods нет.
## Первый вариант ## Возможности
- Сопряжение по HTTPS-адресу домашнего ПК, SHA-256 сертификата и коду. - Сопряжение по HTTPS-адресу домашнего ПК, SHA-256 сертификата и коду.
- Импорт строки `musicbridge://pair?data=…` из QR агента; камера пока не реализована. - Сканирование QR агента камерой, открытие `musicbridge://pair?data=…` и ручной ввод.
- Поиск компьютера через Bonjour и восстановление адреса доверенного ПК.
- Проверка закреплённого DER-сертификата до отправки кода и при следующих соединениях. - Проверка закреплённого DER-сертификата до отправки кода и при следующих соединениях.
- Хранение адреса, отпечатка и токена в Keychain только на этом устройстве. - Хранение адреса, отпечатка и токена в Keychain только на этом устройстве.
- Название, исполнитель, обложка, позиция, play/pause, следующий/предыдущий трек, - Название, исполнитель, обложка, позиция, play/pause, следующий/предыдущий трек,
@@ -14,15 +15,35 @@
- WSS-состояние, переподключение с задержкой, остановка сети в фоне и восстановление - WSS-состояние, переподключение с задержкой, остановка сети в фоне и восстановление
при возврате. Команды автоматически не повторяются. при возврате. Команды автоматически не повторяются.
**Статус:** первый вариант собирается в GitHub Actions на macOS, Xcode 16.4, **Статус:** пользователь подтвердил установку через iloader, рабочий пилот с
Swift 6.1.2. Семь XCTest прошли на симуляторе iOS 18.5; проверка на физическом Windows-агентом и подключение по QR. В 0.4.0 добавлены иконка и обновлённый
iPhone и соединение с Windows-агентом ещё не выполнены. интерфейс, в 0.5.0 — локальная Live Activity и Dynamic Island. CI собирает
[Успешный запуск](https://github.com/polskikh13/musicbridgeios/actions/runs/34444563869) приложение и расширение WidgetKit, выполняет XCTest и Release archive;
проверяет исходники коммита `a761431`. полная проверка устойчивости на физических устройствах остаётся в `PLAN.md`.
## Сборка на 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. Сертификаты
@@ -37,7 +58,7 @@ iPhone и соединение с Windows-агентом ещё не выпол
## 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 подключён:
@@ -51,8 +72,37 @@ Apple ID и сертификаты для этой проверки не тре
Код автоматически не зеркалируется между серверами. Локальная регрессионная Код автоматически не зеркалируется между серверами. Локальная регрессионная
проверка выбора симулятора: `python tools/test_select_simulator.py`. проверка выбора симулятора: `python tools/test_select_simulator.py`.
## Подключение через QR и поиск ПК
Откройте окно сопряжения в Windows-агенте (`--tray --lan`),
на iPhone нажмите «Сканировать QR» и разрешите камеру. После сканирования
нажмите «Подключить компьютер». Ссылки `musicbridge://` также открываются
из стандартной камеры. Приглашение должно быть свежим.
В разделе «Найти компьютер» можно выбрать найденный адрес. Сверьте **весь**
предложенный отпечаток с экраном ПК и введите код. Если поиск не работает,
проверьте доступ к локальной сети в настройках MusicBridge; ручной ввод остаётся.
При смене адреса уже доверенного ПК приложение пробует объявления с тем же
отпечатком и сохраняет новый адрес только после успешной TLS-проверки и авторизации.
## Установка на iPhone через Sideloadly ## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки
с Live Activity используйте версию **0.6.0 (build 7)**:
`build/iPhone-0.6.0-xcode27/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия.
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
play/pause, процент громкости и меню компьютера. Размер обложки зависит от
доступной высоты окна; при крупном системном шрифте экран прокручивается.
Начиная с 0.3.0 обложка повторно загружается после временного сбоя, а после четырёх
неудачных попыток доступен ручной повтор. Отозванный доступ требует нового
сопряжения; автоматические попытки прекращаются. Если свернуть приложение во
время сопряжения, повторите его со свежим QR (удалив созданную запись на ПК,
если она уже успела появиться). При неизвестном результате команды проверьте
плеер перед повтором: приложение не повторяет команды автоматически.
Workflow также выполняет Release archive для `generic/platform=iOS` и создаёт Workflow также выполняет Release archive для `generic/platform=iOS` и создаёт
`MusicBridge-iPhone.ipa` в артефакте `MusicBridge-iPhone`. Это ARM64-сборка для `MusicBridge-iPhone.ipa` в артефакте `MusicBridge-iPhone`. Это ARM64-сборка для
iOS 17+, без подписи. Подпись создаёт Sideloadly на вашем ПК с вашим Apple Account. iOS 17+, без подписи. Подпись создаёт Sideloadly на вашем ПК с вашим Apple Account.
@@ -76,6 +126,46 @@ 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 — 0.6.0
После обновления с 0.5.0 уберите старую карточку кнопкой в приложении и нажмите
«Показать на экране блокировки» заново. Это связывает кнопки с текущим сопряжением
и переводит запись Keychain в режим доступа после первого разблокирования.
Токен остаётся только на этом устройстве, не копируется в виджет или UserDefaults.
На экране блокировки и в развёрнутом Dynamic Island доступны предыдущий трек,
play/pause, следующий трек и обновление. Компактный Island открывает приложение;
для кнопок удерживайте Island. Каждое действие выполняет отдельные HTTPS-запросы
с прежней проверкой сертификата. Оно работает через LiveActivityIntent в процессе
приложения, без открытия интерфейса; постоянного сетевого потока в фоне нет.
Перед командой считываются возможности плеера, затем команда отправляется один раз
и перечитывается состояние. Повторы команд отключены; при неизвестном результате
сначала используйте обновление. Одновременно выполняется одно действие карточки.
После закрытия карточки или удаления её связи с ПК новые команды не отправляются.
Для нового адреса ПК сначала откройте приложение: Bonjour-перенос адреса остаётся
в основном приложении. При отзыве доступа карточка завершается.
При обычном сворачивании показывается время последнего обновления, а не ошибка
«Данные устарели». Прогресс соответствует этому снимку. Реальная ошибка сети или
команды выводится на карточке. Изменения, сделанные непосредственно на ПК, при
приостановленном приложении не появятся сами: нажмите обновление или откройте плеер.
Для постоянных фоновых обновлений остаётся отдельная задача APNs.
В IPA есть расширение `MusicBridgeWidgets.appex`: iloader должен сохранить и подписать
его вместе с приложением. Проверка работы интентов в заблокированном состоянии и
системного отображения Island выполняется на iPhone; CI проверяет сборку, сетевую
логику команд и разметку. После перезагрузки сначала один раз разблокируйте iPhone.
### Встроенный музыкальный пульт iOS
Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+)
для отображения музыки внешнего устройства на экране блокировки и в Control Center.
Это подходит архитектуре MusicBridge. Нужно отдельное расширение `com.apple.nowplaying.remote-media`. Сборка переведена на Xcode 27; 32 теста и Release archive прошли.
Эта интеграция исследована, но ещё не реализована. Для фоновых обновлений удалённых
сессий Apple также предоставляет push-механизм.
[Документация remote media sessions](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions).
## Источники ## Источники
- [Apple: проверка доверия сервера](https://developer.apple.com/documentation/Foundation/performing-manual-server-trust-authentication) - [Apple: проверка доверия сервера](https://developer.apple.com/documentation/Foundation/performing-manual-server-trust-authentication)
@@ -84,3 +174,18 @@ Sideloadly (в том числе его функцией автоматичес
- [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions) - [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions)
План и ограничения — в `PLAN.md`. Копия протокола агента — в `PROTOCOL.md`. План и ограничения — в `PLAN.md`. Копия протокола агента — в `PROTOCOL.md`.
## Сервер сборки и фоновые обновления
GitHub Actions использует Apple Silicon runner `xcode-27` с проверкой версии Xcode.
SSH к локальной macOS-виртуалке настроен, однако она x86_64, без Xcode и не может
запускать Xcode 27. Для SDK 27 используется GitHub Actions.
Тестовый TLS-сервер требует `cryptography==46.0.3`; CI устанавливает пакет
в отдельное Python-окружение. Это зависимость тестов, не приложения.
Пользователь использует бесплатный Apple Account. APNs требует Push Notifications
capability и подписи с соответствующим provisioning profile, недоступных Personal Team.
Поэтому автоматическая фоновая доставка ещё не подключена. Команды Live Activity
выполняют запросы при нажатии; смена трека на ПК при приостановленном приложении
не обновляет карточку без запроса или push.
+29
View File
@@ -0,0 +1,29 @@
import Foundation
import NowPlaying
@available(iOS 27.0, *)
struct SystemPlayerAttributes: RemoteMediaSessionAttributes {
let id: String
var title: String
var artist: String
var source: String
var artworkID: String?
var playing: Bool
var rate: Double
var position: Double
var duration: Double
var timestamp: Date
var capabilities: Capabilities
var volume: Double?
var hasSession: Bool
init(id: String, state: MediaState, now: Date = Date()) {
self.id = id
title = String(state.title.prefix(256)); artist = String(state.artist.prefix(256))
source = String(state.source.prefix(128)); artworkID = state.artworkId
playing = state.playbackStatus == "Playing"; rate = state.playbackRate
position = max(0, state.position(elapsed: 0) - state.startSeconds)
duration = max(0, state.endSeconds - state.startSeconds)
timestamp = now; capabilities = state.capabilities; volume = state.volume; hasSession = state.hasSession
}
}
@@ -0,0 +1,62 @@
import Foundation
import Security
enum SystemPlayerCredentials {
// Derive the signing prefix from our own default Keychain group. Info.plist
// build substitutions cannot know the team that later re-signs an IPA.
private static func signingPrefix() throws -> String {
let marker: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "MusicBridge.signing-prefix.v1", kSecAttrAccount as String: "prefix"]
let status = SecItemAdd(marker.merging([kSecValueData as String: Data(),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]) { _, v in v } as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else { throw failure(status) }
var result: CFTypeRef?
let read = SecItemCopyMatching(marker.merging([kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]) { _, v in v } as CFDictionary, &result)
guard read == errSecSuccess else { throw failure(read) }
guard let attributes = result as? [String: Any],
let group = attributes[kSecAttrAccessGroup as String] as? String,
let separator = group.firstIndex(of: "."), separator != group.startIndex else {
throw failure(errSecMissingEntitlement)
}
return String(group[...separator])
}
private static func query(_ id: String) throws -> [String: Any] {
guard let suffix = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
suffix == "ru.yukinoki.musicbridge.remote-control" else {
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
}
return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1",
kSecAttrAccount as String: id, kSecAttrAccessGroup as String: try signingPrefix() + suffix]
}
static func save(_ connection: Connection, id: String) throws {
let query = try query(id)
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, v in v } as CFDictionary, nil) }
guard status == errSecSuccess else { throw failure(status) }
}
static func load(_ id: String) throws -> Connection {
var query = try query(id)
query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
let saved = try JSONDecoder().decode(Connection.self, from: data)
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.unauthorized }
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
}
static func delete(_ id: String) throws {
let status = SecItemDelete(try query(id) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
}
private static func failure(_ status: OSStatus) -> BridgeError {
.message("Системный пульт не получил доступ к Keychain (\(status)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.")
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
enum SystemPlayerAction { case play, pause, next, previous, seek, volume }
@MainActor
final class SystemPlayerTransport {
private var busy = false
func send(_ action: SystemPlayerAction, value: Double? = nil, sessionID: String) async throws -> MediaState {
guard !busy else { throw BridgeError.message("Предыдущая команда ещё выполняется.") }
busy = true
defer { busy = false }
let saved = try SystemPlayerCredentials.load(sessionID)
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
return try await Self.execute(action, value: value, client: client) {
let current = try SystemPlayerCredentials.load(sessionID)
guard current.endpoint == saved.endpoint, current.fingerprint == saved.fingerprint, current.token == saved.token else {
throw BridgeError.unauthorized
}
}
}
static func execute(_ action: SystemPlayerAction, value: Double? = nil, client: BridgeClient,
validate: () throws -> Void = {}) async throws -> MediaState {
let state = try await client.state()
try validate()
let command = try command(action, value: value, state: state)
do { try await client.command(command) }
catch {
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Откройте плеер перед повтором.") }
throw error
}
do { return try await client.state() }
catch {
if case BridgeError.unauthorized = error { throw error }
throw BridgeError.message("Команда выполнена, но новый трек не получен. Откройте приложение для обновления.")
}
}
static func command(_ action: SystemPlayerAction, value: Double?, state: MediaState) throws -> Command {
switch action {
case .play where state.hasSession && state.capabilities.play: return Command(type: "play", value: nil)
case .pause where state.hasSession && state.capabilities.pause: return Command(type: "pause", value: nil)
case .next where state.hasSession && state.capabilities.next: return Command(type: "next", value: nil)
case .previous where state.hasSession && state.capabilities.previous: return Command(type: "previous", value: nil)
case .seek where state.hasSession && state.capabilities.seek && state.maxSeekSeconds > state.minSeekSeconds:
guard let value, value.isFinite else { throw BridgeError.message("Некорректная позиция трека.") }
return Command(type: "seek", value: min(state.maxSeekSeconds, max(state.minSeekSeconds, state.startSeconds + value)))
case .volume where state.volume != nil:
guard let value, value.isFinite else { throw BridgeError.message("Некорректная громкость.") }
return Command(type: "volume", value: min(1, max(0, value)))
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import ActivityKit
import Foundation
struct MusicActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var title: String
var artist: String
var playing: Bool
var position: Double
var duration: Double
var updatedAt: Date
var connected: Bool
var message: String? = nil
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
static func bounded(_ text: String) -> String {
var result = ""
for scalar in text.unicodeScalars {
guard result.utf8.count + scalar.utf8.count <= 256 else { break }
result.unicodeScalars.append(scalar)
}
return result
}
var progress: Double { duration > 0 ? min(1, max(0, position / duration)) : 0 }
}
}
+70
View File
@@ -0,0 +1,70 @@
import SwiftUI
import AppIntents
struct MusicActivityCard: View {
let state: MusicActivityAttributes.ContentState
let stale: Bool
var activityID: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Image(systemName: "waveform").font(.title).foregroundStyle(.indigo)
TrackLabels(state: state)
Spacer(minLength: 0)
Image(systemName: state.playing ? "play.fill" : "pause.fill")
.accessibilityLabel("Последнее состояние: " + (state.playing ? "воспроизведение" : "пауза"))
}
ProgressView(value: state.progress).tint(.indigo)
ActivityStatus(state: state, stale: stale)
MusicActivityControls(activityID: activityID)
}.padding(12)
}
}
struct TrackLabels: View {
let state: MusicActivityAttributes.ContentState
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(state.title).font(.headline).lineLimit(1)
Text(state.artist.isEmpty ? "MusicBridge · музыка на ПК" : state.artist)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
}
struct ActivityStatus: View {
let state: MusicActivityAttributes.ContentState
let stale: Bool
var body: some View {
if let message = state.message {
Text(message).font(.caption).lineLimit(2)
} else if !state.connected {
Text("Нет связи с ПК · нажмите обновление").font(.caption).foregroundStyle(.secondary)
} else {
HStack(spacing: 4) {
Text("Обновлено в")
Text(state.updatedAt, style: .time)
}.font(.caption).foregroundStyle(.secondary)
}
}
}
struct MusicActivityControls: View {
let activityID: String
var body: some View {
HStack {
control(.previous, icon: "backward.end.fill", label: "Предыдущий трек")
Spacer(minLength: 4)
control(.toggle, icon: "playpause.fill", label: "Воспроизведение или пауза")
Spacer(minLength: 4)
control(.next, icon: "forward.end.fill", label: "Следующий трек")
Spacer(minLength: 4)
control(.refresh, icon: "arrow.clockwise", label: "Обновить трек")
}
}
private func control(_ action: MusicRemoteAction, icon: String, label: String) -> some View {
Button(intent: MusicControlIntent(action, activityID: activityID)) {
Image(systemName: icon).font(.title3).frame(minWidth: 44, minHeight: 44)
}.buttonStyle(.plain).accessibilityLabel(label).disabled(activityID.isEmpty)
}
}
+28
View File
@@ -0,0 +1,28 @@
import AppIntents
enum MusicRemoteAction: String, AppEnum {
case previous, toggle, next, refresh
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Команда плеера"
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.previous: "Предыдущий трек", .toggle: "Воспроизведение / пауза", .next: "Следующий трек", .refresh: "Обновить"
]
}
struct MusicControlIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Управлять музыкой на ПК"
static var openAppWhenRun: Bool = false
static var isDiscoverable: Bool = false
static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
@Parameter(title: "Команда") var action: MusicRemoteAction
@Parameter(title: "Активность") var activityID: String
init() {}
init(_ action: MusicRemoteAction, activityID: String) { self.action = action; self.activityID = activityID }
func perform() async throws -> some IntentResult {
#if !MUSICBRIDGE_WIDGET
await LiveActivityCommands.perform(action, activityID: activityID)
#endif
return .result()
}
}
+25
View File
@@ -0,0 +1,25 @@
# Original vector artwork, rendered to the opaque 1024px asset required by Xcode.
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$iconFolder = Join-Path $PSScriptRoot '..\MusicBridge\Assets.xcassets\AppIcon.appiconset'
[System.IO.Directory]::CreateDirectory($iconFolder) | Out-Null
$bitmap = [System.Drawing.Bitmap]::new(1024, 1024, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$background = [System.Drawing.Drawing2D.LinearGradientBrush]::new(
[System.Drawing.Point]::new(0, 0), [System.Drawing.Point]::new(1024, 1024),
[System.Drawing.Color]::FromArgb(59, 43, 164), [System.Drawing.Color]::FromArgb(23, 145, 158))
$graphics.FillRectangle($background, 0, 0, 1024, 1024)
$ink = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
$heights = @(176, 316, 472, 316, 176)
for ($index = 0; $index -lt 5; $index++) {
$barX = 204 + $index * 132
$barHeight = $heights[$index]
$barY = 512 - $barHeight / 2
$graphics.FillRectangle($ink, $barX, $barY + 42, 88, $barHeight - 84)
$graphics.FillEllipse($ink, $barX, $barY, 88, 88)
$graphics.FillEllipse($ink, $barX, $barY + $barHeight - 88, 88, 88)
}
$bitmap.Save((Join-Path $iconFolder 'AppIcon.png'), [System.Drawing.Imaging.ImageFormat]::Png)
$ink.Dispose(); $background.Dispose(); $graphics.Dispose(); $bitmap.Dispose()
Write-Output 'Generated original MusicBridge icon: 1024x1024 RGB PNG.'
+79 -11
View File
@@ -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,21 +37,42 @@ 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")
widget_group, widget_sources = source_group("MusicBridgeWidgets")
app_sources += shared_sources
widget_sources += [add("widget-shared/" + str(index), "PBXBuildFile", fileRef=objects[build]["fileRef"])
for index, build in enumerate(shared_sources)]
assets = add("assets", "PBXFileReference", lastKnownFileType="folder.assetcatalog", path="Assets.xcassets", sourceTree="<group>")
objects[app_group]["children"].append(assets)
assets_build = add("assets-build", "PBXBuildFile", fileRef=assets)
test_group, test_sources = source_group("MusicBridgeTests") test_group, test_sources = source_group("MusicBridgeTests")
fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>") fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>")
objects[test_group]["children"].append(fixture) objects[test_group]["children"].append(fixture)
fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture) fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture)
tls_fixture = add("tls-fixture", "PBXFileReference", lastKnownFileType="text.json", path="tls-fixture.json", sourceTree="<group>")
objects[test_group]["children"].append(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)]: for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False), ("MusicBridgeRemote", remote_sources, False)]:
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.cfbundle" if is_test else "wrapper.application", is_widget = name == "MusicBridgeWidgets"
includeInIndex=0, path=name + (".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR") is_remote = name == "MusicBridgeRemote"
is_extension = is_widget or is_remote
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.extensionkit-extension" if is_remote else "wrapper.app-extension" if is_extension else "wrapper.cfbundle" if is_test else "wrapper.application",
includeInIndex=0, path=name + (".appex" if is_extension else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR")
products.append(product) 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=[fixture_build] if is_test else [], 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" + (".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 = []
@@ -57,14 +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_extension:
settings.update(INFOPLIST_FILE=name + "/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0",
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
if is_remote:
settings.update(CODE_SIGN_ENTITLEMENTS="MusicBridgeRemote/Remote.entitlements")
else: else:
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="1", MARKETING_VERSION="0.1.0") settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon", CODE_SIGN_ENTITLEMENTS="MusicBridge/App.entitlements")
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
embed_remote = add("embed-remote", "PBXBuildFile", fileRef=uid("MusicBridgeRemoteproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0))
phases.append(add("embed-remote-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="$(EXTENSIONS_FOLDER_PATH)", dstSubfolderSpec=16, files=[embed_remote], name="Embed ExtensionKit Extensions", runOnlyForDeploymentPostprocessing=0))
proxy = add("widget-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets")
deps = [add("widget-dependency", "PBXTargetDependency", target=uid("MusicBridgeWidgetstarget"), targetProxy=proxy)]
remote_proxy = add("remote-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeRemotetarget"), remoteInfo="MusicBridgeRemote")
deps.append(add("remote-dependency", "PBXTargetDependency", target=uid("MusicBridgeRemotetarget"), targetProxy=remote_proxy))
targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product, targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product,
productType="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, 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"],
@@ -100,9 +141,36 @@ test_ref = reference("MusicBridgeTests", "MusicBridgeTests.xctest")
info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", CFBundleExecutable="$(EXECUTABLE_NAME)", info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", CFBundleExecutable="$(EXECUTABLE_NAME)",
CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)", CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)", CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)", CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)",
CFBundlePackageType="APPL", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)", CFBundlePackageType="APPL", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
LSRequiresIPhoneOS=True, UILaunchScreen={}, LSRequiresIPhoneOS=True, UILaunchScreen={}, NSSupportsLiveActivities=True,
UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"], UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"],
NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.", NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.",
NSAppTransportSecurity=dict(NSAllowsLocalNetworking=True)) NSCameraUsageDescription="Камера сканирует QR в MusicBridge на ПК для безопасного сопряжения.",
NSBonjourServices=["_musicbridge._tcp"],
CFBundleURLTypes=[dict(CFBundleURLName="ru.yukinoki.musicbridge.pair", CFBundleURLSchemes=["musicbridge"])],
# iOS 17+ requires explicit IP exceptions for custom self-signed trust.
# BridgeClient still requires HTTPS, an exact SHA-256 pin and valid dates.
# Never disable ATS globally or include public IP ranges.
NSAppTransportSecurity=dict(NSAllowsLocalNetworking=True, NSExceptionDomains={
network: dict(NSExceptionAllowsInsecureHTTPLoads=True)
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
}))
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False)) (ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
widget_info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge",
CFBundleExecutable="$(EXECUTABLE_NAME)", CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)",
CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)", CFBundlePackageType="XPC!",
CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension"))
(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False))
shared_group_name = "$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control"
info["MusicBridgeSharedKeychainGroup"] = "ru.yukinoki.musicbridge.remote-control"
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
remote_info = {k: v for k, v in widget_info.items() if k != "NSExtension"}
remote_info.update(EXAppExtensionAttributes=dict(EXExtensionPointIdentifier="com.apple.nowplaying.remote-media"),
MusicBridgeSharedKeychainGroup=info["MusicBridgeSharedKeychainGroup"],
NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"],
NSAppTransportSecurity=info["NSAppTransportSecurity"])
(ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False))
for folder, filename in (("MusicBridge", "App.entitlements"), ("MusicBridgeRemote", "Remote.entitlements")):
groups = ["$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)", shared_group_name]
(ROOT / folder / filename).write_bytes(plistlib.dumps({"keychain-access-groups": groups}))
print("Generated Xcode project, shared scheme and Info.plist.") print("Generated Xcode project, shared scheme and Info.plist.")
+28
View File
@@ -0,0 +1,28 @@
# iloader: совместимость с MusicBridge 0.7.0
Это отдельная неофициальная portable-сборка iloader для Windows. Исходники:
- https://github.com/nab138/iloader/tree/348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
- https://github.com/nab138/isideload/tree/f6a4d5dba717d72fc2af63eaba26b27ba44116be
- https://github.com/nab138/apple-crates/tree/5da0b8df9b202434b1eff2700059b1ee142592ac
Патчи хранятся в репозитории MusicBridge в tools/iloader. Workflow
iloader-compat.yml запускается вручную, не использует Apple Account/сертификаты.
Apple-авторизация и подпись происходят только локально на компьютере пользователя.
Исправления: обнаружение Extensions вместе с PlugIns, переименование и получение
профиля каждого расширения, вложенная подпись ExtensionKit и общий Keychain для
MusicBridge только в пределах разрешений настоящего provisioning profile Apple.
Регрессионные Rust-тесты проверяют обе папки расширений, сохранение новых Bundle ID,
обработку вложенной подписи и отказ при недопустимой Keychain group.
Использование после успешной сборки:
1. Закрыть обычный iloader. Удалять его и установленный MusicBridge не требуется.
2. Запустить iloader-musicbridge.exe. Заголовок окна содержит compatibility build.
3. Использовать прежний Apple Account; выбрать исходную MusicBridge 0.7.0 IPA.
4. Подписать и установить заново. Все расширения нужно сохранить.
5. Проверить обычное подключение, затем кнопку системного пульта и Пункт управления.
Вариант сохраняет идентификатор/хранилище обычного iloader для локального входа,
поэтому одновременно запускать обе версии не нужно. Обновления из upstream в
этой сборке отключены. Совместимость нового extension point с бесплатным профилем
Apple окончательно подтверждается только установкой на устройстве.
+46
View File
@@ -0,0 +1,46 @@
diff --git a/apple-codesign-quick/src/bundle.rs b/apple-codesign-quick/src/bundle.rs
index 040fd07..2bfa359 100644
--- a/apple-codesign-quick/src/bundle.rs
+++ b/apple-codesign-quick/src/bundle.rs
@@ -16,6 +16,23 @@ const FAIRPLAY_DIR: &str = "SC_Info";
const CODE_SIGNATURE_DIR: &str = "_CodeSignature";
const CODE_RESOURCES_FILE: &str = "CodeResources";
+#[cfg(test)]
+mod musicbridge_extensionkit_tests {
+ use super::*;
+ #[test]
+ fn musicbridge_extensionkit_is_signed_as_nested_bundle() {
+ let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let root = std::env::temp_dir().join(format!("musicbridge-codesign-test-{unique}"));
+ let extension = root.join("Extensions/Remote.appex");
+ std::fs::create_dir_all(&extension).unwrap();
+ std::fs::write(extension.join("Info.plist"), b"fixture").unwrap();
+ let bundle = Bundle { path: root, app_info: Dictionary::new() };
+ assert_eq!(bundle.sub_bundles().unwrap(), vec![extension]);
+ assert!(!should_hash_resource("Extensions/Remote.appex/Remote", "Main"));
+ assert!(should_hash_resource("Assets.car", "Main"));
+ }
+}
+
pub struct BundleSigningSettings<'a> {
pub team_id: String,
pub main_entitlements: Dictionary,
@@ -202,7 +219,7 @@ impl Bundle {
fn sub_bundles(&self) -> Result<Vec<PathBuf>> {
let mut bundles = Vec::new();
- for folder in ["Frameworks", "PlugIns"] {
+ for folder in ["Frameworks", "PlugIns", "Extensions"] {
let dir = self.path.join(folder);
#[cfg(feature = "wasm")]
if !isideload_vfs::fs::metadata(&dir)
@@ -463,7 +480,7 @@ fn should_hash_resource(relative: &str, executable: &str) -> bool {
return false;
}
- for nested_root in ["Frameworks/", "PlugIns/"] {
+ for nested_root in ["Frameworks/", "PlugIns/", "Extensions/"] {
if let Some(rest) = relative.strip_prefix(nested_root)
&& rest.contains('/')
{
+159
View File
@@ -0,0 +1,159 @@
diff --git a/isideload/src/sideload/application.rs b/isideload/src/sideload/application.rs
index 4e0ccd0..be80af4 100644
--- a/isideload/src/sideload/application.rs
+++ b/isideload/src/sideload/application.rs
@@ -20,6 +20,33 @@ pub struct Application {
//pub temp_path: PathBuf,
}
+#[cfg(test)]
+mod musicbridge_tests {
+ use super::*;
+ #[test]
+ fn musicbridge_extensionkit_is_renamed_and_persisted() {
+ let root = std::env::temp_dir().join(format!("musicbridge-sign-test-{}", uuid::Uuid::new_v4()));
+ for (directory, id) in [("", "ru.yukinoki.musicbridge"),
+ ("PlugIns/Widget.appex", "ru.yukinoki.musicbridge.widgets"),
+ ("Extensions/Remote.appex", "ru.yukinoki.musicbridge.remote")] {
+ let path = root.join(directory);
+ std::fs::create_dir_all(&path).unwrap();
+ let mut info = plist::Dictionary::new();
+ info.insert("CFBundleIdentifier".into(), id.into());
+ plist::to_file_xml(path.join("Info.plist"), &info).unwrap();
+ }
+ let mut app = Application::new(root.clone()).unwrap();
+ assert_eq!(app.bundle.app_extensions().len(), 2);
+ app.update_bundle_id("ru.yukinoki.musicbridge", "ru.yukinoki.musicbridge.TEAM").unwrap();
+ app.bundle.write_info().unwrap();
+ for extension in app.bundle.app_extensions_mut() { extension.write_info().unwrap(); }
+ let saved = Application::new(root).unwrap();
+ for extension in saved.bundle.app_extensions() {
+ assert!(extension.bundle_identifier().unwrap().starts_with("ru.yukinoki.musicbridge.TEAM."));
+ }
+ }
+}
+
impl Application {
pub fn new(path: PathBuf) -> Result<Self, Report> {
if !isideload_vfs::fs::metadata(&path).is_ok() {
diff --git a/isideload/src/sideload/bundle.rs b/isideload/src/sideload/bundle.rs
index 67a947d..2cc2282 100644
--- a/isideload/src/sideload/bundle.rs
+++ b/isideload/src/sideload/bundle.rs
@@ -45,23 +45,19 @@ impl Bundle {
"Failed to parse Info.plist".to_string(),
))?;
- // Load app extensions from PlugIns directory
- let plug_ins_dir = bundle_path.join("PlugIns");
- let app_extensions = if isideload_vfs::fs::metadata(&plug_ins_dir).is_ok() {
- isideload_vfs::fs::read_dir(&plug_ins_dir)
- .context(SideloadError::InvalidBundle(
- "Failed to read PlugIns directory".to_string(),
- ))?
- .filter_map(|entry| entry.ok())
- .filter(|entry| {
- entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
- && isideload_vfs::fs::metadata(&entry.path().join("Info.plist")).is_ok()
- })
- .filter_map(|entry| Bundle::new(entry.path()).ok())
- .collect()
- } else {
- Vec::new()
- };
+ // ExtensionKit uses Extensions; WidgetKit and older extensions use PlugIns.
+ // Invalid nested bundles must fail signing, never be silently skipped.
+ let mut app_extensions = Vec::new();
+ for folder in ["PlugIns", "Extensions"] {
+ let directory = bundle_path.join(folder);
+ if !isideload_vfs::fs::metadata(&directory).is_ok() { continue; }
+ for entry in isideload_vfs::fs::read_dir(&directory)? {
+ let entry = entry?;
+ if entry.file_type()?.is_dir() {
+ app_extensions.push(Bundle::new(entry.path())?);
+ }
+ }
+ }
// Load frameworks from Frameworks directory
let frameworks_dir = bundle_path.join("Frameworks");
diff --git a/isideload/src/sideload/sign.rs b/isideload/src/sideload/sign.rs
index a3d9799..c7e6154 100644
--- a/isideload/src/sideload/sign.rs
+++ b/isideload/src/sideload/sign.rs
@@ -5,6 +5,7 @@ use apple_codesign::{
};
use plist::Dictionary;
+use rootcause::option_ext::OptionExt;
use rootcause::prelude::*;
use crate::{
@@ -63,6 +64,10 @@ where
);
}
+ let musicbridge = app.main_bundle_id()? == format!("ru.yukinoki.musicbridge.{}", team.team_id)
+ && app.bundle.app_info.get("MusicBridgeSharedKeychainGroup").and_then(plist::Value::as_string)
+ == Some("ru.yukinoki.musicbridge.remote-control");
+ if musicbridge { entitlements = musicbridge_keychain(&entitlements)?; }
let mut settings = BundleSigningSettings::new(&team.team_id, entitlements, Some(&signer));
settings.embedded_mobileprovision = Some(main_provisioning_profile.encoded_profile.as_ref());
@@ -75,9 +80,56 @@ where
.map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone()))
.collect();
+ if musicbridge {
+ let remote_id = format!("{}.remote", app.main_bundle_id()?);
+ let remote = settings.entitlements_by_bundle_id.get(&remote_id)
+ .ok_or_report().context("MusicBridge remote extension has no provisioning profile")?;
+ let remote = musicbridge_keychain(remote)?;
+ settings.entitlements_by_bundle_id.insert(remote_id, remote);
+ }
+
if let Some(callback) = &progress_callback {
callback(0.5).await;
}
Ok(sign_bundle(&app.bundle.bundle_dir, &settings)?)
}
+
+// Request only a group explicitly permitted by Apple's actual profile.
+fn musicbridge_keychain(profile: &Dictionary) -> Result<Dictionary, Report> {
+ let application = profile.get("application-identifier").and_then(plist::Value::as_string)
+ .ok_or_report().context("Profile has no application-identifier")?;
+ let prefix = application.split_once('.').ok_or_report().context("Invalid app identifier")?.0;
+ let shared = format!("{}.ru.yukinoki.musicbridge.remote-control", prefix);
+ let allowed = profile.get("keychain-access-groups").and_then(plist::Value::as_array)
+ .ok_or_report().context("Profile has no Keychain groups")?;
+ if !allowed.iter().filter_map(plist::Value::as_string).any(|group|
+ group == shared || group == format!("{}.*", prefix)) {
+ bail!("Apple's provisioning profile does not permit the MusicBridge shared Keychain group");
+ }
+ let mut result = profile.clone();
+ // A concrete per-app default group comes first, shared group second.
+ result.insert("keychain-access-groups".into(), plist::Value::Array(vec![
+ plist::Value::String(application.into()), plist::Value::String(shared)]));
+ Ok(result)
+}
+
+#[cfg(test)]
+mod musicbridge_tests {
+ use super::*;
+ fn profile(group: &str) -> Dictionary {
+ let mut p = Dictionary::new();
+ p.insert("application-identifier".into(), "TEAM.ru.yukinoki.musicbridge.TEAM".into());
+ p.insert("keychain-access-groups".into(), plist::Value::Array(vec![group.into()]));
+ p
+ }
+ #[test]
+ fn musicbridge_sharing_respects_profile() {
+ let value = musicbridge_keychain(&profile("TEAM.*")).unwrap();
+ let groups = value["keychain-access-groups"].as_array().unwrap();
+ assert_eq!(groups[0].as_string(), Some("TEAM.ru.yukinoki.musicbridge.TEAM"));
+ assert_eq!(groups[1].as_string(), Some("TEAM.ru.yukinoki.musicbridge.remote-control"));
+ assert!(musicbridge_keychain(&profile("OTHER.*")).is_err());
+ assert!(musicbridge_keychain(&profile("TEAM.unrelated")).is_err());
+ }
+}
+33
View File
@@ -0,0 +1,33 @@
"""Apply reviewed patches to pinned upstream checkouts. No Apple credentials involved."""
import json
from pathlib import Path
import subprocess
import sys
root = Path(sys.argv[1]).resolve()
patches = Path(__file__).resolve().parent
versions = {
"iloader": "348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0",
"isideload": "f6a4d5dba717d72fc2af63eaba26b27ba44116be",
"apple-crates": "5da0b8df9b202434b1eff2700059b1ee142592ac",
}
for name, commit in versions.items():
path = root / name
actual = subprocess.check_output(["git", "-C", str(path), "rev-parse", "HEAD"], text=True).strip()
if actual != commit:
raise RuntimeError(f"Unexpected {name} revision: {actual}")
for name, patch in [("isideload", "isideload.patch"), ("apple-crates", "apple-codesign.patch")]:
subprocess.run(["git", "-C", str(root/name), "apply", "--check", str(patches/patch)], check=True)
subprocess.run(["git", "-C", str(root/name), "apply", str(patches/patch)], check=True)
manifest = root / "iloader/src-tauri/Cargo.toml"
with manifest.open("a", encoding="utf-8") as file:
file.write('\n[patch."https://github.com/nab138/isideload"]\nisideload = { path = "../../isideload/isideload" }\n')
file.write('\n[patch."https://github.com/nab138/apple-crates"]\napple-codesign = { path = "../../apple-crates/apple-codesign-quick" }\n')
# Portable helper: preserve upstream identifier/storage so existing Apple login can
# be reused locally. Use a distinct title and disable upstream update replacement.
config = root / "iloader/src-tauri/ci.conf.json"
value = json.loads(config.read_text(encoding="utf-8"))
value["app"] = {"windows": [{"title": "iloader — MusicBridge compatibility build", "width": 800, "height": 600}]}
config.write_text(json.dumps(value, indent=2), encoding="utf-8")
print("Patched pinned iloader, extension discovery, nested signing and profile-checked Keychain sharing.")
+198
View File
@@ -0,0 +1,198 @@
"""CI-only HTTPS/WSS server with the same certificate shape as the Windows agent."""
import base64
import hashlib
import http.server
import ipaddress
import json
import pathlib
import socket
import socketserver
import ssl
import struct
import subprocess
import threading
import time
import zlib
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
root = pathlib.Path(__file__).resolve().parents[1]
work = root / "build/tls"
work.mkdir(parents=True, exist_ok=True)
# Discover the runner's LAN address without sending a packet.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.connect(("192.0.2.1", 80))
address = probe.getsockname()[0]
assert any(ipaddress.ip_address(address) in ipaddress.ip_network(n)
for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")), address
config = work / "cert.cnf"
config.write_text("""[req]
distinguished_name = dn
prompt = no
[dn]
CN = MusicBridge Agent
[ext]
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:localhost,IP:127.0.0.1
""")
state = json.loads((root / "MusicBridgeTests/state-v1.json").read_text())
counts = {}
counts_lock = threading.Lock()
def count(token, path):
with counts_lock:
key = (token, path)
counts[key] = counts.get(key, 0) + 1
return counts[key]
def png(size):
def chunk(kind, data):
return struct.pack("!I", len(data)) + kind + data + struct.pack("!I", zlib.crc32(kind + data))
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack("!IIBBBBB", size, size, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress((b"\x00" + b"\x00\x80\xff" * size) * size)) + chunk(b"IEND", b""))
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def reply(self, status, body):
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def snapshot(self, token):
result = dict(state)
if token.startswith("live-"):
with counts_lock:
commands = counts.get((token, "command"), 0)
result["title"] = "Track " + str(commands)
if token == "live-unsupported":
result["capabilities"] = {key: False for key in result["capabilities"]}
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
return result
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
if token.startswith("live-"):
count(token, "command")
if token == "live-reject":
self.reply(200, {"success": False, "code": "unsupported", "message": "Rejected"})
else:
self.reply(200, {"success": True, "code": "ok", "message": "OK"})
return
if self.path == "/v1/pair":
code = body.get("code")
if code == "33333333":
time.sleep(0.5)
self.reply(200, {"success": True, "code": "ok", "token": "A" * 64})
else:
error = "pairing_closed" if code == "22222222" else "invalid_code"
self.reply(403, {"success": False, "code": error})
else:
self.reply(200, {"success": False, "code": "unsupported", "message": "Плеер не поддерживает эту команду."})
def do_GET(self):
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
number = count(token, self.path)
if token == "live-refresh-fail" and self.path == "/v1/state":
with counts_lock:
commands = counts.get((token, "command"), 0)
if commands:
self.reply(503, {})
return
if token == "revoked":
self.reply(401, {})
return
if token == "redirect" and self.path == "/v1/state":
self.send_response(302)
self.send_header("Location", "/redirect-target")
self.send_header("Content-Length", "0")
self.end_headers()
return
if token == "recover" and self.path == "/v1/state" and number == 1:
self.reply(503, {})
return
if self.path.startswith("/v1/artwork/"):
if token == "artwork-fail" or (token == "artwork" and number == 1):
self.reply(404, {})
return
size = 1
if token == "late-art":
if self.path.endswith("A" * 64): time.sleep(0.5)
else: size = 2
payload = png(size)
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
if self.path == "/v1/events":
accept = base64.b64encode(hashlib.sha1(
(self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
self.send_response(101)
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept)
self.end_headers()
snapshot = self.snapshot(token)
if token == "late-art":
time.sleep(0.1)
snapshot["artworkId"] = "B" * 64
for _ in range(30 if token == "command-reject" else 1):
payload = json.dumps({"type": "state", "state": snapshot}).encode()
self.wfile.write(b"\x81\x7e" + struct.pack("!H", len(payload)) + payload)
self.wfile.flush()
if token == "command-reject": time.sleep(0.05)
else:
self.reply(200, self.snapshot(token))
fixtures = {}
class Server(http.server.ThreadingHTTPServer):
def server_bind(self):
# HTTPServer's reverse DNS lookup can stall on hosted runner addresses.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address
for name, days in (("valid", "1825"), ("expired", "-1")):
print(f"Generating {name} certificate for {address}", flush=True)
key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem"))
subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", str(config),
"-keyout", str(key), "-out", str(csr)], check=True, capture_output=True, timeout=15)
if name == "expired":
private_key = serialization.load_pem_private_key(key.read_bytes(), password=None)
template = x509.load_pem_x509_certificate((work / "valid.pem").read_bytes())
now = datetime.datetime.now(datetime.timezone.utc)
builder = (x509.CertificateBuilder().subject_name(template.subject).issuer_name(template.issuer)
.public_key(private_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=2))
.not_valid_after(now - datetime.timedelta(days=1)))
for extension in template.extensions:
builder = builder.add_extension(extension.value, extension.critical)
cert.write_bytes(builder.sign(private_key, hashes.SHA256()).public_bytes(serialization.Encoding.PEM))
else:
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256",
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)],
check=True, capture_output=True, timeout=15)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(cert, key)
server = Server((address, 0), Handler)
server.socket = context.wrap_socket(server.socket, server_side=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
fixtures[name] = {"endpoint": f"https://{address}:{server.server_port}",
"fingerprint": hashlib.sha256(ssl.PEM_cert_to_DER_cert(cert.read_text())).hexdigest()}
(root / "MusicBridgeTests/tls-fixture.json").write_text(json.dumps(fixtures))
print("TLS fixtures ready", flush=True)
threading.Event().wait()
+13 -3
View File
@@ -11,12 +11,13 @@ import xml.etree.ElementTree as ET
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
generated = [root / "MusicBridge.xcodeproj/project.pbxproj", generated = [root / "MusicBridge.xcodeproj/project.pbxproj",
root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme", root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme",
root / "MusicBridge/Info.plist"] root / "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"): 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}"
@@ -25,7 +26,16 @@ referenced = set(re.findall(r'"([A-F0-9]{24})"', project))
assert defined == referenced, "Project has dangling object references." assert defined == referenced, "Project has dangling object references."
info = plistlib.loads(generated[2].read_bytes()) info = plistlib.loads(generated[2].read_bytes())
assert info["NSLocalNetworkUsageDescription"] assert info["NSLocalNetworkUsageDescription"]
assert info["NSAppTransportSecurity"] == {"NSAllowsLocalNetworking": True} assert info["NSCameraUsageDescription"]
assert info["NSSupportsLiveActivities"] is True
assert plistlib.loads(generated[3].read_bytes())["NSExtension"]["NSExtensionPointIdentifier"] == "com.apple.widgetkit-extension"
assert info["NSBonjourServices"] == ["_musicbridge._tcp"]
assert info["CFBundleURLTypes"][0]["CFBundleURLSchemes"] == ["musicbridge"]
assert info["NSAppTransportSecurity"] == {
"NSAllowsLocalNetworking": True,
"NSExceptionDomains": {network: {"NSExceptionAllowsInsecureHTTPLoads": True}
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")},
}
ET.parse(generated[1]) ET.parse(generated[1])
fixture = json.loads((root / "MusicBridgeTests/state-v1.json").read_text(encoding="utf-8")) fixture = json.loads((root / "MusicBridgeTests/state-v1.json").read_text(encoding="utf-8"))
assert fixture["protocolVersion"] == 1 and fixture["positionSeconds"] == 42.5 assert fixture["protocolVersion"] == 1 and fixture["positionSeconds"] == 42.5
+39 -1
View File
@@ -29,5 +29,43 @@ 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], "Mach-O is not built for iOS devices" assert platforms == [2], "Mach-O is not built for iOS devices"
assert info["NSSupportsLiveActivities"] is True
extension = prefix + "PlugIns/MusicBridgeWidgets.appex/"
widget = plistlib.loads(archive.read(extension + "Info.plist"))
assert widget["CFBundleIdentifier"] == info["CFBundleIdentifier"] + ".widgets"
assert widget["CFBundleShortVersionString"] == info["CFBundleShortVersionString"]
assert widget["CFBundleVersion"] == info["CFBundleVersion"]
assert widget["NSExtension"]["NSExtensionPointIdentifier"] == "com.apple.widgetkit-extension"
assert widget["CFBundleSupportedPlatforms"] == ["iPhoneOS"]
binary = archive.read(extension + widget["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], "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("Requires local signing with Sideloadly before installation on an iPhone.") print("PASS: embedded WidgetKit extension, matching version, ARM64 iPhoneOS and Live Activities support.")
print("PASS: embedded iOS 27 NowPlaying ExtensionKit extension and matching shared Keychain metadata.")
print("Requires signing all extensions and shared Keychain entitlements before installation.")