mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 01:02:16 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c7863dcfb | ||
|
|
7e374a669a | ||
|
|
b1d0c739b2 | ||
|
|
73fa970021 |
No files matched your search
Generated
+1
-1
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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.');
|
||||
Reference in new issue
Block a user