Compare commits

...
10 changed files with 190 additions and 23 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.5.1-preview.2"
version = "0.5.1-preview.3"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.5.1-preview.2"
version = "0.5.1-preview.3"
edition = "2021"
license = "AGPL-3.0-only"
+2 -1
View File
@@ -21,7 +21,7 @@ static void stop(int){stopped=1;}
#include "gamepad_feedback.h"
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
static bool send(int fd,const int* state){static GamepadTriggers triggers;bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<2;k++)ok=event(fd,EV_KEY,trigger_keys[k],triggers.update(k,state[14+k]))&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
int main(int argc,char**argv){
if((argc!=2&&argc!=4)||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
@@ -58,6 +58,7 @@ int main(int argc,char**argv){
int fd=open("/dev/uinput",O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");shutdown();return 1;}
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0&&ioctl(fd,UI_SET_EVBIT,EV_FF)==0&&ioctl(fd,UI_SET_FFBIT,FF_RUMBLE)==0;
for(auto key:keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
for(auto key:trigger_keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
for(int k=0;k<8;k++){ok=ioctl(fd,UI_SET_ABSBIT,axes[k])==0&&ok;uinput_abs_setup a{};a.code=axes[k];a.absinfo.minimum=k<4?-32768:(k<6?0:-1);a.absinfo.maximum=k<4?32767:(k<6?255:1);a.absinfo.flat=k<4?1024:0;ok=ioctl(fd,UI_ABS_SETUP,&a)==0&&ok;}
uinput_setup device{};device.id={BUS_VIRTUAL,framely_pad_vendor,framely_pad_product,0x0001};device.ff_effects_max=GamepadFeedback::capacity;strcpy(device.name,"Framely Android gamepad");
ok=ioctl(fd,UI_DEV_SETUP,&device)==0&&ioctl(fd,UI_DEV_CREATE)==0&&ok;
+8
View File
@@ -4,3 +4,11 @@
// Xbox/Android maps X to Linux 307 and Y to 308. The positional
// aliases WEST/NORTH do not match these letter names.
inline constexpr int keys[]={BTN_A,BTN_B,BTN_X,BTN_Y,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
inline constexpr int trigger_keys[]={BTN_TL2,BTN_TR2};
// Preserve analog travel while exposing Android's digital L2/R2 events.
// Press at 30/255 and release at 20/255 to avoid noisy edges.
struct GamepadTriggers {
bool down[2]{};
bool update(int side,int value){down[side]=down[side]?value>20:value>=30;return down[side];}
};
@@ -5,6 +5,8 @@ key 307 BUTTON_X
key 308 BUTTON_Y
key 310 BUTTON_L1
key 311 BUTTON_R1
key 312 BUTTON_L2
key 313 BUTTON_R2
key 314 BUTTON_SELECT
key 315 BUTTON_START
key 317 BUTTON_THUMBL
+108 -1
View File
@@ -545,6 +545,48 @@ fn restriction(c: &Container, package: &str) -> Option<(bool, bool, Vec<String>)
}
None
}
// Legacy installs may have reused a package-derived context for another app.
// Allocate around that occupied context instead of touching its files or records.
fn automatic_install_context(
home: &Path,
db: &Database,
cs: &[Container],
package: &str,
) -> Result<String> {
let base = format!("framely-{}", hash(package));
for suffix in 0..1000 {
let id = if suffix == 0 {
base.clone()
} else {
format!("{base}-{}", suffix + 1)
};
let records: Vec<_> = db.records.values().filter(|r| r.context == id).collect();
if let Some(c) = cs.iter().find(|c| c.id == id) {
let packages = if c.baked.exists() {
installed(home, c)?
} else {
Vec::new()
};
if packages.iter().any(|(m, _)| m.package == package) {
return Ok(id); // Updating an existing app also remains valid in legacy shared containers.
}
if packages.iter().any(|(m, _)| m.package != package)
|| records.iter().any(|r| r.metadata.package != package)
{
continue;
}
return Ok(id);
}
if !records.is_empty()
|| fs::symlink_metadata(home.join(".local/share/lepton/contexts").join(&id)).is_ok()
{
continue; // Preserve retained records and unreadable/dangling paths.
}
return Ok(id);
}
anyhow::bail!("No free independent APK container name is available")
}
fn installed(home: &Path, c: &Container) -> Result<Vec<(Metadata, PathBuf)>> {
let p = c.baked.join("data_overlay/system/packages.xml");
let bytes = fs::read(&p)?;
@@ -2591,7 +2633,7 @@ fn operate_internal(
let requested = p["context"].as_str().unwrap_or("");
let cs = containers(home, &db);
let context = if requested.is_empty() {
format!("framely-{}", hash(&metadata.package))
automatic_install_context(home, &db, &cs, &metadata.package)?
} else {
requested.into()
};
@@ -5063,6 +5105,71 @@ setup_props
assert!(f.save_file().exists());
assert!(!load(&f.home).unwrap().records["test/com.example.app"].removed);
}
#[test]
fn automatic_install_context_preserves_occupied_legacy_contexts() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let package = "com.example.new";
let base = format!("framely-{}", hash(package));
let baked = home
.join(".local/share/lepton/contexts")
.join(&base)
.join("baked");
fs::create_dir_all(baked.join("data_overlay/system")).unwrap();
let xml = b"<packages><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>";
fs::write(baked.join("data_overlay/system/packages.xml"), xml).unwrap();
let c = Container {
id: base.clone(),
name: base.clone(),
baked: baked.clone(),
running: false,
managed: true,
steam: false,
};
let mut db = Database::default();
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-2")
);
assert_eq!(
fs::read(baked.join("data_overlay/system/packages.xml")).unwrap(),
xml
);
// An unreadable context must also be skipped, rather than overwritten.
fs::create_dir_all(
home.join(".local/share/lepton/contexts")
.join(format!("{base}-2")),
)
.unwrap();
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-3")
);
// A retained foreign app reserves a name even if its directory is gone.
db.records.insert(
"retained".into(),
Record {
context: format!("{base}-3"),
metadata: Metadata {
package: "com.example.retained".into(),
..Default::default()
},
removed: true,
..Default::default()
},
);
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-4")
);
// Same-package updates still reuse the existing legacy shared container.
fs::write(baked.join("data_overlay/system/packages.xml"), format!("<packages><package name=\"{package}\" codePath=\"/data/app/new\" version=\"1\"/><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>")).unwrap();
assert_eq!(
automatic_install_context(home, &db, &[c], package).unwrap(),
base
);
}
#[test]
fn shared_mount_loss_preserves_launcher_identity_until_confirmed_uninstall() {
let f = Fixture::new();
+32
View File
@@ -0,0 +1,32 @@
async function(p) {
const executable = d => d.strShortcutExe?.replace(/^"|"$/g, '').replace('/./', '/');
const details = id => new Promise((resolve, reject) => {
let sub, settled = false;
const timer = setTimeout(() => { sub?.unregister(); reject(Error('Details timed out')); }, 3000);
sub = SteamClient.Apps.RegisterForAppDetails(id, d => { settled = true; clearTimeout(timer); sub?.unregister(); resolve(d); });
// Steam may invoke a cached callback synchronously.
if (settled) sub?.unregister();
});
let apps, app;
for (let n = 0; n < 10; n++) {
apps = Array.from(appStore.m_mapApps.values());
const matches = apps.filter(a => a.display_name === 'Devkit Game: ' + p.game);
app = matches.length === 1 ? matches[0] : apps.find(a => a.appid === p.previous);
if (app) break;
if (n < 9) await new Promise(r => setTimeout(r, 100));
}
if (app) {
if (app.appid < 2147483648) throw Error('Missing owned shortcut');
const d = await details(app.appid);
if (executable(d) !== p.exe) throw Error('Shortcut target does not match owned APK');
return { id: app.appid };
}
// A previous attempt may have renamed the entry before persisting its ID.
// Recover by exact executable ownership, never by a user-visible app name.
const shortcuts = apps.filter(a => a.appid >= 2147483648);
if (shortcuts.length > 256) throw Error('Too many shortcuts to recover owned entry');
const rows = await Promise.allSettled(shortcuts.map(async a => ({ id: a.appid, details: await details(a.appid) })));
const owned = rows.filter(r => r.status === 'fulfilled' && executable(r.value.details) === p.exe);
if (owned.length !== 1) throw Error('Missing or ambiguous owned shortcut');
return { id: owned[0].value.id };
}
+4 -19
View File
@@ -105,24 +105,8 @@ pub(super) fn configure(
let expression = format!(
r#"(async()=>{{
const p={parameters};
let app;
for(let n=0;n<10;n++){{
const apps=Array.from(appStore.m_mapApps.values());
const matches=apps.filter(a=>a.display_name==='Devkit Game: '+p.game);
app=matches.length===1?matches[0]:apps.find(a=>a.appid===p.previous);
if(app) break;
await new Promise(r=>setTimeout(r,100));
}}
if(!app || app.appid<2147483648) throw Error('Missing owned shortcut');
const id=app.appid;
let sub;
const details=await new Promise((resolve,reject)=>{{
let timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
sub=SteamClient.Apps.RegisterForAppDetails(id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
}});
sub?.unregister();
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
const owned=await ({lookup})(p);
const id=owned.id;
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
SteamClient.Apps.SetShortcutName(id,p.name);
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
@@ -134,7 +118,8 @@ pub(super) fn configure(
}}
}}
return id;
}})()"#
}})()"#,
lookup = include_str!("steam_shortcut_lookup.js")
);
let result = evaluate(&expression)?;
result
+8
View File
@@ -14,4 +14,12 @@ int main(){
while(std::getline(file,line)){std::istringstream row(line);std::string kind,name;int code;if(row>>kind>>code>>name&&kind=="key")entries[code]=name;}
const char* names[]={"BUTTON_A","BUTTON_B","BUTTON_X","BUTTON_Y","BUTTON_L1","BUTTON_R1","BUTTON_SELECT","BUTTON_START","BUTTON_THUMBL","BUTTON_THUMBR"};
for(int i=0;i<10;i++)assert(entries[keys[i]]==names[i]);
assert(trigger_keys[0]==312&&trigger_keys[1]==313);
assert(entries[trigger_keys[0]]=="BUTTON_L2"&&entries[trigger_keys[1]]=="BUTTON_R2");
GamepadTriggers triggers;
assert(!triggers.update(0,0));assert(!triggers.update(0,29));
assert(triggers.update(0,30));assert(triggers.update(0,25));
assert(!triggers.down[1]);assert(triggers.update(1,255));
assert(!triggers.update(0,20));assert(triggers.down[1]);
assert(!triggers.update(1,0));assert(!triggers.down[0]&&!triggers.down[1]);
}
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const lookup=eval(`(${await readFile('src/apk/steam_shortcut_lookup.js','utf8')})`);
const id=0xf1234567, exe='/home/steamos/devkit-game/owned/base.apk';
let targets=new Map([[id,{strShortcutExe:`"${exe}"`}]]),unregistered=0;
globalThis.appStore={m_mapApps:new Map([[id,{appid:id,display_name:'GamePad Tester'}]])};
globalThis.SteamClient={Apps:{RegisterForAppDetails:(id,cb)=>{queueMicrotask(()=>cb(targets.get(id)??{}));return {unregister(){unregistered++}}}}};
assert.deepEqual(await lookup({game:'owned',exe}),{id});
assert.ok(unregistered>0);
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
// Never accept an identically named entry belonging to a different executable.
appStore.m_mapApps.set(id,{appid:id,display_name:'Devkit Game: owned'});
targets.set(id,{strShortcutExe:'/another/app.apk'});
await assert.rejects(lookup({game:'owned',exe}),/target does not match/);
appStore.m_mapApps.set(id,{appid:id,display_name:'GamePad Tester'});
await assert.rejects(lookup({game:'owned',exe}),/Missing or ambiguous/);
targets.set(id,{strShortcutExe:exe});
appStore.m_mapApps.set(id+1,{appid:id+1,display_name:'Another copy'});targets.set(id+1,{strShortcutExe:exe});
await assert.rejects(lookup({game:'owned',exe}),/ambiguous/);
// Cached details can call back before the subscription is returned.
appStore.m_mapApps.delete(id+1);
SteamClient.Apps.RegisterForAppDetails=(id,cb)=>{cb(targets.get(id));return {unregister(){unregistered++}}};
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
console.log('Renamed registration recovery, executable ownership, ambiguity and cached callback checks passed.');