diff --git a/docs/user-guide/apk-manager.md b/docs/user-guide/apk-manager.md index 148e04b..be9e8b4 100644 --- a/docs/user-guide/apk-manager.md +++ b/docs/user-guide/apk-manager.md @@ -93,7 +93,7 @@ The initial implementation supports individual APKs. Split APK bundles, XAPK/APK ### Flat window orientation -In **Manage app → Advanced settings → Window orientation**, choose **Automatic**, **Landscape**, or **Portrait**. The choice is saved per app and applied at its next launch. Forced portrait uses a tall window rather than rotating content sideways. Changing orientation may restart the shared container and stop other apps in it. VR apps ignore this setting. +In **Manage app → Advanced settings → Window orientation**, choose **Automatic**, **Landscape**, or **Portrait**. The choice is saved per app and applied at its next launch. Forced portrait uses a tall window rather than rotating content sideways. Changing orientation may restart the shared container and stop other apps in it. VR apps ignore this setting. Flat APK startup also explicitly selects fullscreen and applies it to the created Android task, avoiding a restored freeform desktop window. This affects startup only; later manual resizing remains available. Moonlight V+ 12.12.12’s connection-creation screen currently crashes on the tested Lepton build because Android’s clipboard service is absent. This is distinct from the window-startup failure. Framely does not modify or re-sign the APK; a compatible Lepton runtime or an app-side fallback is required. @@ -105,7 +105,7 @@ Enabling applies on the next application launch. Framely may restart its contain The bridge runs as the Steam session user and requires access to `/dev/uinput`, active SteamVR and the supported Lepton startup hooks. It does not modify Steam's installed Lepton files. Only its virtual event node and read-only Android key layout are mounted into the target container, where Android claims it exclusively. If this claim fails, no input is forwarded and the launch reports an error. Disable the option to use the normal launch path. Bridge errors are recorded in `~/.local/share/framely/apk-manager/logs/gamepad.log`; key presses are not logged. The native helper and Android adapter ship in both offline and core update packages. -Native Steam launches select the input target before startup acknowledgement. Forwarding begins only after Android claims the virtual device and the target application becomes foreground. Each container's helper has its own temporary SteamVR identity, so another helper cannot replace its registration. Returning to an already running application reselects its input target. +Native Steam launches select the input target before startup acknowledgement. Forwarding begins only after Android claims the virtual device and the target application becomes foreground. Frame direct helpers have separate temporary SteamVR identities. Steam Input helpers do not register OpenVR action sets, because that registration can suppress Steam virtual-gamepad enumeration in SDL. Returning to an already running application reselects its input target. When this option is enabled, launch APKs through the management panel or launcher. A one-shot CLI launch cannot own the persistent input bridge and reports an error instead. For read-only diagnostics, run `framely apk input-status '{"app":"CONTEXT/PACKAGE"}'` as the Steam session user. It reports Android process, foreground and lifecycle status without input events or Steam tokens; it does not prove that the application handles gamepad events. @@ -121,7 +121,7 @@ Steam Input loads SDL3 from Steam’s bundled ARM64 runtime and reads Steam’s **手柄震动**可独立关闭并立即生效。支持游戏请求的 `FF_RUMBLE`,回传给当前输入手柄;切换来源、失去 Android 前台状态、断连或退出时停止,不恢复旧效果。强度上限为一半,单次请求最长十秒;不转发手机振动或 Android VR 触觉。多个手柄以最近产生有效输入的手柄为来源,不合并输入。 -桥接以 Steam 会话用户运行,使用 Steam 随附的 ARM64 SDL3,仅读取 Steam 虚拟手柄。Framely 输出设备使用独立标识并排除自身,避免反馈循环。仅把专用事件节点及只读按键布局挂载进容器,不修改系统或 Steam 安装文件。SDL3 不可用时明确报错,可手动选择 Frame 直连。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键。APK 必须从管理面板或启动台启动,单次 CLI 不能持有持续桥接。 +桥接以 Steam 会话用户运行,使用 Steam 随附的 ARM64 SDL3,仅读取 Steam 虚拟手柄。Framely 输出设备使用独立标识并排除自身,避免反馈循环。仅把专用事件节点及只读按键布局挂载进容器,不修改系统或 Steam 安装文件。Frame 直连使用独立的 SteamVR 身份;Steam Input 不注册 OpenVR 输入动作,避免影响 SDL 的虚拟手柄枚举。SDL3 不可用时明确报错,可手动选择 Frame 直连。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键。APK 必须从管理面板或启动台启动,单次 CLI 不能持有持续桥接。 ## Container lifecycle @@ -155,7 +155,7 @@ Disabling Steam launch removes only Framely's entry; close a native Steam app fi Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。 -原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。 +原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。平面 APK 启动后会通过 Android 任务接口切换为全屏,避免恢复成桌面内的自由窗口;不会持续锁定用户之后的窗口调整。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。 切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。 diff --git a/native/android/FramelyWindow.java b/native/android/FramelyWindow.java new file mode 100644 index 0000000..ce9588f --- /dev/null +++ b/native/android/FramelyWindow.java @@ -0,0 +1,22 @@ +import java.lang.reflect.*; +import java.util.List; +public final class FramelyWindow { + public static void main(String[] args) throws Exception { + if(args.length!=1 || !args[0].matches("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z0-9_]+)+")) throw new IllegalArgumentException("Expected package name"); + Class manager=Class.forName("android.app.ActivityTaskManager"); + Object service=manager.getDeclaredMethod("getService").invoke(null); + Class api=Class.forName("android.app.IActivityTaskManager"); + Method tasks=api.getMethod("getTasks",int.class); + Method mode=api.getMethod("setTaskWindowingMode",int.class,int.class,boolean.class); + for(int attempt=0;attempt<20;attempt++) { + for(Object task:(List)tasks.invoke(service,32)) { + Object top=task.getClass().getField("topActivity").get(task); + if(top==null || !args[0].equals(top.getClass().getMethod("getPackageName").invoke(top)))continue; + int id=task.getClass().getField("taskId").getInt(task); + if(Boolean.TRUE.equals(mode.invoke(service,id,1,true))) {System.out.println("FRAMELY_FULLSCREEN_OK");return;} + } + Thread.sleep(250); + } + throw new IllegalStateException("Target task could not enter fullscreen"); + } +} diff --git a/native/android/README.md b/native/android/README.md new file mode 100644 index 0000000..de62384 --- /dev/null +++ b/native/android/README.md @@ -0,0 +1,30 @@ +# Android task window helper + +`FramelyWindow.java` uses the Android 11 task service to put only the requested +package's foreground task into fullscreen. A startup windowing-mode option alone +can leave a restored Lepton task in freeform mode. This helper changes task mode +without stopping the game or changing its installation, data, Android image or +host compositor. VR launches do not invoke it. It exits after success or a bounded +five-second wait; it does not prevent later manual window adjustments. + +The small checked-in DEX is embedded in Framely. `window.sh` supplies ART's runtime +environment for `podman exec`; both files are mounted read-only into the container. +No APK installation or network access is required at runtime. An unsupported task +API fails explicitly instead of guessing Binder transaction numbers. + +Rebuild with `bash tools/build-android-window.sh` (JDK and curl required). This +uses Google's D8 8.3.37, pinned by SHA-256. D8 is a build tool and is not shipped. + +## 中文 + +`FramelyWindow.java` 通过 Android 11 的任务接口,只将指定包的前台任务切换为全屏。 +Lepton 恢复已有任务时,仅指定启动窗口模式仍可能留下自由窗口。此助手不停止游戏, +不修改安装、数据、Android 镜像或宿主合成器。VR 启动不调用助手。成功后立即退出, +等待最多五秒,不持续限制用户之后手动调整窗口。 + +小型 DEX 随 Framely 内置;`window.sh` 为 `podman exec` 补充 ART 运行环境。二者以 +只读方式挂载,运行时无需安装 APK 或联网。不支持的任务接口会明确失败,不猜测 +Binder 调用编号。 + +执行 `bash tools/build-android-window.sh` 可重新生成,需要 JDK 和 curl。 +构建使用 Google D8 8.3.37,并校验固定 SHA-256;安装包不包含 D8。 diff --git a/native/android/framely-window.dex b/native/android/framely-window.dex new file mode 100644 index 0000000..96dcfa1 Binary files /dev/null and b/native/android/framely-window.dex differ diff --git a/native/android/window.sh b/native/android/window.sh new file mode 100644 index 0000000..fb4fc32 --- /dev/null +++ b/native/android/window.sh @@ -0,0 +1,8 @@ +#!/system/bin/sh +set -eu +# podman exec lacks init's ART environment. Import only the runtime paths. +for entry in $(cat /proc/$(pidof system_server)/environ | tr '\0' '\n' | grep -E '^(ANDROID_(ROOT|DATA|ART_ROOT|I18N_ROOT|TZDATA_ROOT)|BOOTCLASSPATH)='); do + export "$entry" +done +export CLASSPATH=/vendor/share/framely-window.dex +exec app_process /system/bin FramelyWindow "$@" diff --git a/src/apk.rs b/src/apk.rs index a54782d..46a02b3 100644 --- a/src/apk.rs +++ b/src/apk.rs @@ -1109,6 +1109,23 @@ fn persistent_registration(c: &Container, expected: &Metadata, log: &Path) -> Re } // Adapt only the entry script in a private temporary file. Keep Lepton's original // libraries and dev-context data handling; app mode can clear existing baked data. +fn window_runtime(home: &Path) -> Result<(PathBuf, PathBuf)> { + let dir = root(home).join("steam/window-runtime"); + fs::create_dir_all(&dir)?; + let dex = dir.join("framely-window.dex"); + let script = dir.join("window.sh"); + steam_shortcuts::write_owned( + &dex, + include_bytes!("../native/android/framely-window.dex"), + 0o600, + )?; + steam_shortcuts::write_owned( + &script, + include_bytes!("../native/android/window.sh"), + 0o600, + )?; + Ok((dex, script)) +} fn direct_launch_script(source: &str) -> Result { let directory = "SCRIPT_DIR=$( cd -- \"$( dirname -- \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )"; @@ -1189,6 +1206,16 @@ if [[ -n "${FRAMELY_SHADER_CACHE_DIR:-}${FRAMELY_EXTERNAL_MEDIA_DIR:-}" ]]; then fi } fi +# Per-user readonly helper uses Android's task API, without patching its image. +if [[ -n "${FRAMELY_WINDOW_DEX:-}" ]]; then + declare -F setup_podman_mounts >/dev/null || { echo "Unsupported Lepton window mounts" >&2; exit 64; } + eval "$(declare -f setup_podman_mounts | sed '1s/setup_podman_mounts/framely_original_window_mounts/')" + function setup_podman_mounts() { + framely_original_window_mounts "$@" || return + podman_mount_entry "$FRAMELY_WINDOW_DEX" /vendor/share/framely-window.dex ro + podman_mount_entry "${FRAMELY_WINDOW_SCRIPT:?}" /vendor/share/framely-window.sh ro + } +fi # Optional virtual gamepad: mount only its event node, not host input devices. if [[ -n "${FRAMELY_GAMEPAD_EVENT:-}" ]]; then for hook in setup_podman_mounts generate_zygote_launch_rc; do @@ -1357,6 +1384,9 @@ fn start_oriented_container( "false" }, ); + let (window_dex, window_script) = window_runtime(home)?; + cmd.env("FRAMELY_WINDOW_DEX", window_dex) + .env("FRAMELY_WINDOW_SCRIPT", window_script); cmd.env_remove("SteamAppId"); storage::configure( &mut cmd, @@ -1844,26 +1874,32 @@ fn launch_with_started(c: &Container, a: &App, log: &Path, started: impl FnOnce( if a.show_window.unwrap_or(!a.metadata.vr) { apply_orientation(c, a.orientation.as_deref(), log)?; } - let s = podman( - &[ - "exec", - &format!("lepton-{}", c.name), - "am", - "start", - "-W", - "--user", - "0", - "-n", - &component, - ], - Some(log), - )?; + let runtime = format!("lepton-{}", c.name); + let mut launch_args = vec!["exec"]; + launch_args.extend([runtime.as_str(), "am", "start", "-W", "--user", "0"]); + if a.show_window.unwrap_or(!a.metadata.vr) { + launch_args.extend(["--windowingMode", "1"]); + } + launch_args.extend(["-n", &component]); + let s = podman(&launch_args, Some(log))?; ensure!( !s.contains("Error:") && !s.contains("Exception") && s.lines().any(|line| line.trim() == "Status: ok"), "Application launch failed: {s}" ); + if a.show_window.unwrap_or(!a.metadata.vr) { + podman( + &[ + "exec", + &runtime, + "sh", + "/vendor/share/framely-window.sh", + &a.metadata.package, + ], + Some(log), + )?; + } // Select the display mode after the requested activity is foreground. // Lepton's per-app HWC mode can create a zero-sized xdg_surface on Gamescope // and abort its display service. Full-display mode avoids that transition; @@ -4388,7 +4424,7 @@ setup_props launch(&c, &a, &log).unwrap(); let trace = fs::read_to_string(&commands).unwrap(); let start = trace - .find("am start -W --user 0 -n com.example.app/") + .find("am start -W --user 0 --windowingMode 1 -n com.example.app/") .unwrap(); let reveal = trace.find("setprop waydroid.active_apps Waydroid").unwrap(); assert!(start < reveal); @@ -4398,6 +4434,9 @@ setup_props assert!(fs::read_to_string(&commands) .unwrap() .contains("setprop waydroid.active_apps none")); + assert!(!fs::read_to_string(&commands) + .unwrap() + .contains("--windowingMode")); fs::write(&commands, "").unwrap(); a.show_window = Some(true); fs::write(f.dir.path().join("launch-fail"), "").unwrap(); @@ -4998,7 +5037,9 @@ esac assert_eq!(fs::read(&mounted).unwrap(), original); assert!(fs::read_to_string(f.dir.path().join("commands")) .unwrap() - .contains("am start -W --user 0 -n com.example.extra/com.example.extra.Main")); + .contains( + "am start -W --user 0 --windowingMode 1 -n com.example.extra/com.example.extra.Main" + )); } #[test] fn review_tampering_expiry_and_cancel_are_rejected() { diff --git a/src/apk/native.rs b/src/apk/native.rs index 6522fa8..18b9a15 100644 --- a/src/apk/native.rs +++ b/src/apk/native.rs @@ -9,6 +9,17 @@ const NATIVE_HOOKS: &str = r#" function is_app_baked() { return 0; } function get_app_mount_dir() { print "${FRAMELY_NATIVE_MOUNT:?}"; } function get_app_activity() { print "${FRAMELY_NATIVE_ACTIVITY:?}"; } +# Android may otherwise reuse its desktop/freeform launch preference. Select +# fullscreen before creating the activity, rather than resizing it afterwards. +function run_app_commands() { + local APP_ID="${1:-$(get_app_id)}" + local APP_ACTIVITY="${2:-$(get_app_activity)}" + if app_wants_flatscreen; then + print "am start -S --windowingMode 1 ${APP_ID}/${APP_ACTIVITY} && /system/bin/sh /vendor/share/framely-window.sh ${APP_ID}" + else + print "am start -S ${APP_ID}/${APP_ACTIVITY}" + fi +} 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'; } @@ -149,7 +160,10 @@ pub(super) fn prepare( c.baked.join("external") }; let flat = a.show_window.unwrap_or(!a.metadata.vr); + let (window_dex, window_script) = window_runtime(home)?; let mut env = json!({ + "FRAMELY_WINDOW_DEX": window_dex, + "FRAMELY_WINDOW_SCRIPT": window_script, "FRAMELY_LEPTON_DIR":runner.parent().context("Missing Lepton directory")?, "STEAM_COMPAT_DATA_PATH":c.baked.parent().context("Missing application data")?, "FRAMELY_NATIVE_MOUNT":mount, @@ -373,6 +387,7 @@ waitforexitandrun) setup_podman_mounts get_app_mount_dir get_app_activity + run_app_commands test.Package test.Selected teardown ;; esac @@ -387,7 +402,7 @@ esac 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 { + for flat in [true, false] { let out = Command::new("bash") .arg(&entry) .env("FRAMELY_LEPTON_DIR", lib.parent().unwrap()) @@ -397,6 +412,7 @@ esac .env("FRAMELY_NATIVE_LAUNCH", "true") .env("FRAMELY_NATIVE_MOUNT", "/data/app/test") .env("FRAMELY_NATIVE_ACTIVITY", "test.Selected") + .env("APP_WANTS_FLATSCREEN", if flat { "true" } else { "false" }) .env("TEST_DATA", &data) .env("TEST_PROPS", t.path().join("props")) .output() @@ -407,6 +423,12 @@ esac String::from_utf8_lossy(&out.stderr) ); assert!(String::from_utf8_lossy(&out.stdout).contains("test.Selected")); + let expected = if flat { + "am start -S --windowingMode 1 test.Package/test.Selected" + } else { + "am start -S test.Package/test.Selected" + }; + assert!(String::from_utf8_lossy(&out.stdout).contains(expected)); assert_eq!(fs::read(&save).unwrap(), b"saved game"); assert_eq!(fs::read(&download).unwrap(), b"resume-me"); } diff --git a/tools/build-android-window.sh b/tools/build-android-window.sh new file mode 100644 index 0000000..16bf0bb --- /dev/null +++ b/tools/build-android-window.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Reproduce the checked-in tiny DEX helper; normal builds need no JDK/download. +set -euo pipefail +base=$(cd -- "$(dirname -- "$0")/.." && pwd) +work="$base/target/android-window-build" +mkdir -p "$work" +url=https://dl.google.com/dl/android/maven2/com/android/tools/r8/8.3.37/r8-8.3.37.jar +curl -fsSL "$url" -o "$work/r8.jar" +printf '%s %s\n' 59753e70a74f918389cc87f1b7d66b5c0862932559167425708ded159e3de439 "$work/r8.jar" | sha256sum -c - +javac --release 8 -Xlint:-options -d "$work" "$base/native/android/FramelyWindow.java" +java -cp "$work/r8.jar" com.android.tools.r8.D8 --release --min-api 30 --output "$work" "$work/FramelyWindow.class" +cp "$work/classes.dex" "$base/native/android/framely-window.dex"