mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-27 19:03:33 +01:00
feat(q2f): integrate FrameBridge into Quest conversion runtime
This commit is contained in:
1 parent
fd3666f931
commit
d5a039dad9
14 files changed
+377
-9
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
|
||||
|
||||
@@ -98,3 +98,5 @@ See [release maintenance](docs/releases.md) for CEF builds, integration tests, a
|
||||
Original Framely code, including the core, installer, SDK, template, and repository examples, uses **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party dependencies and vendored files retain their respective licenses.
|
||||
|
||||
AGPL allows commercial use. Distribution requires complete corresponding source under the license; modified software that interacts with users over a network must also offer source to those users. Release source corresponds to the repository's `v<VERSION>` or `installer-v<VERSION>` tags. Redistributors must provide modifications and build scripts matching their binaries.
|
||||
|
||||
The optional Quest converter includes [FrameBridge](https://github.com/spoopyghosty0/frameport) to adapt OpenXR graphics and controller input for Steam Frame. FrameBridge retains its GPL-3.0-only license and corresponding source in the separate q2f package. Existing games are not changed automatically when that package updates; select the original Quest APK again to reconvert and update a game.
|
||||
@@ -98,3 +98,5 @@ CEF 构建、集成测试和发布命令见[发行维护](docs/zh-CN/releases.md
|
||||
Framely 原创代码,包括本体、安装器、SDK、模板和仓库示例,采用 **AGPL-3.0-only**,全文见 [LICENSE](LICENSE)。第三方依赖和 vendored 文件保留各自许可证。
|
||||
|
||||
AGPL 允许商业使用;分发需按许可提供完整对应源码,修改后通过网络与用户交互还须向这些用户提供源码。发行源码对应本仓库的 `v<版本>` 或 `installer-v<版本>` 标签;重新分发者须提供与自己二进制匹配的修改和构建脚本。
|
||||
|
||||
可选的 Quest 转换包使用 [FrameBridge](https://github.com/spoopyghosty0/frameport) 适配 Steam Frame 的 OpenXR 画面与手柄输入。FrameBridge 保留 GPL-3.0-only 许可证,其对应源码随独立 q2f 包提供。转换包更新不会自动修改已安装游戏;要升级游戏,请重新选择原始 Quest APK 转换安装。
|
||||
+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 安装。"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -13,7 +13,7 @@ import {ApkUninstall} from './apk-uninstall';
|
||||
import {ApkInstallReview} from './apk-install-review';
|
||||
import {GamepadTriggerSetting} from './gamepad-trigger-setting';
|
||||
type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean};
|
||||
type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean};
|
||||
type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean;q2fVersion?:string|null};
|
||||
type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean};
|
||||
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;retainSteamEntries?:boolean;gamepadEnabled?:boolean;gamepadSource?:string;gamepadRumble?:boolean;gamepadTriggerThreshold?:number;autoStopContainer?:boolean;stopContainerOnClose?:boolean};
|
||||
type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean};
|
||||
@@ -74,7 +74,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
{view==='containers'&&<><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('管理应用'))}
|
||||
{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)}
|
||||
|
||||
Reference in new issue
Block a user