fix(apk): use data-preserving native Steam launches and app artwork

This commit is contained in:
toorux committed 2026-10-07 02:15:02 +08:00
1 parent e205d3bae6
commit 0ec7afeafa
10 files changed
+1099 -36

No files matched your search

Generated
+124 -1
View File
@@ -99,6 +99,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
@@ -151,6 +157,18 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -349,6 +367,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -383,7 +410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -405,6 +432,7 @@ dependencies = [
"clap",
"flate2",
"hex",
"image",
"libc",
"pbkdf2",
"pinyin",
@@ -632,6 +660,32 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -700,6 +754,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -710,6 +774,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "nix"
version = "0.28.0"
@@ -722,6 +796,15 @@ dependencies = [
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -762,6 +845,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
@@ -810,6 +906,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
@@ -1692,3 +1800,18 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+1
View File
@@ -29,6 +29,7 @@ tungstenite = { version = "0.28", default-features = false, features = ["handsha
tar = { version = "0.4", default-features = false }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
tempfile = "3"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
[profile.release]
lto = "thin"
+25 -9
View File
@@ -115,20 +115,36 @@ Monitoring pauses during APK mutations and startup checks. A container restart,
### SteamOS window-close limitation
On Frame Gamescope `3.16.28-76-ge383171f`, both window and Dock close controls log `Closing Wayland windows not supported yet.` for Lepton windows. The close request does not reach Android, so this switch cannot make these controls work on that build. Use the APK panel's **Stop container** action until the system compositor supports Wayland close requests. Framely does not infer close intent from focus changes or this unscoped log message.
On Frame Gamescope `3.16.28-76-ge383171f`, both window and Dock close controls log `Closing Wayland windows not supported yet.` for direct, non-Steam Lepton windows. The close request does not reach Android, so this switch cannot make these controls work on that path. Use **Launch through Steam** as described below, or the APK panel's **Stop container** action. Framely does not infer close intent from focus changes or this unscoped log message.
## Steam-owned launches
## Native Steam / Lepton launches
For an installed sideloaded APK, open **Manage → Advanced settings → Launch through Steam**. This switch defaults to on. On startup and after installation, Framely automatically wraps existing and newly installed launchable sideloaded APKs; explicit opt-outs and Steam-managed apps are preserved. Registration does not start apps or restart their containers. If Steam is unavailable, registration retries in the background when it becomes ready. Framely automatically registers its own Devkit entry using Valve's authenticated local Steam IPC; Steam must be running. No manual shortcut addition, Steam restart, APK reinstallation or new container is required. The original Lepton launcher icon, order and favorites remain; registered wrappers are not added as desktop icons. Once Steam supplies an App ID, that explicit mapping also excludes its duplicate from the launcher Steam list.
**Launch through Steam** defaults to on. Framely registers launchable sideloaded APKs with Steam's native Lepton compatibility tool. Existing opt-outs remain disabled, and Steam-managed titles are not registered again. Registration does not launch or restart applications. Steam must be awake; unavailable registration is retried in the background.
Close an existing running container before its first Steam launch. Framely refuses to reboot a running app just to assign Steam ownership. The wrapper forwards Steam's App ID to a cold Lepton boot, retains the existing data directory, and stays alive while the target app runs. Steam **Stop** ends this wrapper; the user session service stops the exact owned app/container without deleting data. If an old shared container has other live apps, those apps and the container are preserved. A confirmed app-process exit waits 15 seconds; temporary query failures do not count as an exit. Lepton background services may keep the entry running until explicitly stopped.
Steam entries show the APK's application name and icon. Framely generates matching library capsules, headers and hero images from the icon, without downloading artwork. The original launcher icon, order and favorites stay intact; the explicit Steam App ID binding excludes duplicate entries from the launcher Steam category. APK updates refresh generated entry metadata.
The same service monitors a killed wrapper and reconnects after a Framely session restart. PID start identity and container boot identity prevent an old wrapper from stopping a newer container. This does not patch Gamescope. On the tested Frame system, the window and Dock close buttons still reach Gamescope's unsupported Wayland-close path and do not stop the Steam wrapper. Steam wrapping therefore does not repair these two buttons; use Steam **Stop** or APK management **Stop** instead. Disable the switch to remove only Framely's corresponding Steam entry and return to direct launch. Uninstalling its APK or deleting its container also removes that entry; Steam must be available for this cleanup.
The native runtime has a `steamlaunch-…` process/container name, while the APK keeps its existing data directory and Framely identity. No game-data copy, APK reinstall or system compositor modification is required. A private, per-user Lepton entry adapter retains resource storage, selected activity, window orientation, shader cache and optional controller forwarding. It prevents native Lepton from clearing an existing installation after a timestamp change or early exit. Unknown entry-script formats are refused before starting the application.
### Steam 托管启动
Close an already running container before switching its launch path. Framely does not restart a running game to assign Steam ownership. Old shared containers remain discoverable, but only one Steam launch may own their data at a time. Duplicate launches are blocked using an ownership lock that survives the native process launch.
已安装的侧载 APK 可以在 **管理 → 高级设置 → 通过 Steam 启动** 中控制此功能,默认开启。本体启动及安装完成后,会自动包装已有和新安装的可启动侧载 APK,跳过 Steam 已管理的应用,并保留用户主动关闭的选择。注册不启动应用或重启容器;Steam 暂不可用时,会在后台等待并自动重试。Framely 通过 Valve 的本地认证 IPC 自动注册 Devkit 入口,需要 Steam 正在运行;无需手动添加快捷方式、重启 Steam、重装 APK 或新建容器。启动台保留原来的 Lepton 图标、排序和收藏,不生成额外桌面图标;首次取得 Steam App ID 后,根据明确绑定从 Steam 分类中过滤重复入口。
On the tested Frame build, native Lepton ownership lets both the window's lower close button and the Dock close button show Steam's exit confirmation. Confirming exit stops the app and its container. Steam **Stop** and APK management **Stop container** remain available. This result was verified with an isolated flat APK; application-specific and VR behavior may differ. Direct, non-Steam Lepton windows still encounter that build's unsupported Wayland close path.
首次通过 Steam 启动前,请先关闭运行中的容器。Framely 不会为了绑定 Steam 而强制重启正在运行的应用。包装进程将 Steam App ID 传入冷启动的 Lepton,并保留原数据目录;应用运行期间包装进程保持存活。Steam 的“停止”结束包装进程后,用户会话服务停止准确对应的应用和容器,不删除数据。旧共享容器中如果还有其他应用运行,则保护它们和容器。确认目标进程退出后等待 15 秒;查询失败不视为退出。后台服务可能让 Steam 入口继续保持运行,需要手动停止。
Entry metadata and the launch adapter are configured through Steam's existing local library UI API, reached through its local debugging endpoint on port 8080. Framely neither enables the endpoint nor changes Steam launch settings to enable it. If the API is unavailable or the shortcut target cannot be verified, registration reports an error and retries; it does not edit the live shortcuts database or silently switch to an unprotected launch. This Steam UI interface can change with Steam updates.
包装进程被强制结束或 Framely 会话服务重启后,也会通过持久记录继续核对。进程启动标识及容器启动标识防止旧进程停止新容器。本功能不修改 Gamescope。已测试的 Frame 系统中,窗口下方及 Dock 的关闭按钮仍进入 Gamescope 尚未支持的 Wayland 关闭路径,没有停止 Steam 包装进程。因此,Steam 包装不能修复这两个按钮;请使用 Steam 的“停止”或 APK 管理中的“停止”。关闭开关只移除 Framely 对应该应用的 Steam 入口,并恢复直接启动。卸载 APK 或删除其容器时也会移除对应入口,此清理需要 Steam 可用。
Disabling Steam launch removes only Framely's entry; close a native Steam app first. Uninstalling an APK stops its native container before removing its entry. APK data is removed only by the explicitly selected uninstall/data-cleanup operation. The same session service verifies process and container boot identities so an old ownership record cannot stop a newer application.
### Steam / Lepton 原生启动
**通过 Steam 启动**默认开启。Framely 使用 Steam 原生 Lepton 兼容工具注册可启动的侧载 APK,保留主动关闭的选择,跳过 Steam 已管理的应用。注册不会启动应用或重启容器;Steam 需要保持唤醒,不可用时会后台重试。
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
独立平面 APK 的实机验证显示,窗口下方和 Dock 的关闭按钮都能弹出 Steam 退出确认,确认后应用和容器停止。也可使用 Steam 的“停止”或 APK 管理中的“停止容器”。具体应用及 VR 应用行为可能不同;直接启动的非 Steam Lepton 窗口仍受当前系统未实现 Wayland 关闭的限制。
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
+14 -2
View File
@@ -121,6 +121,18 @@ APK 页面提供两个持久保存的开关。**应用退出后自动停止容
APK 安装、更新、其他修改与启动检查期间暂停监测。容器重启、进程状态无法确认或旧共享容器内还有其他应用运行时,不停止容器;Steam 管理的容器交给 Steam。停止使用正常 Podman 停止流程,给予 10 秒退出时间,保留 APK、存档和下载数据。已停止的容器再次打开需要冷启动 Android;关闭两个开关后仍会复用健康的运行中容器。
### SteamOS 窗口关闭限制
### Steam / Lepton 原生启动
Frame 的 Gamescope `3.16.28-76-ge383171f` 对 Lepton 窗口的窗口下方和 Dock 关闭按钮均报 `Closing Wayland windows not supported yet.`。关闭请求未传到 Android,因此该开关无法使这个系统版本的关闭按钮生效。系统合成器支持 Wayland 关闭请求前,请使用 APK 面板的“停止容器”。Framely 不会根据焦点变化或这条没有容器标识的日志猜测关闭意图。
**通过 Steam 启动**默认开启。Framely 使用 Steam 原生 Lepton 兼容工具注册可启动的侧载 APK,保留主动关闭的选择,跳过 Steam 已管理的应用。注册不会启动应用或重启容器;Steam 需要保持唤醒,不可用时会后台重试。
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
独立平面 APK 的实机验证显示,窗口下方和 Dock 的关闭按钮都能弹出 Steam 退出确认,确认后应用和容器停止。也可使用 Steam 的“停止”或 APK 管理中的“停止容器”。具体应用及 VR 应用行为可能不同;直接启动的非 Steam Lepton 窗口仍受当前系统未实现 Wayland 关闭的限制。
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
+119 -7
View File
@@ -21,14 +21,19 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
mod lifecycle;
mod native;
mod steam_bridge;
mod steam_shortcuts;
mod steam_ui;
mod storage;
pub fn steam_session_started() -> Result<()> {
steam_bridge::start_session()?;
steam_shortcuts::start_auto_registration();
Ok(())
}
pub fn native_steam(app: &str, token: &str, command: &[String]) -> Result<()> {
native::run(app, token, command)
}
pub fn steam_wrapper(app: &str, token: &str) -> Result<()> {
steam_bridge::run(app, token)
}
@@ -414,7 +419,10 @@ fn containers(home: &Path, db: &Database) -> Vec<Container> {
if let Ok(baked) = validate_baked(&Path::new(p.trim()).join("baked")) {
if let Some(c) = out.iter_mut().find(|c| c.baked == baked) {
c.name = n.into();
c.steam |= n.starts_with("steamlaunch-");
c.steam |= n.starts_with("steamlaunch-")
&& !db.records.values().any(|r| {
r.context == c.id && native::runtime_name(r).as_deref() == Some(n)
});
c.running = true;
} else {
out.push(Container {
@@ -1097,8 +1105,11 @@ fn direct_launch_script(source: &str) -> Result<String> {
# single-app launcher deletes media/0 before creating its external-storage link.
function framely_prepare_media() {
if [[ -n "${FRAMELY_EXTERNAL_MEDIA_DIR:-}" ]]; then
framely_prepare_external_media
return $?
framely_prepare_external_media || return
if [[ "${FRAMELY_NATIVE_LAUNCH:-false}" == true ]]; then
rm -f -- "$(data_mount_path)/media/0" || return
fi
return 0
fi
local media="$(data_mount_path)/media/0"
local external="${STEAM_COMPAT_DATA_PATH:-}/external"
@@ -1136,6 +1147,11 @@ for framely_mount_hook in setup_mounts setup_podman_mounts; do
fi
if [[ "$framely_mount_body" == *"$framely_media_remove"* ]]; then
framely_mount_body="${framely_mount_body//"$framely_media_remove"/framely_prepare_media || return}"
if [[ "${FRAMELY_NATIVE_LAUNCH:-false}" == true ]]; then
framely_native_link='ln -s "${STEAM_COMPAT_DATA_PATH}/external"'
framely_preserved_link='ln -s "${FRAMELY_EXTERNAL_MEDIA_DIR:?}"'
framely_mount_body="${framely_mount_body//"$framely_native_link"/"$framely_preserved_link"}"
fi
eval "$framely_mount_body"
fi
unset framely_mount_body framely_media_remove
@@ -1269,6 +1285,18 @@ fn start_oriented_container(
wait_android_ready(c)?;
return restore_package_mount(home, c, log);
}
let lock_dir = root(home).join("steam");
fs::create_dir_all(&lock_dir)?;
let _context_lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(lock_dir.join(format!("context-{}.lock", hash(&c.id))))?;
ensure!(
unsafe { libc::flock(_context_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"A native Steam launch still owns this container; wait for it to finish closing"
);
let runner = runner(home)?;
storage::prepare(&c.baked)?;
// Manual container startup must use the same resource-preserving adapter.
@@ -1310,7 +1338,11 @@ fn start_oriented_container(
},
);
cmd.env_remove("SteamAppId");
storage::configure(&mut cmd, &c.baked, c.id.starts_with("external-"));
storage::configure(
&mut cmd,
&c.baked,
c.id.starts_with("external-") || c.name != c.id,
);
if let Some(id) = steam_app_id {
cmd.env("FRAMELY_STEAM_APP_ID", id.to_string());
} else {
@@ -1322,7 +1354,7 @@ fn start_oriented_container(
.env("FRAMELY_GAMEPAD_READY", &mount.ready)
.env("FRAMELY_GAMEPAD_TOKEN", &mount.token);
}
if c.id.starts_with("external-") {
if c.id.starts_with("external-") || c.name != c.id {
cmd.env(
"STEAM_COMPAT_DATA_PATH",
c.baked.parent().context("Missing compatdata parent")?,
@@ -1461,11 +1493,16 @@ fn app(home: &Path, db: &Database, id: &str) -> Result<(App, Container)> {
.find(|a| a.id == id)
.context("APK application not found")?;
ensure!(!a.steam, "Manage Steam APKs through Steam");
let c = cs
let mut c = cs
.iter()
.find(|c| c.id == a.context)
.cloned()
.context("Container unavailable; add its data location first")?;
if !c.running {
if let Some(name) = db.records.get(id).and_then(native::runtime_name) {
c.name = name;
}
}
ensure!(
!cs.iter()
.any(|other| other.name == c.name && other.baked != c.baked),
@@ -2199,8 +2236,17 @@ fn operate_internal(
let enabled = p["enabled"]
.as_bool()
.context("Invalid Steam launch setting")?;
let (a, _) = app(home, &db, id)?;
let (a, c) = app(home, &db, id)?;
ensure!(!a.steam, "This app is already managed by Steam");
if !enabled
&& c.name.starts_with("steamlaunch-")
&& db.records.get(id).and_then(native::runtime_name).is_some()
{
ensure!(
!running(&c.name),
"Close this native Steam application before disabling Steam launch"
);
}
record_app(&mut db, &a);
let rec = db.records.get_mut(id).context("Missing APK record")?;
if enabled {
@@ -2638,6 +2684,9 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
a.pending.is_none(),
"Reconcile the interrupted operation before deleting data"
);
if db.records.get(id).and_then(native::runtime_name).is_some() {
stop(&c, &log)?;
}
if let Some(rec) = db.records.get_mut(id) {
steam_shortcuts::unregister(home, rec)?;
rec.steam_launch = false;
@@ -3447,12 +3496,14 @@ setup_podman_mounts
}
impl Drop for Fixture {
fn drop(&mut self) {
steam_ui::TEST_RESULT.with(|p| *p.borrow_mut() = None);
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None)
}
}
impl Fixture {
fn new() -> Self {
let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
steam_ui::TEST_RESULT.with(|p| *p.borrow_mut() = Some(json!(0x92345678u32)));
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
fs::create_dir_all(&home).unwrap();
@@ -3883,6 +3934,8 @@ setup_props
rec.steam_token = Some("test-token".into());
rec.steam_binding = Some(steam_shortcuts::Binding {
game_id: steam_shortcuts::game_id(id),
native: false,
revision: String::new(),
});
save(&f.home, &db).unwrap();
let path = steam_shortcuts::wrapper(&f.home, id);
@@ -4329,6 +4382,65 @@ setup_props
assert!(!parent.join("restore-test").exists());
}
#[test]
fn native_launch_prepare_preserves_identity_data_and_rejects_running_or_changed_binding() {
let f = Fixture::new();
list(&f.home).unwrap();
let id = "test/com.example.app";
let mut db = load(&f.home).unwrap();
let r = db.records.get_mut(id).unwrap();
r.steam_launch = true;
r.steam_app_id = Some(0x92345678);
r.steam_binding = Some(steam_shortcuts::Binding {
game_id: steam_shortcuts::game_id(id),
native: true,
revision: String::new(),
});
save(&f.home, &db).unwrap();
assert!(native::prepare(&f.home, id, 0x92345678).is_err());
fs::write(f.dir.path().join("running"), "false").unwrap();
let lepton = runner(&f.home).unwrap();
let source = fs::read_to_string(&lepton).unwrap();
fs::write(
&lepton,
format!(
"{source}\nfunction teardown() {{ :; }}\ncase \"${{COMMAND}}\" in\n*) :;;\nesac\n"
),
)
.unwrap();
let baked = f.home.join(".local/share/lepton/contexts/test/baked");
let installed_dir = baked.join("data_overlay/app/xyz/com.example.app");
fs::create_dir_all(installed_dir.join("lib/arm64")).unwrap();
fs::copy(
baked.join("app_overlay/base.apk"),
installed_dir.join("base.apk"),
)
.unwrap();
fs::write(
installed_dir.join("lib/arm64/game.so"),
b"existing extracted library",
)
.unwrap();
assert!(native::prepare(&f.home, id, 0x92345679).is_err());
let (c, a, response) = native::prepare(&f.home, id, 0x92345678).unwrap();
assert_eq!(c.id, "test");
assert_eq!(c.name, "steamlaunch-2452903544");
assert!(!c.steam);
assert_eq!(a.id, id);
assert_eq!(
response["env"]["FRAMELY_NATIVE_APK_DIR"],
json!(installed_dir)
);
assert_eq!(
response["env"]["STEAM_COMPAT_DATA_PATH"],
json!(baked.parent().unwrap())
);
assert_eq!(fs::read(f.save_file()).unwrap(), b"saved progress");
assert_eq!(
fs::read(installed_dir.join("lib/arm64/game.so")).unwrap(),
b"existing extracted library"
);
}
#[test]
fn unreadable_state_is_not_uninstallation_or_a_launch_target() {
let f = Fixture::new();
list(&f.home).unwrap();
+400
View File
@@ -0,0 +1,400 @@
//! Steam invokes its Lepton compatibility tool; a private entry adapter preserves
//! existing storage/input settings without replacing any system/Steam files.
use super::*;
use std::os::unix::process::CommandExt;
const NATIVE_HOOKS: &str = r#"
# The session service has verified the installed package before starting Steam.
# Do not let native APK/depot timestamps reinstall or clear that installation.
function is_app_baked() { return 0; }
function get_app_mount_dir() { print "${FRAMELY_NATIVE_MOUNT:?}"; }
function get_app_activity() { print "${FRAMELY_NATIVE_ACTIVITY:?}"; }
function get_app_hash() { extract_app_hash; }
function get_app_last_depot_version() { extract_app_last_depot_version; }
function clear_baked_app_data() { println 'Framely: keeping installed application data'; }
# Separate native APK overlays in older shared contexts. Keep the installed
# directory (including extracted libraries/splits), rather than hiding it with
# a directory containing only the staged base APK.
if [[ -n "${FRAMELY_NATIVE_APK_DIR:-}" ]]; then
framely_native_mounts="$(declare -f setup_mounts)"
framely_native_lower='APP_APK_DIR="$(dirname "$(get_apk_path)")"'
framely_native_upper='$(get_baked_app_data_dir)/app_overlay'
[[ "$framely_native_mounts" == *"$framely_native_lower"* && "$framely_native_mounts" == *"$framely_native_upper"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
framely_native_mounts="${framely_native_mounts//"$framely_native_lower"/'APP_APK_DIR="${FRAMELY_NATIVE_APK_DIR:?}"'}"
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
function app_workdir() { print "${FRAMELY_NATIVE_OVERLAY:?}/work"; }
eval "$framely_native_mounts"
unset framely_native_mounts framely_native_lower framely_native_upper
fi
"#;
pub(super) fn script(source: &str) -> Result<String> {
let adapted = direct_launch_script(source)?;
let include = "source \"${SCRIPT_DIR}/liblepton/liblepton.sh\"";
// Append native overrides immediately before the entry's case statement,
// after the shared resource, orientation and optional input hooks.
let marker = "case \"${COMMAND}\" in";
ensure!(
source.matches(marker).count() == 1 && source.contains("function teardown()"),
"Unsupported native Lepton entry; keeping the existing installation unchanged"
);
ensure!(adapted.contains(include), "Missing native Lepton library");
Ok(adapted.replacen(marker, &format!("{NATIVE_HOOKS}\n{marker}"), 1))
}
pub(super) fn runtime_name(r: &Record) -> Option<String> {
r.steam_binding.as_ref().filter(|b| b.native)?;
r.steam_app_id
.filter(|id| *id >= 0x80000000)
.map(|id| format!("steamlaunch-{id}"))
}
pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container, App, Value)> {
let _guard = MUTATION
.try_lock()
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
let _lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(init(home)?.join("operation.lock"))?;
ensure!(
unsafe { libc::flock(_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Another APK operation is running"
);
let mut db = load(home)?;
let r = db.records.get(id).context("Unknown APK entry")?;
ensure!(
r.steam_launch
&& r.steam_binding.as_ref().is_some_and(|b| b.native)
&& r.steam_app_id == Some(app_id),
"Steam APK binding changed; refresh the entry and retry"
);
let (a, mut c) = app(home, &db, id)?;
ensure!(
a.installed && a.state_known && a.pending.is_none(),
"APK installation is unavailable or an operation is pending"
);
let component = launch_component(&a)?;
ensure!(!running(&c.name),"Close the running container before native Steam launch; it will not be restarted automatically");
// Also guard the original name: registration can occur while the old launch
// is still running, and two runtime names must never share an overlay.
let original = if c.id.starts_with("external-") {
db.root_contexts
.get(&c.baked)
.cloned()
.context("Unknown external container name")?
} else {
c.id.clone()
};
ensure!(
!running(&original),
"Close the running container before native Steam launch"
);
c.name = format!("steamlaunch-{app_id}");
c.steam = false;
let xml = fs::read_to_string(c.baked.join("data_overlay/system/packages.xml"))?;
let mount = xml
.split('<')
.filter(|t| t.starts_with("package "))
.find(|t| attr(t, "name") == Some(&a.metadata.package))
.and_then(|t| attr(t, "codePath"))
.filter(|p| p.starts_with("/data/") && !p.split('/').any(|x| x == ".."))
.context("Cannot verify the installed APK mount; no application data was changed")?;
let runner = runner(home)?;
let path = root(home)
.join("steam")
.join(format!("native-{}.sh", hash(id)));
fs::create_dir_all(path.parent().unwrap())?;
steam_shortcuts::write_owned(
&path,
script(&fs::read_to_string(&runner)?)?.as_bytes(),
0o700,
)?;
storage::prepare(&c.baked)?;
let media = c.baked.join("data_overlay/media/0");
let adopted = c.baked.parent().unwrap().join("external");
let external = if fs::read_link(&media).is_ok_and(|target| target == adopted) {
ensure!(!adopted.is_symlink(), "Unexpected external media link");
adopted
} else {
c.baked.join("external")
};
let flat = a.show_window.unwrap_or(!a.metadata.vr);
let mut env = json!({
"FRAMELY_LEPTON_DIR":runner.parent().context("Missing Lepton directory")?,
"STEAM_COMPAT_DATA_PATH":c.baked.parent().context("Missing application data")?,
"FRAMELY_NATIVE_MOUNT":mount,
"FRAMELY_NATIVE_ACTIVITY":component.split_once('/').context("Invalid component")?.1,
"FRAMELY_NATIVE_LAUNCH":"true",
"FRAMELY_EXTERNAL_MEDIA_DIR":external,
"FRAMELY_SHADER_CACHE_DIR":c.baked.join("shadercache"),
"FRAMELY_BACKGROUND_BOOT":"false",
"FRAMELY_WINDOW_ORIENTATION":a.orientation.as_deref().unwrap_or("auto"),
"APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"},
"LEPTON_NO_CLEANUP":"true", "TERM":"dumb"
});
let installed_dir = c
.baked
.join("data_overlay")
.join(mount.trim_start_matches("/data/"));
if installed_dir.is_dir() {
let installed_dir = fs::canonicalize(installed_dir)?;
ensure!(
installed_dir.starts_with(fs::canonicalize(c.baked.join("data_overlay"))?),
"Installed APK directory escapes its data location"
);
env["FRAMELY_NATIVE_APK_DIR"] = json!(installed_dir);
env["FRAMELY_NATIVE_OVERLAY"] = json!(c
.baked
.join(format!("native-apk-{}", hash(&a.metadata.package))));
}
if db.gamepad_enabled {
let m = crate::gamepad::prepare(&root(home), &c.name)?;
for (k, v) in [
("FRAMELY_GAMEPAD_EVENT", json!(m.event)),
("FRAMELY_GAMEPAD_GRAB", json!(m.grab)),
("FRAMELY_GAMEPAD_READY", json!(m.ready)),
("FRAMELY_GAMEPAD_TOKEN", json!(m.token)),
] {
env[k] = v;
}
}
db.records.get_mut(id).unwrap().steam_app_id = Some(app_id);
save(home, &db)?;
Ok((c, a, json!({"script":path,"env":env})))
}
pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
ensure!(
unsafe { libc::geteuid() } != 0,
"Native APK launch must run as the Steam session user"
);
let home = crate::steam::home()?;
let expected = runner(&home)?;
let entry = command
.len()
.checked_sub(4)
.context("Unexpected native Lepton command")?;
ensure!(
fs::canonicalize(&command[entry])? == fs::canonicalize(&expected)?
&& command[entry + 1] == "waitforexitandrun"
&& command[entry + 2] == "--",
"Unexpected native Lepton command"
);
if entry != 0 {
// %command% includes Steam's launch wrapper and reaper. Retain both;
// replacing the whole command would lose native lifecycle ownership.
ensure!(
entry == 8
&& Path::new(&command[0])
.file_name()
.is_some_and(|s| s == "steam-launch-wrapper")
&& Path::new(&command[4])
.file_name()
.is_some_and(|s| s == "reaper")
&& command[1] == "--oom-score-adjust"
&& command[2].parse::<i32>().is_ok()
&& command[3] == "--"
&& command[5] == "SteamLaunch"
&& command[6].starts_with("AppId=")
&& command[7] == "--",
"Unsupported Steam launch wrapper; keeping application data unchanged"
);
for i in [0, 4] {
let path = fs::canonicalize(&command[i])?;
ensure!(
path.starts_with(fs::canonicalize(home.join(".local/share/Steam"))?),
"Unexpected Steam launch runtime"
);
}
}
let r = load(&home)?
.records
.get(app)
.cloned()
.context("Unknown APK entry")?;
ensure!(
r.steam_token.as_deref() == Some(token),
"Invalid native APK token"
);
let apk = steam_shortcuts::native_apk(&home, app);
ensure!(
fs::canonicalize(&command[entry + 3])? == fs::canonicalize(&apk)?,
"Unexpected APK launch target"
);
fs::create_dir_all(root(&home).join("steam"))?;
let context_lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(
root(&home)
.join("steam")
.join(format!("context-{}.lock", hash(&r.context))),
)?;
ensure!(
unsafe { libc::flock(context_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Another application is starting or running in this container"
);
ensure!(
unsafe { libc::fcntl(context_lock.as_raw_fd(), libc::F_SETFD, 0) } == 0,
"Cannot retain native storage ownership"
);
let lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(steam_shortcuts::wrapper(&home, app).with_extension("owner.lock"))?;
ensure!(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"This APK already has an active Steam launch"
);
// Keep the ownership lock across exec and Lepton's process-group launcher.
ensure!(
unsafe { libc::fcntl(lock.as_raw_fd(), libc::F_SETFD, 0) } == 0,
"Cannot retain native launch ownership"
);
let response = steam_bridge::native_call(
app,
token,
r.steam_app_id.context("Missing Steam APK App ID")?,
)?;
let path = response["script"]
.as_str()
.context("Missing native entry")?;
ensure!(
Path::new(path)
== root(&home)
.join("steam")
.join(format!("native-{}.sh", hash(app))),
"Invalid native entry path"
);
let mut arguments = command.to_vec();
arguments[entry] = path.to_owned();
let mut cmd = Command::new(&arguments[0]);
cmd.args(&arguments[1..])
.env_remove("FRAMELY_STEAM_APP_ID")
.env("SteamAppId", r.steam_app_id.unwrap().to_string());
for (k, v) in response["env"]
.as_object()
.context("Missing native environment")?
{
cmd.env(k, v.as_str().context("Invalid native environment value")?);
}
Err(cmd.exec().into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_shared_context_uses_complete_installed_directory_and_separate_overlay() {
let source = format!(
r#"
function print() {{ printf '%s\n' "$*"; }}
function setup_mounts() {{
APP_APK_DIR="$(dirname "$(get_apk_path)")"
local APP_OVERLAY_UPPERDIR="$(get_baked_app_data_dir)/app_overlay"
print "$APP_APK_DIR|$APP_OVERLAY_UPPERDIR|$(app_workdir)"
}}
{NATIVE_HOOKS}
setup_mounts
"#
);
for package in ["one", "two"] {
let out = Command::new("bash")
.args(["-c", &source])
.env(
"FRAMELY_NATIVE_APK_DIR",
format!("/baked/data_overlay/app/{package}"),
)
.env("FRAMELY_NATIVE_OVERLAY", format!("/baked/native-{package}"))
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), format!("/baked/data_overlay/app/{package}|/baked/native-{package}/upper|/baked/native-{package}/work"));
}
}
#[test]
fn native_hooks_keep_storage_and_selected_activity_after_early_exit() {
let t = tempfile::tempdir().unwrap();
let lib = t.path().join("lepton/liblepton");
fs::create_dir_all(&lib).unwrap();
fs::write(
lib.join("liblepton.sh"),
r#"
function data_mount_path() { echo "$TEST_DATA"; }
function setup_props() { :; }
function props_file() { echo "$TEST_PROPS"; }
function setup_mounts() {
rm -rf "$(data_mount_path)/media/0"
mkdir -p "$(data_mount_path)/media"
ln -s "${STEAM_COMPAT_DATA_PATH}/external" "$(data_mount_path)/media/0"
}
function setup_podman_mounts() { setup_mounts; }
function podman_mount_entry() { :; }
function print() { printf '%s\n' "$*"; }
function println() { print "$@"; }
"#,
)
.unwrap();
let source = r#"#!/bin/bash
set -euo pipefail
COMMAND=waitforexitandrun
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
function teardown() { clear_baked_app_data 'early exit'; }
case "${COMMAND}" in
waitforexitandrun)
is_app_baked
setup_podman_mounts
get_app_mount_dir
get_app_activity
teardown
;;
esac
"#;
let entry = t.path().join("entry.sh");
fs::write(&entry, script(source).unwrap()).unwrap();
let compat = t.path().join("context");
let data = compat.join("baked/data_overlay");
let download = data.join("media/0/Android/data/test/files/download.part");
fs::create_dir_all(download.parent().unwrap()).unwrap();
fs::write(&download, b"resume-me").unwrap();
let save = data.join("data/test/save");
fs::create_dir_all(save.parent().unwrap()).unwrap();
fs::write(&save, b"saved game").unwrap();
for _ in 0..2 {
let out = Command::new("bash")
.arg(&entry)
.env("FRAMELY_LEPTON_DIR", lib.parent().unwrap())
.env("STEAM_COMPAT_DATA_PATH", &compat)
.env("FRAMELY_EXTERNAL_MEDIA_DIR", compat.join("baked/external"))
.env("FRAMELY_SHADER_CACHE_DIR", compat.join("baked/shadercache"))
.env("FRAMELY_NATIVE_LAUNCH", "true")
.env("FRAMELY_NATIVE_MOUNT", "/data/app/test")
.env("FRAMELY_NATIVE_ACTIVITY", "test.Selected")
.env("TEST_DATA", &data)
.env("TEST_PROPS", t.path().join("props"))
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stdout).contains("test.Selected"));
assert_eq!(fs::read(&save).unwrap(), b"saved game");
assert_eq!(fs::read(&download).unwrap(), b"resume-me");
}
assert!(script("unknown future native entry").is_err());
}
}
+80 -4
View File
@@ -263,6 +263,68 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
.as_str()
.context("Missing Steam APK entry token")?;
match v["method"].as_str() {
Some("native.prepare") => {
let r = record(home, id, token)?;
ensure!(
!r.removed && r.steam_launch && shortcuts::linked(home, &r),
"Steam launch is disabled"
);
let app_id = v["steamAppId"]
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.filter(|v| *v >= 0x80000000)
.context("Launch this APK entry through Steam")?;
let (c, a, response) = super::native::prepare(home, id, app_id)?;
let owner_signature = signature(pid).context("Steam launch exited")?;
let home = home.to_owned();
let id = id.to_owned();
let token = token.to_owned();
std::thread::spawn(move || {
let result = (|| -> Result<Value> {
let deadline = Instant::now() + Duration::from_secs(120);
loop {
ensure!(
signature(pid).as_deref() == Some(&owner_signature),
"Native Lepton launch exited; inspect the APK log"
);
ensure!(
Instant::now() < deadline,
"Native Lepton startup timed out; inspect the APK log"
);
if let Ok(Some(instance)) = current_instance(&c) {
if let Ok(s) = lifecycle::sample(&c) {
if s.package == a.metadata.package
&& s.alive.contains(&a.metadata.package)
{
save_lease(
&home,
&Lease {
app: id.clone(),
token: token.clone(),
pid,
signature: owner_signature.clone(),
instance,
},
)?;
if crate::gamepad::current(&c.name).is_some() {
crate::gamepad::activate(&c.name, &a.metadata.package)?;
}
return Ok(json!({"started":true,"native":true}));
}
}
}
std::thread::sleep(Duration::from_millis(500));
}
})();
if let Some(p) = PENDING.lock().unwrap().remove(&id) {
if result.is_ok() {
(p.progress)(json!({"phase":"started"}));
}
let _ = p.result.send(result.map_err(|e| format!("{e:#}")));
}
});
Ok(response)
}
Some("start") => {
let r = record(home, id, token)?;
ensure!(
@@ -416,7 +478,7 @@ pub(super) fn start_session() -> Result<()> {
let result = peer(&stream).and_then(|pid| {
read_request(&mut stream).and_then(|v| {
let id = v["app"].as_str().unwrap_or("").to_owned();
let start = v["method"] == "start";
let start = v["method"] == "start" || v["method"] == "native.prepare";
if start {
record(&home, &id, v["token"].as_str().unwrap_or(""))?;
ensure!(
@@ -567,13 +629,27 @@ pub(super) fn request_launch(
}
result
}
pub(super) fn native_call(app: &str, token: &str, app_id: u32) -> Result<Value> {
call_with_id(app, token, "native.prepare", 15, Some(app_id))
}
fn call(app: &str, token: &str, method: &str, timeout: u64) -> Result<Value> {
call_with_id(app, token, method, timeout, None)
}
fn call_with_id(
app: &str,
token: &str,
method: &str,
timeout: u64,
owner: Option<u32>,
) -> Result<Value> {
let mut s = UnixStream::connect(socket()?).context("Framely UI session is unavailable")?;
s.set_read_timeout(Some(Duration::from_secs(timeout)))?;
s.set_write_timeout(Some(Duration::from_secs(5)))?;
let app_id = std::env::var("SteamAppId")
.ok()
.and_then(|s| s.parse::<u32>().ok());
let app_id = owner.or_else(|| {
std::env::var("SteamAppId")
.ok()
.and_then(|s| s.parse::<u32>().ok())
});
ipc::write(
&mut s,
&json!({"method":method,"app":app,"token":token,"steamAppId":app_id}),
+120 -13
View File
@@ -5,17 +5,24 @@ use std::os::unix::fs::OpenOptionsExt;
#[serde(rename_all = "camelCase")]
pub(super) struct Binding {
pub game_id: String,
#[serde(default)]
pub native: bool,
#[serde(default)]
pub revision: String,
}
pub(super) fn game_id(id: &str) -> String {
format!("framely{}", hash(id))
}
pub(super) fn native_apk(home: &Path, id: &str) -> PathBuf {
home.join("devkit-game").join(game_id(id)).join("base.apk")
}
pub(super) fn wrapper(home: &Path, id: &str) -> PathBuf {
home.join("devkit-game").join(game_id(id)).join("launch.sh")
}
fn quote(s: &str) -> String {
pub(super) fn quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn write_owned(p: &Path, bytes: &[u8], mode: u32) -> Result<()> {
pub(super) fn write_owned(p: &Path, bytes: &[u8], mode: u32) -> Result<()> {
let parent = p.parent().context("Missing entry parent")?;
ensure!(
fs::symlink_metadata(parent)?.is_dir(),
@@ -142,9 +149,14 @@ pub(super) fn rpc(home: &Path, command: &str, id: Option<&str>) -> Result<String
result
}
pub(super) fn linked(home: &Path, r: &Record) -> bool {
r.steam_binding
.as_ref()
.is_some_and(|b| b.game_id == game_id(&r.id) && wrapper(home, &r.id).is_file())
r.steam_binding.as_ref().is_some_and(|b| {
b.game_id == game_id(&r.id)
&& if b.native {
native_apk(home, &r.id).is_file()
} else {
wrapper(home, &r.id).is_file()
}
})
}
pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
ensure!(
@@ -164,12 +176,22 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
"Invalid Steam entry directory"
);
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
let script=format!("#!/bin/sh\n# Framely-owned entry; application data remains in its existing container.\nexec /var/lib/framely/current/bin/framely apk-steam --app {} --token {}\n",quote(&a.id),quote(&token));
write_owned(&p, script.as_bytes(), 0o700)?;
let db = load(home)?;
let (_, c) = app(home, &db, &a.id)?;
let apk = installed(home, &c)?
.into_iter()
.find(|(m, _)| m.package == a.metadata.package)
.map(|(_, p)| p)
.context("Installed APK is unavailable; existing data was not changed")?;
store_apk(&apk, dir)?;
// A stable APK target lets Steam own the actual Lepton compatibility launch.
for (suffix, value) in [
("argv", json!(["./launch.sh"])),
("env", json!({})),
("settings", json!({"compat_tool":"","steam_play":false})),
("argv", json!(["./base.apk"])),
("env", json!({"LEPTON_NO_CLEANUP":"true"})),
(
"settings",
json!({"compat_tool":"fauxdroid","steam_play":"0"}),
),
] {
write_owned(
&home
@@ -179,6 +201,12 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
0o600,
)?;
}
let marker = dir.join("lepton-show-flatscreen");
if a.show_window.unwrap_or(!a.metadata.vr) {
write_owned(&marker, b"", 0o600)?;
} else if marker.exists() {
fs::remove_file(&marker)?;
}
rpc(home, "create-shortcut", Some(&game))?;
let v: Value = serde_json::from_str(&rpc(home, "list-shortcuts", None)?)?;
ensure!(
@@ -188,7 +216,25 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
.is_some_and(|v| v.iter().any(|v| v == &game)),
"Steam did not register the APK entry"
);
r.steam_binding = Some(Binding { game_id: game });
let launch = format!(
"/var/lib/framely/current/bin/framely apk-native --app {} --token {} -- %command%",
quote(&a.id),
quote(&token)
);
let icon = icon_file(dir, &a.metadata)?;
r.steam_app_id = Some(super::steam_ui::configure(
&game,
r.steam_app_id,
&a.metadata.name,
icon.as_deref(),
&launch,
&native_apk(home, &a.id),
)?);
r.steam_binding = Some(Binding {
game_id: game,
native: true,
revision: revision(a),
});
Ok(())
}
pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
@@ -209,10 +255,59 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
if p.exists() {
fs::remove_file(&p)?;
}
for name in [
"base.apk",
"icon.png",
"icon.webp",
"icon.jpg",
"lepton-show-flatscreen",
] {
let file = p.parent().unwrap().join(name);
if file.exists() {
ensure!(
fs::symlink_metadata(&file)?.is_file(),
"Invalid owned Steam file"
);
fs::remove_file(file)?;
}
}
let _ = fs::remove_dir(p.parent().unwrap());
}
Ok(())
}
fn revision(a: &App) -> String {
hash(&json!({"metadata":a.metadata,"showWindow":a.show_window}).to_string())
}
fn icon_file(dir: &Path, metadata: &Metadata) -> Result<Option<PathBuf>> {
use base64::Engine;
let Some(icon) = metadata.icon.as_deref() else {
return Ok(None);
};
let Some((prefix, data)) = icon.split_once(",") else {
return Ok(None);
};
ensure!(
matches!(
prefix,
"data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64"
),
"Unsupported APK icon"
);
ensure!(data.len() <= 8 * 1024 * 1024, "APK icon is too large");
let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let picture = reader.decode()?.thumbnail(512, 512);
let path = dir.join("icon.png");
let mut png = std::io::Cursor::new(Vec::new());
picture.write_to(&mut png, image::ImageFormat::Png)?;
write_owned(&path, &png.into_inner(), 0o600)?;
Ok(Some(path))
}
// Preference and registration are separate: Steam can be asleep while the
// default-on choice remains enabled. An explicit opt-out is never auto-enabled.
pub(super) fn wanted(r: &Record) -> bool {
@@ -253,7 +348,14 @@ pub(super) fn ensure_registered(home: &Path, id: &str, force: bool) -> Result<bo
record_app(&mut db, &a);
let rec = db.records.get_mut(id).context("Missing APK record")?;
rec.steam_preference = Some(true);
if !force && rec.steam_launch && linked(home, rec) {
if !force
&& rec.steam_launch
&& linked(home, rec)
&& rec
.steam_binding
.as_ref()
.is_some_and(|b| b.native && b.revision == revision(&a))
{
save(home, &db)?;
return Ok(true);
}
@@ -295,7 +397,12 @@ pub(super) fn synchronize(home: &Path) -> Result<()> {
let force = !registered.contains(&game_id(&a.id));
if !force
&& db.records.get(&a.id).is_some_and(|r| {
r.steam_preference == Some(true) && r.steam_launch && linked(home, r)
r.steam_preference == Some(true)
&& r.steam_launch
&& linked(home, r)
&& r.steam_binding
.as_ref()
.is_some_and(|b| b.native && b.revision == revision(&a))
})
{
continue;
+202
View File
@@ -0,0 +1,202 @@
//! Local Steam UI API: metadata and launch options, never edits live VDF files.
use super::*;
use std::{
io::Read,
net::{SocketAddr, TcpStream},
};
use tungstenite::{client, protocol::WebSocketConfig, Message};
#[cfg(test)]
thread_local! {pub(super) static TEST_RESULT: std::cell::RefCell<Option<Value>>= const {std::cell::RefCell::new(None)};}
fn evaluate(expression: &str) -> Result<Value> {
#[cfg(test)]
if let Some(value) = TEST_RESULT.with(|v| v.borrow().clone()) {
return Ok(value);
}
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(3))
.build();
let mut bytes = Vec::new();
agent
.get("http://127.0.0.1:8080/json")
.call()
.context("Steam UI connection is unavailable; keep Steam awake and retry")?
.into_reader()
.take(1024 * 1024 + 1)
.read_to_end(&mut bytes)?;
ensure!(
bytes.len() <= 1024 * 1024,
"Steam UI discovery response is too large"
);
let pages: Vec<Value> = serde_json::from_slice(&bytes)?;
let page = pages
.iter()
.find(|p| {
p["title"] == "SharedJSContext"
&& p["url"]
.as_str()
.is_some_and(|u| u.starts_with("https://steamloopback.host/"))
})
.context("Steam library UI is not ready; keep Steam awake and retry")?;
let address = url::Url::parse(
page["webSocketDebuggerUrl"]
.as_str()
.context("Missing Steam UI endpoint")?,
)?;
ensure!(
address.scheme() == "ws"
&& address.host_str() == Some("127.0.0.1")
&& address.port() == Some(8080)
&& address.path().starts_with("/devtools/page/"),
"Invalid Steam UI endpoint"
);
let stream = TcpStream::connect_timeout(
&SocketAddr::from(([127, 0, 0, 1], 8080)),
Duration::from_secs(3),
)?;
stream.set_read_timeout(Some(Duration::from_secs(6)))?;
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
let (mut ws, _) = client(address.as_str(), stream)
.map_err(|_| anyhow::anyhow!("Cannot connect to Steam UI"))?;
ws.set_config(|c| {
*c = WebSocketConfig::default()
.max_message_size(Some(4 * 1024 * 1024))
.max_frame_size(Some(4 * 1024 * 1024));
});
ws.send(Message::Text(
json!({"id":1,"method":"Runtime.evaluate","params":{
"expression":expression,"returnByValue":true,"awaitPromise":true
}})
.to_string()
.into(),
))?;
let deadline = Instant::now() + Duration::from_secs(8);
loop {
ensure!(Instant::now() < deadline, "Steam UI request timed out");
let message = ws
.read()
.map_err(|_| anyhow::anyhow!("Steam UI request failed"))?;
if let Message::Text(text) = message {
let v: Value = serde_json::from_str(&text)?;
if v["id"] != 1 {
continue;
}
// Do not return JavaScript exception descriptions: they can contain launch tokens.
ensure!(
v.get("error").is_none() && v["result"].get("exceptionDetails").is_none(),
"Steam could not configure the APK entry"
);
return Ok(v["result"]["result"]["value"].clone());
}
}
}
pub(super) fn configure(
game: &str,
previous: Option<u32>,
name: &str,
icon: Option<&Path>,
launch: &str,
executable: &Path,
) -> Result<u32> {
let art = artwork(icon)?;
let parameters = json!({"game":game,"previous":previous,"name":name,
"icon":icon,"launch":launch,"exe":executable,"art":art});
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');
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
SteamClient.Apps.SetShortcutName(id,p.name);
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
for(const a of p.art){{
try{{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind)}}
catch(e){{
await new Promise(r=>setTimeout(r,200));
await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);
}}
}}
return id;
}})()"#
);
let result = evaluate(&expression)?;
result
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.filter(|v| *v >= 0x80000000)
.context("Invalid Steam APK App ID")
}
fn artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
use base64::Engine;
let Some(path) = icon else { return Ok(vec![]) };
let image = image::open(path)?.to_rgba8();
let mut out = Vec::new();
for (kind, w, h) in [(0, 600, 900), (1, 1280, 720), (3, 920, 430), (4, 512, 512)] {
let mut canvas = image::RgbaImage::from_pixel(w, h, image::Rgba([22, 24, 27, 255]));
let size = if kind == 4 {
512
} else {
(w.min(h) * 3 / 5).min(360)
};
let icon = image::imageops::thumbnail(&image, size, size);
image::imageops::overlay(
&mut canvas,
&icon,
((w - icon.width()) / 2) as i64,
((h - icon.height()) / 2) as i64,
);
let mut bytes = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(canvas).write_to(&mut bytes, image::ImageFormat::Png)?;
out.push(json!({"kind":kind,"data":base64::engine::general_purpose::STANDARD.encode(bytes.into_inner())}));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn steam_artwork_has_matching_aspect_ratios_and_never_crops_icon() {
use base64::Engine;
let t = tempfile::tempdir().unwrap();
let p = t.path().join("icon.png");
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
.save(&p)
.unwrap();
let art = artwork(Some(&p)).unwrap();
assert_eq!(art.len(), 4);
for (a, expected) in art
.iter()
.zip([(600, 900), (1280, 720), (920, 430), (512, 512)])
{
let b = base64::engine::general_purpose::STANDARD
.decode(a["data"].as_str().unwrap())
.unwrap();
let i = image::load_from_memory(&b).unwrap().to_rgba8();
assert_eq!(i.dimensions(), expected);
assert_eq!(
i.get_pixel(expected.0 / 2, expected.1 / 2).0,
[230, 30, 20, 255]
);
}
assert!(artwork(None).unwrap().is_empty());
}
}
+14
View File
@@ -48,6 +48,15 @@ enum Cmd {
#[arg(long)]
token: String,
},
/// Internal resource-preserving native Steam/Lepton entry.
ApkNative {
#[arg(long)]
app: String,
#[arg(long)]
token: String,
#[arg(last = true, required = true)]
command: Vec<String>,
},
/// Manage sideloaded APKs as the Steam session user (also works without the panel).
Apk {
operation: String,
@@ -163,6 +172,11 @@ enum Cmd {
fn run() -> Result<()> {
match Cli::parse().command {
Cmd::ApkSteam { app, token } => apk::steam_wrapper(&app, &token),
Cmd::ApkNative {
app,
token,
command,
} => apk::native_steam(&app, &token, &command),
Cmd::Apk {
operation,
params,