Compare commits

..
17 changed files with 392 additions and 28 deletions

No files matched your search

+33
View File
@@ -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
Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.0-preview.1"
version = "0.8.0-preview.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.0-preview.1"
version = "0.8.0-preview.2"
edition = "2021"
license = "AGPL-3.0-only"
+3 -3
View File
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
See [release maintenance](docs/releases.md) for CEF builds, integration tests, and publishing commands. Plugins support `steamos` and `root`; hash verification is not a security review, so check the source and runtime user.
## License
## License and acknowledgments
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.
Framely is licensed under **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party projects retain their own 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<VERSION>` or `installer-v<VERSION>` tags. Redistributors must provide modifications and build scripts matching their binaries.
Thanks to Lepton for the Android runtime, [OVRPort](https://github.com/ovrport/app) for Quest APK conversion, and [FramePort's FrameBridge](https://github.com/spoopyghosty0/frameport) for OpenXR graphics and controller support. The optional q2f package includes the OVRPort and FrameBridge licenses and corresponding source.
+3 -3
View File
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
CEF 构建、集成测试和发布命令见[发行维护](docs/zh-CN/releases.md)。插件支持 `steamos` 和 `root`;哈希校验不代表安全审核,应核对来源和运行用户。
## 许可证
## 许可证与致谢
Framely 原创代码,包括本体、安装器、SDK、模板和仓库示例,采用 **AGPL-3.0-only**,全文见 [LICENSE](LICENSE)。第三方依赖和 vendored 文件保留各自许可证。
Framely 采用 **AGPL-3.0-only**,详见 [LICENSE](LICENSE)。第三方项目保留各自的许可证。
AGPL 允许商业使用;分发需按许可提供完整对应源码,修改后通过网络与用户交互还须向这些用户提供源码。发行源码对应本仓库的 `v<版本>` 或 `installer-v<版本>` 标签;重新分发者须提供与自己二进制匹配的修改和构建脚本。
感谢 Lepton 提供安卓运行环境、[OVRPort](https://github.com/ovrport/app) 提供 Quest APK 转换工具,以及 [FramePort 的 FrameBridge](https://github.com/spoopyghosty0/frameport) 提供 OpenXR 画面与手柄适配。可选 q2f 包附有 OVRPort 和 FrameBridge 的许可证及对应源码。
+22 -1
View File
@@ -85,6 +85,8 @@ struct Record {
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
#[serde(default)]
q2f_version: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct Database {
@@ -195,6 +197,7 @@ struct App {
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
q2f_version: Option<String>,
}
fn gamepad_trigger_threshold_default() -> u8 {
80
@@ -844,6 +847,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: rec.and_then(|r| r.show_window),
orientation: rec.and_then(|r| r.orientation.clone()),
framebuffer_compatibility: rec.is_some_and(|r| r.framebuffer_compatibility),
q2f_version: rec.and_then(|r| r.q2f_version.clone()),
});
}
}
@@ -898,6 +902,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: r.show_window,
orientation: r.orientation.clone(),
framebuffer_compatibility: r.framebuffer_compatibility,
q2f_version: r.q2f_version.clone(),
});
}
}
@@ -943,6 +948,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: None,
orientation: None,
framebuffer_compatibility: false,
q2f_version: None,
});
}
}
@@ -2082,6 +2088,7 @@ fn record_app(db: &mut Database, a: &App) {
show_window: a.show_window,
orientation: a.orientation.clone(),
framebuffer_compatibility: a.framebuffer_compatibility,
q2f_version: a.q2f_version.clone(),
..Default::default()
});
}
@@ -3024,6 +3031,15 @@ fn operate_internal(
.transpose()?;
let ticket = p["ticket"].as_str().context("Missing reviewed APK")?;
let (review, metadata) = review(home, ticket)?;
let q2f_version = match fs::read_to_string(review.join("q2f-version")) {
Ok(value) => {
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"
+126 -1
View File
@@ -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<PathBuf> {
Ok(std::env::current_exe()?
.parent()
@@ -120,6 +165,36 @@ fn workspace(storage: &Path, runtime: &Path) -> Result<PathBuf> {
#[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<Value> {
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?;
+82
View File
@@ -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('<H', data, 18, machine)
return bytes(data)
class FrameBridgePackageTests(unittest.TestCase):
def test_wraps_both_abis_and_keeps_corresponding_source(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
payload = root / 'payload'
adapters = {}
for abi, machine in PACKAGE.FRAMEBRIDGE_ABIS.items():
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib' / abi
lib.mkdir(parents=True)
(lib / 'libopenxr_loader_generic.so').write_bytes(b'original ' + abi.encode())
adapter = root / f'{abi}.so'
adapter.write_bytes(elf(machine, 2 if abi == 'arm64-v8a' else 1))
adapters[abi] = adapter
source = root / 'frameport.tar.gz'
prefix = f'frameport-{PACKAGE.FRAMEBRIDGE_COMMIT}/'
with tarfile.open(source, 'w:gz') as archive:
for name in ('LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md',
'native/build.py', 'native/adapter/frame_adapter.c'):
content = name.encode()
entry = tarfile.TarInfo(prefix + name)
entry.size = len(content)
archive.addfile(entry, io.BytesIO(content))
with patch.object(PACKAGE, 'FRAMEBRIDGE_SOURCE_SHA256', PACKAGE.digest(source)):
PACKAGE.install_framebridge(payload, source, adapters)
self.assertEqual((payload / 'FRAMEBRIDGE_LICENSE').read_bytes(), b'LICENSE')
with tarfile.open(payload / 'FRAMEBRIDGE_SOURCE.tar.gz') as archive:
self.assertIn('framebridge/native/adapter/frame_adapter.c', archive.getnames())
self.assertIn('framebridge/native/build.py', archive.getnames())
for abi in adapters:
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib' / abi
self.assertEqual((lib / 'libopenxr_loader_original.so').read_bytes(), b'original ' + abi.encode())
self.assertEqual((lib / 'libopenxr_loader_generic.so').read_bytes(), adapters[abi].read_bytes())
self.assertIn(b'controller_fix=1', (lib / 'libframe_settings.so').read_bytes())
def test_rejects_wrong_adapter_architecture_before_replacing_loader(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
payload = root / 'payload'
source = root / 'source.tar.gz'
prefix = f'frameport-{PACKAGE.FRAMEBRIDGE_COMMIT}/'
with tarfile.open(source, 'w:gz') as archive:
for name in ('LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md',
'native/build.py', 'native/adapter/frame_adapter.c'):
content = name.encode()
entry = tarfile.TarInfo(prefix + name)
entry.size = len(content)
archive.addfile(entry, io.BytesIO(content))
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib/arm64-v8a'
lib.mkdir(parents=True)
(lib / 'libopenxr_loader_generic.so').write_bytes(b'original')
adapter = root / 'wrong.so'
adapter.write_bytes(elf(40, 1))
with patch.object(PACKAGE, 'FRAMEBRIDGE_SOURCE_SHA256', PACKAGE.digest(source)):
with self.assertRaisesRegex(ValueError, 'wrong architecture'):
PACKAGE.install_framebridge(payload, source, {'arm64-v8a': adapter, 'armeabi-v7a': adapter})
self.assertEqual((lib / 'libopenxr_loader_generic.so').read_bytes(), b'original')
if __name__ == '__main__':
unittest.main()
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
import com.android.apksig.ApkVerifier;
import java.io.File;
/** Verify an OVRPort-produced APK before handing it to Framely's install review. */
public final class FramelyQ2FVerify {
public static void main(String[] args) throws Exception {
if (args.length != 1) {
throw new IllegalArgumentException("Expected one APK path");
}
ApkVerifier.Result result = new ApkVerifier.Builder(new File(args[0])).build().verify();
if (!result.isVerified()) {
for (ApkVerifier.IssueWithParams issue : result.getErrors()) {
System.err.println(issue);
}
throw new IllegalStateException("Quest converter produced an invalid APK signature");
}
}
}
+64 -2
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""Build an independently downloadable Quest conversion runtime."""
import argparse
import gzip
import hashlib
import json
import pathlib
import shutil
import struct
import subprocess
import tarfile
import tempfile
import zipfile
@@ -14,6 +17,9 @@ LIBRARIES_VERSION = '3.4.3-23204ea'
CLI_SHA256 = '307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2'
LIBRARIES_SHA256 = '6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e'
SOURCE_SHA256 = '2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3'
FRAMEBRIDGE_COMMIT = '80ef9ca6a77eed1ca6b2cc6f746d9897e4178b4e'
FRAMEBRIDGE_SOURCE_SHA256 = 'ea90ea8dd2f0fe49d54d2df5cc61c1876c9562e55fb40fce2651b1d5594dab7c'
FRAMEBRIDGE_ABIS = {'arm64-v8a': 183, 'armeabi-v7a': 40}
def digest(path):
@@ -37,6 +43,55 @@ def safe_zip_extract(source, destination):
shutil.copyfileobj(data, output)
def framebridge_source(source, destination):
"""Ship the exact adapter source and its build instructions with the runtime."""
prefix = f'frameport-{FRAMEBRIDGE_COMMIT}/'
fixed = {'LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md', 'native/build.py'}
with tarfile.open(source, 'r:gz') as upstream, destination.open('wb') as output, \
gzip.GzipFile(filename='', mode='wb', fileobj=output, mtime=0) as compressed, \
tarfile.open(fileobj=compressed, mode='w') as selected:
files = [m for m in upstream.getmembers() if m.name.startswith(prefix)
and (m.name[len(prefix):] in fixed or m.name[len(prefix):].startswith('native/adapter/'))
and m.isfile()]
if not fixed.issubset({m.name[len(prefix):] for m in files}) or len(files) > 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('<H', data, 18)[0] != machine):
raise ValueError(f'FrameBridge {abi} binary has the wrong architecture')
lib = payload / 'libraries' / LIBRARIES_VERSION / 'lib' / abi
generic = lib / 'libopenxr_loader_generic.so'
original = lib / 'libopenxr_loader_original.so'
if not generic.is_file() or original.exists():
raise ValueError(f'OVRPort {abi} generic loader cannot be wrapped')
generic.rename(original)
generic.write_bytes(data)
(lib / 'libframe_settings.so').write_text('controller_fix=1\nprofile_remap=1\n')
def package(args):
cli, libraries, java, source = map(pathlib.Path, (args.cli_jar, args.libraries_zip, args.java_home, args.source_archive))
if digest(cli) != CLI_SHA256 or digest(libraries) != LIBRARIES_SHA256 or digest(source) != SOURCE_SHA256:
@@ -49,7 +104,12 @@ def package(args):
payload = pathlib.Path(scratch) / 'payload'
shutil.copytree(java, payload / 'java', symlinks=False)
shutil.copy2(cli, payload / 'overportcli.jar')
verifier = pathlib.Path(__file__).with_name('FramelyQ2FVerify.java')
subprocess.run(['javac', '-cp', str(cli), '-d', str(payload), str(verifier)], check=True)
shutil.copy2(verifier, payload / verifier.name)
safe_zip_extract(libraries, payload / 'libraries' / LIBRARIES_VERSION)
install_framebridge(payload, pathlib.Path(args.framebridge_source), {
'arm64-v8a': args.framebridge_arm64, 'armeabi-v7a': args.framebridge_arm32})
(payload / 'overport-installed.json').write_text(json.dumps({'releases': [
{'version': LIBRARIES_VERSION, 'app_required': '1.2.0', 'is_experimental': True}
]}) + '\n')
@@ -58,7 +118,7 @@ def package(args):
(payload / 'SOURCE').write_text('https://github.com/ovrport/app/tree/c790854\n')
entries = sorted(p for p in payload.rglob('*') if p.is_file())
checksums = ''.join(f'{digest(p)} {p.relative_to(payload).as_posix()}\n' for p in entries)
identity = f'overport-{CLI_VERSION}-{LIBRARIES_VERSION}-{hashlib.sha256(checksums.encode()).hexdigest()[:12]}'
identity = f'overport-{CLI_VERSION}-{LIBRARIES_VERSION}-framebridge-{FRAMEBRIDGE_COMMIT[:8]}-{hashlib.sha256(checksums.encode()).hexdigest()[:12]}'
(payload / 'VERSION').write_text(identity + '\n')
(payload / 'SHA256SUMS').write_text(checksums)
archive = output / f'framely-q2f-{identity}-linux-arm64.tar.gz'
@@ -66,6 +126,7 @@ def package(args):
tar.add(payload, arcname=f'framely-q2f-{identity}')
requirement = {'schemaVersion': 1, 'id': identity,
'cliVersion': CLI_VERSION, 'librariesVersion': LIBRARIES_VERSION,
'framebridgeCommit': FRAMEBRIDGE_COMMIT,
'archive': archive.name, 'size': archive.stat().st_size,
'sha256': digest(archive),
'url': f'https://github.com/{args.repo}/releases/download/{args.tag}/{archive.name}'}
@@ -76,6 +137,7 @@ def package(args):
if __name__ == '__main__':
parser = argparse.ArgumentParser()
for name in ('stage', 'output', 'java-home', 'cli-jar', 'libraries-zip', 'license', 'source-archive', 'repo', 'tag'):
for name in ('stage', 'output', 'java-home', 'cli-jar', 'libraries-zip', 'license', 'source-archive',
'framebridge-source', 'framebridge-arm64', 'framebridge-arm32', 'repo', 'tag'):
parser.add_argument('--' + name, required=True)
package(parser.parse_args())
+6 -1
View File
@@ -24,7 +24,10 @@ q2f_cli=${Q2F_CLI_JAR:-target/overportcli-1.2.3-all.jar}
q2f_libraries=${Q2F_LIBRARIES_ZIP:-target/overport-libraries-3.4.3-23204ea.zip}
q2f_license=${Q2F_LICENSE:-target/overport-source/LICENSE}
q2f_source=${Q2F_SOURCE_ARCHIVE:-target/q2f-source.tar.gz}
build_id="$version-$(find "$binary" target/native media/bin ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE "$q2f_java" "$q2f_cli" "$q2f_libraries" "$q2f_license" "$q2f_source" -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
framebridge_source=${Q2F_FRAMEBRIDGE_SOURCE:-target/q2f-framebridge/source.tar.gz}
framebridge_arm64=${Q2F_FRAMEBRIDGE_ARM64:-target/q2f-framebridge/artifacts/arm64-v8a/libopenxr_loader_generic.so}
framebridge_arm32=${Q2F_FRAMEBRIDGE_ARM32:-target/q2f-framebridge/artifacts/armeabi-v7a/libopenxr_loader_generic.so}
build_id="$version-$(find "$binary" target/native media/bin ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE "$q2f_java" "$q2f_cli" "$q2f_libraries" "$q2f_license" "$q2f_source" "$framebridge_source" "$framebridge_arm64" "$framebridge_arm32" -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
stage="$base/release/framely-$build_id"
[[ ! -e $stage ]] || { echo 'Release already exists.' >&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"
+11
View File
@@ -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):
+3 -1
View File
@@ -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."
}
}
+3 -1
View File
@@ -1299,6 +1299,8 @@
"转换完成,请确认安装。": "转换完成,请确认安装。",
"正在检查转换工具": "正在检查转换工具",
"工具包已缓存": "工具包已缓存",
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包"
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包",
"Quest 转换": "Quest 转换",
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。"
}
}
+3 -3
View File
@@ -6,14 +6,14 @@ import {Switch} from './switch';
import {t} from './i18n';
type Metadata={name:string;package:string;version:string;versionCode:number;minSdk:number;icon?:string;activities:string[];abis:string[]};
type Props={metadata:Metadata;size:string;previous?:Metadata;updating:boolean;containerName:string;running:boolean;displayMode:string;busy:boolean;error:string;converted:boolean;q2f?:{cached:boolean;size:string};q2fError?:string;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onConvert:()=>void;onRevert:()=>void;onConfirm:()=>void};
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,converted,q2f,q2fError,onDisplayMode,onStop,onCancel,onConvert,onRevert,onConfirm}:Props){
type Props={metadata:Metadata;size:string;previous?:Metadata;updating:boolean;containerName:string;running:boolean;displayMode:string;busy:boolean;error:string;converted:boolean;questEnabled:boolean;q2f?:{cached:boolean;size:string};q2fError?:string;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onQuestChange:(checked:boolean)=>void;onConfirm:()=>void};
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,converted,questEnabled,q2f,q2fError,onDisplayMode,onStop,onCancel,onQuestChange,onConfirm}:Props){
const mismatch=!!previous&&previous.package!==metadata.package;
return <div className="apk-install-review">
<h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2>
<p className="apk-install-intro">{t(updating?'更新应用并保留现有存档和设置。':'核对应用信息,选择合适的显示方式。')}</p>
<header className="apk-install-app"><PluginImage kind="lepton" name={metadata.name} src={metadata.icon} size={60}/><div><h3>{metadata.name}</h3><code>{metadata.package}</code><p>{t('版本')} {previous&&`${previous.version} → `}{metadata.version||t('未声明')} <span>·</span> {size}</p></div></header>
<section className="apk-install-option apk-quest-option"><IconBrandMeta size={25} stroke={1.7} aria-hidden="true"/><div><b>{t('这是一个 Quest 游戏?')}</b><small>{t('打开此选项将尝试进行兼容转换,但不保证一定成功')}</small>{!converted&&<small>{q2fError?q2fError:!q2f?t('正在检查转换工具'):q2f.cached?t('工具包已缓存'):t('首次使用需下载 {0} 工具包',{0:q2f.size})}</small>}</div><Switch label={t('这是一个 Quest 游戏?')} checked={converted} disabled={busy||(!converted&&(!q2f||!!q2fError))} onChange={checked=>checked?onConvert():onRevert()}/></section>
<section className="apk-install-option apk-quest-option"><IconBrandMeta size={25} stroke={1.7} aria-hidden="true"/><div><b>{t('这是一个 Quest 游戏?')}</b><small>{t('打开此选项将尝试进行兼容转换,但不保证一定成功')}</small>{questEnabled&&!converted&&<small>{q2fError?q2fError:!q2f?t('正在检查转换工具'):q2f.cached?t('工具包已缓存'):t('首次使用需下载 {0} 工具包',{0:q2f.size})}</small>}</div><Switch label={t('这是一个 Quest 游戏?')} checked={questEnabled||converted} disabled={busy} onChange={onQuestChange}/></section>
<section className="apk-install-option apk-install-location" aria-label={t('目标容器')}><IconFolder size={23} aria-hidden="true"/><div><b>{t(updating?'保留原容器':'创建独立容器')}</b>{updating&&<code>{containerName}</code>}<small>{t('每个新应用使用独立容器,更新保留原容器。')}</small></div><Switch label={t(updating?'保留原容器':'创建独立容器')} checked disabled onChange={()=>{}}/></section>
<div className="apk-install-display"><div><b>{t('显示方式')}</b><small>{t(displayMode==='vr'?'隐藏二维窗口,应用本身需要支持 VR。':'通过二维窗口显示并操作应用。')}</small></div><Select label={t('显示方式')} value={displayMode} disabled={busy} onChange={onDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></div>
<details className="apk-install-technical"><summary>{t('查看 APK 详情')}</summary><dl className="apk-meta"><dt>{t('版本代码')}</dt><dd>{metadata.versionCode}</dd><dt>{t('最低 Android SDK')}</dt><dd>{metadata.minSdk||t('未声明')}</dd><dt>{t('架构')}</dt><dd>{metadata.abis.join(', ')||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{metadata.activities.length||t('未发现启动入口')}</dd></dl></details>
+2
View File
@@ -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}
+10 -10
View File
@@ -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};
@@ -32,25 +32,25 @@ function SteamNameSetting({app,busy,onSave}:{app:App;busy:boolean;onSave:(name:s
</form>;
}
export function ApkManager({view:externalView,onViewChange}:{view?:string;onViewChange?:(view:string)=>void}={}){
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[originalReview,setOriginalReview]=useState<Review|null>(null),[q2f,setQ2f]=useState<Q2fStatus|null>(null),[q2fError,setQ2fError]=useState(''),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[originalReview,setOriginalReview]=useState<Review|null>(null),[questEnabled,setQuestEnabled]=useState(false),[q2f,setQ2f]=useState<Q2fStatus|null>(null),[q2fError,setQ2fError]=useState(''),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);};
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
useEffect(()=>{if(!review||review.q2fVersion){setQ2f(null);setQ2fError('');return;}let active=true;setQ2f(null);setQ2fError('');void api<Q2fStatus>('apk.q2f.status').then(value=>{if(active)setQ2f(value);}).catch(e=>{if(active)setQ2fError(String(e));});return()=>{active=false;};},[review?.ticket]);
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed')setError(t(value.error??'操作失败'));else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setDisplayMode('vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;displayMode?:string}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed'||value.phase==='cancelled'){if(saved?.review){setReview(saved.review);setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(t(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setQuestEnabled(true);setDisplayMode(saved?.displayMode??'vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');if(saved?.review)await perform('install',{ticket:value.result.ticket,context:saved.context,app:saved.app?.id,showWindow:saved.displayMode==='flat',approve:true},saved.review.ticket);else setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
useEffect(()=>{if(data.retainSteamEntries===false||!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps,data.retainSteamEntries]);
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(String(e));}finally{if(alive.current)setLoading(false);}}
useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]);
useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]);
useEffect(()=>{const dialog=document.querySelector<HTMLElement>('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]);
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setUpdating(null);}
async function convert(){if(!review||review.q2fVersion)return;setBusy(true);busyRef.current=true;setError('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:review.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');setOriginalReview(review);setReview(result.result);setDisplayMode('vr');}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(String(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(String(e));}finally{setBusy(false);}}
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setQuestEnabled(false);setUpdating(null);}
async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true});return;}setBusy(true);busyRef.current=true;setError('');setNotice('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:original.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review:original,displayMode}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');const converted=result.result as Review;setOriginalReview(original);setReview(converted);await perform('install',{ticket:converted.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(String(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setQuestEnabled(false);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(String(e));}finally{setBusy(false);}}
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setQuestEnabled(false);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
function choose(a?:App){setUpdating(a??null);if(input.current)openLocalFileInput(input.current,path=>{if(alive.current)void inspect(path,a??null);});}
async function perform(operation:string,params:Record<string,unknown>={}){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setOriginalReview(null);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
async function perform(operation:string,params:Record<string,unknown>={},originalTicket=originalReview?.ticket){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalTicket)await api('apk.review.drop',{ticket:originalTicket}).catch(()=>{});setOriginalReview(null);setQuestEnabled(false);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
function canDeleteContainer(a:App){const members=data.apps.filter(other=>other.context===a.context);const container=data.containers.find(c=>c.id===a.context);return !!container&&!container.steam&&members.length===1&&members[0].id===a.id&&a.stateKnown!==false&&!a.pending;}
function askUninstall(a:App){setError('');setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:'',purge:!a.installed,deleteContainer:!a.installed&&canDeleteContainer(a)});}
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(String(e));}}
@@ -74,8 +74,8 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{containerTitle(c)}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p>{!data.apps.some(a=>a.context===c.id&&a.installed)&&<p>{t('未发现已安装的应用,启动容器不会安装 APK。')}</p>}</div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
{view==='cleanup'&&<><div className="apk-cleanup-heading"><div><h2>{t('数据清理')}</h2><p>{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}</p></div><button className="danger" disabled={busy||!selected.length} onClick={()=>setConfirm({operation:'cleanup',items:selected,title:t('确认数据清理'),description:cleanup.filter(i=>selected.includes(i.id)).map(i=>`${i.name} · ${bytes(i.bytes)}`).join('\n')+`\n${t('选中的文件将永久删除,无法撤销。')}`})}><IconTrash size={22}/>{t('清理所选')}</button></div>{cleanup.map(i=><article className="apk-cleanup-row" key={i.id}><Switch checked={selected.includes(i.id)} label={`${t('选择')} ${i.name}`} disabled={busy||i.unknown} onChange={checked=>setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/><div className="apk-copy"><b>{i.name}</b><p>{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}</p>{i.unknown?<small>{t('无法确认归属,不提供自动删除。')}</small>:i.sensitive&&<small>{t('删除后不可恢复')}</small>}</div>{i.restorable&&<button disabled={busy} onClick={()=>setConfirm({operation:'restore',container:contextFor(i.context!),backup:i.name,title:t('恢复容器备份'),description:t('恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。')})}>{t('恢复')}</button>}</article>)}{!cleanup.length&&<p className="empty">{t('没有可清理的数据。')}</p>}</>}
{!!data.warnings.length&&<details className="apk-warnings"><summary>{t('部分容器无法读取')}</summary>{data.warnings.map((w,i)=><p key={i}>{w}</p>)}</details>}
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage kind="lepton" name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd></dl>{details.installed&&details.stateKnown!==false&&!details.steam&&<section className="apk-steam-names"><h3>{t('自定义 Steam 名称')}</h3><p>{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}</p><SteamNameSetting app={details} busy={busy} onSave={steamName=>void perform('settings',{app:details.id,steamName})}/></section>}{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><SwitchRow label={t('通过 Steam 启动')} description={t('通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}</p><SwitchRow label={t('帧缓冲兼容修复')} description={t('仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。')} checked={details.framebufferCompatibility??false} disabled={busy} onChange={enabled=>void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/><label>{t('窗口方向')}<Select label={t('窗口方向')} value={details.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
{review&&!confirm&&!job&&dialog(<ApkInstallReview metadata={review.metadata} size={bytes(review.bytes)} previous={updating?.metadata} displayMode={displayMode} onDisplayMode={setDisplayMode} containerName={installContainer?containerTitle(installContainer):target} updating={!!updating} running={!!installContainer?.running&&!installContainer.steam} busy={busy} error={error} converted={!!review.q2fVersion} q2f={q2f?{cached:q2f.cached,size:bytes(q2f.bytes)}:undefined} q2fError={q2fError} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onConvert={()=>void convert()} onRevert={()=>void revertConversion()} onConfirm={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}/>,t('安装 APK'))}
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage kind="lepton" name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd>{details.installed&&details.q2fVersion&&<><dt>{t('Quest 转换')}</dt><dd><code>{details.q2fVersion}</code><small>{t('要使用新版转换运行包,请重新选择原始 Quest APK 安装。')}</small></dd></>}</dl>{details.installed&&details.stateKnown!==false&&!details.steam&&<section className="apk-steam-names"><h3>{t('自定义 Steam 名称')}</h3><p>{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}</p><SteamNameSetting app={details} busy={busy} onSave={steamName=>void perform('settings',{app:details.id,steamName})}/></section>}{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><SwitchRow label={t('通过 Steam 启动')} description={t('通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}</p><SwitchRow label={t('帧缓冲兼容修复')} description={t('仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。')} checked={details.framebufferCompatibility??false} disabled={busy} onChange={enabled=>void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/><label>{t('窗口方向')}<Select label={t('窗口方向')} value={details.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
{review&&!confirm&&!job&&dialog(<ApkInstallReview metadata={review.metadata} size={bytes(review.bytes)} previous={updating?.metadata} displayMode={displayMode} onDisplayMode={setDisplayMode} containerName={installContainer?containerTitle(installContainer):target} updating={!!updating} running={!!installContainer?.running&&!installContainer.steam} busy={busy} error={error} converted={!!review.q2fVersion} questEnabled={questEnabled} q2f={q2f?{cached:q2f.cached,size:bytes(q2f.bytes)}:undefined} q2fError={q2fError} onStop={()=>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(<ApkUninstall app={confirm.app} size={bytes(confirm.app.size)} purge={!!confirm.purge} deleteContainer={!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app)} exclusive={canDeleteContainer(confirm.app)} busy={busy} error={error} onPurge={purge=>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(<><h2>{confirm.title}</h2><p className="apk-confirm-copy">{confirm.description}</p>{confirm.app&&<p><b>{confirm.app.metadata.name}</b><br/><code>{confirm.app.metadata.package}</code></p>}{deletesData&&<p>{t('永久删除的数据无法撤销。')}</p>}{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setConfirm(null)}>{t('取消')}</button><button className={deletesData||confirm.operation==='uninstall'?'danger':'primary'} disabled={busy} onClick={()=>void perform(confirm.operation,{app:confirm.app?.id,context:confirm.container?.id,backup:confirm.backup,items:confirm.items,purge:confirm.purge,approve:true})}>{t('确认')}</button></footer></>,confirm.title)}
{job&&dialog(<><h2>{t(job.phase==='preparing'&&job.operation?operationLabels[job.operation]??phases[job.phase]:phases[job.phase]??job.phase)}</h2><p>{t('操作进行中,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('操作已开始,请等待完成;此阶段无法取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}