mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-25 19:00:43 +01:00
Add optional q2f runtime and Quest APK conversion
This commit is contained in:
1 parent
3039c9ef0e
commit
cdb24fd31d
17 files changed
+633
-20
No files matched your search
@@ -54,6 +54,34 @@ jobs:
|
||||
key: cef-linux-arm64-154.0.32-682c378
|
||||
- name: Fetch pinned CEF
|
||||
run: test -d target/cef || bash tools/fetch-cef.sh
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25.0.4.1+1'
|
||||
architecture: aarch64
|
||||
- name: Prepare pinned q2f inputs
|
||||
run: |
|
||||
mkdir -p target/q2f-input
|
||||
curl -fsSL --retry 3 -o target/q2f-input/cli-jar.zip https://github.com/ovrport/app/releases/download/1.2.3/cli-jar.zip
|
||||
python3 - <<'PY'
|
||||
import zipfile
|
||||
with zipfile.ZipFile('target/q2f-input/cli-jar.zip') as source:
|
||||
name = 'overportcli-1.2.3-all.jar'
|
||||
with source.open(name) as data, open('target/q2f-input/' + name, 'xb') as output:
|
||||
import shutil
|
||||
shutil.copyfileobj(data, output)
|
||||
PY
|
||||
curl -fsSL --retry 3 -o target/q2f-input/libraries.zip https://files.crx.moe/ovrport/tracker/releases/download/3.4.3-23204ea/libraries.zip
|
||||
curl -fsSL --retry 3 -o target/q2f-input/LICENSE https://raw.githubusercontent.com/ovrport/app/c790854/LICENSE
|
||||
curl -fsSL --retry 3 -o target/q2f-input/source.tar.gz https://github.com/ovrport/app/archive/c790854.tar.gz
|
||||
sha256sum -c <<'EOF'
|
||||
307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2 target/q2f-input/overportcli-1.2.3-all.jar
|
||||
6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e target/q2f-input/libraries.zip
|
||||
2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3 target/q2f-input/source.tar.gz
|
||||
EOF
|
||||
jlink --strip-debug --no-man-pages --no-header-files --compress=2 \
|
||||
--add-modules java.base,java.datatransfer,java.xml,java.prefs,java.desktop,java.logging,java.net.http,jdk.crypto.ec,jdk.unsupported \
|
||||
--output target/q2f-input/java
|
||||
- name: Validate
|
||||
run: |
|
||||
npm ci
|
||||
@@ -66,11 +94,16 @@ jobs:
|
||||
env:
|
||||
RELEASE_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
Q2F_JAVA_HOME: target/q2f-input/java
|
||||
Q2F_CLI_JAR: target/q2f-input/overportcli-1.2.3-all.jar
|
||||
Q2F_LIBRARIES_ZIP: target/q2f-input/libraries.zip
|
||||
Q2F_LICENSE: target/q2f-input/LICENSE
|
||||
Q2F_SOURCE_ARCHIVE: target/q2f-input/source.tar.gz
|
||||
run: |
|
||||
bash tools/package-release.sh target/cef
|
||||
mkdir -p release-assets
|
||||
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' ! -name 'framely-cef-*' -print -quit)
|
||||
cp release/*.tar.gz release/framely-cef.json release-assets/
|
||||
cp release/*.tar.gz release/framely-cef.json release/framely-q2f.json release-assets/
|
||||
cp tools/bootstrap.py release-assets/bootstrap.py
|
||||
cp install.sh release-assets/install.sh
|
||||
"$CARGO_TARGET_DIR/release/framely" release-manifest --archive "$archive" --url "https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$(basename "$archive")" --output release-assets/framely-release.json --changelog "Framely $RELEASE_TAG"
|
||||
@@ -99,8 +132,8 @@ jobs:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
cd release-assets
|
||||
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json > SHA256SUMS
|
||||
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. A separate framely-cef-* archive is available for runtime downloads.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
|
||||
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json framely-q2f.json > SHA256SUMS
|
||||
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. Separate framely-cef-* and framely-q2f-* archives provide the browser and optional Quest converter.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
|
||||
prerelease=false
|
||||
latest=true
|
||||
if [[ "$RELEASE_TAG" == *-* ]]; then
|
||||
@@ -108,5 +141,5 @@ jobs:
|
||||
latest=false
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" --verify-tag --title "Framely $RELEASE_TAG" --notes-file release-notes.txt --draft --latest=false --prerelease="$prerelease"
|
||||
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json
|
||||
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json framely-q2f.json
|
||||
gh release edit "$RELEASE_TAG" --draft=false --latest="$latest" --prerelease="$prerelease"
|
||||
+1
-1
@@ -58,7 +58,7 @@ Prerelease suffixes create GitHub Prereleases without Latest. Installer releases
|
||||
|
||||
Only tags publish. After every product build succeeds, publication creates a draft, uploads assets/checksums, then makes it public. Failed drafts are not stable download targets. Manual runs leave Actions artifacts only.
|
||||
|
||||
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, `framely-release.json`, `framely-cef.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
|
||||
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, an optional `framely-q2f-*` Quest conversion archive, `framely-release.json`, `framely-cef.json`, `framely-q2f.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. The q2f archive is downloaded only when Quest conversion is chosen; each release pins a verified runtime identity, and already installed APKs are not automatically reconverted. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
|
||||
|
||||
External checksums cover downloaded archives; internal checksums cover extracted payload. Neither independently proves author identity. Online installation trusts the chosen repository/HTTPS, local installation the supplied archive/checksums. Redirects never downgrade HTTPS.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ Do not invoke Lepton's global cleanup commands as an application-specific uninst
|
||||
|
||||
## Supported formats
|
||||
|
||||
The initial implementation supports individual APKs. Split APK bundles, XAPK/APKM, OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
|
||||
The initial implementation supports individual APKs. Optional Quest conversion downloads and verifies a separate `framely-q2f-*` runtime when selected in the APK install review. It preserves the original APK and reviews the converted, newly signed APK before installation. The runtime and per-package signing keys live in the configured APK storage location and move with it. An existing original APK may reject the new signature; Framely will not silently uninstall it or erase saves. Updating q2f does not automatically reconvert installed games. Games can continue downloading their own OBB data into persistent container storage. Split APK bundles, XAPK/APKM, manual OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
|
||||
|
||||
### Flat window orientation
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
|
||||
|
||||
每次构建生成不含 CEF 的本体包 `framely-<版本及构建号>-linux-arm64.tar.gz`、完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、独立运行库 `framely-cef-<运行库标识>-linux-arm64.tar.gz`。更新清单 `framely-release.json` 固定指向不含 CEF 的本体包;首次安装固定选择完整离线包。`framely-cef.json` 提供独立 CEF 的 HTTPS 下载地址、大小及 SHA256。
|
||||
|
||||
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。
|
||||
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。可选的 Quest 转换工具另打 `framely-q2f-*` 包,发行包声明经过验证的确切版本;只有用户选择 Quest 转换时才校验缓存并按需下载,Framely 本体更新不自动重转已安装 APK。
|
||||
|
||||
## GitHub Actions 发布
|
||||
|
||||
@@ -71,7 +71,7 @@ git push origin installer-v0.4.1-preview.10
|
||||
|
||||
只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 Release,上传本产品产物及外部 `SHA256SUMS`,最后公开草稿。Framely Release 额外包含 `bootstrap.py`、入口脚本和更新清单;安装器 Release 只包含五个平台的安装器包和校验文件。失败的草稿不作为最新稳定版提供下载。手动触发用于验证,仅保留 Actions artifacts。
|
||||
|
||||
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
|
||||
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-q2f-*` 为可选 Quest 转换工具包,`framely-q2f.json` 提供下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
|
||||
|
||||
外部 `SHA256SUMS` 包含压缩包哈希,校验传输内容;包内同名文件校验解压后的载荷。哈希不能独立证明作者身份,在线安装依赖指定 GitHub 仓库与 HTTPS;本地安装依赖用户提供的可信包与校验文件。下载遵循 HTTPS 重定向,不允许降级。
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地
|
||||
|
||||
点击“安装 APK”,选择单个 `.apk`,核对名称、包名、版本、最低 Android SDK 和目标容器。在 Frame 内选择本机 APK 会直接在设备上校验,不经过浏览器上传;确认阶段保留独立本机副本,不修改原文件。网络上传暂存于 Frame 持久化文件系统,避免占满较小的 `/tmp`。上传显示真实字节进度;后续显示处理阶段,不使用假百分比。
|
||||
|
||||
Quest 游戏可在安装审核中点击“转换此 APK”。Framely 首次使用时按发行包声明的标识下载独立的 `framely-q2f-*` 工具包,校验完整归档和内部文件,之后复用已验证缓存;普通 APK 安装无需下载。转换调用 OVR Port,原始 APK 不修改,转换结果重新读取并再次审核后才安装。转换会重新签名;原版 APK 已安装时可能出现签名不匹配,Framely 不会自动卸载应用或清除存档。工具包和每个包名的签名密钥保存在配置的 APK 存储目录,随该目录迁移;转换临时文件也使用该位置,不使用 `/tmp`。工具包更新只影响之后发起的转换,不自动重新转换已安装游戏。转换结果能否在 Frame 运行仍取决于游戏。
|
||||
|
||||
在 Frame 内,两个管理面板的文件选择入口统一使用 Framely 内置文件选择器,包括 APK、插件包和语言文件。默认打开下载目录,记住上次选择的目录;左侧为按需加载的文件夹树,覆盖主目录、临时目录和已挂载存储,支持逐级展开、收起和当前路径定位,提供目录导航、手动输入路径、搜索、隐藏文件和文件类型筛选。选中文件后确认;B/Escape 只取消选择器,不关闭管理面板。外部浏览器继续使用系统文件选择器。启动台右侧的“安装APK”按钮打开 APK 管理面板。
|
||||
|
||||
共享容器中的 APK 使用各自独立的 Android 安装目录。容器重启会保留 Android 外部存储中的已下载资源,包括未完成的下载片段和更新进度记录。无法识别的 Lepton 存储初始化脚本会在执行前拒绝,避免丢弃文件;游戏是否继续下载仍由自身更新器决定。旧版单 APK 挂载会在保存的安装包可用时恢复,旧 OBB 资源链接会在替换安装前迁移,并保留原目标。这只能防止后续丢失,旧版启动流程已经删除的下载资源无法仅靠 APK 还原。
|
||||
@@ -97,7 +99,7 @@ Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely
|
||||
|
||||
## 格式范围
|
||||
|
||||
首版支持单个 APK。不包含分包、XAPK/APKM、OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
|
||||
首版支持单个 APK。Quest 兼容转换同样只接受单个 APK;游戏自行下载的 OBB 资源继续使用持久化容器存储。不包含分包、XAPK/APKM、手动 OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
|
||||
|
||||
### 平面窗口方向
|
||||
|
||||
|
||||
+12
@@ -25,6 +25,7 @@ mod graphics;
|
||||
mod lifecycle;
|
||||
mod native;
|
||||
pub(crate) mod permissions;
|
||||
mod q2f;
|
||||
mod steam_bridge;
|
||||
mod steam_shortcuts;
|
||||
pub(crate) mod steam_ui;
|
||||
@@ -2775,6 +2776,17 @@ pub fn reviews_drop(home: &Path, ticket: &str) -> Result<Value> {
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
pub fn q2f_convert(
|
||||
home: &Path,
|
||||
ticket: &str,
|
||||
cancel: &Cancellation,
|
||||
progress: impl Fn(Value) -> Result<()>,
|
||||
) -> Result<Value> {
|
||||
q2f::convert(home, ticket, cancel, progress)
|
||||
}
|
||||
pub fn q2f_status(home: &Path) -> Result<Value> {
|
||||
q2f::status(home)
|
||||
}
|
||||
pub fn operate(
|
||||
home: &Path,
|
||||
kind: &str,
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
//! Optional Quest APK conversion. The runtime is versioned separately from Framely.
|
||||
use super::{apk_metadata, init, review, space, Cancellation, Metadata, MUTATION};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
fs,
|
||||
os::unix::fs::{symlink, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn release_root() -> Result<PathBuf> {
|
||||
Ok(std::env::current_exe()?
|
||||
.parent()
|
||||
.context("Missing Framely binary directory")?
|
||||
.parent()
|
||||
.context("Missing Framely release directory")?
|
||||
.to_path_buf())
|
||||
}
|
||||
|
||||
fn requirement_path() -> Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("FRAMELY_Q2F_DESCRIPTOR") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(release_root()?.join("Q2F_RUNTIME.json"))
|
||||
}
|
||||
|
||||
fn tool_path() -> Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("FRAMELY_Q2F_TOOL") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(release_root()?.join("tools/q2f-runtime.py"))
|
||||
}
|
||||
|
||||
pub(super) fn status(home: &Path) -> Result<Value> {
|
||||
let requirement: Value = serde_json::from_slice(&fs::read(requirement_path()?)?)?;
|
||||
let identity = requirement["id"]
|
||||
.as_str()
|
||||
.context("Invalid q2f runtime identity")?;
|
||||
ensure!(
|
||||
!identity.is_empty()
|
||||
&& identity
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b))
|
||||
&& !identity.contains(".."),
|
||||
"Invalid q2f runtime identity"
|
||||
);
|
||||
let cached = super::root(home).join("q2f/runtimes").join(identity);
|
||||
Ok(json!({"id":identity,"bytes":requirement["size"],
|
||||
"cached":fs::read_to_string(cached.join("VERSION")).is_ok_and(|v|v.trim()==identity)}))
|
||||
}
|
||||
|
||||
fn run(mut command: Command, log: &Path, cancel: &Cancellation) -> Result<()> {
|
||||
let output = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(log)?;
|
||||
let errors = output.try_clone()?;
|
||||
let mut child = command
|
||||
.stdout(Stdio::from(output))
|
||||
.stderr(Stdio::from(errors))
|
||||
.spawn()?;
|
||||
loop {
|
||||
if let Err(error) = cancel.check() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(status) = child.try_wait()? {
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let detail = fs::read_to_string(log).unwrap_or_default();
|
||||
let tail = detail
|
||||
.chars()
|
||||
.rev()
|
||||
.take(3000)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
anyhow::bail!("Quest conversion failed ({}): {}", status, tail.trim());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace(storage: &Path, runtime: &Path) -> Result<PathBuf> {
|
||||
let workspace = storage.join("q2f/workspace");
|
||||
fs::create_dir_all(workspace.join("signatures"))?;
|
||||
fs::set_permissions(
|
||||
workspace.join("signatures"),
|
||||
fs::Permissions::from_mode(0o700),
|
||||
)?;
|
||||
let libraries = workspace.join("libraries");
|
||||
if libraries.is_symlink() || libraries.exists() {
|
||||
if !libraries.is_symlink() {
|
||||
anyhow::bail!("Unexpected q2f libraries path");
|
||||
}
|
||||
fs::remove_file(&libraries)?;
|
||||
}
|
||||
let identity = runtime
|
||||
.file_name()
|
||||
.context("Missing q2f runtime identity")?;
|
||||
// Keep this relative so APK storage migration preserves the link.
|
||||
symlink(
|
||||
Path::new("../runtimes").join(identity).join("libraries"),
|
||||
&libraries,
|
||||
)?;
|
||||
fs::copy(
|
||||
runtime.join("overport-installed.json"),
|
||||
workspace.join("installed.json"),
|
||||
)?;
|
||||
Ok(workspace)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_library_link_survives_apk_storage_move() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let old = temp.path().join("old");
|
||||
let runtime = old.join("q2f/runtimes/test-id");
|
||||
fs::create_dir_all(runtime.join("libraries")).unwrap();
|
||||
fs::write(runtime.join("overport-installed.json"), "{}").unwrap();
|
||||
fs::write(runtime.join("libraries/probe"), "ok").unwrap();
|
||||
let work = workspace(&old, &runtime).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(work.join("libraries/probe")).unwrap(),
|
||||
"ok"
|
||||
);
|
||||
let new = temp.path().join("new");
|
||||
fs::rename(old, &new).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(new.join("q2f/workspace/libraries/probe")).unwrap(),
|
||||
"ok"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn convert(
|
||||
home: &Path,
|
||||
ticket: &str,
|
||||
cancel: &Cancellation,
|
||||
progress: impl Fn(Value) -> Result<()>,
|
||||
) -> Result<Value> {
|
||||
let _guard = MUTATION.lock().unwrap();
|
||||
cancel.check()?;
|
||||
let storage = init(home)?;
|
||||
let (source, before) = review(home, ticket)?;
|
||||
let source_apk = source.join("base.apk");
|
||||
let original_bytes = fs::metadata(&source_apk)?.len();
|
||||
// The observed OVR Port peak was roughly four source APK sizes, plus the
|
||||
// original review. Leave a fixed reserve for unrelated Android writes.
|
||||
space(
|
||||
&storage,
|
||||
original_bytes
|
||||
.saturating_mul(5)
|
||||
.saturating_add(1024 * 1024 * 1024),
|
||||
)?;
|
||||
let descriptor = requirement_path()?;
|
||||
ensure!(
|
||||
descriptor.is_file(),
|
||||
"This Framely build has no q2f runtime requirement"
|
||||
);
|
||||
let requirement: Value = serde_json::from_slice(&fs::read(&descriptor)?)?;
|
||||
let version = requirement["librariesVersion"]
|
||||
.as_str()
|
||||
.context("Invalid q2f runtime version")?;
|
||||
ensure!(
|
||||
version
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)),
|
||||
"Invalid q2f runtime version"
|
||||
);
|
||||
progress(json!({"phase":"q2f-preparing","operation":"convert"}))?;
|
||||
let log = storage.join("logs").join(format!("q2f-{ticket}.log"));
|
||||
let mut prepare = Command::new("python3");
|
||||
prepare.arg(tool_path()?).arg(&descriptor).arg(&storage);
|
||||
run(prepare, &log, cancel)?;
|
||||
let runtime = PathBuf::from(
|
||||
fs::read_to_string(&log)?
|
||||
.lines()
|
||||
.last()
|
||||
.context("q2f runtime path missing")?,
|
||||
);
|
||||
ensure!(
|
||||
runtime.is_dir() && runtime.starts_with(storage.join("q2f/runtimes")),
|
||||
"Invalid q2f runtime path"
|
||||
);
|
||||
let work = workspace(&storage, &runtime)?;
|
||||
let output = source.join("converted");
|
||||
fs::create_dir_all(&output)?;
|
||||
progress(json!({"phase":"q2f-converting","operation":"convert","cancellable":true}))?;
|
||||
let mut java = Command::new(runtime.join("java/bin/java"));
|
||||
java.arg("-Xmx3g")
|
||||
.arg("-jar")
|
||||
.arg(runtime.join("overportcli.jar"))
|
||||
.arg("patch")
|
||||
.arg(format!("--input={}", source_apk.display()))
|
||||
.arg(format!("--output={}", output.display()))
|
||||
.arg("--output-name=base.apk")
|
||||
.arg(format!("--workspace={}", work.display()))
|
||||
.arg(format!("--version={version}"));
|
||||
let conversion = run(java, &log, cancel);
|
||||
let _ = fs::remove_dir_all(work.join("dec"));
|
||||
if let Err(error) = conversion {
|
||||
let _ = fs::remove_dir_all(&output);
|
||||
return Err(error);
|
||||
}
|
||||
let converted = output.join("base.apk");
|
||||
let result = (|| -> Result<Value> {
|
||||
ensure!(converted.is_file(), "Quest converter produced no APK");
|
||||
progress(json!({"phase":"q2f-verifying","operation":"convert"}))?;
|
||||
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"
|
||||
);
|
||||
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 _ = fs::remove_dir_all(&output);
|
||||
let next = result?;
|
||||
let _ = super::reviews_drop(home, ticket);
|
||||
Ok(
|
||||
json!({"ticket":next["ticket"],"metadata":next["metadata"],"bytes":next["bytes"],
|
||||
"q2fVersion":requirement["id"],"originalBytes":original_bytes}),
|
||||
)
|
||||
}
|
||||
@@ -1077,6 +1077,16 @@ impl Agent {
|
||||
&crate::steam::home()?,
|
||||
p["ticket"].as_str().context("Missing APK review")?,
|
||||
),
|
||||
"apk.q2f.status" => crate::apk::q2f_status(&crate::steam::home()?),
|
||||
"apk.q2f.start" => {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 操作或存储迁移完成");
|
||||
let home = crate::steam::home()?;
|
||||
let ticket = p["ticket"].as_str().context("Missing APK review")?.to_owned();
|
||||
self.jobs.task_progress("apk:operation", move |cancel, progress| {
|
||||
crate::apk::q2f_convert(&home, &ticket, &cancel, |value| { progress(value); Ok(()) })
|
||||
})
|
||||
}
|
||||
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
|
||||
"apk.storage.status" => crate::apk::storage_status(&crate::steam::home()?),
|
||||
"apk.permission.pending" => Ok(json!(crate::apk::permissions::pending(&crate::steam::home()?)?)),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'tools/q2f-runtime.py'
|
||||
SPEC = importlib.util.spec_from_file_location('q2f_runtime', SCRIPT)
|
||||
RUNTIME = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(RUNTIME)
|
||||
|
||||
|
||||
class Q2FRuntimeTests(unittest.TestCase):
|
||||
def test_exact_cache_is_reused_and_tampering_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = pathlib.Path(directory)
|
||||
identity = 'overport-test-123456789abc'
|
||||
payload = base / 'payload'
|
||||
(payload / 'java/bin').mkdir(parents=True)
|
||||
for name in ('java/bin/java', 'overportcli.jar', 'overport-installed.json', 'OVR_PORT_LICENSE'):
|
||||
(payload / name).write_text(name)
|
||||
files = sorted(p for p in payload.rglob('*') if p.is_file())
|
||||
sums = ''.join(f'{RUNTIME.digest(p)} {p.relative_to(payload)}\n' for p in files)
|
||||
(payload / 'VERSION').write_text(identity + '\n')
|
||||
(payload / 'SHA256SUMS').write_text(sums)
|
||||
archive = base / f'framely-q2f-{identity}-linux-arm64.tar.gz'
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
tar.add(payload, arcname=f'framely-q2f-{identity}')
|
||||
descriptor = base / 'requirement.json'
|
||||
descriptor.write_text(json.dumps({'schemaVersion': 1, 'id': identity,
|
||||
'archive': archive.name, 'size': archive.stat().st_size,
|
||||
'sha256': RUNTIME.digest(archive), 'url': 'https://example.invalid/q2f'}))
|
||||
copies = []
|
||||
def copy(_requirement, destination):
|
||||
destination.write_bytes(archive.read_bytes())
|
||||
copies.append(destination)
|
||||
with patch.object(RUNTIME, 'download', copy):
|
||||
cached = RUNTIME.prepare(descriptor, base / 'storage')
|
||||
self.assertEqual(cached, RUNTIME.prepare(descriptor, base / 'storage'))
|
||||
self.assertEqual(len(copies), 1)
|
||||
(cached / 'overportcli.jar').write_text('changed')
|
||||
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
|
||||
RUNTIME.prepare(descriptor, base / 'storage')
|
||||
|
||||
def test_archive_rejects_link_and_escape(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = pathlib.Path(directory)
|
||||
identity = 'overport-test-123456789abc'
|
||||
archive = base / 'unsafe.tar.gz'
|
||||
for index, member in enumerate((f'framely-q2f-{identity}/../escape', f'framely-q2f-{identity}/link')):
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
info = tarfile.TarInfo(member)
|
||||
info.type = tarfile.SYMTYPE if member.endswith('/link') else tarfile.REGTYPE
|
||||
info.size = 0
|
||||
tar.addfile(info)
|
||||
with self.assertRaisesRegex(ValueError, 'Unsafe'):
|
||||
RUNTIME.extract(archive, base / f'target-{index}', identity)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build an independently downloadable Quest conversion runtime."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
CLI_VERSION = '1.2.3'
|
||||
LIBRARIES_VERSION = '3.4.3-23204ea'
|
||||
CLI_SHA256 = '307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2'
|
||||
LIBRARIES_SHA256 = '6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e'
|
||||
SOURCE_SHA256 = '2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3'
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as source:
|
||||
return hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def safe_zip_extract(source, destination):
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
for entry in archive.infolist():
|
||||
path = pathlib.PurePosixPath(entry.filename)
|
||||
if (path.is_absolute() or '..' in path.parts or '\\' in entry.filename
|
||||
or (entry.external_attr >> 16) & 0o170000 == 0o120000):
|
||||
raise ValueError('Unsafe OVR Port library entry')
|
||||
target = destination.joinpath(*path.parts)
|
||||
if entry.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(entry) as data, target.open('xb') as output:
|
||||
shutil.copyfileobj(data, output)
|
||||
|
||||
|
||||
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:
|
||||
raise ValueError('Pinned OVR Port artifact checksum mismatch')
|
||||
if not (java / 'bin/java').is_file() or not (java / 'legal/java.base/LICENSE').is_file():
|
||||
raise ValueError('Incomplete Java runtime')
|
||||
output = pathlib.Path(args.output)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='q2f-package-', dir=output) as scratch:
|
||||
payload = pathlib.Path(scratch) / 'payload'
|
||||
shutil.copytree(java, payload / 'java', symlinks=False)
|
||||
shutil.copy2(cli, payload / 'overportcli.jar')
|
||||
safe_zip_extract(libraries, payload / 'libraries' / LIBRARIES_VERSION)
|
||||
(payload / 'overport-installed.json').write_text(json.dumps({'releases': [
|
||||
{'version': LIBRARIES_VERSION, 'app_required': '1.2.0', 'is_experimental': True}
|
||||
]}) + '\n')
|
||||
shutil.copy2(args.license, payload / 'OVR_PORT_LICENSE')
|
||||
shutil.copy2(source, payload / 'OVR_PORT_SOURCE.tar.gz')
|
||||
(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]}'
|
||||
(payload / 'VERSION').write_text(identity + '\n')
|
||||
(payload / 'SHA256SUMS').write_text(checksums)
|
||||
archive = output / f'framely-q2f-{identity}-linux-arm64.tar.gz'
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
tar.add(payload, arcname=f'framely-q2f-{identity}')
|
||||
requirement = {'schemaVersion': 1, 'id': identity,
|
||||
'cliVersion': CLI_VERSION, 'librariesVersion': LIBRARIES_VERSION,
|
||||
'archive': archive.name, 'size': archive.stat().st_size,
|
||||
'sha256': digest(archive),
|
||||
'url': f'https://github.com/{args.repo}/releases/download/{args.tag}/{archive.name}'}
|
||||
pathlib.Path(args.stage, 'Q2F_RUNTIME.json').write_text(json.dumps(requirement, indent=2) + '\n')
|
||||
pathlib.Path(output, 'framely-q2f.json').write_text(json.dumps(requirement, indent=2) + '\n')
|
||||
print(archive)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
for name in ('stage', 'output', 'java-home', 'cli-jar', 'libraries-zip', 'license', 'source-archive', 'repo', 'tag'):
|
||||
parser.add_argument('--' + name, required=True)
|
||||
package(parser.parse_args())
|
||||
@@ -19,7 +19,12 @@ else
|
||||
fi
|
||||
binary=${CARGO_TARGET_DIR:-target}/release/framely
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
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 -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
q2f_java=${Q2F_JAVA_HOME:-target/quest-conversion-jre}
|
||||
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)"
|
||||
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}
|
||||
@@ -74,10 +79,15 @@ cp -a assets/branding "$stage/tools/source/assets/"
|
||||
cp -a examples/showcase "$stage/tools/source/examples/"
|
||||
cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/"
|
||||
cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py tools/export-diagnostics.py "$stage/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py tools/q2f-runtime.py tools/export-diagnostics.py "$stage/tools/"
|
||||
find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} +
|
||||
cp -a docs "$stage/share/"
|
||||
printf '%s\n' "$build_id" > "$stage/VERSION"
|
||||
chmod 755 "$stage"/*.sh "$stage/bin/"* "$stage/lib/cef/framely-vr"
|
||||
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" \
|
||||
--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"
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download and verify the exact optional Quest conversion runtime."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
MAX_ARCHIVE = 512 * 1024 * 1024
|
||||
MAX_EXPANDED = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as source:
|
||||
return hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def validate(root, identity):
|
||||
if root.is_symlink() or not root.is_dir() or (root / 'VERSION').read_text().strip() != identity:
|
||||
raise ValueError('Invalid q2f runtime cache')
|
||||
lines = (root / 'SHA256SUMS').read_text().splitlines()
|
||||
expected = set()
|
||||
for line in lines:
|
||||
match = re.fullmatch(r'([a-f0-9]{64}) ([^\\]+)', line)
|
||||
if not match:
|
||||
raise ValueError('Invalid q2f checksum list')
|
||||
relative = pathlib.PurePosixPath(match[2])
|
||||
if relative.is_absolute() or '..' in relative.parts or not relative.parts:
|
||||
raise ValueError('Invalid q2f payload path')
|
||||
file = root.joinpath(*relative.parts)
|
||||
if file.is_symlink() or not file.is_file() or digest(file) != match[1]:
|
||||
raise ValueError('q2f payload checksum mismatch: ' + match[2])
|
||||
expected.add(relative.as_posix())
|
||||
actual = {p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file()}
|
||||
if actual != expected | {'VERSION', 'SHA256SUMS'}:
|
||||
raise ValueError('Unexpected q2f payload files')
|
||||
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)
|
||||
|
||||
|
||||
class HTTPSRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urllib.parse.urlparse(newurl).scheme != 'https':
|
||||
raise ValueError('HTTPS redirect downgrade rejected')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def download(requirement, destination):
|
||||
url = requirement['url']
|
||||
if urllib.parse.urlparse(url).scheme != 'https':
|
||||
raise ValueError('q2f URL must use HTTPS')
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'Framely-q2f'})
|
||||
with urllib.request.build_opener(HTTPSRedirect()).open(request, timeout=90) as response, destination.open('xb') as output:
|
||||
total = 0
|
||||
while block := response.read(256 * 1024):
|
||||
total += len(block)
|
||||
if total > requirement['size'] or total > MAX_ARCHIVE:
|
||||
raise ValueError('q2f download exceeds declared size')
|
||||
output.write(block)
|
||||
if total != requirement['size'] or digest(destination) != requirement['sha256']:
|
||||
raise ValueError('q2f archive checksum mismatch')
|
||||
|
||||
|
||||
def extract(archive, target, identity):
|
||||
prefix = f'framely-q2f-{identity}'
|
||||
target.mkdir(mode=0o700)
|
||||
with tarfile.open(archive, 'r:gz') as source:
|
||||
members = source.getmembers()
|
||||
if len(members) > 10000 or sum(m.size for m in members) > MAX_EXPANDED:
|
||||
raise ValueError('q2f archive is too large')
|
||||
seen = set()
|
||||
for member in members:
|
||||
path = pathlib.PurePosixPath(member.name)
|
||||
if (path.is_absolute() or '..' in path.parts or '\\' in member.name
|
||||
or not path.parts or path.parts[0] != prefix
|
||||
or member.name in seen or not (member.isfile() or member.isdir())):
|
||||
raise ValueError('Unsafe q2f archive member')
|
||||
seen.add(member.name)
|
||||
for member in members:
|
||||
parts = pathlib.PurePosixPath(member.name).parts[1:]
|
||||
if not parts:
|
||||
continue
|
||||
path = target.joinpath(*parts)
|
||||
if member.isdir():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source.extractfile(member) as data, path.open('xb') as output:
|
||||
shutil.copyfileobj(data, output)
|
||||
path.chmod(0o755 if member.mode & 0o111 else 0o644)
|
||||
validate(target, identity)
|
||||
|
||||
|
||||
def prepare(descriptor, storage):
|
||||
spec = json.loads(pathlib.Path(descriptor).read_text())
|
||||
identity = spec['id']
|
||||
archive_name = f'framely-q2f-{identity}-linux-arm64.tar.gz'
|
||||
if (spec['schemaVersion'] != 1 or not re.fullmatch(r'[a-zA-Z0-9.+-]+', identity)
|
||||
or '..' in identity or spec['archive'] != archive_name
|
||||
or not 0 < spec['size'] <= MAX_ARCHIVE
|
||||
or not re.fullmatch(r'[a-f0-9]{64}', spec['sha256'])):
|
||||
raise ValueError('Invalid q2f requirement')
|
||||
parent = pathlib.Path(storage) / 'q2f' / 'runtimes'
|
||||
if parent.is_symlink():
|
||||
raise ValueError('q2f storage must not be a symlink')
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
cached = parent / identity
|
||||
if cached.exists() or cached.is_symlink():
|
||||
validate(cached, identity)
|
||||
return cached
|
||||
with tempfile.TemporaryDirectory(prefix='.download-', dir=parent) as scratch:
|
||||
scratch = pathlib.Path(scratch)
|
||||
archive = scratch / archive_name
|
||||
download(spec, archive)
|
||||
payload = scratch / 'payload'
|
||||
extract(archive, payload, identity)
|
||||
payload.rename(cached)
|
||||
return cached
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
if len(sys.argv) != 3:
|
||||
raise ValueError('Usage: q2f-runtime.py DESCRIPTOR APK_STORAGE')
|
||||
print(prepare(*sys.argv[1:]), flush=True)
|
||||
except (KeyError, ValueError, OSError, tarfile.TarError) as error:
|
||||
print('Framely q2f: ' + str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
+13
-1
@@ -1285,6 +1285,18 @@
|
||||
"分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。": "Set each app’s name in Steam. Leave blank to use its original name. Names survive APK updates and sync when Steam is ready.",
|
||||
"Invalid Steam display name": "Invalid Steam display name",
|
||||
"Steam display name must be at most 128 characters without control characters": "Steam display name must be at most 128 characters without control characters",
|
||||
"Steam name update was not acknowledged": "Steam name update was not acknowledged"
|
||||
"Steam name update was not acknowledged": "Steam name update was not acknowledged",
|
||||
"Quest 兼容转换": "Quest compatibility conversion",
|
||||
"此 APK 已完成兼容转换并重新签名。": "This APK has been converted and re-signed.",
|
||||
"可选:使用独立 q2f 工具包转换并重新签名 APK。首次使用会下载工具包,转换结果仍需在 Frame 上验证。": "Optional: convert and re-sign this APK with the separate q2f package. The package downloads on first use; the result still needs testing on Frame.",
|
||||
"已转换": "Converted",
|
||||
"转换此 APK": "Convert this APK",
|
||||
"准备 Quest 转换工具": "Preparing Quest converter",
|
||||
"正在转换 APK": "Converting APK",
|
||||
"校验转换结果": "Verifying converted APK",
|
||||
"转换完成,请确认安装。": "Conversion complete. Review the APK before installing.",
|
||||
"正在检查转换工具": "Checking converter",
|
||||
"工具包已缓存": "Converter cached",
|
||||
"首次使用需下载 {0} 工具包": "First use downloads the {0} converter"
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -1285,6 +1285,18 @@
|
||||
"分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。": "分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。",
|
||||
"Invalid Steam display name": "无效的 Steam 显示名称",
|
||||
"Steam display name must be at most 128 characters without control characters": "Steam 显示名称最多 128 个字符,不能包含控制字符",
|
||||
"Steam name update was not acknowledged": "Steam 未确认名称更新"
|
||||
"Steam name update was not acknowledged": "Steam 未确认名称更新",
|
||||
"Quest 兼容转换": "Quest 兼容转换",
|
||||
"此 APK 已完成兼容转换并重新签名。": "此 APK 已完成兼容转换并重新签名。",
|
||||
"可选:使用独立 q2f 工具包转换并重新签名 APK。首次使用会下载工具包,转换结果仍需在 Frame 上验证。": "可选:使用独立 q2f 工具包转换并重新签名 APK。首次使用会下载工具包,转换结果仍需在 Frame 上验证。",
|
||||
"已转换": "已转换",
|
||||
"转换此 APK": "转换此 APK",
|
||||
"准备 Quest 转换工具": "准备 Quest 转换工具",
|
||||
"正在转换 APK": "正在转换 APK",
|
||||
"校验转换结果": "校验转换结果",
|
||||
"转换完成,请确认安装。": "转换完成,请确认安装。",
|
||||
"正在检查转换工具": "正在检查转换工具",
|
||||
"工具包已缓存": "工具包已缓存",
|
||||
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包"
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import {Select} from './localized-select';
|
||||
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;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onConfirm:()=>void};
|
||||
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,onDisplayMode,onStop,onCancel,onConfirm}:Props){
|
||||
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;onConfirm:()=>void};
|
||||
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,converted,q2f,q2fError,onDisplayMode,onStop,onCancel,onConvert,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-quest-option"><div><b>{t('Quest 兼容转换')}</b><small>{t(converted?'此 APK 已完成兼容转换并重新签名。':'可选:使用独立 q2f 工具包转换并重新签名 APK。首次使用会下载工具包,转换结果仍需在 Frame 上验证。')}</small>{!converted&&<small>{q2fError?q2fError:!q2f?t('正在检查转换工具'):q2f.cached?t('工具包已缓存'):t('首次使用需下载 {0} 工具包',{0:q2f.size})}</small>}</div>{converted?<span className="badge accent">{t('已转换')}</span>:<button disabled={busy||!q2f||!!q2fError} onClick={onConvert}>{t('转换此 APK')}</button>}</section>
|
||||
<section className="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></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>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
.apk-uninstall>h2{margin-bottom:8px}.apk-uninstall-intro{font-size:17px;line-height:1.6;color:var(--muted);margin-bottom:24px}.apk-uninstall-app{display:flex;align-items:center;gap:16px;margin:0 0 24px}.apk-uninstall-app>div{min-width:0}.apk-uninstall-app h3{margin:0 0 5px;font-size:23px}.apk-uninstall-app code{display:block;background:none;padding:0;font-size:15px;color:var(--muted);overflow-wrap:anywhere}.apk-uninstall-app small{display:block;margin-top:6px;font-size:15px}.apk-uninstall-options{border-top:1px solid var(--line);padding:8px 0}.apk-uninstall-options .switch-row{padding:16px 0;gap:20px}.apk-uninstall-options .switch-row small{font-size:16px;line-height:1.55}.apk-uninstall-result{display:flex;align-items:flex-start;gap:10px;padding:14px 16px;background:var(--control-hover);border-radius:5px;font-size:17px;line-height:1.55}.apk-uninstall-result p{margin:0}.apk-uninstall-result svg{margin-top:2px}.apk-uninstall-result small{display:block;font-size:15px;margin-top:4px}.apk-uninstall-result.destructive{color:#efa9a8}.apk-uninstall-result.destructive small{color:inherit}.apk-uninstall footer button{white-space:nowrap}.apk-uninstall footer .danger{font-weight:600;background:#362627;border-color:#895b5b}.apk-uninstall footer .danger:hover:not(:disabled){background:#493032}@media(max-width:700px){.apk-uninstall-app{align-items:flex-start}.apk-uninstall footer{display:grid;grid-template-columns:1fr}.apk-uninstall footer .danger{grid-row:1}.apk-uninstall-options .switch-row{gap:12px}}
|
||||
|
||||
.apk-install-review>h2{margin-bottom:8px}.apk-install-intro{font-size:17px;line-height:1.6;color:var(--muted);margin-bottom:24px}.apk-install-app{display:flex;align-items:center;gap:16px;margin-bottom:24px}.apk-install-app>div{min-width:0}.apk-install-app h3{font-size:23px;margin:0 0 5px}.apk-install-app code,.apk-install-location code{background:none;padding:0;font-size:15px;color:var(--muted)}.apk-install-app p{margin:6px 0 0;font-size:16px;color:var(--muted)}.apk-install-app p span{margin:0 6px}.apk-install-location{display:flex;gap:12px;padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.apk-install-location svg{flex:none;margin-top:3px;color:var(--muted)}.apk-install-location b,.apk-install-display b{font-size:19px;font-weight:500}.apk-install-location small,.apk-install-display small{display:block;font-size:16px;line-height:1.55;color:var(--muted);margin-top:5px}.apk-install-location code{margin-top:6px;overflow-wrap:anywhere}.apk-install-display{display:flex;gap:20px;align-items:center;padding:22px 0}.apk-install-display>div:first-child{flex:1;min-width:0}.apk-install-display>.framely-select{flex:0 0 200px}.apk-install-technical{font-size:17px;color:var(--muted)}.apk-install-technical summary{cursor:pointer;padding:8px 0}.apk-install-technical .apk-meta{font-size:16px;margin:16px 0}.apk-install-technical dd{color:var(--text)}.apk-install-update-note{display:flex;gap:10px;margin-top:20px;font-size:16px;line-height:1.55;color:var(--muted)}.apk-install-update-note svg{flex:none;margin-top:3px}.apk-install-update-note p{margin:0}.apk-install-update-note small{display:block;font-size:15px;margin-top:6px}.apk-install-review footer button{white-space:nowrap}@media(max-width:700px){.apk-install-app{align-items:flex-start}.apk-install-display{flex-wrap:wrap;gap:12px}.apk-install-display>.framely-select{flex:1 1 100%}.apk-install-review footer{display:grid;grid-template-columns:1fr}.apk-install-review footer .primary{grid-row:1}}
|
||||
.apk-quest-option{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 0;border-top:1px solid var(--line)}.apk-quest-option>div{flex:1;min-width:0}.apk-quest-option b{display:block;font-size:19px;font-weight:500}.apk-quest-option small{display:block;margin-top:5px;color:var(--muted);font-size:15px;line-height:1.5}.apk-quest-option button{flex:none;margin:0}.apk-quest-option .badge{white-space:nowrap}@media(max-width:700px){.apk-quest-option{align-items:flex-start;flex-wrap:wrap}.apk-quest-option button{width:100%}}
|
||||
|
||||
.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%}}
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;s
|
||||
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};
|
||||
type Review={ticket:string;metadata:Metadata;bytes:number};
|
||||
type Review={ticket:string;metadata:Metadata;bytes:number;q2fVersion?:string};
|
||||
type Q2fStatus={id:string;bytes:number;cached:boolean};
|
||||
type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean;deleteContainer?:boolean};
|
||||
function bytes(n:number){return n<1024?`${n} B`:n<1024*1024?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(2)} GiB`;}
|
||||
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',launching:'启动应用',started:'应用已启动,正在检查',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用',done:'完成',failed:'失败',cancelled:'操作已取消'};
|
||||
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',launching:'启动应用',started:'应用已启动,正在检查',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用','q2f-preparing':'准备 Quest 转换工具','q2f-converting':'正在转换 APK','q2f-verifying':'校验转换结果',done:'完成',failed:'失败',cancelled:'操作已取消'};
|
||||
const operationLabels:Record<string,string>={'steam.settings':'保存 Steam 启动设置','steam.retention.settings':'保存 Steam 入口设置','lifecycle.settings':'保存容器退出设置','gamepad.settings':'保存手柄设置',install:'安装 APK',launch:'启动应用',close:'关闭应用',uninstall:'卸载应用',purge:'清除保留数据',clear:'清除应用数据',reconcile:'核对应用状态',settings:'保存设置','container.start':'启动容器','container.stop':'停止容器','container.delete':'删除容器',restore:'恢复备份',cleanup:'数据清理','root.add':'添加数据位置','root.remove':'移除数据位置'};
|
||||
function SteamNameSetting({app,busy,onSave}:{app:App;busy:boolean;onSave:(name:string)=>void}){
|
||||
const [name,setName]=useState(app.steamName??'');
|
||||
@@ -31,12 +32,13 @@ 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),[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),[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');if(!active.job||!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:active.job});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(value.phase==='failed')setError(t(value.error??'操作失败'));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}|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);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);}}}
|
||||
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();};},[]);
|
||||
@@ -44,6 +46,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
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(()=>{});setReview(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}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');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 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;}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'){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;}}
|
||||
@@ -71,7 +74,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
{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} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onConfirm={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}/>,t('安装 APK'))}
|
||||
{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()} onConfirm={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}/>,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