mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 01:02:16 +02:00
471 lines
19 KiB
Rust
471 lines
19 KiB
Rust
//! Steam invokes its Lepton compatibility tool; a private entry adapter preserves
|
|
//! existing storage/input settings without replacing any system/Steam files.
|
|
use super::*;
|
|
use std::os::unix::process::CommandExt;
|
|
|
|
const NATIVE_HOOKS: &str = r#"
|
|
# The session service has verified the installed package before starting Steam.
|
|
# Do not let native APK/depot timestamps reinstall or clear that installation.
|
|
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'; }
|
|
# Separate native APK overlays in older shared contexts. Keep the installed
|
|
# directory (including extracted libraries/splits), rather than hiding it with
|
|
# a directory containing only the staged base APK.
|
|
if [[ -n "${FRAMELY_NATIVE_APK_DIR:-}" ]]; then
|
|
framely_native_mounts="$(declare -f setup_mounts)"
|
|
framely_native_lower='APP_APK_DIR="$(dirname "$(get_apk_path)")"'
|
|
framely_native_upper='$(get_baked_app_data_dir)/app_overlay'
|
|
[[ "$framely_native_mounts" == *"$framely_native_lower"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
|
|
if [[ "$framely_native_mounts" == *"$framely_native_upper"* ]]; then
|
|
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
|
|
elif [[ "$framely_native_mounts" == *'APP_OVERLAY_UPPERDIR="$(app_datadir)"'* ]] && declare -F app_datadir >/dev/null && [[ "$(declare -f app_datadir)" == *'$(get_baked_app_data_dir $@)/app_overlay'* ]]; then
|
|
# Lepton 3 routes the APK upper directory through app_datadir.
|
|
function app_datadir() { print "${FRAMELY_NATIVE_OVERLAY:?}/upper"; }
|
|
else
|
|
echo 'Unsupported native APK mounts; keeping application data unchanged' >&2
|
|
exit 64
|
|
fi
|
|
framely_native_mounts="${framely_native_mounts//"$framely_native_lower"/'APP_APK_DIR="${FRAMELY_NATIVE_APK_DIR:?}"'}"
|
|
function app_workdir() { print "${FRAMELY_NATIVE_OVERLAY:?}/work"; }
|
|
eval "$framely_native_mounts"
|
|
unset framely_native_mounts framely_native_lower framely_native_upper
|
|
fi
|
|
"#;
|
|
|
|
pub(super) fn script(source: &str) -> Result<String> {
|
|
let adapted = direct_launch_script(source)?;
|
|
let include = "source \"${SCRIPT_DIR}/liblepton/liblepton.sh\"";
|
|
// Append native overrides immediately before the entry's case statement,
|
|
// after the shared resource, orientation and optional input hooks.
|
|
let marker = "case \"${COMMAND}\" in";
|
|
ensure!(
|
|
source.matches(marker).count() == 1 && source.contains("function teardown()"),
|
|
"Unsupported native Lepton entry; keeping the existing installation unchanged"
|
|
);
|
|
ensure!(adapted.contains(include), "Missing native Lepton library");
|
|
Ok(adapted.replacen(marker, &format!("{NATIVE_HOOKS}\n{marker}"), 1))
|
|
}
|
|
|
|
pub(super) fn runtime_name(r: &Record) -> Option<String> {
|
|
r.steam_binding.as_ref().filter(|b| b.native)?;
|
|
r.steam_app_id
|
|
.filter(|id| *id >= 0x80000000)
|
|
.map(|id| format!("steamlaunch-{id}"))
|
|
}
|
|
|
|
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"))?;
|
|
let _lock = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.mode(0o600)
|
|
.open(init(home)?.join("operation.lock"))?;
|
|
ensure!(
|
|
unsafe { libc::flock(_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
|
"Another APK operation is running"
|
|
);
|
|
let mut db = load(home)?;
|
|
let r = db.records.get(id).context("Unknown APK entry")?;
|
|
ensure!(
|
|
r.steam_launch
|
|
&& r.steam_binding.as_ref().is_some_and(|b| b.native)
|
|
&& 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(),
|
|
"APK installation is unavailable or an operation is pending"
|
|
);
|
|
let component = launch_component(&a)?;
|
|
// Also guard the original name: registration can occur while the old launch
|
|
// is still running, and two runtime names must never share an overlay.
|
|
let original = if c.id.starts_with("external-") {
|
|
db.root_contexts
|
|
.get(&c.baked)
|
|
.cloned()
|
|
.context("Unknown external container name")?
|
|
} else {
|
|
c.id.clone()
|
|
};
|
|
ensure!(
|
|
c.name == original || !running(&c.name),
|
|
"This application is already running through Steam"
|
|
);
|
|
let mut installer = c.clone();
|
|
installer.name = original;
|
|
let idle = if running(&installer.name) {
|
|
Some(lifecycle::idle_instance(&installer)?)
|
|
} else {
|
|
None
|
|
};
|
|
c.name = format!("steamlaunch-{app_id}");
|
|
c.steam = false;
|
|
let xml = fs::read_to_string(c.baked.join("data_overlay/system/packages.xml"))?;
|
|
let mount = xml
|
|
.split('<')
|
|
.filter(|t| t.starts_with("package "))
|
|
.find(|t| attr(t, "name") == Some(&a.metadata.package))
|
|
.and_then(|t| attr(t, "codePath"))
|
|
.filter(|p| p.starts_with("/data/") && !p.split('/').any(|x| x == ".."))
|
|
.context("Cannot verify the installed APK mount; no application data was changed")?;
|
|
let runner = runner(home)?;
|
|
let path = root(home)
|
|
.join("steam")
|
|
.join(format!("native-{}.sh", hash(id)));
|
|
fs::create_dir_all(path.parent().unwrap())?;
|
|
steam_shortcuts::write_owned(
|
|
&path,
|
|
script(&fs::read_to_string(&runner)?)?.as_bytes(),
|
|
0o700,
|
|
)?;
|
|
storage::prepare(&c.baked)?;
|
|
let media = c.baked.join("data_overlay/media/0");
|
|
let adopted = c.baked.parent().unwrap().join("external");
|
|
let external = if fs::read_link(&media).is_ok_and(|target| target == adopted) {
|
|
ensure!(!adopted.is_symlink(), "Unexpected external media link");
|
|
adopted
|
|
} else {
|
|
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,
|
|
"FRAMELY_NATIVE_ACTIVITY":component.split_once('/').context("Invalid component")?.1,
|
|
"FRAMELY_NATIVE_LAUNCH":"true",
|
|
"FRAMELY_EXTERNAL_MEDIA_DIR":external,
|
|
"FRAMELY_SHADER_CACHE_DIR":c.baked.join("shadercache"),
|
|
"FRAMELY_BACKGROUND_BOOT":"false",
|
|
"FRAMELY_WINDOW_ORIENTATION":a.orientation.as_deref().unwrap_or("auto"),
|
|
"APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"},
|
|
"LEPTON_NO_CLEANUP":"true", "TERM":"dumb"
|
|
});
|
|
graphics::configure_environment(&mut env, a.framebuffer_compatibility, &a.metadata.package)?;
|
|
let installed_dir = c
|
|
.baked
|
|
.join("data_overlay")
|
|
.join(mount.trim_start_matches("/data/"));
|
|
if installed_dir.is_dir() {
|
|
let installed_dir = fs::canonicalize(installed_dir)?;
|
|
ensure!(
|
|
installed_dir.starts_with(fs::canonicalize(c.baked.join("data_overlay"))?),
|
|
"Installed APK directory escapes its data location"
|
|
);
|
|
env["FRAMELY_NATIVE_APK_DIR"] = json!(installed_dir);
|
|
env["FRAMELY_NATIVE_OVERLAY"] = json!(c
|
|
.baked
|
|
.join(format!("native-apk-{}", hash(&a.metadata.package))));
|
|
}
|
|
// Older releases left the headless installation container alive. Transfer
|
|
// only an unchanged, idle instance after validating the native launch setup.
|
|
if let Some(identity) = idle {
|
|
ensure!(
|
|
lifecycle::idle_instance(&installer)? == identity,
|
|
"Container changed during launch"
|
|
);
|
|
stop(
|
|
&installer,
|
|
&root(home)
|
|
.join("logs")
|
|
.join(format!("{}-native-handoff.log", hash(id))),
|
|
)?;
|
|
}
|
|
if db.gamepad_enabled {
|
|
let m = crate::gamepad::prepare(
|
|
&root(home),
|
|
&c.name,
|
|
&db.gamepad_source,
|
|
db.gamepad_rumble,
|
|
db.gamepad_trigger_threshold,
|
|
)?;
|
|
crate::gamepad::select_target(&c.name, &a.metadata.package)?;
|
|
for (k, v) in [
|
|
(
|
|
"FRAMELY_GAMEPAD_DIRECT",
|
|
json!(if m.direct { "1" } else { "0" }),
|
|
),
|
|
("FRAMELY_GAMEPAD_EVENT", json!(m.event)),
|
|
("FRAMELY_GAMEPAD_GRAB", json!(m.grab)),
|
|
("FRAMELY_GAMEPAD_LAYOUT", json!(m.layout)),
|
|
("FRAMELY_GAMEPAD_READY", json!(m.ready)),
|
|
("FRAMELY_GAMEPAD_TOKEN", json!(m.token)),
|
|
] {
|
|
env[k] = v;
|
|
}
|
|
}
|
|
db.records.get_mut(id).unwrap().steam_app_id = Some(app_id);
|
|
save(home, &db)?;
|
|
Ok((
|
|
c,
|
|
a,
|
|
json!({"script":path,"env":env,"transientGeneration":db.records[id].steam_transient_generation}),
|
|
ownership,
|
|
))
|
|
}
|
|
|
|
pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
|
|
ensure!(
|
|
unsafe { libc::geteuid() } != 0,
|
|
"Native APK launch must run as the Steam session user"
|
|
);
|
|
let home = crate::steam::home()?;
|
|
let expected = runner(&home)?;
|
|
let entry = command
|
|
.len()
|
|
.checked_sub(4)
|
|
.context("Unexpected native Lepton command")?;
|
|
ensure!(
|
|
fs::canonicalize(&command[entry])? == fs::canonicalize(&expected)?
|
|
&& command[entry + 1] == "waitforexitandrun"
|
|
&& command[entry + 2] == "--",
|
|
"Unexpected native Lepton command"
|
|
);
|
|
if entry != 0 {
|
|
// %command% includes Steam's launch wrapper and reaper. Retain both;
|
|
// replacing the whole command would lose native lifecycle ownership.
|
|
ensure!(
|
|
entry == 8
|
|
&& Path::new(&command[0])
|
|
.file_name()
|
|
.is_some_and(|s| s == "steam-launch-wrapper")
|
|
&& Path::new(&command[4])
|
|
.file_name()
|
|
.is_some_and(|s| s == "reaper")
|
|
&& command[1] == "--oom-score-adjust"
|
|
&& command[2].parse::<i32>().is_ok()
|
|
&& command[3] == "--"
|
|
&& command[5] == "SteamLaunch"
|
|
&& command[6].starts_with("AppId=")
|
|
&& command[7] == "--",
|
|
"Unsupported Steam launch wrapper; keeping application data unchanged"
|
|
);
|
|
for i in [0, 4] {
|
|
let path = fs::canonicalize(&command[i])?;
|
|
ensure!(
|
|
path.starts_with(fs::canonicalize(home.join(".local/share/Steam"))?),
|
|
"Unexpected Steam launch runtime"
|
|
);
|
|
}
|
|
}
|
|
let r = load(&home)?
|
|
.records
|
|
.get(app)
|
|
.cloned()
|
|
.context("Unknown APK entry")?;
|
|
ensure!(
|
|
r.steam_token.as_deref() == Some(token),
|
|
"Invalid native APK token"
|
|
);
|
|
let apk = steam_shortcuts::native_apk(&home, app);
|
|
ensure!(
|
|
fs::canonicalize(&command[entry + 3])? == fs::canonicalize(&apk)?,
|
|
"Unexpected APK launch target"
|
|
);
|
|
let response = steam_bridge::native_call(
|
|
app,
|
|
token,
|
|
r.steam_app_id.context("Missing Steam APK App ID")?,
|
|
)?;
|
|
let path = response["script"]
|
|
.as_str()
|
|
.context("Missing native entry")?;
|
|
ensure!(
|
|
Path::new(path)
|
|
== root(&home)
|
|
.join("steam")
|
|
.join(format!("native-{}.sh", hash(app))),
|
|
"Invalid native entry path"
|
|
);
|
|
let mut arguments = command.to_vec();
|
|
arguments[entry] = path.to_owned();
|
|
let mut cmd = Command::new(&arguments[0]);
|
|
cmd.args(&arguments[1..])
|
|
.env_remove("FRAMELY_STEAM_APP_ID")
|
|
.env("SteamAppId", r.steam_app_id.unwrap().to_string());
|
|
for (k, v) in response["env"]
|
|
.as_object()
|
|
.context("Missing native environment")?
|
|
{
|
|
cmd.env(k, v.as_str().context("Invalid native environment value")?);
|
|
}
|
|
Err(cmd.exec().into())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn native_shared_context_uses_complete_installed_directory_and_separate_overlay() {
|
|
let source = format!(
|
|
r#"
|
|
function print() {{ printf '%s\n' "$*"; }}
|
|
function setup_mounts() {{
|
|
APP_APK_DIR="$(dirname "$(get_apk_path)")"
|
|
local APP_OVERLAY_UPPERDIR="$(get_baked_app_data_dir)/app_overlay"
|
|
print "$APP_APK_DIR|$APP_OVERLAY_UPPERDIR|$(app_workdir)"
|
|
}}
|
|
{NATIVE_HOOKS}
|
|
setup_mounts
|
|
"#
|
|
);
|
|
for source in [source.clone(), source.replace(
|
|
" local APP_OVERLAY_UPPERDIR=\"$(get_baked_app_data_dir)/app_overlay\"",
|
|
" local APP_OVERLAY_UPPERDIR=\"$(app_datadir)\"",
|
|
).replacen("function setup_mounts()", "function app_datadir() { print \"$(get_baked_app_data_dir $@)/app_overlay\"; }\nfunction setup_mounts()", 1)] {
|
|
for package in ["one", "two"] {
|
|
let out = Command::new("bash")
|
|
.args(["-c", &source])
|
|
.env(
|
|
"FRAMELY_NATIVE_APK_DIR",
|
|
format!("/baked/data_overlay/app/{package}"),
|
|
)
|
|
.env("FRAMELY_NATIVE_OVERLAY", format!("/baked/native-{package}"))
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
out.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), format!("/baked/data_overlay/app/{package}|/baked/native-{package}/upper|/baked/native-{package}/work"));
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn native_hooks_keep_storage_and_selected_activity_after_early_exit() {
|
|
let t = tempfile::tempdir().unwrap();
|
|
let lib = t.path().join("lepton/liblepton");
|
|
fs::create_dir_all(&lib).unwrap();
|
|
fs::write(
|
|
lib.join("liblepton.sh"),
|
|
r#"
|
|
function data_mount_path() { echo "$TEST_DATA"; }
|
|
function setup_props() { :; }
|
|
function props_file() { echo "$TEST_PROPS"; }
|
|
function setup_mounts() {
|
|
rm -rf "$(data_mount_path)/media/0"
|
|
mkdir -p "$(data_mount_path)/media"
|
|
ln -s "${STEAM_COMPAT_DATA_PATH}/external" "$(data_mount_path)/media/0"
|
|
}
|
|
function setup_podman_mounts() { setup_mounts; }
|
|
function podman_mount_entry() { :; }
|
|
function print() { printf '%s\n' "$*"; }
|
|
function println() { print "$@"; }
|
|
function prepare_baked_data_for_removal() { rm -rf "$TEST_DATA"; }
|
|
function remove_prefix() {
|
|
prepare_baked_data_for_removal
|
|
rm -rf "$LEPTON_PREFIX"
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let source = r#"#!/bin/bash
|
|
set -euo pipefail
|
|
COMMAND=waitforexitandrun
|
|
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
|
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
|
|
function teardown() { clear_baked_app_data 'early exit'; remove_prefix; }
|
|
case "${COMMAND}" in
|
|
waitforexitandrun)
|
|
is_app_baked
|
|
setup_podman_mounts
|
|
get_app_mount_dir
|
|
get_app_activity
|
|
run_app_commands test.Package test.Selected
|
|
teardown
|
|
;;
|
|
esac
|
|
"#;
|
|
let entry = t.path().join("entry.sh");
|
|
fs::write(&entry, script(source).unwrap()).unwrap();
|
|
let compat = t.path().join("context");
|
|
let data = compat.join("baked/data_overlay");
|
|
let download = data.join("media/0/Android/data/test/files/download.part");
|
|
fs::create_dir_all(download.parent().unwrap()).unwrap();
|
|
fs::write(&download, b"resume-me").unwrap();
|
|
let save = data.join("data/test/save");
|
|
fs::create_dir_all(save.parent().unwrap()).unwrap();
|
|
fs::write(&save, b"saved game").unwrap();
|
|
for flat in [true, false] {
|
|
let prefix = t.path().join("lepton-prefix");
|
|
fs::create_dir_all(&prefix).unwrap();
|
|
fs::write(prefix.join("temporary"), b"runtime").unwrap();
|
|
let out = Command::new("bash")
|
|
.arg(&entry)
|
|
.env("FRAMELY_LEPTON_DIR", lib.parent().unwrap())
|
|
.env("STEAM_COMPAT_DATA_PATH", &compat)
|
|
.env("FRAMELY_EXTERNAL_MEDIA_DIR", compat.join("baked/external"))
|
|
.env("FRAMELY_SHADER_CACHE_DIR", compat.join("baked/shadercache"))
|
|
.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("LEPTON_PREFIX", &prefix)
|
|
.env("TEST_PROPS", t.path().join("props"))
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
out.status.success(),
|
|
"{}",
|
|
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");
|
|
assert!(!prefix.exists(), "Temporary prefix must still be cleaned up");
|
|
}
|
|
assert!(script("unknown future native entry").is_err());
|
|
}
|
|
}
|