Compare commits

...

14 Commits

Author SHA1 Message Date
ros 7f90c6a912 Record observed iloader placeholder failure and compatibility build [skip ci] 2026-09-14 01:10:28 +03:00
ros a3133465f9 Import optional profile validation trait [skip ci] 2026-09-14 01:09:34 +03:00
ros b14162b401 Include upstream Windows build prerequisites [skip ci] 2026-09-14 01:08:43 +03:00
ros 63fa120194 Build patched iloader for ExtensionKit IDs, profiles and Keychain [skip ci] 2026-09-14 01:08:19 +03:00
ros 4748363905 Record verified remote player build and remaining device checks [skip ci] 2026-09-14 00:59:14 +03:00
ros 91e305ada5 Close revoked remote sessions and document installation requirements
iOS build and tests / simulator (push) Has been cancelled
2026-09-14 00:51:20 +03:00
ros eaf091b1cf Fix remote player SDK types and signing prefix; test remote commands 2026-09-14 00:48:25 +03:00
ros e2902e6031 Add iOS 27 remote media session and system player extension 2026-09-14 00:17:07 +03:00
ros 2fbf3bf859 Record SSH access and verified Xcode 27 build [skip ci] 2026-09-13 18:13:07 +03:00
ros 1e14bf72a2 Generate deterministic expired TLS fixtures on modern OpenSSL 2026-09-13 18:04:50 +03:00
ros daf9751ccc Move CI to Xcode 27 and select an available secondary simulator 2026-09-13 18:03:22 +03:00
ros e824a8e8eb Document interactive activity release and NowPlaying research [skip ci] 2026-09-13 11:47:51 +03:00
ros 43c78b2a99 Keep lock screen controls within a compact card 2026-09-13 11:41:24 +03:00
ros 1e02247cbe Control PC playback through interactive Live Activity intents 2026-09-13 11:40:23 +03:00
36 changed files with 1601 additions and 94 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Build MusicBridge compatible iloader
on:
workflow_dispatch:
permissions:
contents: read
jobs:
windows:
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/iloader
ref: 348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
path: build/compat/iloader
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/isideload
ref: f6a4d5dba717d72fc2af63eaba26b27ba44116be
path: build/compat/isideload
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: nab138/apple-crates
ref: 5da0b8df9b202434b1eff2700059b1ee142592ac
path: build/compat/apple-crates
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: ilammy/msvc-dev-cmd@v1
- name: Install native build prerequisites
run: choco install strawberryperl make --no-progress -y
- uses: oven-sh/setup-bun@v2
- name: Patch pinned sources
run: python tools/iloader/prepare.py build/compat
- name: Install frontend dependencies
working-directory: build/compat/iloader
run: bun install --frozen-lockfile
- name: Test extension discovery, bundle IDs and signing permissions
working-directory: build/compat/iloader/src-tauri
run: |
cargo test -p isideload --lib musicbridge
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo test -p apple-codesign --lib musicbridge
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build portable GUI
working-directory: build/compat/iloader
run: bunx tauri build --no-bundle --config src-tauri/ci.conf.json
- name: Package helper with provenance
run: |
New-Item -ItemType Directory -Force build/iloader-musicbridge
Copy-Item build/compat/iloader/src-tauri/target/release/iloader.exe build/iloader-musicbridge/iloader-musicbridge.exe
Copy-Item tools/iloader/README.md build/iloader-musicbridge/README.md
Get-ChildItem build/compat/iloader -Filter 'LICENSE*' | Copy-Item -Destination build/iloader-musicbridge
Get-FileHash build/iloader-musicbridge/iloader-musicbridge.exe | Format-List | Out-File build/iloader-musicbridge/SHA256.txt
- uses: actions/upload-artifact@v4
with:
name: iloader-musicbridge-windows
path: build/iloader-musicbridge
if-no-files-found: error
retention-days: 7
+6 -3
View File
@@ -15,7 +15,7 @@ concurrency:
jobs: jobs:
simulator: simulator:
runs-on: macos-15 runs-on: xcode-27
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -24,6 +24,7 @@ jobs:
- name: Inspect toolchain and project - name: Inspect toolchain and project
run: | run: |
xcodebuild -version xcodebuild -version
xcodebuild -version | head -n 1 | grep -E "^Xcode 27([.]|$)"
swift --version swift --version
python3 tools/validate_project.py python3 tools/validate_project.py
xcodebuild -list -project MusicBridge.xcodeproj xcodebuild -list -project MusicBridge.xcodeproj
@@ -36,7 +37,9 @@ jobs:
run: | run: |
set -o pipefail set -o pipefail
mkdir -p build mkdir -p build
python3 -u tools/tls_fixture.py > build/tls-fixture.log 2>&1 & python3 -m venv build/test-tools
build/test-tools/bin/pip install cryptography==46.0.3
build/test-tools/bin/python -u tools/tls_fixture.py > build/tls-fixture.log 2>&1 &
TLS_PID=$! TLS_PID=$!
trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT
for attempt in {1..30}; do for attempt in {1..30}; do
@@ -94,7 +97,7 @@ jobs:
sleep 1 sleep 1
xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-light-screen.png xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-light-screen.png
RUNTIME=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); print(next(k for k,v in d["devices"].items() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)))') RUNTIME=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); print(next(k for k,v in d["devices"].items() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)))')
COMPACT_ID=$(xcrun simctl create MusicBridge-Compact com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation "$RUNTIME") COMPACT_ID=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); v=next(v for v in d["devices"].values() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)); candidates=[x for x in v if x.get("isAvailable") and x.get("name", "").startswith("iPhone") and x["udid"]!=os.environ["SIMULATOR_ID"]]; print(min(candidates,key=lambda x:("Max" in x["name"] or "Plus" in x["name"], "Pro" in x["name"], x["name"]))["udid"])')
xcrun simctl bootstatus "$COMPACT_ID" -b xcrun simctl bootstatus "$COMPACT_ID" -b
xcrun simctl install "$COMPACT_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app xcrun simctl install "$COMPACT_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app
xcrun simctl launch "$COMPACT_ID" ru.yukinoki.musicbridge --preview-player xcrun simctl launch "$COMPACT_ID" ru.yukinoki.musicbridge --preview-player
+267 -20
View File
@@ -55,6 +55,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "76FB6BFFC478599567424755"; "fileRef" = "76FB6BFFC478599567424755";
}; };
"581AD2E9199DAED730DC0F6E" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityCommands.swift";
"sourceTree" = "<group>";
};
"D6D68067600EEBE36972550E" = {
"isa" = "PBXBuildFile";
"fileRef" = "581AD2E9199DAED730DC0F6E";
};
"EE2357BD5CA3755AFEB3171A" = { "EE2357BD5CA3755AFEB3171A" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -115,11 +125,93 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "7148B52E915554F67E875C26"; "fileRef" = "7148B52E915554F67E875C26";
}; };
"088AC623B345717D980B3156" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerManager.swift";
"sourceTree" = "<group>";
};
"27227986C8A227728D784957" = {
"isa" = "PBXBuildFile";
"fileRef" = "088AC623B345717D980B3156";
};
"026F39792BBA0E5032536CED" = { "026F39792BBA0E5032536CED" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridge"; "path" = "MusicBridge";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",); "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "088AC623B345717D980B3156", "8BF729FFE074CAEE622C0292",);
};
"1C6CBD84758D03C1B40D19C7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerAttributes.swift";
"sourceTree" = "<group>";
};
"0D17ED02E5B8770D785508A3" = {
"isa" = "PBXBuildFile";
"fileRef" = "1C6CBD84758D03C1B40D19C7";
};
"77372299BB61F0ED174508F0" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerCredentials.swift";
"sourceTree" = "<group>";
};
"2599D4B5F13D672B723A4BD3" = {
"isa" = "PBXBuildFile";
"fileRef" = "77372299BB61F0ED174508F0";
};
"897E8908B2E9AD60A31E6897" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerTransport.swift";
"sourceTree" = "<group>";
};
"804FFB386BC1AF4252EDA177" = {
"isa" = "PBXBuildFile";
"fileRef" = "897E8908B2E9AD60A31E6897";
};
"D2DF40467DEC980974060FD5" = {
"isa" = "PBXGroup";
"path" = "RemoteShared";
"sourceTree" = "<group>";
"children" = ("1C6CBD84758D03C1B40D19C7", "77372299BB61F0ED174508F0", "897E8908B2E9AD60A31E6897",);
};
"54D4A2CB4EFD85CA136E4B32" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicBridgeRemote.swift";
"sourceTree" = "<group>";
};
"ED075C376D7772FEBD3EC117" = {
"isa" = "PBXBuildFile";
"fileRef" = "54D4A2CB4EFD85CA136E4B32";
};
"E2D7CC5D9CE02F4A7B8243D8" = {
"isa" = "PBXGroup";
"path" = "MusicBridgeRemote";
"sourceTree" = "<group>";
"children" = ("54D4A2CB4EFD85CA136E4B32",);
};
"511889FA962E9B3AC1BC92DE" = {
"isa" = "PBXBuildFile";
"fileRef" = "1C6CBD84758D03C1B40D19C7";
};
"A3C91023F16219D79F80723C" = {
"isa" = "PBXBuildFile";
"fileRef" = "77372299BB61F0ED174508F0";
};
"823EB4050AFBFC9AE72A3C03" = {
"isa" = "PBXBuildFile";
"fileRef" = "897E8908B2E9AD60A31E6897";
};
"8EE58DCF843B86D38CF45CC0" = {
"isa" = "PBXBuildFile";
"fileRef" = "124CF3F5AC075D1D6042DF64";
};
"4AAE356B6279CE0B686A3B9D" = {
"isa" = "PBXBuildFile";
"fileRef" = "065A9D559DC80AAF876DA757";
}; };
"7C5E653CCD928B143AB68F09" = { "7C5E653CCD928B143AB68F09" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -141,11 +233,21 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7"; "fileRef" = "7C0FE298B442263CA281ABB7";
}; };
"6EDC1D9A7204CF776CE3B281" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicControlIntent.swift";
"sourceTree" = "<group>";
};
"962BED0D8E17961AD72EC24F" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"E3C4B39D6D5013477C98CD58" = { "E3C4B39D6D5013477C98CD58" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "Shared"; "path" = "Shared";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",); "children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7", "6EDC1D9A7204CF776CE3B281",);
}; };
"ADFE24674E0FB119E649EC54" = { "ADFE24674E0FB119E649EC54" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -171,6 +273,10 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7"; "fileRef" = "7C0FE298B442263CA281ABB7";
}; };
"41D7F12D1C7D61DAF319E724" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"8BF729FFE074CAEE622C0292" = { "8BF729FFE074CAEE622C0292" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "folder.assetcatalog"; "lastKnownFileType" = "folder.assetcatalog";
@@ -191,6 +297,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "C9F705D432CA45B6056BC572"; "fileRef" = "C9F705D432CA45B6056BC572";
}; };
"65CE96DA4162DE4255412BC7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityCommandTests.swift";
"sourceTree" = "<group>";
};
"E87BA6EF1948C84AC29D8EA8" = {
"isa" = "PBXBuildFile";
"fileRef" = "65CE96DA4162DE4255412BC7";
};
"9819FF7CC21CAAD1BDB8D1B8" = { "9819FF7CC21CAAD1BDB8D1B8" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -221,6 +337,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "464F6C65BC2FAB6BCCC77F46"; "fileRef" = "464F6C65BC2FAB6BCCC77F46";
}; };
"7749A66489642A16382E8E42" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "SystemPlayerTests.swift";
"sourceTree" = "<group>";
};
"340F55A9E43E8FFA66453825" = {
"isa" = "PBXBuildFile";
"fileRef" = "7749A66489642A16382E8E42";
};
"8BED860F1BD59C500D215E8D" = { "8BED860F1BD59C500D215E8D" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -235,7 +361,7 @@
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridgeTests"; "path" = "MusicBridgeTests";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); "children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
}; };
"F16D05EC6B29248D2C61ADB1" = { "F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -267,7 +393,7 @@
"72B74C8A2143F64FD53D71E5" = { "72B74C8A2143F64FD53D71E5" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF",); "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "27227986C8A227728D784957", "0D17ED02E5B8770D785508A3", "2599D4B5F13D672B723A4BD3", "804FFB386BC1AF4252EDA177", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"734C4650C7A5604092763C32" = { "734C4650C7A5604092763C32" = {
@@ -289,6 +415,13 @@
"ATTRIBUTES" = ("RemoveHeadersOnCopy",); "ATTRIBUTES" = ("RemoveHeadersOnCopy",);
}; };
}; };
"013486AD4616563D96D6E9AC" = {
"isa" = "PBXBuildFile";
"fileRef" = "F1BB1F45195BEB71A550539C";
"settings" = {
"ATTRIBUTES" = ("RemoveHeadersOnCopy",);
};
};
"AFC32963E7959E7A34264826" = { "AFC32963E7959E7A34264826" = {
"isa" = "PBXCopyFilesBuildPhase"; "isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
@@ -298,6 +431,15 @@
"name" = "Embed App Extensions"; "name" = "Embed App Extensions";
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"B2EC54BE7DEB4C17571E7035" = {
"isa" = "PBXCopyFilesBuildPhase";
"buildActionMask" = 2147483647;
"dstPath" = "$(EXTENSIONS_FOLDER_PATH)";
"dstSubfolderSpec" = 16;
"files" = ("013486AD4616563D96D6E9AC",);
"name" = "Embed ExtensionKit Extensions";
"runOnlyForDeploymentPostprocessing" = 0;
};
"01E77404E10F254C43E64C55" = { "01E77404E10F254C43E64C55" = {
"isa" = "PBXContainerItemProxy"; "isa" = "PBXContainerItemProxy";
"containerPortal" = "244210E48437B6556980A702"; "containerPortal" = "244210E48437B6556980A702";
@@ -310,6 +452,18 @@
"target" = "BFE9A9DC8C194372006C3ACC"; "target" = "BFE9A9DC8C194372006C3ACC";
"targetProxy" = "01E77404E10F254C43E64C55"; "targetProxy" = "01E77404E10F254C43E64C55";
}; };
"E73C0469E1928499B9BE1AC7" = {
"isa" = "PBXContainerItemProxy";
"containerPortal" = "244210E48437B6556980A702";
"proxyType" = 1;
"remoteGlobalIDString" = "714DB4A269580A6F5127F5CC";
"remoteInfo" = "MusicBridgeRemote";
};
"F76CBC167D5B68BDC0376A94" = {
"isa" = "PBXTargetDependency";
"target" = "714DB4A269580A6F5127F5CC";
"targetProxy" = "E73C0469E1928499B9BE1AC7";
};
"E3322BA79DBD9A84650B2CAE" = { "E3322BA79DBD9A84650B2CAE" = {
"isa" = "XCBuildConfiguration"; "isa" = "XCBuildConfiguration";
"name" = "Debug"; "name" = "Debug";
@@ -324,9 +478,10 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.7.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES"; "ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
@@ -346,9 +501,10 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.7.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
}; };
}; };
@@ -365,9 +521,9 @@
"productReference" = "7C35A0276A762371ABAE5596"; "productReference" = "7C35A0276A762371ABAE5596";
"productType" = "com.apple.product-type.application"; "productType" = "com.apple.product-type.application";
"buildConfigurationList" = "C6336ED5908EABEBF60AE675"; "buildConfigurationList" = "C6336ED5908EABEBF60AE675";
"buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826",); "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826", "B2EC54BE7DEB4C17571E7035",);
"buildRules" = (); "buildRules" = ();
"dependencies" = ("A4C2D386AB0259F33DCDC427",); "dependencies" = ("A4C2D386AB0259F33DCDC427", "F76CBC167D5B68BDC0376A94",);
}; };
"5E4BE508157A7B2CB7B4C2F2" = { "5E4BE508157A7B2CB7B4C2F2" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -379,7 +535,7 @@
"AB453A3F0E1490D48374A69A" = { "AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); "files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"9AA2A4C344B5C474AF5D336C" = { "9AA2A4C344B5C474AF5D336C" = {
@@ -471,7 +627,7 @@
"FF6BDEA70DDE9A3C7EEA9DCF" = { "FF6BDEA70DDE9A3C7EEA9DCF" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E",); "files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E", "41D7F12D1C7D61DAF319E724",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"49C806A88B3E70B1004AB10D" = { "49C806A88B3E70B1004AB10D" = {
@@ -500,14 +656,14 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES"; "APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES"; "SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES"; "ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG MUSICBRIDGE_WIDGET";
}; };
}; };
"6A281BFCCFF96276806F5934" = { "6A281BFCCFF96276806F5934" = {
@@ -524,12 +680,13 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES"; "APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES"; "SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = " MUSICBRIDGE_WIDGET";
}; };
}; };
"5DCAC255CDC0CA72231320F4" = { "5DCAC255CDC0CA72231320F4" = {
@@ -549,16 +706,106 @@
"buildRules" = (); "buildRules" = ();
"dependencies" = (); "dependencies" = ();
}; };
"F1BB1F45195BEB71A550539C" = {
"isa" = "PBXFileReference";
"explicitFileType" = "wrapper.extensionkit-extension";
"includeInIndex" = 0;
"path" = "MusicBridgeRemote.appex";
"sourceTree" = "BUILT_PRODUCTS_DIR";
};
"C2BA95929A255B0F1FC04A79" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("ED075C376D7772FEBD3EC117", "511889FA962E9B3AC1BC92DE", "A3C91023F16219D79F80723C", "823EB4050AFBFC9AE72A3C03", "8EE58DCF843B86D38CF45CC0", "4AAE356B6279CE0B686A3B9D",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"D3E3B87195B5FF1DA14795C1" = {
"isa" = "PBXFrameworksBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"9C3BF80554FD9B39BDF1988D" = {
"isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ();
"runOnlyForDeploymentPostprocessing" = 0;
};
"809F689056832D3F2A4D91B6" = {
"isa" = "XCBuildConfiguration";
"name" = "Debug";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "27.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
};
};
"5E42ACAF0DE32186ED14E82F" = {
"isa" = "XCBuildConfiguration";
"name" = "Release";
"buildSettings" = {
"PRODUCT_NAME" = "$(TARGET_NAME)";
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote";
"SWIFT_VERSION" = "5.0";
"IPHONEOS_DEPLOYMENT_TARGET" = "27.0";
"SDKROOT" = "iphoneos";
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
"TARGETED_DEVICE_FAMILY" = "1";
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist";
"CURRENT_PROJECT_VERSION" = "8";
"MARKETING_VERSION" = "0.7.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements";
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
};
};
"AD0B31AD2D7D0B6DCF3ACF58" = {
"isa" = "XCConfigurationList";
"buildConfigurations" = ("809F689056832D3F2A4D91B6", "5E42ACAF0DE32186ED14E82F",);
"defaultConfigurationIsVisible" = 0;
"defaultConfigurationName" = "Release";
};
"714DB4A269580A6F5127F5CC" = {
"isa" = "PBXNativeTarget";
"name" = "MusicBridgeRemote";
"productName" = "MusicBridgeRemote";
"productReference" = "F1BB1F45195BEB71A550539C";
"productType" = "com.apple.product-type.extensionkit-extension";
"buildConfigurationList" = "AD0B31AD2D7D0B6DCF3ACF58";
"buildPhases" = ("C2BA95929A255B0F1FC04A79", "D3E3B87195B5FF1DA14795C1", "9C3BF80554FD9B39BDF1988D",);
"buildRules" = ();
"dependencies" = ();
};
"0A3E27B8CA818264D75C8D81" = { "0A3E27B8CA818264D75C8D81" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"name" = "Products"; "name" = "Products";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F",); "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F", "F1BB1F45195BEB71A550539C",);
}; };
"0D6E4079E36703EBD37C0072" = { "0D6E4079E36703EBD37C0072" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); "children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "D2DF40467DEC980974060FD5", "E2D7CC5D9CE02F4A7B8243D8", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",);
}; };
"73EBF6644258C120893E685D" = { "73EBF6644258C120893E685D" = {
"isa" = "XCBuildConfiguration"; "isa" = "XCBuildConfiguration";
@@ -600,7 +847,7 @@
"productRefGroup" = "0A3E27B8CA818264D75C8D81"; "productRefGroup" = "0A3E27B8CA818264D75C8D81";
"projectDirPath" = ""; "projectDirPath" = "";
"projectRoot" = ""; "projectRoot" = "";
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",); "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC", "714DB4A269580A6F5127F5CC",);
}; };
}; };
"rootObject" = "244210E48437B6556980A702"; "rootObject" = "244210E48437B6556980A702";
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</array>
</dict>
</plist>
+3 -3
View File
@@ -59,11 +59,11 @@ final class BridgeClient {
private let session: URLSession private let session: URLSession
private var socket: URLSessionWebSocketTask? private var socket: URLSessionWebSocketTask?
init(_ connection: Connection) { init(_ connection: Connection, timeout: TimeInterval = 10) {
self.connection = connection self.connection = connection
let config = URLSessionConfiguration.ephemeral let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 10 config.timeoutIntervalForRequest = timeout
config.timeoutIntervalForResource = 15 config.timeoutIntervalForResource = timeout + 5
config.urlCache = nil config.urlCache = nil
config.httpCookieStorage = nil config.httpCookieStorage = nil
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil) session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
+22 -2
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
@ObservedObject var liveActivity: LiveActivityManager @ObservedObject var liveActivity: LiveActivityManager
@ObservedObject var systemPlayer: SystemPlayerManager
@State private var showForget = false @State private var showForget = false
var body: some View { var body: some View {
@@ -20,14 +21,33 @@ struct ContentView: View {
} else { } else {
PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30)))) PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30))))
if #available(iOS 27.0, *) {
VStack(alignment: .leading, spacing: 10) {
Button {
if systemPlayer.running { systemPlayer.end() }
else if let state = store.state, let connection = store.connection {
Task { await systemPlayer.start(state: state, connection: connection) }
}
} label: {
Label(systemPlayer.running ? "Выключить системный пульт" : "Включить системный пульт iOS", systemImage: "play.rectangle")
}.disabled(systemPlayer.busy || (!systemPlayer.running && (!store.connected || store.state?.hasSession != true)))
if systemPlayer.busy { ProgressView() }
Text("Музыка ПК в Пункте управления. Звук остаётся на компьютере.")
.font(.caption).foregroundStyle(.secondary)
Text("Без фоновых push-обновлений трек обновляется при открытом приложении или после команды пульта.")
.font(.caption).foregroundStyle(.secondary)
if let error = systemPlayer.error { Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
}
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Button { Button {
if liveActivity.running { liveActivity.end() } if liveActivity.running { liveActivity.end() }
else if let state = store.state { liveActivity.start(state) } else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) }
} label: { } label: {
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle") Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true)) }.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.") Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) } if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard() }.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
+2
View File
@@ -72,5 +72,7 @@
</dict> </dict>
</dict> </dict>
</dict> </dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>ru.yukinoki.musicbridge.remote-control</string>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -22,7 +22,7 @@ enum KeychainStore {
static func save(_ connection: Connection) throws { static func save(_ connection: Connection) throws {
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection), let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary) var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
if status == errSecItemNotFound { if status == errSecItemNotFound {
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil) status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
+68
View File
@@ -0,0 +1,68 @@
import ActivityKit
import Foundation
@MainActor
enum LiveActivityCommands {
private static var busy = false
static let bindingKey = "MusicBridge.liveActivityPC"
static func perform(_ action: MusicRemoteAction, activityID: String) async {
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
activity.activityState == .active || activity.activityState == .stale else { return }
busy = true
defer { busy = false }
do {
guard let saved = try KeychainStore.load(),
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
throw BridgeError.message("Откройте приложение и включите карточку заново.")
}
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
let fresh = try await execute(action, client: client) {
(activity.activityState == .active || activity.activityState == .stale) &&
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
}
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
} catch {
if case BridgeError.unauthorized = error {
await activity.end(nil, dismissalPolicy: .immediate)
return
}
var value = activity.content.state
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
await activity.update(ActivityContent(state: value, staleDate: Date()))
}
}
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
stillValid: () -> Bool = { true }) async throws -> MediaState {
let before = try await client.state()
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
if action == .refresh { return before }
guard before.hasSession else { return before }
let command = try command(action, state: before)
do { try await client.command(Command(type: command, value: nil)) }
catch {
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") }
throw error
}
do { return try await client.state() }
catch {
if case BridgeError.unauthorized = error { throw error }
throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.")
}
}
static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String {
switch action {
case .previous where state.capabilities.previous: return "previous"
case .next where state.capabilities.next: return "next"
case .toggle where state.capabilities.toggle: return "toggle"
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
}
}
}
+6 -1
View File
@@ -32,18 +32,22 @@ final class LiveActivityManager: ObservableObject {
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true) duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
} }
func start(_ state: MediaState) { func start(_ state: MediaState, connection: Connection) {
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return } guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
guard connection.token != nil else { error = "Сначала выполните сопряжение с ПК."; return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else { guard ActivityAuthorizationInfo().areActivitiesEnabled else {
error = "Разрешите Live Activities в настройках MusicBridge на iPhone." error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
return return
} }
do { do {
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
try KeychainStore.save(connection)
let value = Self.content(state) let value = Self.content(state)
let new = try Activity.request(attributes: MusicActivityAttributes(), let new = try Activity.request(attributes: MusicActivityAttributes(),
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)), content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
pushType: nil) pushType: nil)
activity = new activity = new
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
last = value last = value
running = true running = true
error = nil error = nil
@@ -72,6 +76,7 @@ final class LiveActivityManager: ObservableObject {
func end() { func end() {
guard let activity else { return } guard let activity else { return }
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
self.activity = nil self.activity = nil
running = false running = false
last = nil last = nil
+1 -1
View File
@@ -119,7 +119,7 @@ struct Invitation: Decodable {
} }
} }
struct Capabilities: Decodable { struct Capabilities: Codable {
let play, pause, toggle, next, previous, seek: Bool let play, pause, toggle, next, previous, seek: Bool
} }
+4 -1
View File
@@ -4,6 +4,7 @@ import SwiftUI
struct MusicBridgeApp: App { struct MusicBridgeApp: App {
@StateObject private var store = PreviewSupport.makeStore() @StateObject private var store = PreviewSupport.makeStore()
@StateObject private var liveActivity = LiveActivityManager() @StateObject private var liveActivity = LiveActivityManager()
@StateObject private var systemPlayer = SystemPlayerManager()
@Environment(\.scenePhase) private var phase @Environment(\.scenePhase) private var phase
var body: some Scene { var body: some Scene {
@@ -20,13 +21,15 @@ struct MusicBridgeApp: App {
}.padding() }.padding()
} }
} else { } else {
ContentView(store: store, liveActivity: liveActivity) ContentView(store: store, liveActivity: liveActivity, systemPlayer: systemPlayer)
} }
} }
.dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5) .dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
.onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } } .onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } }
.task { .task {
store.liveActivity = liveActivity store.liveActivity = liveActivity
store.systemPlayer = systemPlayer
if !PreviewSupport.enabled { await systemPlayer.restore(connection: store.connection) }
if !PreviewSupport.enabled { store.setActive(phase == .active) } if !PreviewSupport.enabled { store.setActive(phase == .active) }
} }
.onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } } .onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
+4 -1
View File
@@ -15,6 +15,7 @@ final class PlayerStore: ObservableObject {
@Published var incomingInvitation: String? @Published var incomingInvitation: String?
let discovery = DiscoveryStore() let discovery = DiscoveryStore()
var liveActivity: LiveActivityManager? var liveActivity: LiveActivityManager?
var systemPlayer: SystemPlayerManager?
private(set) var receivedAt = Date() private(set) var receivedAt = Date()
private var client: BridgeClient? private var client: BridgeClient?
private var runner: Task<Void, Never>? private var runner: Task<Void, Never>?
@@ -170,6 +171,7 @@ final class PlayerStore: ObservableObject {
connected = true connected = true
status = "Подключено" status = "Подключено"
liveActivity?.receive(snapshot) liveActivity?.receive(snapshot)
if let connection { systemPlayer?.receive(snapshot, connection: connection) }
guard artworkID != snapshot.artworkId else { return } guard artworkID != snapshot.artworkId else { return }
artworkID = snapshot.artworkId artworkID = snapshot.artworkId
artworkTask?.cancel() artworkTask?.cancel()
@@ -248,6 +250,7 @@ final class PlayerStore: ObservableObject {
do { try storage.delete() } do { try storage.delete() }
catch { self.error = error.localizedDescription; return } catch { self.error = error.localizedDescription; return }
liveActivity?.end() liveActivity?.end()
systemPlayer?.end()
stop() stop()
cancelPairing() cancelPairing()
needsPairing = false needsPairing = false
@@ -257,7 +260,6 @@ final class PlayerStore: ObservableObject {
} }
private func stop() { private func stop() {
liveActivity?.unavailable()
generation = UUID() generation = UUID()
runner?.cancel() runner?.cancel()
runner = nil runner = nil
@@ -274,6 +276,7 @@ final class PlayerStore: ObservableObject {
private func revokeAccess() { private func revokeAccess() {
liveActivity?.end() liveActivity?.end()
systemPlayer?.end()
stop() stop()
needsPairing = true needsPairing = true
status = "Нужно повторное сопряжение" status = "Нужно повторное сопряжение"
+114
View File
@@ -0,0 +1,114 @@
import SwiftUI
import NowPlaying
@MainActor
final class SystemPlayerManager: ObservableObject {
@Published private(set) var running = false
@Published private(set) var busy = false
@Published private(set) var error: String?
private var storage: Any?
private var id: String?
private var generation = UUID()
private var updates: Task<Void, Never>?
private var lastUpdate = Date.distantPast
private var lastTrack = ""
private var lastPlaying = false
private let savedIDKey = "MusicBridge.systemPlayerSession"
func start(state: MediaState, connection: Connection) async {
guard !busy, !running, state.hasSession, connection.token != nil,
UIApplication.shared.applicationState == .active else { return }
guard #available(iOS 27.0, *) else { error = "Системный пульт требует iOS 27."; return }
busy = true; error = nil
let operation = generation
let newID = UUID().uuidString
id = newID
defer { if generation == operation { busy = false } }
do {
try SystemPlayerCredentials.save(connection, id: newID)
_ = try SystemPlayerCredentials.load(newID)
UserDefaults.standard.set(newID, forKey: savedIDKey)
let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state))
guard generation == operation else { try? await session.end(); return }
storage = session
try await session.requestToBecomeSystemPrimary()
guard generation == operation else { try? await session.end(); return }
running = true
UserDefaults.standard.set(newID, forKey: savedIDKey)
} catch {
guard generation == operation else { return }
self.error = "Не удалось включить системный пульт: " + BridgeError.describe(error)
end()
}
}
func restore(connection: Connection?) async {
guard #available(iOS 27.0, *), id == nil, !busy,
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
busy = true
id = savedID
let operation = generation
defer { if generation == operation { busy = false } }
do {
let saved = try SystemPlayerCredentials.load(savedID)
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
try SystemPlayerCredentials.delete(savedID)
UserDefaults.standard.removeObject(forKey: savedIDKey)
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
if generation == operation { id = nil }
return
}
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
guard generation == operation else { return }
storage = session; id = savedID; running = true
} else {
guard generation == operation else { return }
try SystemPlayerCredentials.delete(savedID)
UserDefaults.standard.removeObject(forKey: savedIDKey)
id = nil
}
} catch { if generation == operation { self.error = BridgeError.describe(error); id = nil } }
}
func receive(_ state: MediaState, connection: Connection) {
guard #available(iOS 27.0, *), running, let id,
let session = storage as? RemoteMediaSession<SystemPlayerAttributes> else { return }
guard state.hasSession else { end(); return }
let track = state.title + "\n" + state.artist
let playing = state.playbackStatus == "Playing"
guard track != lastTrack || playing != lastPlaying || Date().timeIntervalSince(lastUpdate) >= 5 else { return }
lastUpdate = Date(); lastTrack = track; lastPlaying = playing
let value = SystemPlayerAttributes(id: id, state: state)
let operation = generation
enqueue { [weak self] in
guard let self, self.generation == operation else { return }
do {
try SystemPlayerCredentials.save(connection, id: id)
try await session.update(value)
} catch { self.error = BridgeError.describe(error) }
}
}
func end() {
generation = UUID()
busy = false; running = false
if let id {
do { try SystemPlayerCredentials.delete(id) }
catch { self.error = BridgeError.describe(error) }
}
UserDefaults.standard.removeObject(forKey: savedIDKey)
id = nil
if #available(iOS 27.0, *), let session = storage as? RemoteMediaSession<SystemPlayerAttributes> {
enqueue { [weak self] in
do { try await session.end() }
catch { self?.error = BridgeError.describe(error) }
}
}
storage = nil
}
private func enqueue(_ action: @escaping @MainActor () async -> Void) {
let previous = updates
updates = Task { await previous?.value; await action() }
}
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>ru</string>
<key>CFBundleDisplayName</key>
<string>MusicBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.nowplaying.remote-media</string>
</dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>ru.yukinoki.musicbridge.remote-control</string>
<key>NSLocalNetworkUsageDescription</key>
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>10.0.0.0/8</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>172.16.0.0/12</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>192.168.0.0/16</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
+77
View File
@@ -0,0 +1,77 @@
import ExtensionFoundation
import NowPlaying
import Observation
import Foundation
@main
struct MusicBridgeRemote: RemoteMediaSessionExtension {
var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
RemoteMediaSessionExtensionConfiguration(extension: self)
}
func session(_ attributes: SystemPlayerAttributes) async throws -> PCMediaSession {
_ = try SystemPlayerCredentials.load(attributes.id)
return PCMediaSession(attributes)
}
}
@MainActor @Observable
final class PCMediaSession: RemoteMediaSessionRepresentable {
let id: String
var attributes: SystemPlayerAttributes
private let transport = SystemPlayerTransport()
init(_ attributes: SystemPlayerAttributes) { id = attributes.id; self.attributes = attributes }
func update(_ attributes: SystemPlayerAttributes) { self.attributes = attributes }
var content: (any MediaContentRepresentable)? {
guard attributes.hasSession else { return nil }
let artwork = attributes.artworkID.map { artworkID in
Artwork(id: artworkID) { _ in
let saved = try SystemPlayerCredentials.load(self.id)
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
return try ArtworkRepresentation(data: await client.artwork(artworkID))
}
}
return MusicContent(id: attributes.source + "/" + attributes.title + "/" + attributes.artist,
songTitle: attributes.title.isEmpty ? "Без названия" : attributes.title,
artistName: attributes.artist, albumName: "", type: .audio,
duration: attributes.duration > 0 ? .finite(attributes.duration) : nil, artwork: artwork)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: Float(attributes.rate)) : .paused,
elapsedTime: attributes.position, timestamp: attributes.timestamp)
}
var commands: [MediaCommand] {
guard attributes.hasSession else { return [] }
return [
.play { try await self.send(.play) }.enabled(attributes.capabilities.play),
.pause { try await self.send(.pause) }.enabled(attributes.capabilities.pause),
.next { try await self.send(.next) }.enabled(attributes.capabilities.next),
.previous { try await self.send(.previous) }.enabled(attributes.capabilities.previous),
.seekToPosition { try await self.send(.seek, value: $0) }.enabled(attributes.capabilities.seek)
]
}
var devices: [MediaDevice] {
let capabilities: [MediaDevice.Capability] = attributes.volume.map { volume in
[.absoluteVolume(Float(volume)) { try await self.send(.volume, value: Double($0)) }]
} ?? []
return [MediaDevice(id: id, name: "MusicBridge · ПК", type: .speaker, capabilities: capabilities)]
}
private func send(_ action: SystemPlayerAction, value: Double? = nil) async throws {
do {
let state = try await transport.send(action, value: value, sessionID: id)
attributes = SystemPlayerAttributes(id: id, state: state)
} catch BridgeError.unauthorized {
try SystemPlayerCredentials.delete(id)
attributes.hasSession = false
attributes.playing = false
attributes.volume = nil
throw BridgeError.unauthorized
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</array>
</dict>
</plist>
@@ -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("Команда выполнена")) }
}
}
+54
View File
@@ -0,0 +1,54 @@
import XCTest
@testable import MusicBridge
@MainActor
final class SystemPlayerTests: XCTestCase {
private func state(_ changes: [String: Any] = [:]) throws -> MediaState {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json"))
var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any])
changes.forEach { json[$0.key] = $0.value }
return try JSONDecoder().decode(MediaState.self, from: JSONSerialization.data(withJSONObject: json))
}
func testSeekConvertsRelativePositionAndClampsToAllowedRange() throws {
let state = try state(["startSeconds": 100, "endSeconds": 300, "minSeekSeconds": 110, "maxSeekSeconds": 280])
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 30, state: state).value, 130)
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: -50, state: state).value, 110)
XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 500, state: state).value, 280)
XCTAssertThrowsError(try SystemPlayerTransport.command(.seek, value: .nan, state: state))
}
func testVolumeBoundsAndUnsupportedPlayback() throws {
let state = try state()
XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: 2, state: state).value, 1)
XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: -1, state: state).value, 0)
XCTAssertThrowsError(try SystemPlayerTransport.command(.volume, value: .infinity, state: state))
XCTAssertThrowsError(try SystemPlayerTransport.command(.play, value: nil, state: state))
XCTAssertThrowsError(try SystemPlayerTransport.command(.next, value: nil, state: self.state(["hasSession": false])))
}
func testRemoteSnapshotContainsOnlyPublicPlaybackMetadata() throws {
guard #available(iOS 27.0, *) else { throw XCTSkip("Requires NowPlaying") }
let snapshot = SystemPlayerAttributes(id: "session", state: try state(["positionSeconds": 130, "startSeconds": 100, "endSeconds": 300]))
XCTAssertEqual(snapshot.position, 30)
XCTAssertEqual(snapshot.duration, 200)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any])
XCTAssertEqual(Set(json.keys), Set(["id", "title", "artist", "source", "playing", "rate", "position", "duration", "timestamp", "capabilities", "volume", "hasSession"]))
}
func testRevokedSessionDoesNotSendCommandAndNextIsSentOnce() async throws {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
let f = try XCTUnwrap(fixtures["valid"])
let client = BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: "live-system-next"))
defer { client.close() }
do {
_ = try await SystemPlayerTransport.execute(.next, client: client) { throw BridgeError.unauthorized }
XCTFail("Revoked session must be rejected")
} catch BridgeError.unauthorized { }
let unchanged = try await client.state()
XCTAssertEqual(unchanged.title, "Track 0")
let updated = try await SystemPlayerTransport.execute(.next, client: client)
XCTAssertEqual(updated.title, "Track 1")
}
}
+2 -3
View File
@@ -10,7 +10,7 @@ struct MusicBridgeWidgets: WidgetBundle {
struct MusicBridgeLiveActivity: Widget { struct MusicBridgeLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: MusicActivityAttributes.self) { context in ActivityConfiguration(for: MusicActivityAttributes.self) { context in
MusicActivityCard(state: context.state, stale: context.isStale) MusicActivityCard(state: context.state, stale: context.isStale, activityID: context.activityID)
.activityBackgroundTint(Color(.secondarySystemBackground)) .activityBackgroundTint(Color(.secondarySystemBackground))
.activitySystemActionForegroundColor(.indigo) .activitySystemActionForegroundColor(.indigo)
.widgetURL(URL(string: "musicbridge://player")) .widgetURL(URL(string: "musicbridge://player"))
@@ -27,8 +27,7 @@ struct MusicBridgeLiveActivity: Widget {
TrackLabels(state: context.state) TrackLabels(state: context.state)
ProgressView(value: context.state.progress).tint(.cyan) ProgressView(value: context.state.progress).tint(.cyan)
ActivityStatus(state: context.state, stale: context.isStale) ActivityStatus(state: context.state, stale: context.isStale)
Link("Открыть плеер", destination: URL(string: "musicbridge://player")!) MusicActivityControls(activityID: context.activityID).foregroundStyle(.cyan)
.font(.caption.weight(.semibold)).foregroundStyle(.cyan)
}.padding(.bottom, 4) }.padding(.bottom, 4)
} }
} compactLeading: { } compactLeading: {
+125 -7
View File
@@ -1,10 +1,87 @@
# MusicBridge iOS — план # MusicBridge iOS — план
Обновлено: 2026-09-13. Пользователь разрешил начать нативное iOS-приложение. Обновлено: 2026-09-14. Пользователь разрешил начать нативное iOS-приложение.
Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios. Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios.
Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`. Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`.
## Правила проекта ## Системный пульт 0.7.0 (8) — текущий этап
- [x] Реализация NowPlaying RemoteMediaSession и отдельного ExtensionKit target для iOS 27.
- [x] Кнопка включения/выключения в приложении; музыка продолжает играть на ПК.
- [x] Метаданные, обложка, play/pause, предыдущий/следующий трек, перемотка, громкость ПК.
- [x] Перед командой проверяются свежие capabilities и действующее сопряжение; команды не повторяются автоматически.
- [x] Токен хранится в общем Keychain, не передаётся в атрибутах системной сессии.
- [x] Перемотка переводится из позиции относительно трека в диапазон Windows API.
- [x] Выключение и удаление сопряжения удаляют доступ расширения; возврат запоздавшего restore не восстанавливает сессию.
- [x] Итоговый `91e305a`: [Actions 34785082289](https://github.com/polskikh13/musicbridgeios/actions/runs/34785082289), Xcode 27, 36 XCTest без отказов, успешный Release archive. Первая попытка 34783380221 выявила Double/Float и исправлена.
- [ ] Установка с подписью обоих расширений и общим Keychain; проверка на физическом iPhone.
- [ ] Пункт управления: кнопки, перемотка, громкость; блокировка телефона, сон ПК, отзыв токена, повторная подпись.
IPA скачана в `build/iPhone-0.7.0/MusicBridge-iPhone.ipa` (без подписи).
Локально проверены ZIP, ARM64 iPhoneOS всех трёх исполняемых файлов,
совпадение версий, ExtensionKit extension point и общий Keychain suffix.
SHA-256: `a84e146a5259c0c7250dab829f2eb07093f33d5e212ff1e1880f6c3fd29d65ee`.
Логи тестов находятся в `build/iPhone-0.7.0/tests`, снимки — в `preview`.
Просмотрены светлый плеер и дополнительный iPhone: кнопка пульта видна снизу,
пояснения доступны прокруткой. Системная карточка NowPlaying на этих снимках
не проверялась: для запуска нужен подписанный общий Keychain.
**Ограничение iloader:** проверен официальный исходник `nab138/isideload`, ветка
`apple-codesign-quick`, commit `f6a4d5dba717d72fc2af63eaba26b27ba44116be`, который использует текущий iloader.
`isideload/src/sideload/bundle.rs` обнаруживает расширения только в `PlugIns`,
а NowPlaying размещается в `Extensions`. `sign.rs` берёт entitlements из profile,
добавление shared Keychain реализовано для отдельных SpecialApp, не для MusicBridge.
Поэтому обычную установку 0.7.0 через iloader нельзя считать поддержанной.
Не переносить ExtensionKit в PlugIns наугад и не передавать токен через публичные атрибуты.
Следующий этап установки: совместимый подписыватель или доработка iloader для
ExtensionKit и общего Keychain с реальными provisioning profiles. Xcode signing
всех трёх targets — вариант при наличии совместимого Mac и профилей; разрешение
нового extension point бесплатной командой ещё не подтверждено на устройстве.
2026-09-14 пользователь попробовал установку 0.7.0: IXErrorDomain 2/8,
родитель стал `ru.yukinoki.musicbridge.<TeamID>`, а remote остался
`ru.yukinoki.musicbridge.remote`. Это подтверждает пропуск Extensions при переименовании.
Дополнительно библиотека apple-codesign-quick также обходила только Frameworks/PlugIns.
В `tools/iloader` добавлены патчи обоих уровней и воспроизводимая подготовка
закреплённых upstream revisions. Отдельный ручной workflow `iloader-compat.yml`
собирает portable Windows GUI; исходное приложение iloader на ПК не заменяется.
Патч сохраняет публичные Bundle ID приложения до подписи, затем iloader сам
применяет TeamID ко всем расширениям и получает для них настоящие Apple profiles.
Shared Keychain для MusicBridge добавляется только при разрешении в profile.
- [x] Проверено чистое применение патчей к закреплённым исходникам.
- [ ] Rust-тесты на переименование/обнаружение ExtensionKit, вложенную подпись и Keychain.
- [ ] Готовый portable EXE и повторная установка исходной IPA 0.7.0 через него.
- [ ] Подтвердить регистрацию NowPlaying и общий Keychain на iPhone.
Без APNs расширение читает ПК при команде; открытое приложение обновляет сессию
при смене трека/статуса и периодически. Смена трека на самом ПК при приостановленном
приложении не гарантирует автоматического обновления карточки. Фиктивное аудио не используется.
## Инфраструктура — текущее состояние
- SSH к macOS проверен: `aresov@10.67.67.28`, macOS 26.6.2, x86_64,
около 459 ГБ свободно. Xcode не установлен. Локальный отдельный ключ:
`C:/Users/areso/.ssh/musicbridge_macos_ed25519` (закрытый ключ не включать в репо).
Использовать `C:/Windows/System32/OpenSSH/ssh.exe` с `-F NUL`,
`-o ProxyCommand=none -o ProxyJump=none -o IdentitiesOnly=yes` и этим ключом;
другая установленная версия ssh не подключалась. Host key сохранён в known_hosts.
- Xcode 27 требует Apple Silicon; эта Intel-виртуалка не подходит для SDK 27.
CI переведён на GitHub-hosted `xcode-27`: Xcode 27.0 (27A5252f), Swift 6.4, SDK iOS 27.0.
[Actions 34764552877](https://github.com/polskikh13/musicbridgeios/actions/runs/34764552877)
для `1e14bf7`: 32 XCTest, 0 отказов, успешный Release archive.
IPA: `build/iPhone-0.6.0-xcode27/MusicBridge-iPhone.ipa`; проверены SDK 27, ARM64
приложения/расширения и структура пакета. Предпросмотр карточки просмотрен.
Версия осталась 0.6.0 (7); новые функции в пересборке не добавлены.
Просроченный тестовый сертификат создаётся с явными датами через
cryptography==46.0.3 в отдельном venv, поскольку новый OpenSSL отвергает -days -1.
- Пользователь подтвердил бесплатный Apple Account через iloader.
Фоновая доставка APNs заблокирована отсутствием Push Notifications capability
и соответствующей подписи. Платная Developer Program, App ID, ключ APNs и
provisioning profile пока отсутствуют; не обещать работу push после простой
смены Xcode. После появления доступа нужны регистрация токенов, отправитель
обновлений с Windows/сервера и тестирование доставки на физическом iPhone.
## Правила реализации
- SwiftUI, без веб-клиента. Музыка играет на Windows. - SwiftUI, без веб-клиента. Музыка играет на Windows.
- Сверять контракт с Windows PROTOCOL.md; не менять серверный протокол молча. - Сверять контракт с Windows PROTOCOL.md; не менять серверный протокол молча.
@@ -46,7 +123,10 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает». - [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
- [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0. - [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader. - [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
- [ ] App Intents: прямые действия из Live Activity и команды Siri. - [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0.
- [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone.
- [ ] Команды Siri.
- [x] NowPlaying RemoteMediaSession (iOS 27+): SDK 27 и remote-media extension реализованы в 0.7.0; установка и физическая проверка — в текущем этапе выше.
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне. - [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
@@ -143,13 +223,51 @@ Bonjour, если доступны объявления того же агент
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК. раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст. Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
Пользователь подтвердил работу обновления на телефоне перед началом этапа 3. Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
3. **Системные интеграции — 0.5.0 (build 6).** Локальная Live Activity и Dynamic Island 3. **Системные интеграции — 0.6.0 (build 7).** Добавлены прямые действия LiveActivityIntent.
реализованы; 26 XCTest и Release archive прошли. Карточка показывает последний снимок Убрана ошибка устаревания при обычном сворачивании; каждое нажатие перечитывает ПК.
с ПК, открывает плеер, помечает устаревшие данные. App Intents и APNs не реализованы. 32 XCTest и Release archive прошли. APNs и встроенный системный музыкальный пульт ещё не реализованы.
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке. Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один. 4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
Изменения третьего этапа — Live Activities 0.5.0: Продолжение третьего этапа — 0.6.0:
- Итоговый `43c78b2`: [Actions 34748340408](https://github.com/polskikh13/musicbridgeios/actions/runs/34748340408)
— 32 XCTest без отказов, успешный Release archive. IPA 0.6.0 (7) скачана в
`build/iPhone-0.6.0/MusicBridge-iPhone.ipa`; проверены ARM64 iPhoneOS приложения
и расширения, версии, метаданные интентов. Просмотрены светлая и тёмная карточки.
Работа системного запуска LiveActivityIntent и Keychain после блокировки требует
проверки на телефоне: обновить IPA, выключить/включить карточку, заблокировать,
проверить next/previous/toggle/refresh; затем повторить после перезагрузки и первого unlock.
- Пользователь сообщил, что карточка постоянно пишет об устаревших данных.
Причина: `PlayerStore.stop()` вызывал `unavailable()` при каждом уходе в фон.
Удалён этот вызов; реальные сетевые отказы продолжают отмечаться отдельно.
Карточка показывает время последнего снимка и отдельные ошибки запросов.
- Shared `MusicControlIntent` соответствует LiveActivityIntent. WidgetKit содержит
определение интента, выполнение сети компилируется только в основной app target;
iOS запускает процесс приложения без открытия UI. На компактном/минимальном Island
кнопок нет: нужно раскрыть долгим нажатием. Кнопки не отключаются из-за старого снимка.
- `LiveActivityCommands`: одно действие одновременно, проверка живой Activity и связи
её ID с fingerprint текущего сопряжения. После чтения состояния повторно проверяется
актуальность карточки, затем проверяются capabilities и отправляется максимум одна
команда. Refresh вообще не отправляет команду. Повторов команд нет.
- После успеха перечитывается трек и обновляется Activity. 401 завершает её;
неизвестный результат и неудача чтения после успеха имеют разные сообщения.
Нет бесконечного WSS/Bonjour в фоне; после смены IP сначала открыть приложение.
- Keychain: AfterFirstUnlockThisDeviceOnly вместо WhenUnlockedThisDeviceOnly,
чтобы команды работали при заблокированном iPhone. Существующий токен мигрирует
при повторном включении карточки. Он не покидает Keychain и не попадает в payload.
В UserDefaults сохраняется только соответствие ID активности и публичного pin.
После обновления с 0.5.0 карточку выключить и включить заново в приложении.
- HTTPS-тесты проверяют одну отправку next и новый трек, refresh без команды,
закрытую карточку, неподдерживаемую команду, отсутствие повторов отказавшей команды,
ошибку чтения после успешной команды. Физический запуск интента проверяется отдельно.
- Ответ на вопрос пользователя о встроенном музыкальном центре: официальный
[NowPlaying RemoteMediaSession](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions)
предназначен для внешнего воспроизведения. Metadata Apple указывает iOS 27.0.
Текущий runner собирает Xcode 16.4: следующий этап — SDK/Xcode 27 и отдельное
`com.apple.nowplaying.remote-media` расширение. Интеграция ещё не реализована.
История третьего этапа — Live Activities 0.5.0 (поведение фона изменено в 0.6.0):
- Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034) - Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034)
— 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive. — 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive.
+74 -28
View File
@@ -23,7 +23,27 @@ Windows-агентом и подключение по QR. В 0.4.0 добавл
## Сборка на Mac ## Сборка на Mac
1. Открыть `MusicBridge.xcodeproj` в Xcode 16 или новее. Версия **0.7.0 (8)** добавляет системный пульт iOS 27: в плеере нажать
«Включить системный пульт iOS», затем открыть Пункт управления. Реализованы
play/pause, следующий/предыдущий трек, перемотка и громкость ПК. Звук остаётся на ПК.
Проверка этой функции на физическом iPhone пока не завершена.
Код `91e305a` прошёл [Actions](https://github.com/polskikh13/musicbridgeios/actions/runs/34785082289):
36 XCTest без отказов и Release archive. Неподписанная IPA:
`build/iPhone-0.7.0/MusicBridge-iPhone.ipa`.
Для этой версии нужно подписать **MusicBridge, MusicBridgeWidgets и MusicBridgeRemote**.
У приложения и MusicBridgeRemote должны совпадать разрешённые Keychain Sharing
groups: `<AppIdentifierPrefix>ru.yukinoki.musicbridge.remote-control`.
Префикс определяется из подписи при запуске, без хранения Apple Account в проекте.
MusicBridgeRemote — ExtensionKit в `Extensions`, а WidgetKit — в `PlugIns`.
Текущий iloader не обрабатывает эту комбинацию полностью: обычную переподпись
0.7.0 считать поддержанной нельзя. Для прежней установки оставлена IPA 0.6.0 ниже.
Состояние работ и требования к подписывателю описаны в `PLAN.md`.
Без APNs трек обновляется из открытого приложения или после команды системного
пульта. Изменения на ПК при приостановленном приложении могут отображаться с задержкой.
1. Открыть `MusicBridge.xcodeproj` в Xcode 27.
2. Выбрать схему MusicBridge и симулятор iPhone. Run запускает приложение, 2. Выбрать схему MusicBridge и симулятор iPhone. Run запускает приложение,
Test выполняет `MusicBridgeTests`. Test выполняет `MusicBridgeTests`.
3. Для физического iPhone выбрать свою Team в Signing & Capabilities. Сертификаты 3. Для физического iPhone выбрать свою Team в Signing & Capabilities. Сертификаты
@@ -38,7 +58,7 @@ Windows-агентом и подключение по QR. В 0.4.0 добавл
## GitHub Actions ## GitHub Actions
`.github/workflows/ios.yml` собирает приложение и выполняет XCTest на `macos-15` `.github/workflows/ios.yml` собирает приложение и выполняет XCTest на `xcode-27`
при push в main, pull request или ручном запуске. Сохраняет лог, `.xcresult` при push в main, pull request или ручном запуске. Сохраняет лог, `.xcresult`
и ZIP приложения **для симулятора**, который нельзя установить на физический iPhone. и ZIP приложения **для симулятора**, который нельзя установить на физический iPhone.
Apple ID и сертификаты для этой проверки не требуются. GitHub remote подключён: Apple ID и сертификаты для этой проверки не требуются. GitHub remote подключён:
@@ -68,8 +88,8 @@ Apple ID и сертификаты для этой проверки не тре
## Установка на iPhone через Sideloadly ## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки Пользователь также подтвердил установку через iloader. Для повторной установки
с Live Activity используйте версию **0.5.0 (build 6)**: с Live Activity используйте версию **0.6.0 (build 7)**:
`build/iPhone-0.5.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и `build/iPhone-0.6.0-xcode27/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия. подпишите тем же Apple Account, которым установлена первая версия.
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
@@ -106,34 +126,45 @@ Sideloadly (в том числе его функцией автоматичес
[Sideloadly FAQ](https://sideloadly.io/faq) · [Sideloadly FAQ](https://sideloadly.io/faq) ·
[Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device) [Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device)
## Live Activity и Dynamic Island ## Live Activity и Dynamic Island — 0.6.0
После подключения к ПК и появления трека нажмите под плеером После обновления с 0.5.0 уберите старую карточку кнопкой в приложении и нажмите
«Показать на экране блокировки». Та же карточка появится в Dynamic Island «Показать на экране блокировки» заново. Это связывает кнопки с текущим сопряжением
на моделях, где он доступен. Долгое нажатие на Island раскрывает представление; и переводит запись Keychain в режим доступа после первого разблокирования.
нажатие на карточку или «Открыть плеер» возвращает в приложение. Токен остаётся только на этом устройстве, не копируется в виджет или UserDefaults.
Кнопка «Убрать с экрана блокировки» завершает активность. Она также завершается,
когда ПК сообщает об отсутствии медиасессии, доступ отозван или компьютер забыт.
Удалённая пользователем карточка не создаётся снова автоматически.
Это **последний снимок состояния ПК**: название, исполнитель, состояние и прогресс. На экране блокировки и в развёрнутом Dynamic Island доступны предыдущий трек,
В обычном режиме позиция обновляется не чаще раза в пять секунд; смена трека play/pause, следующий трек и обновление. Компактный Island открывает приложение;
и play/pause отправляются сразу. Прогресс не выдумывает движение без связи с ПК. для кнопок удерживайте Island. Каждое действие выполняет отдельные HTTPS-запросы
При уходе приложения в фон или потере связи отображается отметка устаревших данных; с прежней проверкой сертификата. Оно работает через LiveActivityIntent в процессе
резервный `staleDate` — 15 секунд, если приложение было внезапно остановлено. приложения, без открытия интерфейса; постоянного сетевого потока в фоне нет.
Для обновления нажмите карточку. Прямых кнопок управления внутри неё пока нет.
Live Activity не поддерживает постоянный сетевой поток сама по себе. Для обновления Перед командой считываются возможности плеера, затем команда отправляется один раз
при приостановленном приложении нужен отдельный механизм ActivityKit push через и перечитывается состояние. Повторы команд отключены; при неизвестном результате
APNs. В этой версии нет push-токенов, сервера APNs, фонового аудио и App Intents. сначала используйте обновление. Одновременно выполняется одно действие карточки.
[Ограничения и жизненный цикл ActivityKit](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities). После закрытия карточки или удаления её связи с ПК новые команды не отправляются.
Для нового адреса ПК сначала откройте приложение: Bonjour-перенос адреса остаётся
в основном приложении. При отзыве доступа карточка завершается.
В IPA включено расширение `MusicBridgeWidgets.appex`; при установке iloader должен При обычном сворачивании показывается время последнего обновления, а не ошибка
сохранить и подписать его вместе с приложением. Если Live Activity не появилась, «Данные устарели». Прогресс соответствует этому снимку. Реальная ошибка сети или
проверьте её разрешение в настройках MusicBridge и наличие расширения в логе установки. команды выводится на карточке. Изменения, сделанные непосредственно на ПК, при
Автоматические снимки `live-card-*-screen.png` показывают общую SwiftUI-разметку приостановленном приложении не появятся сами: нажмите обновление или откройте плеер.
карточки в Debug-предпросмотре: это не доказательство её регистрации на реальном Для постоянных фоновых обновлений остаётся отдельная задача APNs.
экране блокировки или системного отображения Dynamic Island.
В IPA есть расширение `MusicBridgeWidgets.appex`: iloader должен сохранить и подписать
его вместе с приложением. Проверка работы интентов в заблокированном состоянии и
системного отображения Island выполняется на iPhone; CI проверяет сборку, сетевую
логику команд и разметку. После перезагрузки сначала один раз разблокируйте iPhone.
### Встроенный музыкальный пульт iOS
Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+)
для отображения музыки внешнего устройства на экране блокировки и в Control Center.
Это подходит архитектуре MusicBridge. Нужно отдельное расширение `com.apple.nowplaying.remote-media`. Сборка переведена на Xcode 27; 32 теста и Release archive прошли.
Эта интеграция исследована, но ещё не реализована. Для фоновых обновлений удалённых
сессий Apple также предоставляет push-механизм.
[Документация remote media sessions](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions).
## Источники ## Источники
@@ -143,3 +174,18 @@ APNs. В этой версии нет push-токенов, сервера APNs,
- [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions) - [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions)
План и ограничения — в `PLAN.md`. Копия протокола агента — в `PROTOCOL.md`. План и ограничения — в `PLAN.md`. Копия протокола агента — в `PROTOCOL.md`.
## Сервер сборки и фоновые обновления
GitHub Actions использует Apple Silicon runner `xcode-27` с проверкой версии Xcode.
SSH к локальной macOS-виртуалке настроен, однако она x86_64, без Xcode и не может
запускать Xcode 27. Для SDK 27 используется GitHub Actions.
Тестовый TLS-сервер требует `cryptography==46.0.3`; CI устанавливает пакет
в отдельное Python-окружение. Это зависимость тестов, не приложения.
Пользователь использует бесплатный Apple Account. APNs требует Push Notifications
capability и подписи с соответствующим provisioning profile, недоступных Personal Team.
Поэтому автоматическая фоновая доставка ещё не подключена. Команды Live Activity
выполняют запросы при нажатии; смена трека на ПК при приостановленном приложении
не обновляет карточку без запроса или push.
+29
View File
@@ -0,0 +1,29 @@
import Foundation
import NowPlaying
@available(iOS 27.0, *)
struct SystemPlayerAttributes: RemoteMediaSessionAttributes {
let id: String
var title: String
var artist: String
var source: String
var artworkID: String?
var playing: Bool
var rate: Double
var position: Double
var duration: Double
var timestamp: Date
var capabilities: Capabilities
var volume: Double?
var hasSession: Bool
init(id: String, state: MediaState, now: Date = Date()) {
self.id = id
title = String(state.title.prefix(256)); artist = String(state.artist.prefix(256))
source = String(state.source.prefix(128)); artworkID = state.artworkId
playing = state.playbackStatus == "Playing"; rate = state.playbackRate
position = max(0, state.position(elapsed: 0) - state.startSeconds)
duration = max(0, state.endSeconds - state.startSeconds)
timestamp = now; capabilities = state.capabilities; volume = state.volume; hasSession = state.hasSession
}
}
@@ -0,0 +1,62 @@
import Foundation
import Security
enum SystemPlayerCredentials {
// Derive the signing prefix from our own default Keychain group. Info.plist
// build substitutions cannot know the team that later re-signs an IPA.
private static func signingPrefix() throws -> String {
let marker: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "MusicBridge.signing-prefix.v1", kSecAttrAccount as String: "prefix"]
let status = SecItemAdd(marker.merging([kSecValueData as String: Data(),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]) { _, v in v } as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else { throw failure(status) }
var result: CFTypeRef?
let read = SecItemCopyMatching(marker.merging([kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]) { _, v in v } as CFDictionary, &result)
guard read == errSecSuccess else { throw failure(read) }
guard let attributes = result as? [String: Any],
let group = attributes[kSecAttrAccessGroup as String] as? String,
let separator = group.firstIndex(of: "."), separator != group.startIndex else {
throw failure(errSecMissingEntitlement)
}
return String(group[...separator])
}
private static func query(_ id: String) throws -> [String: Any] {
guard let suffix = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
suffix == "ru.yukinoki.musicbridge.remote-control" else {
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
}
return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1",
kSecAttrAccount as String: id, kSecAttrAccessGroup as String: try signingPrefix() + suffix]
}
static func save(_ connection: Connection, id: String) throws {
let query = try query(id)
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, v in v } as CFDictionary, nil) }
guard status == errSecSuccess else { throw failure(status) }
}
static func load(_ id: String) throws -> Connection {
var query = try query(id)
query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
let saved = try JSONDecoder().decode(Connection.self, from: data)
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.unauthorized }
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
}
static func delete(_ id: String) throws {
let status = SecItemDelete(try query(id) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
}
private static func failure(_ status: OSStatus) -> BridgeError {
.message("Системный пульт не получил доступ к Keychain (\(status)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.")
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
enum SystemPlayerAction { case play, pause, next, previous, seek, volume }
@MainActor
final class SystemPlayerTransport {
private var busy = false
func send(_ action: SystemPlayerAction, value: Double? = nil, sessionID: String) async throws -> MediaState {
guard !busy else { throw BridgeError.message("Предыдущая команда ещё выполняется.") }
busy = true
defer { busy = false }
let saved = try SystemPlayerCredentials.load(sessionID)
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
return try await Self.execute(action, value: value, client: client) {
let current = try SystemPlayerCredentials.load(sessionID)
guard current.endpoint == saved.endpoint, current.fingerprint == saved.fingerprint, current.token == saved.token else {
throw BridgeError.unauthorized
}
}
}
static func execute(_ action: SystemPlayerAction, value: Double? = nil, client: BridgeClient,
validate: () throws -> Void = {}) async throws -> MediaState {
let state = try await client.state()
try validate()
let command = try command(action, value: value, state: state)
do { try await client.command(command) }
catch {
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Откройте плеер перед повтором.") }
throw error
}
do { return try await client.state() }
catch {
if case BridgeError.unauthorized = error { throw error }
throw BridgeError.message("Команда выполнена, но новый трек не получен. Откройте приложение для обновления.")
}
}
static func command(_ action: SystemPlayerAction, value: Double?, state: MediaState) throws -> Command {
switch action {
case .play where state.hasSession && state.capabilities.play: return Command(type: "play", value: nil)
case .pause where state.hasSession && state.capabilities.pause: return Command(type: "pause", value: nil)
case .next where state.hasSession && state.capabilities.next: return Command(type: "next", value: nil)
case .previous where state.hasSession && state.capabilities.previous: return Command(type: "previous", value: nil)
case .seek where state.hasSession && state.capabilities.seek && state.maxSeekSeconds > state.minSeekSeconds:
guard let value, value.isFinite else { throw BridgeError.message("Некорректная позиция трека.") }
return Command(type: "seek", value: min(state.maxSeekSeconds, max(state.minSeekSeconds, state.startSeconds + value)))
case .volume where state.volume != nil:
guard let value, value.isFinite else { throw BridgeError.message("Некорректная громкость.") }
return Command(type: "volume", value: min(1, max(0, value)))
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
}
}
}
+1
View File
@@ -10,6 +10,7 @@ struct MusicActivityAttributes: ActivityAttributes {
var duration: Double var duration: Double
var updatedAt: Date var updatedAt: Date
var connected: Bool var connected: Bool
var message: String? = nil
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit. // Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
static func bounded(_ text: String) -> String { static func bounded(_ text: String) -> String {
+30 -6
View File
@@ -1,10 +1,12 @@
import SwiftUI import SwiftUI
import AppIntents
struct MusicActivityCard: View { struct MusicActivityCard: View {
let state: MusicActivityAttributes.ContentState let state: MusicActivityAttributes.ContentState
let stale: Bool let stale: Bool
var activityID: String = ""
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "waveform").font(.title).foregroundStyle(.indigo) Image(systemName: "waveform").font(.title).foregroundStyle(.indigo)
TrackLabels(state: state) TrackLabels(state: state)
@@ -14,8 +16,8 @@ struct MusicActivityCard: View {
} }
ProgressView(value: state.progress).tint(.indigo) ProgressView(value: state.progress).tint(.indigo)
ActivityStatus(state: state, stale: stale) ActivityStatus(state: state, stale: stale)
Label("Открыть плеер", systemImage: "arrow.up.forward.app").font(.caption.weight(.semibold)) MusicActivityControls(activityID: activityID)
}.padding(16) }.padding(12)
} }
} }
@@ -34,13 +36,35 @@ struct ActivityStatus: View {
let state: MusicActivityAttributes.ContentState let state: MusicActivityAttributes.ContentState
let stale: Bool let stale: Bool
var body: some View { var body: some View {
if stale || !state.connected { if let message = state.message {
Text("Данные устарели · откройте плеер").font(.caption).foregroundStyle(.secondary) Text(message).font(.caption).lineLimit(2)
} else if !state.connected {
Text("Нет связи с ПК · нажмите обновление").font(.caption).foregroundStyle(.secondary)
} else { } else {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("Снимок с ПК в") Text("Обновлено в")
Text(state.updatedAt, style: .time) Text(state.updatedAt, style: .time)
}.font(.caption).foregroundStyle(.secondary) }.font(.caption).foregroundStyle(.secondary)
} }
} }
} }
struct MusicActivityControls: View {
let activityID: String
var body: some View {
HStack {
control(.previous, icon: "backward.end.fill", label: "Предыдущий трек")
Spacer(minLength: 4)
control(.toggle, icon: "playpause.fill", label: "Воспроизведение или пауза")
Spacer(minLength: 4)
control(.next, icon: "forward.end.fill", label: "Следующий трек")
Spacer(minLength: 4)
control(.refresh, icon: "arrow.clockwise", label: "Обновить трек")
}
}
private func control(_ action: MusicRemoteAction, icon: String, label: String) -> some View {
Button(intent: MusicControlIntent(action, activityID: activityID)) {
Image(systemName: icon).font(.title3).frame(minWidth: 44, minHeight: 44)
}.buttonStyle(.plain).accessibilityLabel(label).disabled(activityID.isEmpty)
}
}
+28
View File
@@ -0,0 +1,28 @@
import AppIntents
enum MusicRemoteAction: String, AppEnum {
case previous, toggle, next, refresh
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Команда плеера"
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.previous: "Предыдущий трек", .toggle: "Воспроизведение / пауза", .next: "Следующий трек", .refresh: "Обновить"
]
}
struct MusicControlIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Управлять музыкой на ПК"
static var openAppWhenRun: Bool = false
static var isDiscoverable: Bool = false
static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
@Parameter(title: "Команда") var action: MusicRemoteAction
@Parameter(title: "Активность") var activityID: String
init() {}
init(_ action: MusicRemoteAction, activityID: String) { self.action = action; self.activityID = activityID }
func perform() async throws -> some IntentResult {
#if !MUSICBRIDGE_WIDGET
await LiveActivityCommands.perform(action, activityID: activityID)
#endif
return .result()
}
}
+42 -11
View File
@@ -22,6 +22,8 @@ def configurations(name, settings):
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O") values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
if mode == "Debug": if mode == "Debug":
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG") values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
if name == "MusicBridgeWidgets":
values["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = values.get("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "") + " MUSICBRIDGE_WIDGET"
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values)) configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs, return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
defaultConfigurationIsVisible=0, defaultConfigurationName="Release") defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
@@ -35,6 +37,13 @@ def source_group(folder):
return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds
app_group, app_sources = source_group("MusicBridge") app_group, app_sources = source_group("MusicBridge")
remote_shared_group, remote_shared_sources = source_group("RemoteShared")
remote_group, remote_sources = source_group("MusicBridgeRemote")
remote_sources += [add("remote-shared/" + str(index), "PBXBuildFile", fileRef=objects[build]["fileRef"])
for index, build in enumerate(remote_shared_sources)]
for filename in ("Models.swift", "BridgeClient.swift"):
remote_sources.append(add("remote-core/" + filename, "PBXBuildFile", fileRef=uid("MusicBridge/" + filename)))
app_sources += remote_shared_sources
shared_group, shared_sources = source_group("Shared") shared_group, shared_sources = source_group("Shared")
widget_group, widget_sources = source_group("MusicBridgeWidgets") widget_group, widget_sources = source_group("MusicBridgeWidgets")
app_sources += shared_sources app_sources += shared_sources
@@ -52,16 +61,18 @@ objects[test_group]["children"].append(tls_fixture)
tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture) tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture)
products = [] products = []
targets = [] targets = []
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False)]: for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False), ("MusicBridgeRemote", remote_sources, False)]:
is_widget = name == "MusicBridgeWidgets" is_widget = name == "MusicBridgeWidgets"
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.app-extension" if is_widget else "wrapper.cfbundle" if is_test else "wrapper.application", is_remote = name == "MusicBridgeRemote"
includeInIndex=0, path=name + (".appex" if is_widget else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR") is_extension = is_widget or is_remote
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.extensionkit-extension" if is_remote else "wrapper.app-extension" if is_extension else "wrapper.cfbundle" if is_test else "wrapper.application",
includeInIndex=0, path=name + (".appex" if is_extension else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR")
products.append(product) products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0), phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0), add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0),
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[] if is_widget else [fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)] add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[] if is_extension else [fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)]
settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".widgets" if is_widget else ".tests" if is_test else ""), settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".remote" if is_remote else ".widgets" if is_widget else ".tests" if is_test else ""),
SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos", SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="27.0" if is_remote else "17.0", SDKROOT="iphoneos",
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
GENERATE_INFOPLIST_FILE="YES" if is_test else "NO") GENERATE_INFOPLIST_FILE="YES" if is_test else "NO")
deps = [] deps = []
@@ -69,24 +80,32 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
settings.update(TEST_HOST="$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge", BUNDLE_LOADER="$(TEST_HOST)") settings.update(TEST_HOST="$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge", BUNDLE_LOADER="$(TEST_HOST)")
proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge") proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)] deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
elif is_widget: elif is_extension:
settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", settings.update(INFOPLIST_FILE=name + "/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0",
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES", APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks") LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
if is_remote:
settings.update(CODE_SIGN_ENTITLEMENTS="MusicBridgeRemote/Remote.entitlements")
else: else:
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon") settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon", CODE_SIGN_ENTITLEMENTS="MusicBridge/App.entitlements")
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"])) embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
embed_remote = add("embed-remote", "PBXBuildFile", fileRef=uid("MusicBridgeRemoteproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647, phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0)) dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0))
phases.append(add("embed-remote-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="$(EXTENSIONS_FOLDER_PATH)", dstSubfolderSpec=16, files=[embed_remote], name="Embed ExtensionKit Extensions", runOnlyForDeploymentPostprocessing=0))
proxy = add("widget-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, proxy = add("widget-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets") remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets")
deps = [add("widget-dependency", "PBXTargetDependency", target=uid("MusicBridgeWidgetstarget"), targetProxy=proxy)] deps = [add("widget-dependency", "PBXTargetDependency", target=uid("MusicBridgeWidgetstarget"), targetProxy=proxy)]
remote_proxy = add("remote-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeRemotetarget"), remoteInfo="MusicBridgeRemote")
deps.append(add("remote-dependency", "PBXTargetDependency", target=uid("MusicBridgeRemotetarget"), targetProxy=remote_proxy))
targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product, targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product,
productType="com.apple.product-type.app-extension" if is_widget else "com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application", productType="com.apple.product-type.extensionkit-extension" if is_remote else "com.apple.product-type.app-extension" if is_widget else "com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application",
buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps)) buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products) product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products)
main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, shared_group, widget_group, test_group, product_group]) main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, shared_group, widget_group, remote_shared_group, remote_group, test_group, product_group])
project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"), project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"),
buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")), buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")),
compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"], compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"],
@@ -142,4 +161,16 @@ widget_info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBri
CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension")) NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension"))
(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False)) (ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False))
shared_group_name = "$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control"
info["MusicBridgeSharedKeychainGroup"] = "ru.yukinoki.musicbridge.remote-control"
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
remote_info = {k: v for k, v in widget_info.items() if k != "NSExtension"}
remote_info.update(EXAppExtensionAttributes=dict(EXExtensionPointIdentifier="com.apple.nowplaying.remote-media"),
MusicBridgeSharedKeychainGroup=info["MusicBridgeSharedKeychainGroup"],
NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"],
NSAppTransportSecurity=info["NSAppTransportSecurity"])
(ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False))
for folder, filename in (("MusicBridge", "App.entitlements"), ("MusicBridgeRemote", "Remote.entitlements")):
groups = ["$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)", shared_group_name]
(ROOT / folder / filename).write_bytes(plistlib.dumps({"keychain-access-groups": groups}))
print("Generated Xcode project, shared scheme and Info.plist.") print("Generated Xcode project, shared scheme and Info.plist.")
+28
View File
@@ -0,0 +1,28 @@
# iloader: совместимость с MusicBridge 0.7.0
Это отдельная неофициальная portable-сборка iloader для Windows. Исходники:
- https://github.com/nab138/iloader/tree/348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0
- https://github.com/nab138/isideload/tree/f6a4d5dba717d72fc2af63eaba26b27ba44116be
- https://github.com/nab138/apple-crates/tree/5da0b8df9b202434b1eff2700059b1ee142592ac
Патчи хранятся в репозитории MusicBridge в tools/iloader. Workflow
iloader-compat.yml запускается вручную, не использует Apple Account/сертификаты.
Apple-авторизация и подпись происходят только локально на компьютере пользователя.
Исправления: обнаружение Extensions вместе с PlugIns, переименование и получение
профиля каждого расширения, вложенная подпись ExtensionKit и общий Keychain для
MusicBridge только в пределах разрешений настоящего provisioning profile Apple.
Регрессионные Rust-тесты проверяют обе папки расширений, сохранение новых Bundle ID,
обработку вложенной подписи и отказ при недопустимой Keychain group.
Использование после успешной сборки:
1. Закрыть обычный iloader. Удалять его и установленный MusicBridge не требуется.
2. Запустить iloader-musicbridge.exe. Заголовок окна содержит compatibility build.
3. Использовать прежний Apple Account; выбрать исходную MusicBridge 0.7.0 IPA.
4. Подписать и установить заново. Все расширения нужно сохранить.
5. Проверить обычное подключение, затем кнопку системного пульта и Пункт управления.
Вариант сохраняет идентификатор/хранилище обычного iloader для локального входа,
поэтому одновременно запускать обе версии не нужно. Обновления из upstream в
этой сборке отключены. Совместимость нового extension point с бесплатным профилем
Apple окончательно подтверждается только установкой на устройстве.
+46
View File
@@ -0,0 +1,46 @@
diff --git a/apple-codesign-quick/src/bundle.rs b/apple-codesign-quick/src/bundle.rs
index 040fd07..2bfa359 100644
--- a/apple-codesign-quick/src/bundle.rs
+++ b/apple-codesign-quick/src/bundle.rs
@@ -16,6 +16,23 @@ const FAIRPLAY_DIR: &str = "SC_Info";
const CODE_SIGNATURE_DIR: &str = "_CodeSignature";
const CODE_RESOURCES_FILE: &str = "CodeResources";
+#[cfg(test)]
+mod musicbridge_extensionkit_tests {
+ use super::*;
+ #[test]
+ fn musicbridge_extensionkit_is_signed_as_nested_bundle() {
+ let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let root = std::env::temp_dir().join(format!("musicbridge-codesign-test-{unique}"));
+ let extension = root.join("Extensions/Remote.appex");
+ std::fs::create_dir_all(&extension).unwrap();
+ std::fs::write(extension.join("Info.plist"), b"fixture").unwrap();
+ let bundle = Bundle { path: root, app_info: Dictionary::new() };
+ assert_eq!(bundle.sub_bundles().unwrap(), vec![extension]);
+ assert!(!should_hash_resource("Extensions/Remote.appex/Remote", "Main"));
+ assert!(should_hash_resource("Assets.car", "Main"));
+ }
+}
+
pub struct BundleSigningSettings<'a> {
pub team_id: String,
pub main_entitlements: Dictionary,
@@ -202,7 +219,7 @@ impl Bundle {
fn sub_bundles(&self) -> Result<Vec<PathBuf>> {
let mut bundles = Vec::new();
- for folder in ["Frameworks", "PlugIns"] {
+ for folder in ["Frameworks", "PlugIns", "Extensions"] {
let dir = self.path.join(folder);
#[cfg(feature = "wasm")]
if !isideload_vfs::fs::metadata(&dir)
@@ -463,7 +480,7 @@ fn should_hash_resource(relative: &str, executable: &str) -> bool {
return false;
}
- for nested_root in ["Frameworks/", "PlugIns/"] {
+ for nested_root in ["Frameworks/", "PlugIns/", "Extensions/"] {
if let Some(rest) = relative.strip_prefix(nested_root)
&& rest.contains('/')
{
+159
View File
@@ -0,0 +1,159 @@
diff --git a/isideload/src/sideload/application.rs b/isideload/src/sideload/application.rs
index 4e0ccd0..be80af4 100644
--- a/isideload/src/sideload/application.rs
+++ b/isideload/src/sideload/application.rs
@@ -20,6 +20,33 @@ pub struct Application {
//pub temp_path: PathBuf,
}
+#[cfg(test)]
+mod musicbridge_tests {
+ use super::*;
+ #[test]
+ fn musicbridge_extensionkit_is_renamed_and_persisted() {
+ let root = std::env::temp_dir().join(format!("musicbridge-sign-test-{}", uuid::Uuid::new_v4()));
+ for (directory, id) in [("", "ru.yukinoki.musicbridge"),
+ ("PlugIns/Widget.appex", "ru.yukinoki.musicbridge.widgets"),
+ ("Extensions/Remote.appex", "ru.yukinoki.musicbridge.remote")] {
+ let path = root.join(directory);
+ std::fs::create_dir_all(&path).unwrap();
+ let mut info = plist::Dictionary::new();
+ info.insert("CFBundleIdentifier".into(), id.into());
+ plist::to_file_xml(path.join("Info.plist"), &info).unwrap();
+ }
+ let mut app = Application::new(root.clone()).unwrap();
+ assert_eq!(app.bundle.app_extensions().len(), 2);
+ app.update_bundle_id("ru.yukinoki.musicbridge", "ru.yukinoki.musicbridge.TEAM").unwrap();
+ app.bundle.write_info().unwrap();
+ for extension in app.bundle.app_extensions_mut() { extension.write_info().unwrap(); }
+ let saved = Application::new(root).unwrap();
+ for extension in saved.bundle.app_extensions() {
+ assert!(extension.bundle_identifier().unwrap().starts_with("ru.yukinoki.musicbridge.TEAM."));
+ }
+ }
+}
+
impl Application {
pub fn new(path: PathBuf) -> Result<Self, Report> {
if !isideload_vfs::fs::metadata(&path).is_ok() {
diff --git a/isideload/src/sideload/bundle.rs b/isideload/src/sideload/bundle.rs
index 67a947d..2cc2282 100644
--- a/isideload/src/sideload/bundle.rs
+++ b/isideload/src/sideload/bundle.rs
@@ -45,23 +45,19 @@ impl Bundle {
"Failed to parse Info.plist".to_string(),
))?;
- // Load app extensions from PlugIns directory
- let plug_ins_dir = bundle_path.join("PlugIns");
- let app_extensions = if isideload_vfs::fs::metadata(&plug_ins_dir).is_ok() {
- isideload_vfs::fs::read_dir(&plug_ins_dir)
- .context(SideloadError::InvalidBundle(
- "Failed to read PlugIns directory".to_string(),
- ))?
- .filter_map(|entry| entry.ok())
- .filter(|entry| {
- entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
- && isideload_vfs::fs::metadata(&entry.path().join("Info.plist")).is_ok()
- })
- .filter_map(|entry| Bundle::new(entry.path()).ok())
- .collect()
- } else {
- Vec::new()
- };
+ // ExtensionKit uses Extensions; WidgetKit and older extensions use PlugIns.
+ // Invalid nested bundles must fail signing, never be silently skipped.
+ let mut app_extensions = Vec::new();
+ for folder in ["PlugIns", "Extensions"] {
+ let directory = bundle_path.join(folder);
+ if !isideload_vfs::fs::metadata(&directory).is_ok() { continue; }
+ for entry in isideload_vfs::fs::read_dir(&directory)? {
+ let entry = entry?;
+ if entry.file_type()?.is_dir() {
+ app_extensions.push(Bundle::new(entry.path())?);
+ }
+ }
+ }
// Load frameworks from Frameworks directory
let frameworks_dir = bundle_path.join("Frameworks");
diff --git a/isideload/src/sideload/sign.rs b/isideload/src/sideload/sign.rs
index a3d9799..c7e6154 100644
--- a/isideload/src/sideload/sign.rs
+++ b/isideload/src/sideload/sign.rs
@@ -5,6 +5,7 @@ use apple_codesign::{
};
use plist::Dictionary;
+use rootcause::option_ext::OptionExt;
use rootcause::prelude::*;
use crate::{
@@ -63,6 +64,10 @@ where
);
}
+ let musicbridge = app.main_bundle_id()? == format!("ru.yukinoki.musicbridge.{}", team.team_id)
+ && app.bundle.app_info.get("MusicBridgeSharedKeychainGroup").and_then(plist::Value::as_string)
+ == Some("ru.yukinoki.musicbridge.remote-control");
+ if musicbridge { entitlements = musicbridge_keychain(&entitlements)?; }
let mut settings = BundleSigningSettings::new(&team.team_id, entitlements, Some(&signer));
settings.embedded_mobileprovision = Some(main_provisioning_profile.encoded_profile.as_ref());
@@ -75,9 +80,56 @@ where
.map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone()))
.collect();
+ if musicbridge {
+ let remote_id = format!("{}.remote", app.main_bundle_id()?);
+ let remote = settings.entitlements_by_bundle_id.get(&remote_id)
+ .ok_or_report().context("MusicBridge remote extension has no provisioning profile")?;
+ let remote = musicbridge_keychain(remote)?;
+ settings.entitlements_by_bundle_id.insert(remote_id, remote);
+ }
+
if let Some(callback) = &progress_callback {
callback(0.5).await;
}
Ok(sign_bundle(&app.bundle.bundle_dir, &settings)?)
}
+
+// Request only a group explicitly permitted by Apple's actual profile.
+fn musicbridge_keychain(profile: &Dictionary) -> Result<Dictionary, Report> {
+ let application = profile.get("application-identifier").and_then(plist::Value::as_string)
+ .ok_or_report().context("Profile has no application-identifier")?;
+ let prefix = application.split_once('.').ok_or_report().context("Invalid app identifier")?.0;
+ let shared = format!("{}.ru.yukinoki.musicbridge.remote-control", prefix);
+ let allowed = profile.get("keychain-access-groups").and_then(plist::Value::as_array)
+ .ok_or_report().context("Profile has no Keychain groups")?;
+ if !allowed.iter().filter_map(plist::Value::as_string).any(|group|
+ group == shared || group == format!("{}.*", prefix)) {
+ bail!("Apple's provisioning profile does not permit the MusicBridge shared Keychain group");
+ }
+ let mut result = profile.clone();
+ // A concrete per-app default group comes first, shared group second.
+ result.insert("keychain-access-groups".into(), plist::Value::Array(vec![
+ plist::Value::String(application.into()), plist::Value::String(shared)]));
+ Ok(result)
+}
+
+#[cfg(test)]
+mod musicbridge_tests {
+ use super::*;
+ fn profile(group: &str) -> Dictionary {
+ let mut p = Dictionary::new();
+ p.insert("application-identifier".into(), "TEAM.ru.yukinoki.musicbridge.TEAM".into());
+ p.insert("keychain-access-groups".into(), plist::Value::Array(vec![group.into()]));
+ p
+ }
+ #[test]
+ fn musicbridge_sharing_respects_profile() {
+ let value = musicbridge_keychain(&profile("TEAM.*")).unwrap();
+ let groups = value["keychain-access-groups"].as_array().unwrap();
+ assert_eq!(groups[0].as_string(), Some("TEAM.ru.yukinoki.musicbridge.TEAM"));
+ assert_eq!(groups[1].as_string(), Some("TEAM.ru.yukinoki.musicbridge.remote-control"));
+ assert!(musicbridge_keychain(&profile("OTHER.*")).is_err());
+ assert!(musicbridge_keychain(&profile("TEAM.unrelated")).is_err());
+ }
+}
+33
View File
@@ -0,0 +1,33 @@
"""Apply reviewed patches to pinned upstream checkouts. No Apple credentials involved."""
import json
from pathlib import Path
import subprocess
import sys
root = Path(sys.argv[1]).resolve()
patches = Path(__file__).resolve().parent
versions = {
"iloader": "348eefd7de78e9bc612c9d619b8b1e7a80ba3ba0",
"isideload": "f6a4d5dba717d72fc2af63eaba26b27ba44116be",
"apple-crates": "5da0b8df9b202434b1eff2700059b1ee142592ac",
}
for name, commit in versions.items():
path = root / name
actual = subprocess.check_output(["git", "-C", str(path), "rev-parse", "HEAD"], text=True).strip()
if actual != commit:
raise RuntimeError(f"Unexpected {name} revision: {actual}")
for name, patch in [("isideload", "isideload.patch"), ("apple-crates", "apple-codesign.patch")]:
subprocess.run(["git", "-C", str(root/name), "apply", "--check", str(patches/patch)], check=True)
subprocess.run(["git", "-C", str(root/name), "apply", str(patches/patch)], check=True)
manifest = root / "iloader/src-tauri/Cargo.toml"
with manifest.open("a", encoding="utf-8") as file:
file.write('\n[patch."https://github.com/nab138/isideload"]\nisideload = { path = "../../isideload/isideload" }\n')
file.write('\n[patch."https://github.com/nab138/apple-crates"]\napple-codesign = { path = "../../apple-crates/apple-codesign-quick" }\n')
# Portable helper: preserve upstream identifier/storage so existing Apple login can
# be reused locally. Use a distinct title and disable upstream update replacement.
config = root / "iloader/src-tauri/ci.conf.json"
value = json.loads(config.read_text(encoding="utf-8"))
value["app"] = {"windows": [{"title": "iloader — MusicBridge compatibility build", "width": 800, "height": 600}]}
config.write_text(json.dumps(value, indent=2), encoding="utf-8")
print("Patched pinned iloader, extension discovery, nested signing and profile-checked Keychain sharing.")
+35
View File
@@ -13,6 +13,9 @@ import subprocess
import threading import threading
import time import time
import zlib import zlib
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
work = root / "build/tls" work = root / "build/tls"
@@ -67,11 +70,25 @@ class Handler(http.server.BaseHTTPRequestHandler):
def snapshot(self, token): def snapshot(self, token):
result = dict(state) result = dict(state)
if token.startswith("live-"):
with counts_lock:
commands = counts.get((token, "command"), 0)
result["title"] = "Track " + str(commands)
if token == "live-unsupported":
result["capabilities"] = {key: False for key in result["capabilities"]}
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
return result return result
def do_POST(self): def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0")))) body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
if token.startswith("live-"):
count(token, "command")
if token == "live-reject":
self.reply(200, {"success": False, "code": "unsupported", "message": "Rejected"})
else:
self.reply(200, {"success": True, "code": "ok", "message": "OK"})
return
if self.path == "/v1/pair": if self.path == "/v1/pair":
code = body.get("code") code = body.get("code")
if code == "33333333": if code == "33333333":
@@ -86,6 +103,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
token = self.headers.get("Authorization", "").removeprefix("Bearer ") token = self.headers.get("Authorization", "").removeprefix("Bearer ")
number = count(token, self.path) number = count(token, self.path)
if token == "live-refresh-fail" and self.path == "/v1/state":
with counts_lock:
commands = counts.get((token, "command"), 0)
if commands:
self.reply(503, {})
return
if token == "revoked": if token == "revoked":
self.reply(401, {}) self.reply(401, {})
return return
@@ -147,6 +170,18 @@ for name, days in (("valid", "1825"), ("expired", "-1")):
key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem")) key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem"))
subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", str(config), subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", str(config),
"-keyout", str(key), "-out", str(csr)], check=True, capture_output=True, timeout=15) "-keyout", str(key), "-out", str(csr)], check=True, capture_output=True, timeout=15)
if name == "expired":
private_key = serialization.load_pem_private_key(key.read_bytes(), password=None)
template = x509.load_pem_x509_certificate((work / "valid.pem").read_bytes())
now = datetime.datetime.now(datetime.timezone.utc)
builder = (x509.CertificateBuilder().subject_name(template.subject).issuer_name(template.issuer)
.public_key(private_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=2))
.not_valid_after(now - datetime.timedelta(days=1)))
for extension in template.extensions:
builder = builder.add_extension(extension.value, extension.critical)
cert.write_bytes(builder.sign(private_key, hashes.SHA256()).public_bytes(serialization.Encoding.PEM))
else:
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256", subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256",
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)], "-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)],
check=True, capture_output=True, timeout=15) check=True, capture_output=True, timeout=15)
+3 -2
View File
@@ -11,12 +11,13 @@ import xml.etree.ElementTree as ET
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
generated = [root / "MusicBridge.xcodeproj/project.pbxproj", generated = [root / "MusicBridge.xcodeproj/project.pbxproj",
root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme", root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme",
root / "MusicBridge/Info.plist", root / "MusicBridgeWidgets/Info.plist"] root / "MusicBridge/Info.plist", root / "MusicBridgeWidgets/Info.plist",
root / "MusicBridgeRemote/Info.plist", root / "MusicBridge/App.entitlements", root / "MusicBridgeRemote/Remote.entitlements"]
before = [p.read_bytes() for p in generated] before = [p.read_bytes() for p in generated]
subprocess.run([sys.executable, str(root / "tools/generate_project.py")], check=True) subprocess.run([sys.executable, str(root / "tools/generate_project.py")], check=True)
assert before == [p.read_bytes() for p in generated], "Generated project is stale; review and commit regeneration." assert before == [p.read_bytes() for p in generated], "Generated project is stale; review and commit regeneration."
project = generated[0].read_text(encoding="utf-8") project = generated[0].read_text(encoding="utf-8")
for folder in ("MusicBridge", "MusicBridgeTests", "Shared", "MusicBridgeWidgets"): for folder in ("MusicBridge", "MusicBridgeTests", "Shared", "MusicBridgeWidgets", "RemoteShared", "MusicBridgeRemote"):
for source in (root / folder).glob("*.swift"): for source in (root / folder).glob("*.swift"):
key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper() key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper()
assert key in project, f"Missing source: {source.name}" assert key in project, f"Missing source: {source.name}"
+20 -1
View File
@@ -47,6 +47,25 @@ with zipfile.ZipFile(path) as archive:
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0]) platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
offset += size offset += size
assert platforms == [2], "Widget is not built for iOS devices" assert platforms == [2], "Widget is not built for iOS devices"
remote_path = prefix + "Extensions/MusicBridgeRemote.appex/"
remote = plistlib.loads(archive.read(remote_path + "Info.plist"))
assert remote["CFBundleIdentifier"] == info["CFBundleIdentifier"] + ".remote"
assert remote["CFBundleShortVersionString"] == info["CFBundleShortVersionString"]
assert remote["CFBundleVersion"] == info["CFBundleVersion"]
assert remote["MinimumOSVersion"] == "27.0"
assert remote["EXAppExtensionAttributes"]["EXExtensionPointIdentifier"] == "com.apple.nowplaying.remote-media"
assert remote["MusicBridgeSharedKeychainGroup"] == info["MusicBridgeSharedKeychainGroup"] == "ru.yukinoki.musicbridge.remote-control"
binary = archive.read(remote_path + remote["CFBundleExecutable"])
assert struct.unpack_from("<II", binary) == (0xFEEDFACF, 0x0100000C)
offset, platforms = 32, []
for _ in range(struct.unpack_from("<I", binary, 16)[0]):
command, size = struct.unpack_from("<II", binary, offset)
assert size >= 8 and offset + size <= len(binary)
if command == 0x32:
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
offset += size
assert platforms == [2], "Remote extension is not built for iOS devices"
print(f"PASS: {path.name}: valid IPA, iPhoneOS ARM64, iOS 17+, ru.yukinoki.musicbridge.") print(f"PASS: {path.name}: valid IPA, iPhoneOS ARM64, iOS 17+, ru.yukinoki.musicbridge.")
print("PASS: embedded WidgetKit extension, matching version, ARM64 iPhoneOS and Live Activities support.") print("PASS: embedded WidgetKit extension, matching version, ARM64 iPhoneOS and Live Activities support.")
print("Requires local signing with Sideloadly before installation on an iPhone.") print("PASS: embedded iOS 27 NowPlaying ExtensionKit extension and matching shared Keychain metadata.")
print("Requires signing all extensions and shared Keychain entitlements before installation.")