From d5a039dad918c1f8d63e75c35e180dfedba36f2d Mon Sep 17 00:00:00 2001 From: toorux Date: Mon, 12 Oct 2026 22:35:08 +0800 Subject: [PATCH] feat(q2f): integrate FrameBridge into Quest conversion runtime --- .github/workflows/release.yml | 33 +++++++++ README.md | 2 + README.zh-CN.md | 2 + src/apk.rs | 23 +++++- src/apk/q2f.rs | 127 +++++++++++++++++++++++++++++++++- tests/test_package_q2f.py | 82 ++++++++++++++++++++++ tools/FramelyQ2FVerify.java | 19 +++++ tools/package-q2f.py | 66 +++++++++++++++++- tools/package-release.sh | 7 +- tools/q2f-runtime.py | 11 +++ ui/locales/en-US.json | 4 +- ui/locales/zh-CN.json | 4 +- ui/src/apk-manager.css | 2 + ui/src/apk-manager.tsx | 4 +- 14 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 tests/test_package_q2f.py create mode 100644 tools/FramelyQ2FVerify.java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d78b29c..90228d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,33 @@ concurrency: group: framely-release-${{ github.ref }} cancel-in-progress: false jobs: + framebridge: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Build pinned FrameBridge adapter from source + run: | + mkdir -p target/q2f-framebridge/source target/q2f-framebridge/artifacts/{arm64-v8a,armeabi-v7a} + curl -fsSL --retry 3 -o target/q2f-framebridge/source.tar.gz https://github.com/spoopyghosty0/frameport/archive/80ef9ca6a77eed1ca6b2cc6f746d9897e4178b4e.tar.gz + echo 'ea90ea8dd2f0fe49d54d2df5cc61c1876c9562e55fb40fce2651b1d5594dab7c target/q2f-framebridge/source.tar.gz' | sha256sum -c + tar -xzf target/q2f-framebridge/source.tar.gz -C target/q2f-framebridge/source --strip-components=1 + FRAMEPORT_ARTIFACTS_OUT="$PWD/target/q2f-framebridge/artifacts" python3 target/q2f-framebridge/source/native/build.py --only adapter + for abi in arm64-v8a armeabi-v7a; do + test -s "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" + readelf -W --dyn-syms "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" | grep xrGetInstanceProcAddr + done + mkdir -p target/framebridge-upload/artifacts/{arm64-v8a,armeabi-v7a} + cp target/q2f-framebridge/source.tar.gz target/framebridge-upload/ + for abi in arm64-v8a armeabi-v7a; do + cp "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" "target/framebridge-upload/artifacts/$abi/" + done + - uses: actions/upload-artifact@v4 + with: + name: framely-framebridge + path: target/framebridge-upload/ + if-no-files-found: error frame: + needs: [framebridge] runs-on: ubuntu-24.04-arm env: CARGO_HOME: ${{ github.workspace }}/target/framely-cargo @@ -59,6 +85,10 @@ jobs: distribution: temurin java-version: '25' architecture: aarch64 + - uses: actions/download-artifact@v4 + with: + name: framely-framebridge + path: target/q2f-framebridge - name: Prepare pinned q2f inputs run: | mkdir -p target/q2f-input @@ -99,6 +129,9 @@ jobs: Q2F_LIBRARIES_ZIP: target/q2f-input/libraries.zip Q2F_LICENSE: target/q2f-input/LICENSE Q2F_SOURCE_ARCHIVE: target/q2f-input/source.tar.gz + Q2F_FRAMEBRIDGE_SOURCE: target/q2f-framebridge/source.tar.gz + Q2F_FRAMEBRIDGE_ARM64: target/q2f-framebridge/artifacts/arm64-v8a/libopenxr_loader_generic.so + Q2F_FRAMEBRIDGE_ARM32: target/q2f-framebridge/artifacts/armeabi-v7a/libopenxr_loader_generic.so run: | bash tools/package-release.sh target/cef mkdir -p release-assets diff --git a/README.md b/README.md index 5e88c6b..fa99af8 100644 --- a/README.md +++ b/README.md @@ -98,3 +98,5 @@ See [release maintenance](docs/releases.md) for CEF builds, integration tests, a Original Framely code, including the core, installer, SDK, template, and repository examples, uses **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party dependencies and vendored files retain their respective licenses. AGPL allows commercial use. Distribution requires complete corresponding source under the license; modified software that interacts with users over a network must also offer source to those users. Release source corresponds to the repository's `v` or `installer-v` tags. Redistributors must provide modifications and build scripts matching their binaries. + +The optional Quest converter includes [FrameBridge](https://github.com/spoopyghosty0/frameport) to adapt OpenXR graphics and controller input for Steam Frame. FrameBridge retains its GPL-3.0-only license and corresponding source in the separate q2f package. Existing games are not changed automatically when that package updates; select the original Quest APK again to reconvert and update a game. diff --git a/README.zh-CN.md b/README.zh-CN.md index eacd6a0..bcb5d6f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -98,3 +98,5 @@ CEF 构建、集成测试和发布命令见[发行维护](docs/zh-CN/releases.md Framely 原创代码,包括本体、安装器、SDK、模板和仓库示例,采用 **AGPL-3.0-only**,全文见 [LICENSE](LICENSE)。第三方依赖和 vendored 文件保留各自许可证。 AGPL 允许商业使用;分发需按许可提供完整对应源码,修改后通过网络与用户交互还须向这些用户提供源码。发行源码对应本仓库的 `v<版本>` 或 `installer-v<版本>` 标签;重新分发者须提供与自己二进制匹配的修改和构建脚本。 + +可选的 Quest 转换包使用 [FrameBridge](https://github.com/spoopyghosty0/frameport) 适配 Steam Frame 的 OpenXR 画面与手柄输入。FrameBridge 保留 GPL-3.0-only 许可证,其对应源码随独立 q2f 包提供。转换包更新不会自动修改已安装游戏;要升级游戏,请重新选择原始 Quest APK 转换安装。 diff --git a/src/apk.rs b/src/apk.rs index 1b113d3..8065228 100644 --- a/src/apk.rs +++ b/src/apk.rs @@ -85,6 +85,8 @@ struct Record { orientation: Option, #[serde(default)] framebuffer_compatibility: bool, + #[serde(default)] + q2f_version: Option, } #[derive(Serialize, Deserialize)] struct Database { @@ -195,6 +197,7 @@ struct App { orientation: Option, #[serde(default)] framebuffer_compatibility: bool, + q2f_version: Option, } fn gamepad_trigger_threshold_default() -> u8 { 80 @@ -844,6 +847,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec (Vec (Vec { + let id = value.trim(); + ensure!(safe(id) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b)), "Invalid q2f review version"); + Some(id.to_owned()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; let requested = p["context"].as_str().unwrap_or(""); let cs = containers(home, &db); let context = if requested.is_empty() { @@ -3127,7 +3143,7 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?; // Installation boots a headless container solely for the package manager. // Finish that runtime before Steam takes ownership of the first app launch. stop(&c,&log)?; - let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;save(home,&db)?;fs::remove_dir_all(review)?; + let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;rec.q2f_version=q2f_version;save(home,&db)?;fs::remove_dir_all(review)?; // Mirror the operation log under the stable application id for newly installed apps. if id!=app_id {let _=fs::copy(&log,r.join("logs").join(format!("{}-{}.log",hash(&app_id),now())));} Ok(json!({"app":app_id,"backup":backup_id,"launchable":!metadata.activities.is_empty()}))}) @@ -5225,12 +5241,17 @@ setup_props assert!(load(&f.home).unwrap().records.is_empty()); for (version, show) in [(43, false), (44, true)] { let ticket = f.review(version); + if version == 43 { + fs::write(root(&f.home).join("reviews").join(&ticket).join("q2f-version"), "test-q2f-1").unwrap(); + } f.operation( "install", json!({"ticket":ticket,"context":"test","app":id,"approve":true,"showWindow":show}), ) .unwrap(); assert_eq!(load(&f.home).unwrap().records[id].show_window, Some(show)); + assert_eq!(load(&f.home).unwrap().records[id].q2f_version.as_deref(), + if version == 43 { Some("test-q2f-1") } else { None }); assert!( !running("test"), "installation must finish its headless runtime" diff --git a/src/apk/q2f.rs b/src/apk/q2f.rs index 4edbcc7..cd6beb3 100644 --- a/src/apk/q2f.rs +++ b/src/apk/q2f.rs @@ -4,6 +4,7 @@ use anyhow::{ensure, Context, Result}; use serde_json::{json, Value}; use std::{ fs, + io::Read, os::unix::fs::{symlink, PermissionsExt}, path::{Path, PathBuf}, process::{Command, Stdio}, @@ -11,6 +12,50 @@ use std::{ time::Duration, }; +fn verify_framebridge( + apk: &Path, + runtime: &Path, + libraries_version: &str, + abis: &[String], +) -> Result<()> { + let mut archive = zip::ZipArchive::new(fs::File::open(apk)?)?; + let mut found = false; + for abi in abis + .iter() + .filter(|abi| abi.as_str() == "arm64-v8a" || abi.as_str() == "armeabi-v7a") + { + found = true; + let library = runtime + .join("libraries") + .join(libraries_version) + .join("lib") + .join(abi); + for (apk_name, source_name) in [ + ("libopenxr_loader_generic.so", "libopenxr_loader_generic.so"), + ( + "libopenxr_loader_original.so", + "libopenxr_loader_original.so", + ), + ("libframe_settings.so", "libframe_settings.so"), + ] { + let expected = fs::read(library.join(source_name))?; + let mut entry = archive.by_name(&format!("lib/{abi}/{apk_name}"))?; + ensure!( + entry.size() == expected.len() as u64, + "Quest adapter file size mismatch: {abi}/{apk_name}" + ); + let mut actual = Vec::with_capacity(expected.len()); + entry.read_to_end(&mut actual)?; + ensure!( + actual == expected, + "Quest adapter file mismatch: {abi}/{apk_name}" + ); + } + } + ensure!(found, "Quest APK has no supported FrameBridge architecture"); + Ok(()) +} + fn release_root() -> Result { Ok(std::env::current_exe()? .parent() @@ -120,6 +165,36 @@ fn workspace(storage: &Path, runtime: &Path) -> Result { #[cfg(test)] mod tests { use super::*; + use std::io::Write; + + #[test] + fn converted_apk_requires_the_exact_wrapped_loader_and_settings() { + let temp = tempfile::tempdir().unwrap(); + let runtime = temp.path().join("runtime"); + let lib = runtime.join("libraries/test/lib/arm64-v8a"); + fs::create_dir_all(&lib).unwrap(); + let apk = temp.path().join("converted.apk"); + let mut writer = zip::ZipWriter::new(fs::File::create(&apk).unwrap()); + for (name, data) in [ + ("libopenxr_loader_generic.so", b"framebridge".as_slice()), + ("libopenxr_loader_original.so", b"overport".as_slice()), + ("libframe_settings.so", b"controller_fix=1\n".as_slice()), + ] { + fs::write(lib.join(name), data).unwrap(); + writer + .start_file( + format!("lib/arm64-v8a/{name}"), + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + writer.write_all(data).unwrap(); + } + writer.finish().unwrap(); + let abis = vec!["arm64-v8a".to_string()]; + verify_framebridge(&apk, &runtime, "test", &abis).unwrap(); + fs::write(lib.join("libframe_settings.so"), b"controller_fix=0\n").unwrap(); + assert!(verify_framebridge(&apk, &runtime, "test", &abis).is_err()); + } #[test] fn workspace_library_link_survives_apk_storage_move() { @@ -141,6 +216,34 @@ mod tests { "ok" ); } + + #[test] + fn runtime_upgrade_keeps_per_app_signing_keys() { + let temp = tempfile::tempdir().unwrap(); + let storage = temp.path(); + for id in ["old", "framebridge"] { + let runtime = storage.join("q2f/runtimes").join(id); + fs::create_dir_all(runtime.join("libraries")).unwrap(); + fs::write(runtime.join("overport-installed.json"), "{}").unwrap(); + fs::write(runtime.join("libraries/probe"), id).unwrap(); + workspace(storage, &runtime).unwrap(); + if id == "old" { + fs::write( + storage.join("q2f/workspace/signatures/game.keystore"), + "original key", + ) + .unwrap(); + } + } + assert_eq!( + fs::read_to_string(storage.join("q2f/workspace/signatures/game.keystore")).unwrap(), + "original key" + ); + assert_eq!( + fs::read_to_string(storage.join("q2f/workspace/libraries/probe")).unwrap(), + "framebridge" + ); + } } pub(super) fn convert( @@ -217,16 +320,38 @@ pub(super) fn convert( let result = (|| -> Result { ensure!(converted.is_file(), "Quest converter produced no APK"); progress(json!({"phase":"q2f-verifying","operation":"convert"}))?; + let mut verifier = Command::new(runtime.join("java/bin/java")); + verifier + .arg("-cp") + .arg(format!( + "{}:{}", + runtime.join("overportcli.jar").display(), + runtime.display() + )) + .arg("FramelyQ2FVerify") + .arg(&converted); + run(verifier, &log, cancel)?; let after: Metadata = apk_metadata::read(&converted)?; ensure!( after.package == before.package && after.version_code == before.version_code, "Quest conversion changed APK package or version" ); + verify_framebridge(&converted, &runtime, version, &after.abis)?; cancel.check()?; // Create a fresh review after conversion so the user has the full // review window even when patching takes several minutes. drop(_guard); - super::inspect(home, &converted, cancel) + let inspected = super::inspect(home, &converted, cancel)?; + let reviewed = storage.join("reviews").join( + inspected["ticket"] + .as_str() + .context("Converted review has no ticket")?, + ); + fs::write( + reviewed.join("q2f-version"), + requirement["id"].as_str().context("Invalid q2f identity")?, + )?; + Ok(inspected) })(); let _ = fs::remove_dir_all(&output); let next = result?; diff --git a/tests/test_package_q2f.py b/tests/test_package_q2f.py new file mode 100644 index 0000000..16e8d39 --- /dev/null +++ b/tests/test_package_q2f.py @@ -0,0 +1,82 @@ +import importlib.util +import io +import pathlib +import struct +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'tools/package-q2f.py' +SPEC = importlib.util.spec_from_file_location('package_q2f', SCRIPT) +PACKAGE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PACKAGE) + + +def elf(machine, elf_class): + data = bytearray(1024) + data[:6] = b'\x7fELF' + bytes((elf_class, 1)) + struct.pack_into(' 100: + raise ValueError('Incomplete FrameBridge corresponding source') + for member in sorted(files, key=lambda m: m.name): + relative = member.name[len(prefix):] + if member.size > 2 * 1024 * 1024 or '..' in pathlib.PurePosixPath(relative).parts: + raise ValueError('Invalid FrameBridge source entry') + copy = tarfile.TarInfo(f'framebridge/{relative}') + copy.size, copy.mode, copy.mtime = member.size, 0o644, 0 + selected.addfile(copy, upstream.extractfile(member)) + + +def install_framebridge(payload, source, adapters): + if digest(source) != FRAMEBRIDGE_SOURCE_SHA256: + raise ValueError('Pinned FrameBridge source checksum mismatch') + with tarfile.open(source, 'r:gz') as upstream: + license_file = upstream.extractfile(f'frameport-{FRAMEBRIDGE_COMMIT}/LICENSE') + if license_file is None: + raise ValueError('FrameBridge license missing') + (payload / 'FRAMEBRIDGE_LICENSE').write_bytes(license_file.read()) + framebridge_source(source, payload / 'FRAMEBRIDGE_SOURCE.tar.gz') + (payload / 'FRAMEBRIDGE_SOURCE').write_text( + f'https://github.com/spoopyghosty0/frameport/tree/{FRAMEBRIDGE_COMMIT}\n') + for abi, machine in FRAMEBRIDGE_ABIS.items(): + adapter = pathlib.Path(adapters[abi]) + data = adapter.read_bytes() + elf_class = 2 if abi == 'arm64-v8a' else 1 + if (len(data) < 1024 or data[:6] != b'\x7fELF' + bytes((elf_class, 1)) + or struct.unpack_from('&2; exit 1; } mkdir -p "$stage"/{bin,lib/cef,lib/openvr,lib/media,share/ui,share/licenses,tools} @@ -88,6 +91,8 @@ python3 tools/package-q2f.py --stage "$stage" --output release \ --java-home "$q2f_java" --cli-jar "$q2f_cli" \ --libraries-zip "$q2f_libraries" --license "$q2f_license" \ --source-archive "$q2f_source" \ + --framebridge-source "$framebridge_source" \ + --framebridge-arm64 "$framebridge_arm64" --framebridge-arm32 "$framebridge_arm32" \ --repo "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" --tag "${RELEASE_TAG:-v$version}" python3 tools/package-runtime.py "$stage" "$cef" "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" "${RELEASE_TAG:-v$version}" echo "Package: release/framely-$build_id-linux-arm64.tar.gz" diff --git a/tools/q2f-runtime.py b/tools/q2f-runtime.py index 7e4be61..87c5e81 100644 --- a/tools/q2f-runtime.py +++ b/tools/q2f-runtime.py @@ -43,6 +43,17 @@ def validate(root, identity): for required in ('java/bin/java', 'overportcli.jar', 'overport-installed.json', 'OVR_PORT_LICENSE'): if required not in expected: raise ValueError('Incomplete q2f runtime: ' + required) + if '-framebridge-' in identity: + for required in ('FRAMEBRIDGE_LICENSE', 'FRAMEBRIDGE_SOURCE', 'FRAMEBRIDGE_SOURCE.tar.gz', + 'FramelyQ2FVerify.java', 'FramelyQ2FVerify.class'): + if required not in expected: + raise ValueError('Incomplete q2f runtime: ' + required) + releases = root / 'libraries' + for abi in ('arm64-v8a', 'armeabi-v7a'): + libs = list(releases.glob(f'*/lib/{abi}')) + if len(libs) != 1 or not all((libs[0] / name).is_file() for name in ( + 'libopenxr_loader_generic.so', 'libopenxr_loader_original.so', 'libframe_settings.so')): + raise ValueError('Incomplete FrameBridge libraries: ' + abi) class HTTPSRedirect(urllib.request.HTTPRedirectHandler): diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index a7d80a3..1bc21d6 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -1299,6 +1299,8 @@ "转换完成,请确认安装。": "Conversion complete. Review the APK before installing.", "正在检查转换工具": "Checking converter", "工具包已缓存": "Converter cached", - "首次使用需下载 {0} 工具包": "First use downloads the {0} converter" + "首次使用需下载 {0} 工具包": "First use downloads the {0} converter", + "Quest 转换": "Quest conversion", + "要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "To use an updated converter, select the original Quest APK and install it again." } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index 807d5db..80bfa5d 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -1299,6 +1299,8 @@ "转换完成,请确认安装。": "转换完成,请确认安装。", "正在检查转换工具": "正在检查转换工具", "工具包已缓存": "工具包已缓存", - "首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包" + "首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包", + "Quest 转换": "Quest 转换", + "要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。" } } diff --git a/ui/src/apk-manager.css b/ui/src/apk-manager.css index a8b2dce..7038327 100644 --- a/ui/src/apk-manager.css +++ b/ui/src/apk-manager.css @@ -24,3 +24,5 @@ .apk-trigger-setting{display:flex;align-items:center;gap:24px;padding:18px 0}.apk-trigger-setting>div:first-child{flex:1;min-width:0}.apk-trigger-setting label{margin:0}.apk-trigger-setting b{font-size:19px}.apk-trigger-setting small{display:block;font-size:17px;color:var(--muted);line-height:1.55;margin-top:5px}.apk-trigger-controls{display:flex;align-items:center;gap:16px;flex:0 0 390px}.apk-trigger-controls>div{display:flex;align-items:center;gap:12px;flex:1;min-width:0}.apk-trigger-controls input[type=range]{width:100%;min-width:0;accent-color:var(--blue);padding:0;margin:0;min-height:44px;cursor:pointer}.apk-trigger-controls output{font-size:18px;min-width:48px;text-align:right;font-variant-numeric:tabular-nums}.apk-trigger-controls button{margin:0}@media(max-width:700px){.apk-trigger-setting{flex-wrap:wrap;gap:12px}.apk-trigger-setting>div:first-child{flex-basis:100%}.apk-trigger-controls{flex:1 1 100%}} .apk-steam-names{width:100%;margin-bottom:24px}.apk-steam-names>p{color:var(--muted);line-height:1.55}.apk-steam-name{display:flex;align-items:center;gap:24px;padding:18px 0;border-bottom:1px solid var(--line);width:100%}.apk-steam-name .apk-copy code{display:block;margin-top:6px;color:var(--muted)}.apk-steam-name-controls{display:flex;align-items:center;gap:12px;flex:0 1 50%;min-width:0}.apk-steam-name-controls input{flex:1;min-width:0;margin:0}.apk-steam-name-controls button{flex:none;margin:0}@media(max-width:700px){.apk-steam-name{flex-wrap:wrap;gap:12px}.apk-steam-name-controls{flex-basis:100%}} + +.apk-meta dd small{display:block;margin-top:6px;color:var(--muted);line-height:1.5} diff --git a/ui/src/apk-manager.tsx b/ui/src/apk-manager.tsx index 003dfd4..a153169 100644 --- a/ui/src/apk-manager.tsx +++ b/ui/src/apk-manager.tsx @@ -13,7 +13,7 @@ import {ApkUninstall} from './apk-uninstall'; import {ApkInstallReview} from './apk-install-review'; import {GamepadTriggerSetting} from './gamepad-trigger-setting'; type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean}; -type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean}; +type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean;q2fVersion?:string|null}; type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean}; type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;retainSteamEntries?:boolean;gamepadEnabled?:boolean;gamepadSource?:string;gamepadRumble?:boolean;gamepadTriggerThreshold?:number;autoStopContainer?:boolean;stopContainerOnClose?:boolean}; type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean}; @@ -74,7 +74,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView {view==='containers'&&<>
{data.containers.map(c=>

{containerTitle(c)}

{c.baked}

{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}

{!data.apps.some(a=>a.context===c.id&&a.installed)&&

{t('未发现已安装的应用,启动容器不会安装 APK。')}

}
{!c.steam&&<>}
)}

{t('其他数据位置')}

{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}

setPath(e.target.value)}/>setContextName(e.target.value)}/>
{data.roots.map(p=>
{p}
)}
} {view==='cleanup'&&<>

{t('数据清理')}

{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}

{cleanup.map(i=>
setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/>
{i.name}

{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}

{i.unknown?{t('无法确认归属,不提供自动删除。')}:i.sensitive&&{t('删除后不可恢复')}}
{i.restorable&&}
)}{!cleanup.length&&

{t('没有可清理的数据。')}

}} {!!data.warnings.length&&
{t('部分容器无法读取')}{data.warnings.map((w,i)=>

{w}

)}
} - {details&&!confirm&&!review&&!log&&!job&&dialog(<>

{details.metadata.name}

{details.metadata.package}
{t('版本')}
{details.metadata.version} ({details.metadata.versionCode})
{t('容器')}
{contextFor(details.context)?.name??details.context}
{t('空间占用')}
{bytes(details.size)}
{details.installed&&details.stateKnown!==false&&!details.steam&&

{t('自定义 Steam 名称')}

{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}

void perform('settings',{app:details.id,steamName})}/>
}{details.pending&&<>

{t(details.pending)}

}
{details.installed&&details.stateKnown!==false&&!details.steam&&<>}{!details.installed&&details.stateKnown!==false&&!details.steam&&}
{details.installed&&details.stateKnown!==false&&!details.steam&&
{t('高级设置')}void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&

{t('等待 Steam 注册,Steam 就绪后会自动重试。')}

}{details.steamRegistrationError&&

{details.steamRegistrationError}

}

{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}

void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&void perform('settings',{app:details.id,showWindow:checked})}/>}

{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}

void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/>
}
,t('管理应用'))} + {details&&!confirm&&!review&&!log&&!job&&dialog(<>

{details.metadata.name}

{details.metadata.package}
{t('版本')}
{details.metadata.version} ({details.metadata.versionCode})
{t('容器')}
{contextFor(details.context)?.name??details.context}
{t('空间占用')}
{bytes(details.size)}
{details.installed&&details.q2fVersion&&<>
{t('Quest 转换')}
{details.q2fVersion}{t('要使用新版转换运行包,请重新选择原始 Quest APK 安装。')}
}
{details.installed&&details.stateKnown!==false&&!details.steam&&

{t('自定义 Steam 名称')}

{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}

void perform('settings',{app:details.id,steamName})}/>
}{details.pending&&<>

{t(details.pending)}

}
{details.installed&&details.stateKnown!==false&&!details.steam&&<>}{!details.installed&&details.stateKnown!==false&&!details.steam&&}
{details.installed&&details.stateKnown!==false&&!details.steam&&
{t('高级设置')}void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&

{t('等待 Steam 注册,Steam 就绪后会自动重试。')}

}{details.steamRegistrationError&&

{details.steamRegistrationError}

}

{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}

void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&void perform('settings',{app:details.id,showWindow:checked})}/>}

{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}

void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/>
}
,t('管理应用'))} {review&&!confirm&&!job&&dialog(installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onQuestChange={checked=>{if(!checked&&review.q2fVersion)void revertConversion();else setQuestEnabled(checked);}} onConfirm={()=>void installReviewed()}/>,t('安装 APK'))} {confirm?.app&&!job&&['uninstall','purge'].includes(confirm.operation)&&dialog(setConfirm(c=>c?.app?{...c,purge,deleteContainer:purge&&canDeleteContainer(c.app)}:c)} onDeleteContainer={deleteContainer=>setConfirm(c=>c?{...c,deleteContainer}:c)} onCancel={()=>setConfirm(null)} onConfirm={()=>void perform(confirm.operation,{app:confirm.app!.id,purge:confirm.purge,deleteContainer:!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app!),approve:true})}/>,confirm.title)} {confirm&&!job&&!(confirm.app&&['uninstall','purge'].includes(confirm.operation))&&dialog(<>

{confirm.title}

{confirm.description}

{confirm.app&&

{confirm.app.metadata.name}
{confirm.app.metadata.package}

}{deletesData&&

{t('永久删除的数据无法撤销。')}

}{error&&

{error}

},confirm.title)}