mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-20 23:01:49 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d751541a | ||
|
|
9c7dc06bad | ||
|
|
be36694719 | ||
|
|
786c8b7928 | ||
|
|
2c7863dcfb | ||
|
|
7e374a669a | ||
|
|
b1d0c739b2 | ||
|
|
73fa970021 |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.5.1-preview.2"
|
||||
version = "0.5.1-preview.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.5.1-preview.2"
|
||||
version = "0.5.1-preview.4"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -174,3 +174,9 @@ Container list headings show installed application names; the data path identifi
|
||||
### Uninstalling while keeping the container
|
||||
|
||||
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.
|
||||
@@ -148,3 +148,9 @@ 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;
|
||||
}
|
||||
+7
-4
@@ -21,7 +21,8 @@ static void stop(int){stopped=1;}
|
||||
#include "gamepad_feedback.h"
|
||||
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
|
||||
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
|
||||
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
|
||||
static GamepadTriggers triggers;
|
||||
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<2;k++)ok=event(fd,EV_KEY,trigger_keys[k],triggers.update(k,state[14+k]))&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
|
||||
int main(int argc,char**argv){
|
||||
if((argc!=2&&argc!=4)||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
|
||||
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
|
||||
@@ -58,6 +59,7 @@ int main(int argc,char**argv){
|
||||
int fd=open("/dev/uinput",O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");shutdown();return 1;}
|
||||
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0&&ioctl(fd,UI_SET_EVBIT,EV_FF)==0&&ioctl(fd,UI_SET_FFBIT,FF_RUMBLE)==0;
|
||||
for(auto key:keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(auto key:trigger_keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(int k=0;k<8;k++){ok=ioctl(fd,UI_SET_ABSBIT,axes[k])==0&&ok;uinput_abs_setup a{};a.code=axes[k];a.absinfo.minimum=k<4?-32768:(k<6?0:-1);a.absinfo.maximum=k<4?32767:(k<6?255:1);a.absinfo.flat=k<4?1024:0;ok=ioctl(fd,UI_ABS_SETUP,&a)==0&&ok;}
|
||||
uinput_setup device{};device.id={BUS_VIRTUAL,framely_pad_vendor,framely_pad_product,0x0001};device.ff_effects_max=GamepadFeedback::capacity;strcpy(device.name,"Framely Android gamepad");
|
||||
ok=ioctl(fd,UI_DEV_SETUP,&device)==0&&ioctl(fd,UI_DEV_CREATE)==0&&ok;
|
||||
@@ -69,8 +71,9 @@ int main(int argc,char**argv){
|
||||
if(witness<0){perror("Gamepad event access");ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 1;}
|
||||
printf("%s\n",node.c_str());fflush(stdout);
|
||||
// Parent owns routing. Start neutral; EOF, disconnect or disabled actions release input.
|
||||
bool enabled=false;std::string commands;int previous[18]{};GamepadFeedback feedback;std::array<uint16_t,2> lastRumble{};uint64_t rumbleAt=0;uint32_t source=0;bool frameConnected[2]{};
|
||||
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);if(command=="enable")enabled=true;else if(command=="disable")enabled=false;else if(command=="rumble-on")rumbleEnabled=true;else if(command=="rumble-off")rumbleEnabled=false;}if(commands.size()>128)break;}
|
||||
if(const char* threshold=std::getenv("FRAMELY_GAMEPAD_TRIGGER_THRESHOLD"))triggers.configure(std::atoi(threshold));
|
||||
bool triggerChanged=false;bool enabled=false;std::string commands;int previous[18]{};GamepadFeedback feedback;std::array<uint16_t,2> lastRumble{};uint64_t rumbleAt=0;uint32_t source=0;bool frameConnected[2]{};
|
||||
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);if(command=="enable")enabled=true;else if(command=="disable")enabled=false;else if(command=="rumble-on")rumbleEnabled=true;else if(command=="rumble-off")rumbleEnabled=false;else{int percent;char extra;if(std::sscanf(command.c_str(),"trigger-threshold %d %c",&percent,&extra)==1&&triggers.configure(percent))triggerChanged=true;}}if(commands.size()>128)break;}
|
||||
const auto now=milliseconds();
|
||||
// Android may upload effects even while unfocused. Always acknowledge the
|
||||
// kernel requests; only playback is gated by foreground ownership.
|
||||
@@ -101,7 +104,7 @@ int main(int argc,char**argv){
|
||||
lastRumble=vibration;rumbleAt=now;
|
||||
}
|
||||
if(!steamMode&&(vibration[0]||vibration[1]))for(int k=0;k<2;k++)if(haptics[k]!=vr::k_ulInvalidActionHandle&&vibration[k])input->TriggerHapticVibrationAction(haptics[k],0,.012f,100.f,float(vibration[k])/65535.f,vr::k_ulInvalidInputValueHandle);
|
||||
if(memcmp(previous,state,sizeof state)){if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
if(triggerChanged||memcmp(previous,state,sizeof state)){triggerChanged=false;if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(8));
|
||||
}
|
||||
steam.stop();feedback.stop();int neutral[18]{};send(fd,neutral);close(witness);ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 0;
|
||||
|
||||
@@ -4,3 +4,13 @@
|
||||
// Xbox/Android maps X to Linux 307 and Y to 308. The positional
|
||||
// aliases WEST/NORTH do not match these letter names.
|
||||
inline constexpr int keys[]={BTN_A,BTN_B,BTN_X,BTN_Y,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
|
||||
|
||||
inline constexpr int trigger_keys[]={BTN_TL2,BTN_TR2};
|
||||
// Preserve analog travel while exposing Android's digital L2/R2 events.
|
||||
// Default press at 80%; release five percentage points below it to avoid noise.
|
||||
struct GamepadTriggers {
|
||||
bool down[2]{};
|
||||
int press=204,release=191;
|
||||
bool configure(int percent){if(percent<1||percent>100)return false;press=(percent*255+99)/100;release=(percent>5?percent-5:0)*255/100;return true;}
|
||||
bool update(int side,int value){down[side]=down[side]?value>release:value>=press;return down[side];}
|
||||
};
|
||||
@@ -5,6 +5,8 @@ key 307 BUTTON_X
|
||||
key 308 BUTTON_Y
|
||||
key 310 BUTTON_L1
|
||||
key 311 BUTTON_R1
|
||||
key 312 BUTTON_L2
|
||||
key 313 BUTTON_R2
|
||||
key 314 BUTTON_SELECT
|
||||
key 315 BUTTON_START
|
||||
key 317 BUTTON_THUMBL
|
||||
|
||||
+209
-4
@@ -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 {
|
||||
@@ -90,6 +93,8 @@ struct Database {
|
||||
gamepad_source: String,
|
||||
#[serde(default = "close_container_default")]
|
||||
gamepad_rumble: bool,
|
||||
#[serde(default = "gamepad_trigger_threshold_default")]
|
||||
gamepad_trigger_threshold: u8,
|
||||
#[serde(default)]
|
||||
owned_contexts: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -108,6 +113,7 @@ impl Default for Database {
|
||||
gamepad_enabled: false,
|
||||
gamepad_source: gamepad_source_default(),
|
||||
gamepad_rumble: true,
|
||||
gamepad_trigger_threshold: gamepad_trigger_threshold_default(),
|
||||
owned_contexts: Vec::new(),
|
||||
root_contexts: BTreeMap::new(),
|
||||
roots: Vec::new(),
|
||||
@@ -141,6 +147,11 @@ struct App {
|
||||
activity: Option<String>,
|
||||
show_window: Option<bool>,
|
||||
orientation: Option<String>,
|
||||
#[serde(default)]
|
||||
framebuffer_compatibility: bool,
|
||||
}
|
||||
fn gamepad_trigger_threshold_default() -> u8 {
|
||||
80
|
||||
}
|
||||
fn gamepad_source_default() -> String {
|
||||
"steam".into()
|
||||
@@ -545,6 +556,48 @@ fn restriction(c: &Container, package: &str) -> Option<(bool, bool, Vec<String>)
|
||||
}
|
||||
None
|
||||
}
|
||||
// Legacy installs may have reused a package-derived context for another app.
|
||||
// Allocate around that occupied context instead of touching its files or records.
|
||||
fn automatic_install_context(
|
||||
home: &Path,
|
||||
db: &Database,
|
||||
cs: &[Container],
|
||||
package: &str,
|
||||
) -> Result<String> {
|
||||
let base = format!("framely-{}", hash(package));
|
||||
for suffix in 0..1000 {
|
||||
let id = if suffix == 0 {
|
||||
base.clone()
|
||||
} else {
|
||||
format!("{base}-{}", suffix + 1)
|
||||
};
|
||||
let records: Vec<_> = db.records.values().filter(|r| r.context == id).collect();
|
||||
if let Some(c) = cs.iter().find(|c| c.id == id) {
|
||||
let packages = if c.baked.exists() {
|
||||
installed(home, c)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if packages.iter().any(|(m, _)| m.package == package) {
|
||||
return Ok(id); // Updating an existing app also remains valid in legacy shared containers.
|
||||
}
|
||||
if packages.iter().any(|(m, _)| m.package != package)
|
||||
|| records.iter().any(|r| r.metadata.package != package)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Ok(id);
|
||||
}
|
||||
if !records.is_empty()
|
||||
|| fs::symlink_metadata(home.join(".local/share/lepton/contexts").join(&id)).is_ok()
|
||||
{
|
||||
continue; // Preserve retained records and unreadable/dangling paths.
|
||||
}
|
||||
return Ok(id);
|
||||
}
|
||||
anyhow::bail!("No free independent APK container name is available")
|
||||
}
|
||||
|
||||
fn installed(home: &Path, c: &Container) -> Result<Vec<(Metadata, PathBuf)>> {
|
||||
let p = c.baked.join("data_overlay/system/packages.xml");
|
||||
let bytes = fs::read(&p)?;
|
||||
@@ -737,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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -790,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -834,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -888,7 +944,7 @@ pub fn list(home: &Path) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
Ok(
|
||||
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
|
||||
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"gamepadTriggerThreshold":db.gamepad_trigger_threshold,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
|
||||
)
|
||||
}
|
||||
pub fn launcher(home: &Path) -> Vec<Value> {
|
||||
@@ -1272,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)
|
||||
@@ -1531,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,
|
||||
@@ -1686,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()
|
||||
});
|
||||
}
|
||||
@@ -2016,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",
|
||||
@@ -2519,12 +2585,26 @@ fn operate_internal(
|
||||
} else {
|
||||
db.gamepad_rumble
|
||||
};
|
||||
let threshold = if let Some(value) = p.get("triggerThreshold") {
|
||||
let value = value
|
||||
.as_u64()
|
||||
.context("Invalid gamepad trigger threshold")?;
|
||||
ensure!(
|
||||
(1..=100).contains(&value),
|
||||
"Gamepad trigger threshold must be between 1 and 100 percent"
|
||||
);
|
||||
value as u8
|
||||
} else {
|
||||
db.gamepad_trigger_threshold
|
||||
};
|
||||
cancel.commit(|| {
|
||||
db.gamepad_enabled = enabled;
|
||||
db.gamepad_source = source;
|
||||
db.gamepad_rumble = rumble;
|
||||
db.gamepad_trigger_threshold = threshold;
|
||||
save(home, &db)?;
|
||||
crate::gamepad::set_rumble_enabled(rumble);
|
||||
crate::gamepad::set_trigger_threshold(threshold);
|
||||
if !enabled {
|
||||
crate::gamepad::stop();
|
||||
}
|
||||
@@ -2591,7 +2671,7 @@ fn operate_internal(
|
||||
let requested = p["context"].as_str().unwrap_or("");
|
||||
let cs = containers(home, &db);
|
||||
let context = if requested.is_empty() {
|
||||
format!("framely-{}", hash(&metadata.package))
|
||||
automatic_install_context(home, &db, &cs, &metadata.package)?
|
||||
} else {
|
||||
requested.into()
|
||||
};
|
||||
@@ -2759,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()
|
||||
@@ -2838,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(
|
||||
@@ -2886,6 +2977,7 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
&c.name,
|
||||
&db.gamepad_source,
|
||||
db.gamepad_rumble,
|
||||
db.gamepad_trigger_threshold,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
@@ -3395,6 +3487,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!load(home).unwrap().gamepad_enabled);
|
||||
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, 80);
|
||||
for enabled in [true, false] {
|
||||
operate(
|
||||
home,
|
||||
@@ -3428,7 +3521,23 @@ mod tests {
|
||||
assert_eq!(saved.gamepad_source, source);
|
||||
assert!(!saved.gamepad_rumble);
|
||||
}
|
||||
for threshold in [1, 65, 80, 100] {
|
||||
operate(
|
||||
home,
|
||||
"gamepad.settings",
|
||||
&json!({"enabled":false,"triggerThreshold":threshold}),
|
||||
Cancellation::default(),
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, threshold);
|
||||
}
|
||||
for invalid in [
|
||||
json!({"enabled":true,"triggerThreshold":0}),
|
||||
json!({"enabled":true,"triggerThreshold":101}),
|
||||
json!({"enabled":true,"triggerThreshold":80.5}),
|
||||
json!({"enabled":true,"triggerThreshold":"80"}),
|
||||
json!({"enabled":true,"triggerThreshold":-1}),
|
||||
json!({"enabled":true,"source":"physical"}),
|
||||
json!({"enabled":true,"source":7}),
|
||||
json!({"enabled":true,"rumble":"yes"}),
|
||||
@@ -3442,11 +3551,13 @@ mod tests {
|
||||
)
|
||||
.is_err());
|
||||
assert!(!load(home).unwrap().gamepad_enabled);
|
||||
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, 100);
|
||||
}
|
||||
fs::write(root(home).join("state.json"), "{\"records\":{}}").unwrap();
|
||||
assert!(!load(home).unwrap().gamepad_enabled);
|
||||
assert_eq!(load(home).unwrap().gamepad_source, "steam");
|
||||
assert!(load(home).unwrap().gamepad_rumble);
|
||||
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, 80);
|
||||
}
|
||||
#[test]
|
||||
fn gamepad_foreground_does_not_match_another_package() {
|
||||
@@ -4394,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());
|
||||
@@ -5063,6 +5175,71 @@ setup_props
|
||||
assert!(f.save_file().exists());
|
||||
assert!(!load(&f.home).unwrap().records["test/com.example.app"].removed);
|
||||
}
|
||||
#[test]
|
||||
fn automatic_install_context_preserves_occupied_legacy_contexts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let package = "com.example.new";
|
||||
let base = format!("framely-{}", hash(package));
|
||||
let baked = home
|
||||
.join(".local/share/lepton/contexts")
|
||||
.join(&base)
|
||||
.join("baked");
|
||||
fs::create_dir_all(baked.join("data_overlay/system")).unwrap();
|
||||
let xml = b"<packages><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>";
|
||||
fs::write(baked.join("data_overlay/system/packages.xml"), xml).unwrap();
|
||||
let c = Container {
|
||||
id: base.clone(),
|
||||
name: base.clone(),
|
||||
baked: baked.clone(),
|
||||
running: false,
|
||||
managed: true,
|
||||
steam: false,
|
||||
};
|
||||
let mut db = Database::default();
|
||||
assert_eq!(
|
||||
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
|
||||
format!("{base}-2")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(baked.join("data_overlay/system/packages.xml")).unwrap(),
|
||||
xml
|
||||
);
|
||||
// An unreadable context must also be skipped, rather than overwritten.
|
||||
fs::create_dir_all(
|
||||
home.join(".local/share/lepton/contexts")
|
||||
.join(format!("{base}-2")),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
|
||||
format!("{base}-3")
|
||||
);
|
||||
// A retained foreign app reserves a name even if its directory is gone.
|
||||
db.records.insert(
|
||||
"retained".into(),
|
||||
Record {
|
||||
context: format!("{base}-3"),
|
||||
metadata: Metadata {
|
||||
package: "com.example.retained".into(),
|
||||
..Default::default()
|
||||
},
|
||||
removed: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
|
||||
format!("{base}-4")
|
||||
);
|
||||
// Same-package updates still reuse the existing legacy shared container.
|
||||
fs::write(baked.join("data_overlay/system/packages.xml"), format!("<packages><package name=\"{package}\" codePath=\"/data/app/new\" version=\"1\"/><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>")).unwrap();
|
||||
assert_eq!(
|
||||
automatic_install_context(home, &db, &[c], package).unwrap(),
|
||||
base
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_mount_loss_preserves_launcher_identity_until_confirmed_uninstall() {
|
||||
let f = Fixture::new();
|
||||
@@ -5279,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());
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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")
|
||||
@@ -206,8 +207,13 @@ pub(super) fn prepare(
|
||||
)?;
|
||||
}
|
||||
if db.gamepad_enabled {
|
||||
let m =
|
||||
crate::gamepad::prepare(&root(home), &c.name, &db.gamepad_source, db.gamepad_rumble)?;
|
||||
let m = crate::gamepad::prepare(
|
||||
&root(home),
|
||||
&c.name,
|
||||
&db.gamepad_source,
|
||||
db.gamepad_rumble,
|
||||
db.gamepad_trigger_threshold,
|
||||
)?;
|
||||
crate::gamepad::select_target(&c.name, &a.metadata.package)?;
|
||||
for (k, v) in [
|
||||
("FRAMELY_GAMEPAD_EVENT", json!(m.event)),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
async function(p) {
|
||||
const executable = d => d.strShortcutExe?.replace(/^"|"$/g, '').replace('/./', '/');
|
||||
const details = id => new Promise((resolve, reject) => {
|
||||
let sub, settled = false;
|
||||
const timer = setTimeout(() => { sub?.unregister(); reject(Error('Details timed out')); }, 3000);
|
||||
sub = SteamClient.Apps.RegisterForAppDetails(id, d => { settled = true; clearTimeout(timer); sub?.unregister(); resolve(d); });
|
||||
// Steam may invoke a cached callback synchronously.
|
||||
if (settled) sub?.unregister();
|
||||
});
|
||||
let apps, app;
|
||||
for (let n = 0; n < 10; n++) {
|
||||
apps = Array.from(appStore.m_mapApps.values());
|
||||
const matches = apps.filter(a => a.display_name === 'Devkit Game: ' + p.game);
|
||||
app = matches.length === 1 ? matches[0] : apps.find(a => a.appid === p.previous);
|
||||
if (app) break;
|
||||
if (n < 9) await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
if (app) {
|
||||
if (app.appid < 2147483648) throw Error('Missing owned shortcut');
|
||||
const d = await details(app.appid);
|
||||
if (executable(d) !== p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
return { id: app.appid };
|
||||
}
|
||||
// A previous attempt may have renamed the entry before persisting its ID.
|
||||
// Recover by exact executable ownership, never by a user-visible app name.
|
||||
const shortcuts = apps.filter(a => a.appid >= 2147483648);
|
||||
if (shortcuts.length > 256) throw Error('Too many shortcuts to recover owned entry');
|
||||
const rows = await Promise.allSettled(shortcuts.map(async a => ({ id: a.appid, details: await details(a.appid) })));
|
||||
const owned = rows.filter(r => r.status === 'fulfilled' && executable(r.value.details) === p.exe);
|
||||
if (owned.length !== 1) throw Error('Missing or ambiguous owned shortcut');
|
||||
return { id: owned[0].value.id };
|
||||
}
|
||||
+4
-19
@@ -105,24 +105,8 @@ pub(super) fn configure(
|
||||
let expression = format!(
|
||||
r#"(async()=>{{
|
||||
const p={parameters};
|
||||
let app;
|
||||
for(let n=0;n<10;n++){{
|
||||
const apps=Array.from(appStore.m_mapApps.values());
|
||||
const matches=apps.filter(a=>a.display_name==='Devkit Game: '+p.game);
|
||||
app=matches.length===1?matches[0]:apps.find(a=>a.appid===p.previous);
|
||||
if(app) break;
|
||||
await new Promise(r=>setTimeout(r,100));
|
||||
}}
|
||||
if(!app || app.appid<2147483648) throw Error('Missing owned shortcut');
|
||||
const id=app.appid;
|
||||
let sub;
|
||||
const details=await new Promise((resolve,reject)=>{{
|
||||
let timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
|
||||
sub=SteamClient.Apps.RegisterForAppDetails(id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
|
||||
}});
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
const owned=await ({lookup})(p);
|
||||
const id=owned.id;
|
||||
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
|
||||
SteamClient.Apps.SetShortcutName(id,p.name);
|
||||
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
|
||||
@@ -134,7 +118,8 @@ pub(super) fn configure(
|
||||
}}
|
||||
}}
|
||||
return id;
|
||||
}})()"#
|
||||
}})()"#,
|
||||
lookup = include_str!("steam_shortcut_lookup.js")
|
||||
);
|
||||
let result = evaluate(&expression)?;
|
||||
result
|
||||
|
||||
+19
-1
@@ -96,6 +96,12 @@ pub fn set_rumble_enabled(enabled: bool) {
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn set_trigger_threshold(percent: u8) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
for bridge in bridges.contexts.values_mut() {
|
||||
let _ = writeln!(bridge.input, "trigger-threshold {percent}");
|
||||
}
|
||||
}
|
||||
pub fn stop_context(context: &str) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
if bridges.active.as_deref() == Some(context) {
|
||||
@@ -129,7 +135,13 @@ pub fn current(context: &str) -> Option<Mount> {
|
||||
}
|
||||
bridges.contexts.get(context).map(|b| b.mount.clone())
|
||||
}
|
||||
pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Result<Mount> {
|
||||
pub fn prepare(
|
||||
storage: &Path,
|
||||
context: &str,
|
||||
source: &str,
|
||||
rumble: bool,
|
||||
threshold: u8,
|
||||
) -> Result<Mount> {
|
||||
ensure!(
|
||||
matches!(source, "steam" | "frame"),
|
||||
"Invalid gamepad input source"
|
||||
@@ -139,6 +151,10 @@ pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Res
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"Gamepad input must run as the Steam session user"
|
||||
);
|
||||
ensure!(
|
||||
(1..=100).contains(&threshold),
|
||||
"Invalid gamepad trigger threshold"
|
||||
);
|
||||
let current = current(context);
|
||||
{
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
@@ -148,6 +164,7 @@ pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Res
|
||||
}
|
||||
if let Some(bridge) = bridges.contexts.get_mut(context) {
|
||||
bridge.package = None;
|
||||
writeln!(bridge.input, "trigger-threshold {threshold}")?;
|
||||
}
|
||||
}
|
||||
if let Some(mount) = current {
|
||||
@@ -194,6 +211,7 @@ pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Res
|
||||
};
|
||||
let mut child = Command::new(helper)
|
||||
.env("FRAMELY_GAMEPAD_SOURCE", source)
|
||||
.env("FRAMELY_GAMEPAD_TRIGGER_THRESHOLD", threshold.to_string())
|
||||
.env("FRAMELY_GAMEPAD_RUMBLE", if rumble { "1" } else { "0" })
|
||||
.env(
|
||||
"FRAMELY_STEAM_SDL_LIBRARY",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
pub const CHUNK: usize = 4 * 1024 * 1024;
|
||||
pub const CHUNK: usize = 16 * 1024 * 1024;
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Uploads(Arc<Mutex<Option<Upload>>>);
|
||||
struct Upload {
|
||||
|
||||
@@ -5,7 +5,7 @@ const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unk
|
||||
const meta=(pkg:string,name:string,launch=true)=>({package:pkg,name,version:'1.2.3',versionCode:123,minSdk:24,activities:launch?[`${pkg}.Main`]:[],declaredActivities:[`${pkg}.Main`],abis:['arm64-v8a'],vr:false});
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),steamLaunch:true,steamRegistered:true,installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const containers=[{acceptsAdditionalApps:false,id:'framely-wolvic',name:'framely-wolvic',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/framely-wolvic/baked'},{acceptsAdditionalApps:true,id:'dev',name:'dev',managed:false,running:true,baked:'/home/steamos/.local/share/lepton/contexts/dev/baked'},{id:'old',name:'old',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/old/baked'}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let uninstallError:string|null=null;let counter=0,fail=false,gamepadEnabled=false,autoStopContainer=false,stopContainerOnClose=true,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let uninstallError:string|null=null;let counter=0,fail=false,gamepadEnabled=false,gamepadTriggerThreshold=80,autoStopContainer=false,stopContainerOnClose=true,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const p=JSON.parse(String(init?.body));calls.push({method:String(url),params:p});let result:any=true;if(String(url).endsWith('/list'))result={path:'/home/steamos/Downloads',parent:'/home/steamos',roots:[],entries:[{name:'device.apk',path:'/home/steamos/Downloads/device.apk',directory:false,size:1024}],total:1,offset:0,skipped:0};else if(String(url).endsWith('/apk-inspect')){const id='job-'+counter++;jobs.set(id,{id,kind:'apk',phase:'done',result:{ticket:'local-review-ticket',metadata:meta('com.example.local','Local APK'),bytes:1024},received:0});result={job:id};}return new Response(JSON.stringify({result}));}if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='apk.upload.start')result={upload:'fixture-upload',chunkSize:512*1024};
|
||||
else if(method==='apk.cleanup.list')result=cleanupItems;
|
||||
@@ -13,9 +13,9 @@ window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const
|
||||
else if(method==='apk.logs')result={name:'apk.log',text:'Installation complete\nAndroid package manager: Success'};
|
||||
else if(method==='apk.list.start'||method==='apk.inspect.start'||method==='apk.operation.start'){
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,autoStopContainer,stopContainerOnClose};
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,gamepadTriggerThreshold,autoStopContainer,stopContainerOnClose};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings')gamepadEnabled=params.enabled;const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings'){gamepadEnabled=params.enabled;if(params.triggerThreshold!==undefined)gamepadTriggerThreshold=params.triggerThreshold;}const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:method==='apk.operation.start'&&(fail||(params.operation==='uninstall'&&uninstallError))?'failed':'done',result:value,error:uninstallError??'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
@@ -34,6 +34,12 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
|
||||
if(gamepad().getAttribute('aria-checked')!=='false')throw Error('Gamepad input does not default off');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='true'&&!gamepad().disabled);if(!gamepadEnabled)throw Error('Gamepad switch was not persisted');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='false'&&!gamepad().disabled);if(gamepadEnabled)throw Error('Gamepad input did not turn off');
|
||||
const trigger=()=>document.querySelector<HTMLInputElement>('input[type=range][aria-valuetext]')!;
|
||||
if(trigger().value!=='80')throw Error('Trigger threshold default is not 80%');
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(trigger(),'65');trigger().dispatchEvent(new Event('input',{bubbles:true}));await until(()=>trigger().getAttribute('aria-valuetext')==='65%');
|
||||
if(gamepadTriggerThreshold!==80)throw Error('Unsaved trigger threshold applied');
|
||||
button('Save',document.querySelector('.apk-trigger-setting')!).click();await until(()=>gamepadTriggerThreshold===65&&!trigger().disabled&&button('Save',document.querySelector('.apk-trigger-setting')!).disabled);
|
||||
if(trigger().value!=='65'||gamepadEnabled||!calls.some(c=>c.params.operation==='gamepad.settings'&&c.params.triggerThreshold===65))throw Error('Trigger setting was not persisted without enabling input');
|
||||
// Container startup and shutdown are not data deletion operations.
|
||||
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
|
||||
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
|
||||
@@ -93,4 +99,4 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
|
||||
uninstallError=null;purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
button('Retry',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'&&c.params.deleteContainer));if(!calls.find(c=>c.params.operation==='uninstall'&&c.params.deleteContainer)!.params.purge)throw Error('Container deletion omitted purge');
|
||||
console.log('FRAMELY_APK_MANAGER_PASS');console.log('FRAMELY_BRIDGE_PASS');
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+(e.stack??e)));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -14,4 +14,20 @@ int main(){
|
||||
while(std::getline(file,line)){std::istringstream row(line);std::string kind,name;int code;if(row>>kind>>code>>name&&kind=="key")entries[code]=name;}
|
||||
const char* names[]={"BUTTON_A","BUTTON_B","BUTTON_X","BUTTON_Y","BUTTON_L1","BUTTON_R1","BUTTON_SELECT","BUTTON_START","BUTTON_THUMBL","BUTTON_THUMBR"};
|
||||
for(int i=0;i<10;i++)assert(entries[keys[i]]==names[i]);
|
||||
assert(trigger_keys[0]==312&&trigger_keys[1]==313);
|
||||
assert(entries[trigger_keys[0]]=="BUTTON_L2"&&entries[trigger_keys[1]]=="BUTTON_R2");
|
||||
GamepadTriggers triggers;
|
||||
assert(!triggers.update(0,0));assert(!triggers.update(0,203));
|
||||
assert(triggers.update(0,204));assert(triggers.update(0,192));
|
||||
assert(!triggers.down[1]);assert(triggers.update(1,255));
|
||||
assert(!triggers.update(0,191));assert(triggers.down[1]);
|
||||
assert(!triggers.update(1,0));assert(!triggers.down[0]&&!triggers.down[1]);
|
||||
assert(triggers.configure(50));assert(!triggers.update(0,127));assert(triggers.update(0,128));
|
||||
assert(triggers.update(0,115));assert(!triggers.update(0,114));
|
||||
// Changing the setting recalculates a held trigger without axis movement.
|
||||
assert(triggers.update(0,180));assert(triggers.configure(80));assert(!triggers.update(0,180));
|
||||
assert(triggers.configure(60));assert(triggers.update(0,180));assert(!triggers.update(0,0));
|
||||
assert(triggers.configure(1));assert(!triggers.update(0,2));assert(triggers.update(0,3));assert(!triggers.update(0,0));
|
||||
assert(triggers.configure(100));assert(!triggers.update(1,254));assert(triggers.update(1,255));assert(!triggers.update(1,242));
|
||||
assert(!triggers.configure(0));assert(!triggers.configure(101));assert(triggers.press==255);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
const lookup=eval(`(${await readFile('src/apk/steam_shortcut_lookup.js','utf8')})`);
|
||||
const id=0xf1234567, exe='/home/steamos/devkit-game/owned/base.apk';
|
||||
let targets=new Map([[id,{strShortcutExe:`"${exe}"`}]]),unregistered=0;
|
||||
globalThis.appStore={m_mapApps:new Map([[id,{appid:id,display_name:'GamePad Tester'}]])};
|
||||
globalThis.SteamClient={Apps:{RegisterForAppDetails:(id,cb)=>{queueMicrotask(()=>cb(targets.get(id)??{}));return {unregister(){unregistered++}}}}};
|
||||
assert.deepEqual(await lookup({game:'owned',exe}),{id});
|
||||
assert.ok(unregistered>0);
|
||||
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
|
||||
// Never accept an identically named entry belonging to a different executable.
|
||||
appStore.m_mapApps.set(id,{appid:id,display_name:'Devkit Game: owned'});
|
||||
targets.set(id,{strShortcutExe:'/another/app.apk'});
|
||||
await assert.rejects(lookup({game:'owned',exe}),/target does not match/);
|
||||
appStore.m_mapApps.set(id,{appid:id,display_name:'GamePad Tester'});
|
||||
await assert.rejects(lookup({game:'owned',exe}),/Missing or ambiguous/);
|
||||
targets.set(id,{strShortcutExe:exe});
|
||||
appStore.m_mapApps.set(id+1,{appid:id+1,display_name:'Another copy'});targets.set(id+1,{strShortcutExe:exe});
|
||||
await assert.rejects(lookup({game:'owned',exe}),/ambiguous/);
|
||||
// Cached details can call back before the subscription is returned.
|
||||
appStore.m_mapApps.delete(id+1);
|
||||
SteamClient.Apps.RegisterForAppDetails=(id,cb)=>{cb(targets.get(id));return {unregister(){unregistered++}}};
|
||||
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
|
||||
console.log('Renamed registration recovery, executable ownership, ambiguity and cached callback checks passed.');
|
||||
@@ -4,10 +4,10 @@ import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:['ui/src/upload.ts'],bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {uploadFile}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
test('250 MiB selection uses bounded blobs and never reads the entire file',async()=>{
|
||||
const size=250*1024*1024,chunk=4*1024*1024;let received=0,calls=0,last=0,nextOffset=0;
|
||||
const size=250*1024*1024,chunk=16*1024*1024;let received=0,calls=0,last=0,nextOffset=0;
|
||||
const file={size,arrayBuffer(){throw Error('whole file read');},slice(start,end){assert.equal(start,nextOffset);nextOffset+=chunk;return new Blob([new Uint8Array(Math.min(end,size)-start)]);}};
|
||||
const id=await uploadFile(file,new AbortController().signal,p=>last=p,{start:async n=>{assert.equal(n,size);return {upload:'ticket',chunkSize:chunk};},chunk:async(id,offset,blob)=>{assert.equal(id,'ticket');assert.equal(offset,received);assert.ok(blob.size<=chunk);received+=blob.size;calls++;},abort:async()=>assert.fail('unexpected abort')});
|
||||
assert.equal(id,'ticket');assert.equal(received,size);assert.equal(calls,63);assert.equal(last,100);
|
||||
assert.equal(id,'ticket');assert.equal(received,size);assert.equal(calls,16);assert.equal(last,100);
|
||||
});
|
||||
test('cancellation stops scheduling new chunks and clears the upload',async()=>{
|
||||
const controller=new AbortController();let calls=0,aborted='';
|
||||
@@ -15,7 +15,7 @@ test('cancellation stops scheduling new chunks and clears the upload',async()=>{
|
||||
assert.equal(calls,4);assert.equal(aborted,'partial');
|
||||
});
|
||||
test('invalid server chunk size and failed requests clean up uploads',async()=>{
|
||||
for(const chunkSize of [0,8*1024*1024,1]){let aborted=false;await assert.rejects(uploadFile({size:2,slice:()=>new Blob(['a'])},new AbortController().signal,()=>{},{start:async()=>({upload:'partial',chunkSize}),chunk:async()=>{throw Error('network');},abort:async()=>{aborted=true;}}));assert.ok(aborted);}
|
||||
for(const chunkSize of [0,16*1024*1024+1,1]){let aborted=false;await assert.rejects(uploadFile({size:2,slice:()=>new Blob(['a'])},new AbortController().signal,()=>{},{start:async()=>({upload:'partial',chunkSize}),chunk:async()=>{throw Error('network');},abort:async()=>{aborted=true;}}));assert.ok(aborted);}
|
||||
});
|
||||
|
||||
test('four workers allow out-of-order completion with monotonic acknowledged progress',async()=>{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"locale": "en-US",
|
||||
"name": "English",
|
||||
"messages": {
|
||||
"L2 / R2 按键触发行程": "L2 / R2 button activation threshold",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "Default: 80%. Buttons activate at this trigger travel. Analog axes retain their full range; saving applies immediately.",
|
||||
"更新应用并保留现有存档和设置。": "Update the app while keeping your saves and settings.",
|
||||
"核对应用信息,选择合适的显示方式。": "Check the app details and choose how to display it.",
|
||||
"保留原容器": "Keep existing container",
|
||||
@@ -977,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"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
"locale": "zh-CN",
|
||||
"name": "简体中文",
|
||||
"messages": {
|
||||
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。",
|
||||
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。",
|
||||
"关闭容器": "关闭容器",
|
||||
"更新应用并保留现有存档和设置。": "更新应用并保留现有存档和设置。",
|
||||
@@ -977,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 更新包。"
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,5 @@
|
||||
.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-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%}}
|
||||
@@ -11,10 +11,11 @@ import {t} from './i18n';
|
||||
import './apk-manager.css';
|
||||
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;autoStopContainer?:boolean;stopContainerOnClose?: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 Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean;deleteContainer?:boolean};
|
||||
@@ -56,12 +57,12 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
{!data.available&&<p className="banner">{t('未检测到 Lepton,请先通过 Steam 安装。')}</p>}
|
||||
{notice&&<p className="apk-notice" role="status">{notice}</p>}{error&&<div className="error apk-error" role="alert">{error}<button aria-label={t('关闭')} onClick={()=>setError('')}><IconX size={20}/></button></div>}
|
||||
{!externalView&&<div className="apk-tabs" role="tablist">{[['apps','应用'],['containers','容器'],['cleanup','数据清理'],['settings','设置']].map(([key,label])=><button key={key} role="tab" aria-selected={view===key} className={view===key?'active':''} disabled={busy} onClick={()=>{setView(key);if(key==='cleanup')void loadCleanup();}}>{t(label)}</button>)}</div>}
|
||||
{view==='settings'&&<><SwitchRow label={t('保留安装的 APK 到 Steam')} description={t('默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。')} checked={data.retainSteamEntries??true} disabled={busy||loading} onChange={enabled=>void perform('steam.retention.settings',{enabled})}/><SwitchRow label={t('应用退出后自动停止容器')} description={t('默认关闭。应用进程连续消失 15 秒后停止容器;切到后台不会停止。')} checked={!!data.autoStopContainer} disabled={busy||loading} onChange={autoStopContainer=>void perform('lifecycle.settings',{autoStopContainer,stopContainerOnClose:data.stopContainerOnClose??true})}/><SwitchRow label={t('关闭窗口时停止容器')} description={t('收到系统窗口关闭信号后停止容器并保留数据。部分 SteamOS 版本不支持关闭 Lepton 窗口;切到后台不会停止,共享容器中的其他应用会受到保护。')} checked={data.stopContainerOnClose??true} disabled={busy||loading} onChange={stopContainerOnClose=>void perform('lifecycle.settings',{autoStopContainer:!!data.autoStopContainer,stopContainerOnClose})}/><SwitchRow label={t('手柄输入')} description={t('默认关闭。通过 Steam Input 将手柄转发为 Android 标准游戏手柄;也可选择 Frame 直连。开启后下次打开 APK 生效,关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/><div className="switch-row apk-input-source"><div><b>{t('手柄输入来源')}</b><small>{t('来源切换需关闭容器后重新启动。Steam Input 使用 Steam 的手柄配置,并支持 Steam 已识别的外接手柄。')}</small></div><Select label={t('手柄输入来源')} value={data.gamepadSource??'steam'} disabled={busy||loading} onChange={source=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,source})} options={[{value:'steam',label:t('Steam Input(推荐)')},{value:'frame',label:t('Frame 直连(兼容模式)')}]}/></div><SwitchRow label={t('手柄震动')} description={t('将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。')} checked={data.gamepadRumble??true} disabled={busy||loading} onChange={rumble=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,rumble})}/></>}
|
||||
{view==='settings'&&<><SwitchRow label={t('保留安装的 APK 到 Steam')} description={t('默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。')} checked={data.retainSteamEntries??true} disabled={busy||loading} onChange={enabled=>void perform('steam.retention.settings',{enabled})}/><SwitchRow label={t('应用退出后自动停止容器')} description={t('默认关闭。应用进程连续消失 15 秒后停止容器;切到后台不会停止。')} checked={!!data.autoStopContainer} disabled={busy||loading} onChange={autoStopContainer=>void perform('lifecycle.settings',{autoStopContainer,stopContainerOnClose:data.stopContainerOnClose??true})}/><SwitchRow label={t('关闭窗口时停止容器')} description={t('收到系统窗口关闭信号后停止容器并保留数据。部分 SteamOS 版本不支持关闭 Lepton 窗口;切到后台不会停止,共享容器中的其他应用会受到保护。')} checked={data.stopContainerOnClose??true} disabled={busy||loading} onChange={stopContainerOnClose=>void perform('lifecycle.settings',{autoStopContainer:!!data.autoStopContainer,stopContainerOnClose})}/><SwitchRow label={t('手柄输入')} description={t('默认关闭。通过 Steam Input 将手柄转发为 Android 标准游戏手柄;也可选择 Frame 直连。开启后下次打开 APK 生效,关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/><div className="switch-row apk-input-source"><div><b>{t('手柄输入来源')}</b><small>{t('来源切换需关闭容器后重新启动。Steam Input 使用 Steam 的手柄配置,并支持 Steam 已识别的外接手柄。')}</small></div><Select label={t('手柄输入来源')} value={data.gamepadSource??'steam'} disabled={busy||loading} onChange={source=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,source})} options={[{value:'steam',label:t('Steam Input(推荐)')},{value:'frame',label:t('Frame 直连(兼容模式)')}]}/></div><GamepadTriggerSetting value={data.gamepadTriggerThreshold??80} busy={busy||loading} onSave={triggerThreshold=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,triggerThreshold})}/><SwitchRow label={t('手柄震动')} description={t('将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。')} checked={data.gamepadRumble??true} disabled={busy||loading} onChange={rumble=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,rumble})}/></>}
|
||||
{view==='apps'&&<><div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage kind="lepton" src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
|
||||
{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)}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import React,{useEffect,useId,useState} from 'react';
|
||||
import {t} from './i18n';
|
||||
export function GamepadTriggerSetting({value,busy,onSave}:{value:number;busy:boolean;onSave:(value:number)=>void}){
|
||||
const[draft,setDraft]=useState(value);const id=useId();
|
||||
useEffect(()=>setDraft(value),[value]);
|
||||
return <div className="apk-trigger-setting"><div><label htmlFor={id}><b>{t('L2 / R2 按键触发行程')}</b></label><small id={`${id}-description`}>{t('默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。')}</small></div><div className="apk-trigger-controls"><div><input id={id} type="range" min={1} max={100} step={1} value={draft} disabled={busy} aria-describedby={`${id}-description`} aria-valuetext={`${draft}%`} onChange={e=>setDraft(Number(e.target.value))}/><output htmlFor={id}>{draft}%</output></div><button disabled={busy||draft===value} onClick={()=>onSave(draft)}>{t('保存')}</button></div></div>;
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ export async function uploadFile(file:Pick<File,'size'|'slice'>,signal:AbortSign
|
||||
try{
|
||||
signal.throwIfAborted();const start=await transport.start(file.size);ticket=start.upload;
|
||||
signal.throwIfAborted();
|
||||
if(!Number.isSafeInteger(start.chunkSize)||start.chunkSize<=0||start.chunkSize>4*1024*1024)throw new Error('Invalid upload chunk size');
|
||||
if(!Number.isSafeInteger(start.chunkSize)||start.chunkSize<=0||start.chunkSize>16*1024*1024)throw new Error('Invalid upload chunk size');
|
||||
const worker=async()=>{
|
||||
try{
|
||||
while(nextOffset<file.size){
|
||||
|
||||
Reference in new issue
Block a user