mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 05:01:23 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e62eefb85 | ||
|
|
d5a039dad9 | ||
|
|
fd3666f931 |
No files matched your search
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -1299,6 +1299,8 @@
|
||||
"转换完成,请确认安装。": "转换完成,请确认安装。",
|
||||
"正在检查转换工具": "正在检查转换工具",
|
||||
"工具包已缓存": "工具包已缓存",
|
||||
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包"
|
||||
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包",
|
||||
"Quest 转换": "Quest 转换",
|
||||
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 操作进度'))}
|
||||
|
||||
Reference in new issue
Block a user