diff --git a/docs/user-guide/apk-manager.md b/docs/user-guide/apk-manager.md index c711919..3dbd70a 100644 --- a/docs/user-guide/apk-manager.md +++ b/docs/user-guide/apk-manager.md @@ -95,7 +95,7 @@ Moonlight V+ 12.12.12’s connection-creation screen currently crashes on the te **APK → Apps → Frame gamepad input** is off by default, including after upgrading an existing installation. Enable it to use the two Frame controllers as one standard Android gamepad for APKs opened through Framely. It includes ABXY, the D-pad and diagonals, both sticks and stick clicks, shoulder buttons, analog triggers, Select and Start. Applications must support Android gamepad input; this does not map controls to touchscreen gestures or provide Android VR controller tracking or vibration. -Enabling applies on the next application launch. Framely may restart its container to attach the input device, stopping other apps in that container. Disabling stops forwarding immediately. Only one Framely-launched APK owns the bridge at a time; switching containers releases the previous pad. Input pauses when another Android activity is foreground, and releases on controller disconnect, application exit, container stop or session-service restart. After a service restart, launch the APK again to reconnect. Keep the intended Android window selected in Dock; the bridge does not change Steam's physical-controller bindings. +Enabling applies on the next application launch. Framely may restart its container to attach the input device, stopping other apps in that container. Disabling stops forwarding immediately. Only one Framely-launched APK receives input at a time. Switching apps, closing an app, transient process-query failures and foreground changes neutralize input without removing the virtual pad. Each running container retains its device for reuse; container stop, disabling the feature or session-service restart releases it. After a service restart, launch the APK again to reconnect. Keep the intended Android window selected in Dock; the bridge does not change Steam's physical-controller bindings. 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 is 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. diff --git a/docs/zh-CN/user-guide/apk-manager.md b/docs/zh-CN/user-guide/apk-manager.md index 1acbb29..ad0ba7f 100644 --- a/docs/zh-CN/user-guide/apk-manager.md +++ b/docs/zh-CN/user-guide/apk-manager.md @@ -97,7 +97,7 @@ Moonlight V+ 12.12.12 的创建连接界面在当前测试的 Lepton 版本上 **APK → 应用 → Frame 手柄输入**默认关闭,旧配置升级后也保持关闭。开启后,通过 Framely 打开的 APK 可以将两个 Frame 手柄作为一个标准 Android 游戏手柄使用,包括 ABXY、十字键及斜向组合、双摇杆及按下、肩键、模拟扳机、Select 和 Start。应用本身需要支持 Android 游戏手柄;该功能不把按键映射为触屏操作,也不提供 Android VR 手柄追踪或振动。 -开启后在下次打开应用时生效。接入输入设备可能需要重启容器,影响其中其他正在运行的应用。关闭开关立即停止转发。同一时间只向一个由 Framely 打开的 APK 转发;切换容器会释放原来的虚拟手柄。其他 Android 页面进入前台时暂停输入;手柄断连、应用退出、容器停止或用户会话服务重启时释放输入。服务重启后再次打开 APK 即可重新接入。使用时在 Dock 中选中目标 Android 窗口;该功能不修改 Steam 的实体手柄绑定。 +开启后在下次打开应用时生效。接入输入设备可能需要重启容器,影响其中其他正在运行的应用。关闭开关立即停止转发。同一时间只向一个由 Framely 打开的 APK 转发。切换或关闭应用、进程查询暂时失败、前台切换时只暂停输入,不移除虚拟手柄。每个运行中的容器保留自己的输入设备供下次打开复用;停止容器、关闭开关或会话服务重启时释放设备。服务重启后再次打开 APK 即可重新接入。使用时在 Dock 中选中目标 Android 窗口;该功能不修改 Steam 的实体手柄绑定。 桥接以 Steam 会话用户运行,需要 `/dev/uinput` 访问权限、已运行的 SteamVR 和受支持的 Lepton 启动接口,不修改 Steam 安装目录中的 Lepton 文件。只向目标容器挂载自己的虚拟输入节点,由 Android 独占接收。接管失败时不转发输入,并提示启动错误;可以关闭开关恢复普通启动。错误记录在 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键内容。原生桥接和 Android 适配库均包含在离线包及核心更新包中。 diff --git a/src/gamepad.rs b/src/gamepad.rs index 646613d..f1745d8 100644 --- a/src/gamepad.rs +++ b/src/gamepad.rs @@ -1,6 +1,7 @@ -//! Optional, session-user-only input bridge. One container owns the virtual pad. +//! Optional, session-user-only input bridges. Each running container retains its pad. use anyhow::{ensure, Context, Result}; use std::{ + collections::BTreeMap, fs, io::{BufRead, BufReader, Read, Write}, os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt}, @@ -24,7 +25,34 @@ struct Bridge { package: Option, enabled: bool, } -static BRIDGE: Mutex> = Mutex::new(None); +#[derive(Default)] +struct Bridges { + contexts: BTreeMap, + active: Option, +} +static BRIDGE: Mutex = Mutex::new(Bridges { + contexts: BTreeMap::new(), + active: None, +}); +impl Bridge { + fn pause(&mut self) -> Result<()> { + writeln!(self.input, "disable")?; + self.enabled = false; + Ok(()) + } + fn update(&mut self, alive: Option, focused: bool) -> Result<()> { + // Activity/process records can disappear briefly during game updates, + // splash-screen handoffs or a query timeout. Keep the mounted device; + // only neutralize input until the selected app is foreground again. + let enabled = alive != Some(false) && focused && claimed(&self.mount.ready); + if self.enabled != enabled { + writeln!(self.input, "{}", if enabled { "enable" } else { "disable" })?; + self.enabled = enabled; + } + Ok(()) + } +} + static SESSION: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); pub fn session_started() { SESSION.store(true, std::sync::atomic::Ordering::Relaxed); @@ -49,32 +77,42 @@ impl Drop for Bridge { } } pub fn stop() { - BRIDGE.lock().unwrap().take(); + let mut bridges = BRIDGE.lock().unwrap(); + bridges.active = None; + bridges.contexts.clear(); } pub fn stop_context(context: &str) { - let mut b = BRIDGE.lock().unwrap(); - if b.as_ref().is_some_and(|b| b.mount.context == context) { - b.take(); + let mut bridges = BRIDGE.lock().unwrap(); + if bridges.active.as_deref() == Some(context) { + bridges.active = None; } + bridges.contexts.remove(context); } pub fn stop_app(context: &str, package: &str) { - let mut b = BRIDGE.lock().unwrap(); - if b.as_ref() - .is_some_and(|b| b.mount.context == context && b.package.as_deref() == Some(package)) - { - b.take(); + let mut bridges = BRIDGE.lock().unwrap(); + if let Some(bridge) = bridges.contexts.get_mut(context) { + if bridge.package.as_deref() == Some(package) { + let _ = bridge.pause(); + bridge.package = None; + if bridges.active.as_deref() == Some(context) { + bridges.active = None; + } + } } } pub fn current(context: &str) -> Option { - let mut b = BRIDGE.lock().unwrap(); - if let Some(value) = b.as_mut() { - if value.child.try_wait().ok().flatten().is_some() { - b.take(); + let mut bridges = BRIDGE.lock().unwrap(); + if bridges + .contexts + .get_mut(context) + .is_some_and(|b| b.child.try_wait().ok().flatten().is_some()) + { + bridges.contexts.remove(context); + if bridges.active.as_deref() == Some(context) { + bridges.active = None; } } - b.as_ref() - .filter(|b| b.mount.context == context) - .map(|b| b.mount.clone()) + bridges.contexts.get(context).map(|b| b.mount.clone()) } pub fn prepare(storage: &Path, context: &str) -> Result { ensure!(SESSION.load(std::sync::atomic::Ordering::Relaxed),"Gamepad launch requires the UI session; open the APK from the management panel or launcher"); @@ -82,16 +120,20 @@ pub fn prepare(storage: &Path, context: &str) -> Result { unsafe { libc::geteuid() } != 0, "Gamepad input must run as the Steam session user" ); - if let Some(m) = current(context) { - let mut b = BRIDGE.lock().unwrap(); - if let Some(bridge) = b.as_mut() { - writeln!(bridge.input, "disable")?; + let current = current(context); + { + let mut bridges = BRIDGE.lock().unwrap(); + bridges.active = None; + for bridge in bridges.contexts.values_mut() { + bridge.pause()?; + } + if let Some(bridge) = bridges.contexts.get_mut(context) { bridge.package = None; - bridge.enabled = false; } - return Ok(m); } - stop(); + if let Some(mount) = current { + return Ok(mount); + } let exe = std::env::current_exe()?; let distribution = exe .parent() @@ -155,44 +197,53 @@ pub fn prepare(storage: &Path, context: &str) -> Result { ); bridge.mount.event = event; let mount = bridge.mount.clone(); - *BRIDGE.lock().unwrap() = Some(bridge); + BRIDGE + .lock() + .unwrap() + .contexts + .insert(context.into(), bridge); WATCHER.get_or_init(|| { std::thread::spawn(|| loop { std::thread::sleep(Duration::from_millis(700)); let target = { - let b = BRIDGE.lock().unwrap(); - b.as_ref() + let mut bridges = BRIDGE.lock().unwrap(); + bridges + .contexts + .retain(|_, b| b.child.try_wait().ok().flatten().is_none()); + if bridges + .active + .as_ref() + .is_some_and(|context| !bridges.contexts.contains_key(context)) + { + bridges.active = None; + } + bridges + .active + .as_ref() + .and_then(|context| bridges.contexts.get(context)) .and_then(|b| b.package.as_ref().map(|p| (b.mount.clone(), p.clone()))) }; let Some((mount, package)) = target else { continue; }; let (alive, focused) = crate::apk::gamepad_health(&mount.context, &package); - let mut b = BRIDGE.lock().unwrap(); - if b.as_ref().is_none_or(|b| b.mount.token != mount.token) { + let mut bridges = BRIDGE.lock().unwrap(); + if bridges.active.as_deref() != Some(&mount.context) { continue; } - if alive == Some(false) - || b.as_mut() - .is_some_and(|b| b.child.try_wait().ok().flatten().is_some()) + let Some(bridge) = bridges + .contexts + .get_mut(&mount.context) + .filter(|b| b.mount.token == mount.token && b.package.as_deref() == Some(&package)) + else { + continue; + }; + if bridge.child.try_wait().ok().flatten().is_some() + || bridge.update(alive, focused).is_err() { - b.take(); - continue; - } - let bridge = b.as_mut().unwrap(); - let enabled = focused && claimed(&mount.ready); - if bridge.enabled != enabled { - if writeln!( - bridge.input, - "{}", - if enabled { "enable" } else { "disable" } - ) - .is_err() - { - b.take(); - continue; - } - bridge.enabled = enabled; + eprintln!("Gamepad bridge exited for container {}", mount.context); + bridges.contexts.remove(&mount.context); + bridges.active = None; } }); }); @@ -208,12 +259,14 @@ pub fn activate(context: &str, package: &str) -> Result<()> { claimed(&mount.ready), "Android did not claim the virtual gamepad; restart the container and retry" ); - let mut b = BRIDGE.lock().unwrap(); - let bridge = b - .as_mut() + let mut bridges = BRIDGE.lock().unwrap(); + let bridge = bridges + .contexts + .get_mut(context) .filter(|b| b.mount.token == mount.token) .context("Gamepad input is no longer running")?; bridge.package = Some(package.into()); + bridges.active = Some(context.into()); Ok(()) } fn claimed(directory: &Path) -> bool { @@ -241,6 +294,55 @@ fn valid_node(p: &Path) -> bool { mod tests { use super::*; #[test] + fn temporary_game_handoffs_neutralize_input_without_disconnect() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("ready"), "ready\n").unwrap(); + let mut child = Command::new("sh") + .args([ + "-c", + "while IFS= read -r line; do printf '%s\n' \"$line\"; done", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let input = child.stdin.take().unwrap(); + let mut output = BufReader::new(child.stdout.take().unwrap()); + let id = child.id(); + let mut bridge = Bridge { + mount: Mount { + context: "test".into(), + token: "same-device".into(), + event: PathBuf::from("/dev/input/event999"), + grab: PathBuf::new(), + ready: directory.path().into(), + }, + child, + input, + package: Some("com.example.game".into()), + enabled: false, + }; + let mut read = || { + let mut line = String::new(); + output.read_line(&mut line).unwrap(); + line + }; + bridge.update(Some(true), true).unwrap(); + assert_eq!(read(), "enable\n"); + bridge.update(Some(false), false).unwrap(); + assert_eq!(read(), "disable\n"); + bridge.update(None, false).unwrap(); + assert!(bridge.child.try_wait().unwrap().is_none()); + assert!(claimed(&bridge.mount.ready)); + bridge.update(Some(true), true).unwrap(); + assert_eq!(read(), "enable\n"); + bridge.pause().unwrap(); + assert_eq!(read(), "disable\n"); + assert_eq!(bridge.child.id(), id); + assert_eq!(bridge.mount.token, "same-device"); + assert!(bridge.child.try_wait().unwrap().is_none()); + } + #[test] fn readiness_requires_a_regular_acknowledgement() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ready");