mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-25 17:01:49 +01:00
Add opt-in per-app framebuffer compatibility fix
This commit is contained in:
1 parent
be36694719
commit
9c7dc06bad
13 files changed
+378
-7
No files matched your search
@@ -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.
|
||||
@@ -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 修复层只在所选应用中,于绑定纹理后检查帧缓冲状态,不改变其他应用、系统驱动、游戏文件或存档。关闭后,下次启动恢复正常渲染路径。这是针对特定问题的兼容措施,并非通用画质或性能优化。
|
||||
@@ -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
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 更新包。"
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user