mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-23 18:01:56 +02:00
fix(apk): retain gamepad and storage ownership across native launch
This commit is contained in:
1 parent
0ec7afeafa
commit
5a2bb2dacd
5 files changed
+127
-42
No files matched your search
+5
-3
@@ -1,7 +1,6 @@
|
||||
// Session-owned OpenVR -> Android gamepad. No CEF dependency or timed test limit.
|
||||
#include "openvr.h"
|
||||
#include <linux/uinput.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <poll.h>
|
||||
@@ -23,7 +22,10 @@ static bool event(int fd,int type,int code,int value){input_event e{};e.type=typ
|
||||
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;}
|
||||
int main(int argc,char**argv){
|
||||
if(argc!=2||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
|
||||
auto parent=getppid();prctl(PR_SET_PDEATHSIG,SIGTERM);if(getppid()!=parent)return 1;
|
||||
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
|
||||
// follows that thread's lifetime, so it destroys the pad as soon as prepare
|
||||
// returns. The session-owned stdin pipe and parent process own this helper.
|
||||
const auto parent=getppid();
|
||||
signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN);
|
||||
vr::EVRInitError error;vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){fprintf(stderr,"SteamVR gamepad input unavailable: %d\n",error);return 1;}
|
||||
auto*input=vr::VRInput();vr::VRActiveActionSet_t set{};
|
||||
@@ -47,7 +49,7 @@ int main(int argc,char**argv){
|
||||
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]{};
|
||||
while(!stopped){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);enabled=command=="enable";}if(commands.size()>128)break;}
|
||||
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);enabled=command=="enable";}if(commands.size()>128)break;}
|
||||
int state[18]{};
|
||||
// If Android releases its exclusive claim (e.g. InputReader restart),
|
||||
// pause until it claims the node again. Do not feed other host consumers.
|
||||
|
||||
+4
-1
@@ -4421,7 +4421,8 @@ setup_props
|
||||
)
|
||||
.unwrap();
|
||||
assert!(native::prepare(&f.home, id, 0x92345679).is_err());
|
||||
let (c, a, response) = native::prepare(&f.home, id, 0x92345678).unwrap();
|
||||
let (c, a, response, ownership) = native::prepare(&f.home, id, 0x92345678).unwrap();
|
||||
assert!(native::prepare(&f.home, id, 0x92345678).is_err());
|
||||
assert_eq!(c.id, "test");
|
||||
assert_eq!(c.name, "steamlaunch-2452903544");
|
||||
assert!(!c.steam);
|
||||
@@ -4439,6 +4440,8 @@ setup_props
|
||||
fs::read(installed_dir.join("lib/arm64/game.so")).unwrap(),
|
||||
b"existing extracted library"
|
||||
);
|
||||
drop(ownership);
|
||||
assert!(native::prepare(&f.home, id, 0x92345678).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn unreadable_state_is_not_uninstallation_or_a_launch_target() {
|
||||
|
||||
+23
-36
@@ -49,7 +49,11 @@ pub(super) fn runtime_name(r: &Record) -> Option<String> {
|
||||
.map(|id| format!("steamlaunch-{id}"))
|
||||
}
|
||||
|
||||
pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container, App, Value)> {
|
||||
pub(super) fn prepare(
|
||||
home: &Path,
|
||||
id: &str,
|
||||
app_id: u32,
|
||||
) -> Result<(Container, App, Value, fs::File)> {
|
||||
let _guard = MUTATION
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
|
||||
@@ -71,6 +75,23 @@ pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container,
|
||||
&& r.steam_app_id == Some(app_id),
|
||||
"Steam APK binding changed; refresh the entry and retry"
|
||||
);
|
||||
// Steam's launcher closes inherited descriptors. Keep this lock in the
|
||||
// session service, which owns it until the launch and container have ended.
|
||||
fs::create_dir_all(root(home).join("steam"))?;
|
||||
let ownership = 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(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another application is starting or running in this container"
|
||||
);
|
||||
let (a, mut c) = app(home, &db, id)?;
|
||||
ensure!(
|
||||
a.installed && a.state_known && a.pending.is_none(),
|
||||
@@ -163,7 +184,7 @@ pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container,
|
||||
}
|
||||
db.records.get_mut(id).unwrap().steam_app_id = Some(app_id);
|
||||
save(home, &db)?;
|
||||
Ok((c, a, json!({"script":path,"env":env})))
|
||||
Ok((c, a, json!({"script":path,"env":env}), ownership))
|
||||
}
|
||||
|
||||
pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
|
||||
@@ -224,40 +245,6 @@ pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
|
||||
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,
|
||||
|
||||
+33
-2
@@ -274,14 +274,18 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
|
||||
.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 (c, a, response, ownership) = 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();
|
||||
let gamepad_event = response["env"]["FRAMELY_GAMEPAD_EVENT"]
|
||||
.as_str()
|
||||
.map(PathBuf::from);
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Result<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
let creation_deadline = Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
ensure!(
|
||||
signature(pid).as_deref() == Some(&owner_signature),
|
||||
@@ -291,7 +295,14 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
|
||||
Instant::now() < deadline,
|
||||
"Native Lepton startup timed out; inspect the APK log"
|
||||
);
|
||||
if let Ok(Some(instance)) = current_instance(&c) {
|
||||
let instance = current_instance(&c)?;
|
||||
if instance.is_none() {
|
||||
if let Some(event) = &gamepad_event {
|
||||
ensure!(event.exists(), "Virtual gamepad disappeared before Lepton created its container; retry the launch");
|
||||
}
|
||||
ensure!(Instant::now() < creation_deadline, "Lepton did not create its container; inspect the native Lepton startup log");
|
||||
}
|
||||
if let Some(instance) = instance {
|
||||
if let Ok(s) = lifecycle::sample(&c) {
|
||||
if s.package == a.metadata.package
|
||||
&& s.alive.contains(&a.metadata.package)
|
||||
@@ -316,12 +327,32 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
})();
|
||||
// A failed Podman run can leave Lepton waiting for a create
|
||||
// event forever. End only this verified, still-uncreated launch;
|
||||
// never interrupt a container that has already booted.
|
||||
if result.is_err() && matches!(current_instance(&c), Ok(None)) {
|
||||
if signature(pid).as_deref() == Some(&owner_signature) {
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
crate::gamepad::stop_context(&c.name);
|
||||
}
|
||||
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:#}")));
|
||||
}
|
||||
// Preserve storage ownership after reporting startup success.
|
||||
// Steam drops inherited fds, so the service retains this lock
|
||||
// through exit and the container's eventual shutdown.
|
||||
while signature(pid).as_deref() == Some(&owner_signature)
|
||||
|| !matches!(current_instance(&c), Ok(None))
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
drop(ownership);
|
||||
});
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame integration check: worker exit retains the pad, pipe EOF removes it.
|
||||
|
||||
Run as the Steam user with SteamVR awake. The helper remains disabled throughout;
|
||||
this test creates its own neutral virtual device and never reads physical input.
|
||||
Usage: python3 tests/gamepad_lifetime.py HELPER ACTION_MANIFEST
|
||||
"""
|
||||
import pathlib
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def check(helper, manifest):
|
||||
state = {}
|
||||
|
||||
def worker():
|
||||
try:
|
||||
child = subprocess.Popen(
|
||||
[helper, manifest], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
state['child'] = child
|
||||
if not select.select([child.stdout], [], [], 8)[0]:
|
||||
raise RuntimeError('Virtual gamepad readiness timed out')
|
||||
state['node'] = pathlib.Path(child.stdout.readline().decode().strip())
|
||||
except Exception as error:
|
||||
state['error'] = error
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
thread.join(10)
|
||||
child = state.get('child')
|
||||
try:
|
||||
assert not thread.is_alive(), 'Preparation worker did not finish'
|
||||
if 'error' in state:
|
||||
raise state['error']
|
||||
node = state['node']
|
||||
assert node.parent == pathlib.Path('/dev/input') and node.name.startswith('event')
|
||||
time.sleep(2)
|
||||
assert child.poll() is None, 'Worker exit destroyed the virtual gamepad'
|
||||
assert node.is_char_device(), 'Virtual gamepad node disappeared'
|
||||
child.stdin.close()
|
||||
assert child.wait(timeout=5) == 0, 'Control pipe EOF did not cleanly stop the helper'
|
||||
assert not node.exists(), 'Virtual gamepad survived control pipe EOF'
|
||||
print('PASS: worker exit preserves the gamepad; session pipe close removes it')
|
||||
finally:
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit(__doc__)
|
||||
check(*sys.argv[1:])
|
||||
Reference in new issue
Block a user