From 63fa120194973ff9e2a69dcda8e0e5948adfc42a Mon Sep 17 00:00:00 2001 From: ares Date: Mon, 14 Sep 2026 01:08:19 +0300 Subject: [PATCH] Build patched iloader for ExtensionKit IDs, profiles and Keychain [skip ci] --- .github/workflows/iloader-compat.yml | 62 +++++++++++ tools/iloader/README.md | 28 +++++ tools/iloader/apple-codesign.patch | 46 ++++++++ tools/iloader/isideload.patch | 151 +++++++++++++++++++++++++++ tools/iloader/prepare.py | 33 ++++++ 5 files changed, 320 insertions(+) create mode 100644 .github/workflows/iloader-compat.yml create mode 100644 tools/iloader/README.md create mode 100644 tools/iloader/apple-codesign.patch create mode 100644 tools/iloader/isideload.patch create mode 100644 tools/iloader/prepare.py diff --git a/.github/workflows/iloader-compat.yml b/.github/workflows/iloader-compat.yml new file mode 100644 index 0000000..e079ba1 --- /dev/null +++ b/.github/workflows/iloader-compat.yml @@ -0,0 +1,62 @@ +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 + - 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 diff --git a/tools/iloader/README.md b/tools/iloader/README.md new file mode 100644 index 0000000..4a0fda8 --- /dev/null +++ b/tools/iloader/README.md @@ -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 окончательно подтверждается только установкой на устройстве. diff --git a/tools/iloader/apple-codesign.patch b/tools/iloader/apple-codesign.patch new file mode 100644 index 0000000..3efa3c9 --- /dev/null +++ b/tools/iloader/apple-codesign.patch @@ -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> { + 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('/') + { diff --git a/tools/iloader/isideload.patch b/tools/iloader/isideload.patch new file mode 100644 index 0000000..b9c85b2 --- /dev/null +++ b/tools/iloader/isideload.patch @@ -0,0 +1,151 @@ +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 { + 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..cf3904d 100644 +--- a/isideload/src/sideload/sign.rs ++++ b/isideload/src/sideload/sign.rs +@@ -63,6 +63,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 +79,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 { ++ 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()); ++ } ++} diff --git a/tools/iloader/prepare.py b/tools/iloader/prepare.py new file mode 100644 index 0000000..205de99 --- /dev/null +++ b/tools/iloader/prepare.py @@ -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.")