Add opt-in per-app framebuffer compatibility fix

This commit is contained in:
toorux committed 2026-10-07 18:07:23 +08:00
1 parent be36694719
commit 9c7dc06bad
13 files changed
+378 -7

No files matched your search

+4
View File
@@ -176,3 +176,7 @@ Container list headings show installed application names; the data path identifi
Keeping application data uses Android PackageManager readiness instead of requiring user unlock or external storage mounting. Deleting data still waits for those conditions. Framely boots a stopped container without showing its desktop and repairs only the selected APK registration if needed. It reports startup, PackageManager, unlock, storage and container-exit failures separately. Steam entries are removed after Android confirms successful uninstall; Android success is saved first so a Steam cleanup retry does not uninstall again. Failed startup or uninstall preserves the application record, Steam binding and application data. The error dialog offers Retry and Export logs. A container that cannot boot still requires diagnosis; Framely does not silently delete it or edit Android package databases.
L2/R2 digital button activation is adjustable in **APK Manager → Settings**, from 1% to 100%, with an 80% default. Both triggers use this threshold with either Steam Input or Frame direct input. Analog trigger travel is unaffected. Saving applies to running bridges immediately; release occurs five percentage points below the selected threshold (at 0% for thresholds below 5%).
## Per-app graphics compatibility
Under **Manage app → Advanced settings**, **Framebuffer compatibility fix** is off by default. Enable it only for games that play sound but show a black screen (tested with Rock Kingdom on the current Mesa/Zink driver). Restart the app after changing it. The GLES layer validates framebuffer status after texture attachment, only in the selected application; other apps, system drivers, game files and saved data remain unchanged. Turning it off restores the normal rendering path on the next app launch. It is a targeted workaround, not a general graphics or performance enhancement.
+4
View File
@@ -150,3 +150,7 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
在“APK 管理 → 设置”中可调节 L2/R2 按键触发行程,范围 1%~100%,默认 80%。两个扳机共用该设置,Steam Input 和 Frame 直连均适用,不改变模拟轴的完整行程。保存后立即应用到正在运行的转发进程;松开阈值比按下低 5 个百分点,设置低于 5% 时在归零后松开。
## 单应用图形兼容
在**管理应用 → 高级设置**中,“帧缓冲兼容修复”默认关闭。仅为有声音但黑屏的游戏开启(已在当前 Mesa/Zink 驱动下验证洛克王国),修改后需重启应用。GLES 修复层只在所选应用中,于绑定纹理后检查帧缓冲状态,不改变其他应用、系统驱动、游戏文件或存档。关闭后,下次启动恢复正常渲染路径。这是针对特定问题的兼容措施,并非通用画质或性能优化。
+23
View File
@@ -0,0 +1,23 @@
# Always mount the shipped layer if available, but activate it only for the
# explicitly selected package. No Android/system/Lepton files are replaced.
if [[ -n "${FRAMELY_FBO_LIBRARY:-}" ]]; then
declare -F setup_podman_mounts >/dev/null || { echo 'Unsupported Lepton graphics hooks' >&2; exit 64; }
eval "$(declare -f setup_podman_mounts | sed '1s/setup_podman_mounts/framely_original_fbo_mounts/')"
function setup_podman_mounts() {
framely_original_fbo_mounts "$@" || return
podman_mount_entry "$FRAMELY_FBO_LIBRARY" /data/local/debug/gles/libFramelyFBOCompat.so ro
}
fi
if [[ -n "${FRAMELY_FBO_LIBRARY:-}" ]]; then
[[ -z "${FRAMELY_FBO_PACKAGE:-}" || "$FRAMELY_FBO_PACKAGE" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ && -n "${FRAMELY_FBO_LIBRARY:-}" ]] || { echo 'Invalid framebuffer compatibility configuration' >&2; exit 64; }
declare -F generate_vulkan_settings_script >/dev/null || { echo 'Unsupported Lepton graphics settings hook' >&2; exit 64; }
eval "$(declare -f generate_vulkan_settings_script | sed '1s/generate_vulkan_settings_script/framely_original_fbo_settings/')"
function generate_vulkan_settings_script() {
# Lepton does not clear stale GLES settings when its GLES list is empty.
# Remove our previous selection before its own settings are generated.
printf '%s\n' 'if [ "$(settings get global gpu_debug_layers_gles)" = libFramelyFBOCompat.so ]; then settings delete global gpu_debug_layers_gles; settings delete global gpu_debug_layer_app; fi;'
framely_original_fbo_settings "$@" || return
[[ -n "${FRAMELY_FBO_PACKAGE:-}" ]] || return 0
printf '%s\n' 'settings put global enable_gpu_debug_layers 1;' "settings put global gpu_debug_app $FRAMELY_FBO_PACKAGE;" 'settings put global gpu_debug_layers_gles libFramelyFBOCompat.so;' "settings put global gpu_debug_layer_app $FRAMELY_FBO_PACKAGE;"
}
fi
+32
View File
@@ -0,0 +1,32 @@
// Android GLES layer: opt-in workaround for missing framebuffer attachments.
// No libc/GL dependency: entry points come from Android's layer loader.
typedef void (*Attach)(unsigned, unsigned, unsigned, unsigned, int);
typedef unsigned (*Check)(unsigned);
static Attach attach;
static Check check;
static int same(const char *a, const char *b) {
while (*a && *a == *b) { ++a; ++b; }
return *a == *b;
}
static void validate_attachment(unsigned target, unsigned attachment,
unsigned texture_target, unsigned texture, int level) {
Attach next = __atomic_load_n(&attach, __ATOMIC_RELAXED);
Check validate = __atomic_load_n(&check, __ATOMIC_RELAXED);
next(target, attachment, texture_target, texture, level);
// Force driver attachment validation before the next draw. Keep all GL
// arguments, shader code and render commands unchanged.
if (validate) validate(target);
}
__attribute__((visibility("default")))
void AndroidGLESLayer_Initialize(void *id, void *(*lookup)(void *, const char *)) {
__atomic_store_n(&attach, (Attach)lookup(id, "glFramebufferTexture2D"), __ATOMIC_RELAXED);
__atomic_store_n(&check, (Check)lookup(id, "glCheckFramebufferStatus"), __ATOMIC_RELAXED);
}
__attribute__((visibility("default")))
void *AndroidGLESLayer_GetProcAddress(const char *name, void *next) {
if (next && __atomic_load_n(&check, __ATOMIC_RELAXED) && same(name, "glFramebufferTexture2D")) {
__atomic_store_n(&attach, (Attach)next, __ATOMIC_RELAXED);
return (void *)validate_attachment;
}
return next;
}
+60 -2
View File
@@ -21,6 +21,7 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
mod cleanup;
mod graphics;
mod lifecycle;
mod native;
mod steam_bridge;
@@ -75,6 +76,8 @@ struct Record {
show_window: Option<bool>,
#[serde(default)]
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
}
#[derive(Serialize, Deserialize)]
struct Database {
@@ -144,6 +147,8 @@ struct App {
activity: Option<String>,
show_window: Option<bool>,
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
}
fn gamepad_trigger_threshold_default() -> u8 {
80
@@ -785,6 +790,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
activity,
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),
});
}
}
@@ -838,6 +844,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
activity: r.activity.clone(),
show_window: r.show_window,
orientation: r.orientation.clone(),
framebuffer_compatibility: r.framebuffer_compatibility,
});
}
}
@@ -882,6 +889,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
activity: None,
show_window: None,
orientation: None,
framebuffer_compatibility: false,
});
}
}
@@ -1320,7 +1328,14 @@ function setup_props() {
"#;
Ok(source
.replace(directory, "SCRIPT_DIR=\"${FRAMELY_LEPTON_DIR:?}\"")
.replace(include, &format!("{include}\n{}\n{hooks}", storage::HOOKS)))
.replace(
include,
&format!(
"{include}\n{}\n{hooks}\n{}",
storage::HOOKS,
graphics::HOOKS
),
))
}
fn start(home: &Path, c: &Container, show: Option<bool>, log: &Path) -> Result<()> {
start_container(home, c, show, true, log)
@@ -1579,6 +1594,7 @@ fn start_oriented_container_for(
let (window_dex, window_script) = window_runtime(home)?;
cmd.env("FRAMELY_WINDOW_DEX", window_dex)
.env("FRAMELY_WINDOW_SCRIPT", window_script);
graphics::configure_command(&mut cmd, false, "")?;
cmd.env_remove("SteamAppId");
storage::configure(
&mut cmd,
@@ -1734,6 +1750,7 @@ fn record_app(db: &mut Database, a: &App) {
activity: a.activity.clone(),
show_window: a.show_window,
orientation: a.orientation.clone(),
framebuffer_compatibility: a.framebuffer_compatibility,
..Default::default()
});
}
@@ -2064,6 +2081,7 @@ fn launch(c: &Container, a: &App, log: &Path) -> Result<()> {
}
fn launch_with_started(c: &Container, a: &App, log: &Path, started: impl FnOnce()) -> Result<()> {
let component = launch_component(a)?;
graphics::apply(c, a, log)?;
podman(
&[
"exec",
@@ -2821,6 +2839,11 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
ensure!(v.is_boolean() || v.is_null(), "Invalid window setting");
rec.show_window = v.as_bool();
}
if let Some(v) = p.get("framebufferCompatibility") {
rec.framebuffer_compatibility = v
.as_bool()
.context("Invalid framebuffer compatibility setting")?;
}
if let Some(v) = p.get("orientation") {
ensure!(
v.as_str()
@@ -2900,6 +2923,12 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
launch_component(&a)?;
progress(json!({"phase":"starting"}));
let flat = a.show_window.unwrap_or(!a.metadata.vr);
if a.framebuffer_compatibility {
graphics::library(true)?;
if running(&c.name) && !graphics::mounted(&c, &log)? {
stop(&c, &log)?;
}
}
if flat && running(&c.name) {
let name = format!("lepton-{}", c.name);
let headless = podman(
@@ -4476,7 +4505,8 @@ setup_props
);
assert!(!fs::read_to_string(f.dir.path().join("commands"))
.unwrap()
.contains("stop "));
.lines()
.any(|line| line.starts_with("stop ")));
// Unknown lifecycle output refuses a destructive stop.
fs::write(f.dir.path().join("bad-probe"), "").unwrap();
assert!(steam_bridge::test_request(&f.home, id, "test-token", "stop").is_err());
@@ -5426,6 +5456,34 @@ esac
assert!(inspect(&f.home, &f.dir.path().join("upload.apk"), &c).is_err());
}
#[test]
fn framebuffer_preference_defaults_off_validates_and_survives_refresh() {
let f = Fixture::new();
list(&f.home).unwrap();
let id = "test/com.example.app";
assert!(!load(&f.home).unwrap().records[id].framebuffer_compatibility);
assert!(f
.operation(
"settings",
json!({"app":id,"framebufferCompatibility":"yes"})
)
.is_err());
f.operation(
"settings",
json!({"app":id,"framebufferCompatibility":true}),
)
.unwrap();
list(&f.home).unwrap();
let db = load(&f.home).unwrap();
assert!(db.records[id].framebuffer_compatibility);
assert!(app(&f.home, &db, id).unwrap().0.framebuffer_compatibility);
f.operation(
"settings",
json!({"app":id,"framebufferCompatibility":false}),
)
.unwrap();
assert!(!load(&f.home).unwrap().records[id].framebuffer_compatibility);
}
#[test]
fn window_orientation_is_validated_persisted_and_applied() {
let f = Fixture::new();
list(&f.home).unwrap();
+207
View File
@@ -0,0 +1,207 @@
//! Per-application GLES workaround. The library is inert unless Android's
//! debug-layer loader selects this exact package at process startup.
use super::*;
pub(super) const HOOKS: &str = include_str!("../../native/android/fbo-compat.sh");
const LAYER: &str = "libFramelyFBOCompat.so";
pub(super) fn library(required: bool) -> Result<Option<PathBuf>> {
let exe = std::env::current_exe()?;
let path = exe
.parent()
.and_then(Path::parent)
.context("Missing Framely runtime directory")?
.join("lib/openvr")
.join(LAYER);
ensure!(
!required || path.is_file(),
"Framebuffer compatibility runtime is missing; install a complete Framely build"
);
Ok(path.is_file().then_some(path))
}
pub(super) fn configure_command(cmd: &mut Command, enabled: bool, package: &str) -> Result<()> {
// Do not inherit a launch configuration from another application/process.
cmd.env_remove("FRAMELY_FBO_LIBRARY")
.env_remove("FRAMELY_FBO_PACKAGE");
if let Some(path) = library(enabled)? {
cmd.env("FRAMELY_FBO_LIBRARY", path);
}
if enabled {
ensure!(apk_metadata::valid_package(package), "Invalid APK package");
cmd.env("FRAMELY_FBO_PACKAGE", package);
}
Ok(())
}
pub(super) fn configure_environment(env: &mut Value, enabled: bool, package: &str) -> Result<()> {
// Empty values also prevent inherited Steam environment settings from
// enabling a layer for an application whose preference is off.
env["FRAMELY_FBO_LIBRARY"] = json!(library(enabled)?);
if env["FRAMELY_FBO_LIBRARY"].is_null() {
env["FRAMELY_FBO_LIBRARY"] = json!("");
}
ensure!(
!enabled || apk_metadata::valid_package(package),
"Invalid APK package"
);
env["FRAMELY_FBO_PACKAGE"] = json!(if enabled { package } else { "" });
Ok(())
}
pub(super) fn mounted(c: &Container, log: &Path) -> Result<bool> {
Ok(podman(
&[
"exec",
&format!("lepton-{}", c.name),
"sh",
"-c",
"if [ -r /data/local/debug/gles/libFramelyFBOCompat.so ]; then echo ready; fi",
],
Some(log),
)?
.trim()
== "ready")
}
pub(super) fn apply(c: &Container, a: &App, log: &Path) -> Result<()> {
ensure!(
apk_metadata::valid_package(&a.metadata.package),
"Invalid APK package"
);
// Reusing a container is safe: restart only the selected app if its loader
// configuration changed, and clear only our own layer when disabling it.
let script = r#"
package=$1
enabled=$2
layer=$(settings get global gpu_debug_layers_gles)
target=$(settings get global gpu_debug_app)
if [ "$enabled" = true ]; then
[ -r /data/local/debug/gles/libFramelyFBOCompat.so ] || exit 64
active=$(settings get global enable_gpu_debug_layers)
if [ "$layer" != libFramelyFBOCompat.so ] || [ "$target" != "$package" ] || [ "$active" != 1 ]; then
am force-stop --user 0 "$package" || exit
fi
settings put global enable_gpu_debug_layers 1 || exit
settings put global gpu_debug_app "$package" || exit
settings put global gpu_debug_layers_gles libFramelyFBOCompat.so || exit
settings put global gpu_debug_layer_app "$package" || exit
elif [ "$layer" = libFramelyFBOCompat.so ]; then
if [ "$target" = "$package" ]; then am force-stop --user 0 "$package" || exit; fi
settings delete global gpu_debug_layers_gles || exit
settings delete global gpu_debug_layer_app || exit
fi
"#;
podman(
&[
"exec",
&format!("lepton-{}", c.name),
"sh",
"-c",
script,
"framely-fbo",
&a.metadata.package,
if a.framebuffer_compatibility {
"true"
} else {
"false"
},
],
Some(log),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compiled_layer_preserves_calls_and_handles_missing_symbols() {
let dir = tempfile::tempdir().unwrap();
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
let library = dir.path().join("libFramelyFBOCompat.so");
let tester = dir.path().join("test-layer");
assert!(Command::new("gcc")
.args([
"-shared",
"-fPIC",
"-nostdlib",
"-fno-stack-protector",
"-fvisibility=hidden",
"-Wl,-z,defs",
"-Wall",
"-Wextra",
"-Werror"
])
.arg(base.join("native/fbo_compat.c"))
.arg("-o")
.arg(&library)
.status()
.unwrap()
.success());
assert!(Command::new("gcc")
.arg(base.join("tests/fbo-layer.c"))
.args(["-ldl", "-o"])
.arg(&tester)
.status()
.unwrap()
.success());
assert!(Command::new(tester)
.arg(library)
.status()
.unwrap()
.success());
}
#[test]
fn boot_hooks_scope_layer_and_clear_stale_configuration() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("test.sh");
fs::write(
&script,
format!(
r#"set -euo pipefail
function setup_podman_mounts() {{ echo original-mounts; }}
function podman_mount_entry() {{ printf '%s|%s|%s\n' "$1" "$2" "$3"; }}
function generate_vulkan_settings_script() {{ echo original-vulkan-settings; }}
{HOOKS}
setup_podman_mounts
generate_vulkan_settings_script
"#
),
)
.unwrap();
for enabled in [false, true] {
let result = Command::new("bash")
.arg(&script)
.env("FRAMELY_FBO_LIBRARY", "/runtime/layer.so")
.env(
"FRAMELY_FBO_PACKAGE",
if enabled { "com.example.game" } else { "" },
)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let output = String::from_utf8(result.stdout).unwrap();
assert!(output.contains("original-mounts"));
assert!(output
.contains("/runtime/layer.so|/data/local/debug/gles/libFramelyFBOCompat.so|ro"));
assert!(output.contains("settings delete global gpu_debug_layers_gles"));
assert!(output.contains("original-vulkan-settings"));
assert_eq!(
output.contains("settings put global gpu_debug_app com.example.game"),
enabled
);
assert_eq!(
output.contains("settings put global gpu_debug_layers_gles libFramelyFBOCompat.so"),
enabled
);
}
let result = Command::new("bash")
.arg(&script)
.env("FRAMELY_FBO_LIBRARY", "/runtime/layer.so")
.env("FRAMELY_FBO_PACKAGE", "bad;command")
.output()
.unwrap();
assert!(!result.status.success());
}
}
+1
View File
@@ -176,6 +176,7 @@ pub(super) fn prepare(
"APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"},
"LEPTON_NO_CLEANUP":"true", "TERM":"dumb"
});
graphics::configure_environment(&mut env, a.framebuffer_compatibility, &a.metadata.package)?;
let installed_dir = c
.baked
.join("data_overlay")
+31
View File
@@ -0,0 +1,31 @@
// Verify actual compiled GLES entry points without needing a graphics context.
#include <assert.h>
#include <dlfcn.h>
#include <string.h>
static int calls, queries;
static void attachment(unsigned t, unsigned a, unsigned tt, unsigned id, int l) {
assert(t==1 && a==2 && tt==3 && id==4 && l==5); ++calls;
}
static unsigned status(unsigned t) { assert(t==1 && calls==queries+1); ++queries; return 0x8cd5; }
static void *lookup(void *id, const char *name) {
assert(id==(void*)42);
if (!strcmp(name,"glFramebufferTexture2D")) return attachment;
if (!strcmp(name,"glCheckFramebufferStatus")) return status;
return 0;
}
static void *missing(void *id, const char *name) { (void)id; (void)name; return 0; }
int main(int argc, char **argv) {
assert(argc==2);
void *lib=dlopen(argv[1],RTLD_NOW); assert(lib);
void (*init)(void*,void*(*)(void*,const char*))=dlsym(lib,"AndroidGLESLayer_Initialize");
void *(*get)(const char*,void*)=dlsym(lib,"AndroidGLESLayer_GetProcAddress");
assert(init && get);
init((void*)42,lookup);
assert(get("glDrawArrays",(void*)123)==(void*)123);
assert(get("glFramebufferTexture2D",0)==0);
void (*hook)(unsigned,unsigned,unsigned,unsigned,int)=get("glFramebufferTexture2D",attachment);
hook(1,2,3,4,5); assert(calls==1 && queries==1);
init(0,missing);
assert(get("glFramebufferTexture2D",attachment)==attachment);
dlclose(lib);
}
+3
View File
@@ -11,3 +11,6 @@ g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/
# Independent of CEF; both binaries ship in the core/update archive.
g++ -std=c++17 -O2 -I"$openvr" "$base/native/gamepad.cpp" -L"$openvr/lib/linuxarm64" -lopenvr_api -ldl -pthread -Wl,-rpath,'$ORIGIN' -o "$out/framely-gamepad"
gcc -shared -fPIC -nostdlib -fno-stack-protector -Wl,--hash-style=both "$base/native/gamepad_grab.c" -o "$out/libframely-gamepad-grab.so"
# Android GLES layer uses loader-supplied symbols; no host glibc dependency.
gcc -shared -fPIC -nostdlib -fno-stack-protector -fvisibility=hidden -Wl,-z,defs,--hash-style=both,-soname,libFramelyFBOCompat.so "$base/native/fbo_compat.c" -o "$out/libFramelyFBOCompat.so"
+1 -1
View File
@@ -23,7 +23,7 @@ rm -f "$stage/lib/cef/framely-probe" "$stage/lib/cef/framely-browser-probe"
# Remove distribution debug information; browser functionality is unchanged.
strip --strip-debug "$stage/lib/cef/libcef.so"
cp native/vendor/openvr/lib/linuxarm64/libopenvr_api.so "$stage/lib/openvr/"
cp target/native/framely-gamepad target/native/libframely-gamepad-grab.so "$stage/lib/openvr/"
cp target/native/framely-gamepad target/native/libframely-gamepad-grab.so target/native/libFramelyFBOCompat.so "$stage/lib/openvr/"
cp -a ui/dist/. "$stage/share/ui/"
cp -a assets/search "$stage/share/"
cp assets/search/COPYING "$stage/share/licenses/ipadic.txt"
+5 -1
View File
@@ -979,6 +979,10 @@
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry.",
"在此 Frame 上运行": "Run on this Frame",
"远程设备 {0}": "Remote device {0}",
"架构": "Architecture"
"架构": "Architecture",
"帧缓冲兼容修复": "Framebuffer compatibility fix",
"仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。": "For specific games with sound but a black screen. Off by default. Restart the app after changing this option; system drivers and game files are unchanged.",
"Invalid framebuffer compatibility setting": "Invalid framebuffer compatibility setting",
"Framebuffer compatibility runtime is missing; install a complete Framely build": "Framebuffer compatibility runtime is missing; install a complete Framely build"
}
}
+5 -1
View File
@@ -979,6 +979,10 @@
"默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。": "默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。",
"在此 Frame 上运行": "在此 Frame 上运行",
"远程设备 {0}": "远程设备 {0}",
"架构": "架构"
"架构": "架构",
"帧缓冲兼容修复": "帧缓冲兼容修复",
"仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。": "仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。",
"Invalid framebuffer compatibility setting": "无效的帧缓冲兼容设置",
"Framebuffer compatibility runtime is missing; install a complete Framely build": "缺少帧缓冲兼容修复组件,请安装完整的 Framely 更新包。"
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ import {ApkUninstall} from './apk-uninstall';
import {ApkInstallReview} from './apk-install-review';
import {GamepadTriggerSetting} from './gamepad-trigger-setting';
type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean};
type App={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};
type App={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 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};
@@ -62,7 +62,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.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><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></dl>{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'))}
{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)}