Compare commits
32 Commits
f5c44cb323
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f90c6a912 | |||
| a3133465f9 | |||
| b14162b401 | |||
| 63fa120194 | |||
| 4748363905 | |||
| 91e305ada5 | |||
| eaf091b1cf | |||
| e2902e6031 | |||
| 2fbf3bf859 | |||
| 1e14bf72a2 | |||
| daf9751ccc | |||
| e824a8e8eb | |||
| 43c78b2a99 | |||
| 1e02247cbe | |||
| fcc66aa328 | |||
| ddd44e8edb | |||
| 2ca6201ad6 | |||
| aa0b0f0d8e | |||
| cbd3c0f6f4 | |||
| bce04f5d96 | |||
| bde6f7a466 | |||
| c461c26f7f | |||
| afa771198d | |||
| e384e9ae1e | |||
| ad695f01ae | |||
| 6afafcf0e2 | |||
| 397caf23ce | |||
| 5f16ec48aa | |||
| 93f7c9cb4e | |||
| a17a7f8027 | |||
| f8de824752 | |||
| d886a8a523 |
@@ -0,0 +1,64 @@
|
|||||||
|
name: Build MusicBridge compatible iloader
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: nab138/iloader
|
||||||
|
ref: 348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
|
||||||
|
path: build/compat/iloader
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: nab138/isideload
|
||||||
|
ref: f6a4d5dba717d72fc2af63eaba26b27ba44116be
|
||||||
|
path: build/compat/isideload
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: nab138/apple-crates
|
||||||
|
ref: 5da0b8df9b202434b1eff2700059b1ee142592ac
|
||||||
|
path: build/compat/apple-crates
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
- name: Install native build prerequisites
|
||||||
|
run: choco install strawberryperl make --no-progress -y
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
- name: Patch pinned sources
|
||||||
|
run: python tools/iloader/prepare.py build/compat
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: build/compat/iloader
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Test extension discovery, bundle IDs and signing permissions
|
||||||
|
working-directory: build/compat/iloader/src-tauri
|
||||||
|
run: |
|
||||||
|
cargo test -p isideload --lib musicbridge
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
cargo test -p apple-codesign --lib musicbridge
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
- name: Build portable GUI
|
||||||
|
working-directory: build/compat/iloader
|
||||||
|
run: bunx tauri build --no-bundle --config src-tauri/ci.conf.json
|
||||||
|
- name: Package helper with provenance
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force build/iloader-musicbridge
|
||||||
|
Copy-Item build/compat/iloader/src-tauri/target/release/iloader.exe build/iloader-musicbridge/iloader-musicbridge.exe
|
||||||
|
Copy-Item tools/iloader/README.md build/iloader-musicbridge/README.md
|
||||||
|
Get-ChildItem build/compat/iloader -Filter 'LICENSE*' | Copy-Item -Destination build/iloader-musicbridge
|
||||||
|
Get-FileHash build/iloader-musicbridge/iloader-musicbridge.exe | Format-List | Out-File build/iloader-musicbridge/SHA256.txt
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iloader-musicbridge-windows
|
||||||
|
path: build/iloader-musicbridge
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -15,7 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
simulator:
|
simulator:
|
||||||
runs-on: macos-15
|
runs-on: xcode-27
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -24,6 +24,7 @@ jobs:
|
|||||||
- name: Inspect toolchain and project
|
- name: Inspect toolchain and project
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
|
xcodebuild -version | head -n 1 | grep -E "^Xcode 27([.]|$)"
|
||||||
swift --version
|
swift --version
|
||||||
python3 tools/validate_project.py
|
python3 tools/validate_project.py
|
||||||
xcodebuild -list -project MusicBridge.xcodeproj
|
xcodebuild -list -project MusicBridge.xcodeproj
|
||||||
@@ -36,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:
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ enum KeychainStore {
|
|||||||
|
|
||||||
static func save(_ connection: Connection) throws {
|
static func save(_ connection: Connection) throws {
|
||||||
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||||
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||||
if status == errSecItemNotFound {
|
if status == errSecItemNotFound {
|
||||||
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum LiveActivityCommands {
|
||||||
|
private static var busy = false
|
||||||
|
static let bindingKey = "MusicBridge.liveActivityPC"
|
||||||
|
|
||||||
|
static func perform(_ action: MusicRemoteAction, activityID: String) async {
|
||||||
|
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
|
||||||
|
activity.activityState == .active || activity.activityState == .stale else { return }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
guard let saved = try KeychainStore.load(),
|
||||||
|
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
|
||||||
|
throw BridgeError.message("Откройте приложение и включите карточку заново.")
|
||||||
|
}
|
||||||
|
let client = BridgeClient(saved, timeout: 5)
|
||||||
|
defer { client.close() }
|
||||||
|
let fresh = try await execute(action, client: client) {
|
||||||
|
(activity.activityState == .active || activity.activityState == .stale) &&
|
||||||
|
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
|
||||||
|
}
|
||||||
|
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
|
||||||
|
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
|
||||||
|
} catch {
|
||||||
|
if case BridgeError.unauthorized = error {
|
||||||
|
await activity.end(nil, dismissalPolicy: .immediate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var value = activity.content.state
|
||||||
|
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
|
||||||
|
await activity.update(ActivityContent(state: value, staleDate: Date()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
|
||||||
|
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
|
||||||
|
stillValid: () -> Bool = { true }) async throws -> MediaState {
|
||||||
|
let before = try await client.state()
|
||||||
|
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
|
||||||
|
if action == .refresh { return before }
|
||||||
|
guard before.hasSession else { return before }
|
||||||
|
let command = try command(action, state: before)
|
||||||
|
do { try await client.command(Command(type: command, value: nil)) }
|
||||||
|
catch {
|
||||||
|
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
do { return try await client.state() }
|
||||||
|
catch {
|
||||||
|
if case BridgeError.unauthorized = error { throw error }
|
||||||
|
throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String {
|
||||||
|
switch action {
|
||||||
|
case .previous where state.capabilities.previous: return "previous"
|
||||||
|
case .next where state.capabilities.next: return "next"
|
||||||
|
case .toggle where state.capabilities.toggle: return "toggle"
|
||||||
|
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
|
||||||
|
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
|
||||||
|
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,43 @@ 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 {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import NowPlaying
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SystemPlayerManager: ObservableObject {
|
||||||
|
@Published private(set) var running = false
|
||||||
|
@Published private(set) var busy = false
|
||||||
|
@Published private(set) var error: String?
|
||||||
|
private var storage: Any?
|
||||||
|
private var id: String?
|
||||||
|
private var generation = UUID()
|
||||||
|
private var updates: Task<Void, Never>?
|
||||||
|
private var lastUpdate = Date.distantPast
|
||||||
|
private var lastTrack = ""
|
||||||
|
private var lastPlaying = false
|
||||||
|
private let savedIDKey = "MusicBridge.systemPlayerSession"
|
||||||
|
|
||||||
|
func start(state: MediaState, connection: Connection) async {
|
||||||
|
guard !busy, !running, state.hasSession, connection.token != nil,
|
||||||
|
UIApplication.shared.applicationState == .active else { return }
|
||||||
|
guard #available(iOS 27.0, *) else { error = "Системный пульт требует iOS 27."; return }
|
||||||
|
busy = true; error = nil
|
||||||
|
let operation = generation
|
||||||
|
let newID = UUID().uuidString
|
||||||
|
id = newID
|
||||||
|
defer { if generation == operation { busy = false } }
|
||||||
|
do {
|
||||||
|
try SystemPlayerCredentials.save(connection, id: newID)
|
||||||
|
_ = try SystemPlayerCredentials.load(newID)
|
||||||
|
UserDefaults.standard.set(newID, forKey: savedIDKey)
|
||||||
|
let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state))
|
||||||
|
guard generation == operation else { try? await session.end(); return }
|
||||||
|
storage = session
|
||||||
|
try await session.requestToBecomeSystemPrimary()
|
||||||
|
guard generation == operation else { try? await session.end(); return }
|
||||||
|
running = true
|
||||||
|
UserDefaults.standard.set(newID, forKey: savedIDKey)
|
||||||
|
} catch {
|
||||||
|
guard generation == operation else { return }
|
||||||
|
self.error = "Не удалось включить системный пульт: " + BridgeError.describe(error)
|
||||||
|
end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(connection: Connection?) async {
|
||||||
|
guard #available(iOS 27.0, *), id == nil, !busy,
|
||||||
|
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
|
||||||
|
busy = true
|
||||||
|
id = savedID
|
||||||
|
let operation = generation
|
||||||
|
defer { if generation == operation { busy = false } }
|
||||||
|
do {
|
||||||
|
let saved = try SystemPlayerCredentials.load(savedID)
|
||||||
|
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
|
||||||
|
try SystemPlayerCredentials.delete(savedID)
|
||||||
|
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||||
|
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
|
||||||
|
if generation == operation { id = nil }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
|
||||||
|
guard generation == operation else { return }
|
||||||
|
storage = session; id = savedID; running = true
|
||||||
|
} else {
|
||||||
|
guard generation == operation else { return }
|
||||||
|
try SystemPlayerCredentials.delete(savedID)
|
||||||
|
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||||
|
id = nil
|
||||||
|
}
|
||||||
|
} catch { if generation == operation { self.error = BridgeError.describe(error); id = nil } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive(_ state: MediaState, connection: Connection) {
|
||||||
|
guard #available(iOS 27.0, *), running, let id,
|
||||||
|
let session = storage as? RemoteMediaSession<SystemPlayerAttributes> else { return }
|
||||||
|
guard state.hasSession else { end(); return }
|
||||||
|
let track = state.title + "\n" + state.artist
|
||||||
|
let playing = state.playbackStatus == "Playing"
|
||||||
|
guard track != lastTrack || playing != lastPlaying || Date().timeIntervalSince(lastUpdate) >= 5 else { return }
|
||||||
|
lastUpdate = Date(); lastTrack = track; lastPlaying = playing
|
||||||
|
let value = SystemPlayerAttributes(id: id, state: state)
|
||||||
|
let operation = generation
|
||||||
|
enqueue { [weak self] in
|
||||||
|
guard let self, self.generation == operation else { return }
|
||||||
|
do {
|
||||||
|
try SystemPlayerCredentials.save(connection, id: id)
|
||||||
|
try await session.update(value)
|
||||||
|
} catch { self.error = BridgeError.describe(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
generation = UUID()
|
||||||
|
busy = false; running = false
|
||||||
|
if let id {
|
||||||
|
do { try SystemPlayerCredentials.delete(id) }
|
||||||
|
catch { self.error = BridgeError.describe(error) }
|
||||||
|
}
|
||||||
|
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||||
|
id = nil
|
||||||
|
if #available(iOS 27.0, *), let session = storage as? RemoteMediaSession<SystemPlayerAttributes> {
|
||||||
|
enqueue { [weak self] in
|
||||||
|
do { try await session.end() }
|
||||||
|
catch { self?.error = BridgeError.describe(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueue(_ action: @escaping @MainActor () async -> Void) {
|
||||||
|
let previous = updates
|
||||||
|
updates = Task { await previous?.value; await action() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>ru</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>MusicBridge</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>EXAppExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>EXExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.nowplaying.remote-media</string>
|
||||||
|
</dict>
|
||||||
|
<key>MusicBridgeSharedKeychainGroup</key>
|
||||||
|
<string>ru.yukinoki.musicbridge.remote-control</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>10.0.0.0/8</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>172.16.0.0/12</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>192.168.0.0/16</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import ExtensionFoundation
|
||||||
|
import NowPlaying
|
||||||
|
import Observation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MusicBridgeRemote: RemoteMediaSessionExtension {
|
||||||
|
var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
|
||||||
|
RemoteMediaSessionExtensionConfiguration(extension: self)
|
||||||
|
}
|
||||||
|
func session(_ attributes: SystemPlayerAttributes) async throws -> PCMediaSession {
|
||||||
|
_ = try SystemPlayerCredentials.load(attributes.id)
|
||||||
|
return PCMediaSession(attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class PCMediaSession: RemoteMediaSessionRepresentable {
|
||||||
|
let id: String
|
||||||
|
var attributes: SystemPlayerAttributes
|
||||||
|
private let transport = SystemPlayerTransport()
|
||||||
|
init(_ attributes: SystemPlayerAttributes) { id = attributes.id; self.attributes = attributes }
|
||||||
|
|
||||||
|
func update(_ attributes: SystemPlayerAttributes) { self.attributes = attributes }
|
||||||
|
|
||||||
|
var content: (any MediaContentRepresentable)? {
|
||||||
|
guard attributes.hasSession else { return nil }
|
||||||
|
let artwork = attributes.artworkID.map { artworkID in
|
||||||
|
Artwork(id: artworkID) { _ in
|
||||||
|
let saved = try SystemPlayerCredentials.load(self.id)
|
||||||
|
let client = BridgeClient(saved, timeout: 5)
|
||||||
|
defer { client.close() }
|
||||||
|
return try ArtworkRepresentation(data: await client.artwork(artworkID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MusicContent(id: attributes.source + "/" + attributes.title + "/" + attributes.artist,
|
||||||
|
songTitle: attributes.title.isEmpty ? "Без названия" : attributes.title,
|
||||||
|
artistName: attributes.artist, albumName: "", type: .audio,
|
||||||
|
duration: attributes.duration > 0 ? .finite(attributes.duration) : nil, artwork: artwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
var playbackSnapshot: MediaPlaybackSnapshot? {
|
||||||
|
MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: Float(attributes.rate)) : .paused,
|
||||||
|
elapsedTime: attributes.position, timestamp: attributes.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands: [MediaCommand] {
|
||||||
|
guard attributes.hasSession else { return [] }
|
||||||
|
return [
|
||||||
|
.play { try await self.send(.play) }.enabled(attributes.capabilities.play),
|
||||||
|
.pause { try await self.send(.pause) }.enabled(attributes.capabilities.pause),
|
||||||
|
.next { try await self.send(.next) }.enabled(attributes.capabilities.next),
|
||||||
|
.previous { try await self.send(.previous) }.enabled(attributes.capabilities.previous),
|
||||||
|
.seekToPosition { try await self.send(.seek, value: $0) }.enabled(attributes.capabilities.seek)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices: [MediaDevice] {
|
||||||
|
let capabilities: [MediaDevice.Capability] = attributes.volume.map { volume in
|
||||||
|
[.absoluteVolume(Float(volume)) { try await self.send(.volume, value: Double($0)) }]
|
||||||
|
} ?? []
|
||||||
|
return [MediaDevice(id: id, name: "MusicBridge · ПК", type: .speaker, capabilities: capabilities)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ action: SystemPlayerAction, value: Double? = nil) async throws {
|
||||||
|
do {
|
||||||
|
let state = try await transport.send(action, value: value, sessionID: id)
|
||||||
|
attributes = SystemPlayerAttributes(id: id, state: state)
|
||||||
|
} catch BridgeError.unauthorized {
|
||||||
|
try SystemPlayerCredentials.delete(id)
|
||||||
|
attributes.hasSession = false
|
||||||
|
attributes.playing = false
|
||||||
|
attributes.volume = nil
|
||||||
|
throw BridgeError.unauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,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("Команда выполнена")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import NowPlaying
|
||||||
|
|
||||||
|
@available(iOS 27.0, *)
|
||||||
|
struct SystemPlayerAttributes: RemoteMediaSessionAttributes {
|
||||||
|
let id: String
|
||||||
|
var title: String
|
||||||
|
var artist: String
|
||||||
|
var source: String
|
||||||
|
var artworkID: String?
|
||||||
|
var playing: Bool
|
||||||
|
var rate: Double
|
||||||
|
var position: Double
|
||||||
|
var duration: Double
|
||||||
|
var timestamp: Date
|
||||||
|
var capabilities: Capabilities
|
||||||
|
var volume: Double?
|
||||||
|
var hasSession: Bool
|
||||||
|
|
||||||
|
init(id: String, state: MediaState, now: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
title = String(state.title.prefix(256)); artist = String(state.artist.prefix(256))
|
||||||
|
source = String(state.source.prefix(128)); artworkID = state.artworkId
|
||||||
|
playing = state.playbackStatus == "Playing"; rate = state.playbackRate
|
||||||
|
position = max(0, state.position(elapsed: 0) - state.startSeconds)
|
||||||
|
duration = max(0, state.endSeconds - state.startSeconds)
|
||||||
|
timestamp = now; capabilities = state.capabilities; volume = state.volume; hasSession = state.hasSession
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum SystemPlayerCredentials {
|
||||||
|
// Derive the signing prefix from our own default Keychain group. Info.plist
|
||||||
|
// build substitutions cannot know the team that later re-signs an IPA.
|
||||||
|
private static func signingPrefix() throws -> String {
|
||||||
|
let marker: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: "MusicBridge.signing-prefix.v1", kSecAttrAccount as String: "prefix"]
|
||||||
|
let status = SecItemAdd(marker.merging([kSecValueData as String: Data(),
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]) { _, v in v } as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess || status == errSecDuplicateItem else { throw failure(status) }
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let read = SecItemCopyMatching(marker.merging([kSecReturnAttributes as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne]) { _, v in v } as CFDictionary, &result)
|
||||||
|
guard read == errSecSuccess else { throw failure(read) }
|
||||||
|
guard let attributes = result as? [String: Any],
|
||||||
|
let group = attributes[kSecAttrAccessGroup as String] as? String,
|
||||||
|
let separator = group.firstIndex(of: "."), separator != group.startIndex else {
|
||||||
|
throw failure(errSecMissingEntitlement)
|
||||||
|
}
|
||||||
|
return String(group[...separator])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func query(_ id: String) throws -> [String: Any] {
|
||||||
|
guard let suffix = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
|
||||||
|
suffix == "ru.yukinoki.musicbridge.remote-control" else {
|
||||||
|
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
|
||||||
|
}
|
||||||
|
return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1",
|
||||||
|
kSecAttrAccount as String: id, kSecAttrAccessGroup as String: try signingPrefix() + suffix]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ connection: Connection, id: String) throws {
|
||||||
|
let query = try query(id)
|
||||||
|
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||||
|
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||||
|
if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, v in v } as CFDictionary, nil) }
|
||||||
|
guard status == errSecSuccess else { throw failure(status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(_ id: String) throws -> Connection {
|
||||||
|
var query = try query(id)
|
||||||
|
query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var item: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
|
||||||
|
let saved = try JSONDecoder().decode(Connection.self, from: data)
|
||||||
|
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.unauthorized }
|
||||||
|
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(_ id: String) throws {
|
||||||
|
let status = SecItemDelete(try query(id) as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func failure(_ status: OSStatus) -> BridgeError {
|
||||||
|
.message("Системный пульт не получил доступ к Keychain (\(status)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SystemPlayerAction { case play, pause, next, previous, seek, volume }
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SystemPlayerTransport {
|
||||||
|
private var busy = false
|
||||||
|
|
||||||
|
func send(_ action: SystemPlayerAction, value: Double? = nil, sessionID: String) async throws -> MediaState {
|
||||||
|
guard !busy else { throw BridgeError.message("Предыдущая команда ещё выполняется.") }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
let saved = try SystemPlayerCredentials.load(sessionID)
|
||||||
|
let client = BridgeClient(saved, timeout: 5)
|
||||||
|
defer { client.close() }
|
||||||
|
return try await Self.execute(action, value: value, client: client) {
|
||||||
|
let current = try SystemPlayerCredentials.load(sessionID)
|
||||||
|
guard current.endpoint == saved.endpoint, current.fingerprint == saved.fingerprint, current.token == saved.token else {
|
||||||
|
throw BridgeError.unauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func execute(_ action: SystemPlayerAction, value: Double? = nil, client: BridgeClient,
|
||||||
|
validate: () throws -> Void = {}) async throws -> MediaState {
|
||||||
|
let state = try await client.state()
|
||||||
|
try validate()
|
||||||
|
let command = try command(action, value: value, state: state)
|
||||||
|
do { try await client.command(command) }
|
||||||
|
catch {
|
||||||
|
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Откройте плеер перед повтором.") }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
do { return try await client.state() }
|
||||||
|
catch {
|
||||||
|
if case BridgeError.unauthorized = error { throw error }
|
||||||
|
throw BridgeError.message("Команда выполнена, но новый трек не получен. Откройте приложение для обновления.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func command(_ action: SystemPlayerAction, value: Double?, state: MediaState) throws -> Command {
|
||||||
|
switch action {
|
||||||
|
case .play where state.hasSession && state.capabilities.play: return Command(type: "play", value: nil)
|
||||||
|
case .pause where state.hasSession && state.capabilities.pause: return Command(type: "pause", value: nil)
|
||||||
|
case .next where state.hasSession && state.capabilities.next: return Command(type: "next", value: nil)
|
||||||
|
case .previous where state.hasSession && state.capabilities.previous: return Command(type: "previous", value: nil)
|
||||||
|
case .seek where state.hasSession && state.capabilities.seek && state.maxSeekSeconds > state.minSeekSeconds:
|
||||||
|
guard let value, value.isFinite else { throw BridgeError.message("Некорректная позиция трека.") }
|
||||||
|
return Command(type: "seek", value: min(state.maxSeekSeconds, max(state.minSeekSeconds, state.startSeconds + value)))
|
||||||
|
case .volume where state.volume != nil:
|
||||||
|
guard let value, value.isFinite else { throw BridgeError.message("Некорректная громкость.") }
|
||||||
|
return Command(type: "volume", value: min(1, max(0, value)))
|
||||||
|
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# iloader: совместимость с MusicBridge 0.7.0
|
||||||
|
|
||||||
|
Это отдельная неофициальная portable-сборка iloader для Windows. Исходники:
|
||||||
|
- https://github.com/nab138/iloader/tree/348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
|
||||||
|
- https://github.com/nab138/isideload/tree/f6a4d5dba717d72fc2af63eaba26b27ba44116be
|
||||||
|
- https://github.com/nab138/apple-crates/tree/5da0b8df9b202434b1eff2700059b1ee142592ac
|
||||||
|
|
||||||
|
Патчи хранятся в репозитории MusicBridge в tools/iloader. Workflow
|
||||||
|
iloader-compat.yml запускается вручную, не использует Apple Account/сертификаты.
|
||||||
|
Apple-авторизация и подпись происходят только локально на компьютере пользователя.
|
||||||
|
|
||||||
|
Исправления: обнаружение Extensions вместе с PlugIns, переименование и получение
|
||||||
|
профиля каждого расширения, вложенная подпись ExtensionKit и общий Keychain для
|
||||||
|
MusicBridge только в пределах разрешений настоящего provisioning profile Apple.
|
||||||
|
Регрессионные Rust-тесты проверяют обе папки расширений, сохранение новых Bundle ID,
|
||||||
|
обработку вложенной подписи и отказ при недопустимой Keychain group.
|
||||||
|
|
||||||
|
Использование после успешной сборки:
|
||||||
|
1. Закрыть обычный iloader. Удалять его и установленный MusicBridge не требуется.
|
||||||
|
2. Запустить iloader-musicbridge.exe. Заголовок окна содержит compatibility build.
|
||||||
|
3. Использовать прежний Apple Account; выбрать исходную MusicBridge 0.7.0 IPA.
|
||||||
|
4. Подписать и установить заново. Все расширения нужно сохранить.
|
||||||
|
5. Проверить обычное подключение, затем кнопку системного пульта и Пункт управления.
|
||||||
|
|
||||||
|
Вариант сохраняет идентификатор/хранилище обычного iloader для локального входа,
|
||||||
|
поэтому одновременно запускать обе версии не нужно. Обновления из upstream в
|
||||||
|
этой сборке отключены. Совместимость нового extension point с бесплатным профилем
|
||||||
|
Apple окончательно подтверждается только установкой на устройстве.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
diff --git a/apple-codesign-quick/src/bundle.rs b/apple-codesign-quick/src/bundle.rs
|
||||||
|
index 040fd07..2bfa359 100644
|
||||||
|
--- a/apple-codesign-quick/src/bundle.rs
|
||||||
|
+++ b/apple-codesign-quick/src/bundle.rs
|
||||||
|
@@ -16,6 +16,23 @@ const FAIRPLAY_DIR: &str = "SC_Info";
|
||||||
|
const CODE_SIGNATURE_DIR: &str = "_CodeSignature";
|
||||||
|
const CODE_RESOURCES_FILE: &str = "CodeResources";
|
||||||
|
|
||||||
|
+#[cfg(test)]
|
||||||
|
+mod musicbridge_extensionkit_tests {
|
||||||
|
+ use super::*;
|
||||||
|
+ #[test]
|
||||||
|
+ fn musicbridge_extensionkit_is_signed_as_nested_bundle() {
|
||||||
|
+ let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||||
|
+ let root = std::env::temp_dir().join(format!("musicbridge-codesign-test-{unique}"));
|
||||||
|
+ let extension = root.join("Extensions/Remote.appex");
|
||||||
|
+ std::fs::create_dir_all(&extension).unwrap();
|
||||||
|
+ std::fs::write(extension.join("Info.plist"), b"fixture").unwrap();
|
||||||
|
+ let bundle = Bundle { path: root, app_info: Dictionary::new() };
|
||||||
|
+ assert_eq!(bundle.sub_bundles().unwrap(), vec![extension]);
|
||||||
|
+ assert!(!should_hash_resource("Extensions/Remote.appex/Remote", "Main"));
|
||||||
|
+ assert!(should_hash_resource("Assets.car", "Main"));
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
pub struct BundleSigningSettings<'a> {
|
||||||
|
pub team_id: String,
|
||||||
|
pub main_entitlements: Dictionary,
|
||||||
|
@@ -202,7 +219,7 @@ impl Bundle {
|
||||||
|
|
||||||
|
fn sub_bundles(&self) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut bundles = Vec::new();
|
||||||
|
- for folder in ["Frameworks", "PlugIns"] {
|
||||||
|
+ for folder in ["Frameworks", "PlugIns", "Extensions"] {
|
||||||
|
let dir = self.path.join(folder);
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
if !isideload_vfs::fs::metadata(&dir)
|
||||||
|
@@ -463,7 +480,7 @@ fn should_hash_resource(relative: &str, executable: &str) -> bool {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
- for nested_root in ["Frameworks/", "PlugIns/"] {
|
||||||
|
+ for nested_root in ["Frameworks/", "PlugIns/", "Extensions/"] {
|
||||||
|
if let Some(rest) = relative.strip_prefix(nested_root)
|
||||||
|
&& rest.contains('/')
|
||||||
|
{
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
diff --git a/isideload/src/sideload/application.rs b/isideload/src/sideload/application.rs
|
||||||
|
index 4e0ccd0..be80af4 100644
|
||||||
|
--- a/isideload/src/sideload/application.rs
|
||||||
|
+++ b/isideload/src/sideload/application.rs
|
||||||
|
@@ -20,6 +20,33 @@ pub struct Application {
|
||||||
|
//pub temp_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
+#[cfg(test)]
|
||||||
|
+mod musicbridge_tests {
|
||||||
|
+ use super::*;
|
||||||
|
+ #[test]
|
||||||
|
+ fn musicbridge_extensionkit_is_renamed_and_persisted() {
|
||||||
|
+ let root = std::env::temp_dir().join(format!("musicbridge-sign-test-{}", uuid::Uuid::new_v4()));
|
||||||
|
+ for (directory, id) in [("", "ru.yukinoki.musicbridge"),
|
||||||
|
+ ("PlugIns/Widget.appex", "ru.yukinoki.musicbridge.widgets"),
|
||||||
|
+ ("Extensions/Remote.appex", "ru.yukinoki.musicbridge.remote")] {
|
||||||
|
+ let path = root.join(directory);
|
||||||
|
+ std::fs::create_dir_all(&path).unwrap();
|
||||||
|
+ let mut info = plist::Dictionary::new();
|
||||||
|
+ info.insert("CFBundleIdentifier".into(), id.into());
|
||||||
|
+ plist::to_file_xml(path.join("Info.plist"), &info).unwrap();
|
||||||
|
+ }
|
||||||
|
+ let mut app = Application::new(root.clone()).unwrap();
|
||||||
|
+ assert_eq!(app.bundle.app_extensions().len(), 2);
|
||||||
|
+ app.update_bundle_id("ru.yukinoki.musicbridge", "ru.yukinoki.musicbridge.TEAM").unwrap();
|
||||||
|
+ app.bundle.write_info().unwrap();
|
||||||
|
+ for extension in app.bundle.app_extensions_mut() { extension.write_info().unwrap(); }
|
||||||
|
+ let saved = Application::new(root).unwrap();
|
||||||
|
+ for extension in saved.bundle.app_extensions() {
|
||||||
|
+ assert!(extension.bundle_identifier().unwrap().starts_with("ru.yukinoki.musicbridge.TEAM."));
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
impl Application {
|
||||||
|
pub fn new(path: PathBuf) -> Result<Self, Report> {
|
||||||
|
if !isideload_vfs::fs::metadata(&path).is_ok() {
|
||||||
|
diff --git a/isideload/src/sideload/bundle.rs b/isideload/src/sideload/bundle.rs
|
||||||
|
index 67a947d..2cc2282 100644
|
||||||
|
--- a/isideload/src/sideload/bundle.rs
|
||||||
|
+++ b/isideload/src/sideload/bundle.rs
|
||||||
|
@@ -45,23 +45,19 @@ impl Bundle {
|
||||||
|
"Failed to parse Info.plist".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
- // Load app extensions from PlugIns directory
|
||||||
|
- let plug_ins_dir = bundle_path.join("PlugIns");
|
||||||
|
- let app_extensions = if isideload_vfs::fs::metadata(&plug_ins_dir).is_ok() {
|
||||||
|
- isideload_vfs::fs::read_dir(&plug_ins_dir)
|
||||||
|
- .context(SideloadError::InvalidBundle(
|
||||||
|
- "Failed to read PlugIns directory".to_string(),
|
||||||
|
- ))?
|
||||||
|
- .filter_map(|entry| entry.ok())
|
||||||
|
- .filter(|entry| {
|
||||||
|
- entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
|
||||||
|
- && isideload_vfs::fs::metadata(&entry.path().join("Info.plist")).is_ok()
|
||||||
|
- })
|
||||||
|
- .filter_map(|entry| Bundle::new(entry.path()).ok())
|
||||||
|
- .collect()
|
||||||
|
- } else {
|
||||||
|
- Vec::new()
|
||||||
|
- };
|
||||||
|
+ // ExtensionKit uses Extensions; WidgetKit and older extensions use PlugIns.
|
||||||
|
+ // Invalid nested bundles must fail signing, never be silently skipped.
|
||||||
|
+ let mut app_extensions = Vec::new();
|
||||||
|
+ for folder in ["PlugIns", "Extensions"] {
|
||||||
|
+ let directory = bundle_path.join(folder);
|
||||||
|
+ if !isideload_vfs::fs::metadata(&directory).is_ok() { continue; }
|
||||||
|
+ for entry in isideload_vfs::fs::read_dir(&directory)? {
|
||||||
|
+ let entry = entry?;
|
||||||
|
+ if entry.file_type()?.is_dir() {
|
||||||
|
+ app_extensions.push(Bundle::new(entry.path())?);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Load frameworks from Frameworks directory
|
||||||
|
let frameworks_dir = bundle_path.join("Frameworks");
|
||||||
|
diff --git a/isideload/src/sideload/sign.rs b/isideload/src/sideload/sign.rs
|
||||||
|
index a3d9799..c7e6154 100644
|
||||||
|
--- a/isideload/src/sideload/sign.rs
|
||||||
|
+++ b/isideload/src/sideload/sign.rs
|
||||||
|
@@ -5,6 +5,7 @@ use apple_codesign::{
|
||||||
|
};
|
||||||
|
|
||||||
|
use plist::Dictionary;
|
||||||
|
+use rootcause::option_ext::OptionExt;
|
||||||
|
use rootcause::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
@@ -63,6 +64,10 @@ where
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
+ let musicbridge = app.main_bundle_id()? == format!("ru.yukinoki.musicbridge.{}", team.team_id)
|
||||||
|
+ && app.bundle.app_info.get("MusicBridgeSharedKeychainGroup").and_then(plist::Value::as_string)
|
||||||
|
+ == Some("ru.yukinoki.musicbridge.remote-control");
|
||||||
|
+ if musicbridge { entitlements = musicbridge_keychain(&entitlements)?; }
|
||||||
|
let mut settings = BundleSigningSettings::new(&team.team_id, entitlements, Some(&signer));
|
||||||
|
settings.embedded_mobileprovision = Some(main_provisioning_profile.encoded_profile.as_ref());
|
||||||
|
|
||||||
|
@@ -75,9 +80,56 @@ where
|
||||||
|
.map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
+ if musicbridge {
|
||||||
|
+ let remote_id = format!("{}.remote", app.main_bundle_id()?);
|
||||||
|
+ let remote = settings.entitlements_by_bundle_id.get(&remote_id)
|
||||||
|
+ .ok_or_report().context("MusicBridge remote extension has no provisioning profile")?;
|
||||||
|
+ let remote = musicbridge_keychain(remote)?;
|
||||||
|
+ settings.entitlements_by_bundle_id.insert(remote_id, remote);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
if let Some(callback) = &progress_callback {
|
||||||
|
callback(0.5).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(sign_bundle(&app.bundle.bundle_dir, &settings)?)
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+// Request only a group explicitly permitted by Apple's actual profile.
|
||||||
|
+fn musicbridge_keychain(profile: &Dictionary) -> Result<Dictionary, Report> {
|
||||||
|
+ let application = profile.get("application-identifier").and_then(plist::Value::as_string)
|
||||||
|
+ .ok_or_report().context("Profile has no application-identifier")?;
|
||||||
|
+ let prefix = application.split_once('.').ok_or_report().context("Invalid app identifier")?.0;
|
||||||
|
+ let shared = format!("{}.ru.yukinoki.musicbridge.remote-control", prefix);
|
||||||
|
+ let allowed = profile.get("keychain-access-groups").and_then(plist::Value::as_array)
|
||||||
|
+ .ok_or_report().context("Profile has no Keychain groups")?;
|
||||||
|
+ if !allowed.iter().filter_map(plist::Value::as_string).any(|group|
|
||||||
|
+ group == shared || group == format!("{}.*", prefix)) {
|
||||||
|
+ bail!("Apple's provisioning profile does not permit the MusicBridge shared Keychain group");
|
||||||
|
+ }
|
||||||
|
+ let mut result = profile.clone();
|
||||||
|
+ // A concrete per-app default group comes first, shared group second.
|
||||||
|
+ result.insert("keychain-access-groups".into(), plist::Value::Array(vec![
|
||||||
|
+ plist::Value::String(application.into()), plist::Value::String(shared)]));
|
||||||
|
+ Ok(result)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+#[cfg(test)]
|
||||||
|
+mod musicbridge_tests {
|
||||||
|
+ use super::*;
|
||||||
|
+ fn profile(group: &str) -> Dictionary {
|
||||||
|
+ let mut p = Dictionary::new();
|
||||||
|
+ p.insert("application-identifier".into(), "TEAM.ru.yukinoki.musicbridge.TEAM".into());
|
||||||
|
+ p.insert("keychain-access-groups".into(), plist::Value::Array(vec![group.into()]));
|
||||||
|
+ p
|
||||||
|
+ }
|
||||||
|
+ #[test]
|
||||||
|
+ fn musicbridge_sharing_respects_profile() {
|
||||||
|
+ let value = musicbridge_keychain(&profile("TEAM.*")).unwrap();
|
||||||
|
+ let groups = value["keychain-access-groups"].as_array().unwrap();
|
||||||
|
+ assert_eq!(groups[0].as_string(), Some("TEAM.ru.yukinoki.musicbridge.TEAM"));
|
||||||
|
+ assert_eq!(groups[1].as_string(), Some("TEAM.ru.yukinoki.musicbridge.remote-control"));
|
||||||
|
+ assert!(musicbridge_keychain(&profile("OTHER.*")).is_err());
|
||||||
|
+ assert!(musicbridge_keychain(&profile("TEAM.unrelated")).is_err());
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Apply reviewed patches to pinned upstream checkouts. No Apple credentials involved."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = Path(sys.argv[1]).resolve()
|
||||||
|
patches = Path(__file__).resolve().parent
|
||||||
|
versions = {
|
||||||
|
"iloader": "348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0",
|
||||||
|
"isideload": "f6a4d5dba717d72fc2af63eaba26b27ba44116be",
|
||||||
|
"apple-crates": "5da0b8df9b202434b1eff2700059b1ee142592ac",
|
||||||
|
}
|
||||||
|
for name, commit in versions.items():
|
||||||
|
path = root / name
|
||||||
|
actual = subprocess.check_output(["git", "-C", str(path), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
if actual != commit:
|
||||||
|
raise RuntimeError(f"Unexpected {name} revision: {actual}")
|
||||||
|
for name, patch in [("isideload", "isideload.patch"), ("apple-crates", "apple-codesign.patch")]:
|
||||||
|
subprocess.run(["git", "-C", str(root/name), "apply", "--check", str(patches/patch)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(root/name), "apply", str(patches/patch)], check=True)
|
||||||
|
|
||||||
|
manifest = root / "iloader/src-tauri/Cargo.toml"
|
||||||
|
with manifest.open("a", encoding="utf-8") as file:
|
||||||
|
file.write('\n[patch."https://github.com/nab138/isideload"]\nisideload = { path = "../../isideload/isideload" }\n')
|
||||||
|
file.write('\n[patch."https://github.com/nab138/apple-crates"]\napple-codesign = { path = "../../apple-crates/apple-codesign-quick" }\n')
|
||||||
|
# Portable helper: preserve upstream identifier/storage so existing Apple login can
|
||||||
|
# be reused locally. Use a distinct title and disable upstream update replacement.
|
||||||
|
config = root / "iloader/src-tauri/ci.conf.json"
|
||||||
|
value = json.loads(config.read_text(encoding="utf-8"))
|
||||||
|
value["app"] = {"windows": [{"title": "iloader — MusicBridge compatibility build", "width": 800, "height": 600}]}
|
||||||
|
config.write_text(json.dumps(value, indent=2), encoding="utf-8")
|
||||||
|
print("Patched pinned iloader, extension discovery, nested signing and profile-checked Keychain sharing.")
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user