Compare commits

...
4 Commits
7 changed files with 108 additions and 15 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.4"
version = "0.8.5-preview.1"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.4"
version = "0.8.5-preview.1"
edition = "2021"
license = "AGPL-3.0-only"
+1 -1
View File
@@ -221,7 +221,7 @@ cat > /etc/systemd/system/framely-session.service <<EOF
[Unit]
Description=Framely SteamVR UI session
After=framely.service user@$steam_uid.service
Requires=framely.service
Requires=framely.service user@$steam_uid.service
[Service]
User=$steam_user
Environment=HOME=$steam_home
+44 -1
View File
@@ -2276,11 +2276,35 @@ fn backup(home: &Path, c: &Container, db: &Database, log: &Path) -> Result<Strin
fs::create_dir(&d)?;
let dest = d.join("baked");
fs::create_dir(&dest)?;
for entry in fs::read_dir(&c.baked)?.flatten() {
for entry in fs::read_dir(&c.baked)? {
let entry = entry?;
let name = entry.file_name();
if name.to_string_lossy().ends_with("_workdir") {
continue;
}
// Native launches keep their OverlayFS upper/work pair here. The
// kernel-owned work/work can be unreadable even after stopping.
let native_overlay = name.to_str().and_then(|n| n.strip_prefix("native-apk-"))
.is_some_and(|id| id.len() == 24 && id.bytes().all(|b| b.is_ascii_hexdigit()));
if native_overlay && entry.file_type()?.is_dir() {
let target = dest.join(&name);
fs::create_dir(&target)?;
for child in fs::read_dir(entry.path())? {
let child = child?;
if child.file_name() == "work" {
ensure!(child.file_type()?.is_dir(), "Invalid native OverlayFS work directory");
// Include a fresh work directory in the verified snapshot,
// preserving all persistent data (including upper).
let work = target.join("work");
fs::create_dir(&work)?;
fs::set_permissions(work, fs::Permissions::from_mode(0o700))?;
} else {
copy(&child.path(), &target.join(child.file_name()), log)?;
}
}
fs::set_permissions(&target, fs::metadata(entry.path())?.permissions())?;
continue;
}
copy(&entry.path(), &dest.join(name), log)?;
}
@@ -6558,7 +6582,22 @@ esac
c.baked.join("data_overlay/media/0"),
)
.unwrap();
let native_name = format!("native-apk-{}", hash("com.example.app"));
let native = c.baked.join(&native_name);
fs::create_dir_all(native.join("upper/files/work")).unwrap();
fs::write(native.join("upper/files/work/save"), b"native progress").unwrap();
std::os::unix::fs::symlink("files/work/save", native.join("upper/save-link")).unwrap();
fs::create_dir_all(native.join("work/work")).unwrap();
fs::write(native.join("work/work/transient"), b"kernel scratch").unwrap();
fs::set_permissions(native.join("work/work"), fs::Permissions::from_mode(0)).unwrap();
fs::create_dir_all(c.baked.join("app_workdir/work")).unwrap();
fs::set_permissions(c.baked.join("app_workdir/work"), fs::Permissions::from_mode(0)).unwrap();
let id = backup(&f.home, &c, &db, &f.dir.path().join("test.log")).unwrap();
let saved = root(&f.home).join("backups").join(&id).join("baked");
assert_eq!(fs::read_dir(saved.join(&native_name).join("work")).unwrap().count(), 0);
assert!(!saved.join("app_workdir").exists());
assert_eq!(fs::read(saved.join(&native_name).join("upper/save-link")).unwrap(), b"native progress");
verified_snapshot(saved.parent().unwrap()).unwrap();
assert!(clean_candidates(&f.home, &db)
.unwrap()
.iter()
@@ -6566,6 +6605,7 @@ esac
fs::write(f.save_file(), "changed progress").unwrap();
fs::write(&resource, b"changed download").unwrap();
fs::write(&shader, b"changed shader").unwrap();
fs::write(native.join("upper/files/work/save"), b"changed native progress").unwrap();
let scratch = c.baked.join("data_workdir/work");
fs::create_dir_all(&scratch).unwrap();
fs::set_permissions(&scratch, fs::Permissions::from_mode(0)).unwrap();
@@ -6579,6 +6619,9 @@ esac
assert_eq!(fs::read_to_string(f.save_file()).unwrap(), "saved progress");
assert_eq!(fs::read(&resource).unwrap(), b"unfinished download");
assert_eq!(fs::read(&shader).unwrap(), b"compiled shader");
assert_eq!(fs::read(native.join("upper/save-link")).unwrap(), b"native progress");
assert_eq!(fs::read_dir(native.join("work")).unwrap().count(), 0);
assert_eq!(fs::metadata(native.join("work")).unwrap().permissions().mode() & 0o777, 0o700);
assert_eq!(
fs::read(
c.baked
+10 -6
View File
@@ -52,13 +52,14 @@ fn runtime() -> Result<PathBuf> {
let uid = unsafe { libc::geteuid() };
ensure!(uid != 0, "Steam APK wrapper must run as the session user");
let p = PathBuf::from(format!("/run/user/{uid}/framely-apk-steam"));
fs::create_dir_all(&p)?;
let m = fs::symlink_metadata(&p)?;
fs::create_dir_all(&p).with_context(|| format!("Create Steam APK runtime directory {}", p.display()))?;
let m = fs::symlink_metadata(&p).with_context(|| format!("Inspect Steam APK runtime directory {}", p.display()))?;
ensure!(
m.is_dir() && m.uid() == uid,
"Invalid Steam wrapper runtime directory"
);
fs::set_permissions(&p, fs::Permissions::from_mode(0o700))?;
fs::set_permissions(&p, fs::Permissions::from_mode(0o700))
.with_context(|| format!("Protect Steam APK runtime directory {}", p.display()))?;
Ok(p)
}
fn socket() -> Result<PathBuf> {
@@ -503,7 +504,8 @@ pub(super) fn start_session() -> Result<()> {
.create(true)
.truncate(false)
.mode(0o600)
.open(dir.join("server.lock"))?;
.open(dir.join("server.lock"))
.with_context(|| format!("Open Steam APK bridge lock {}", dir.join("server.lock").display()))?;
ensure!(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Steam APK bridge is already running"
@@ -516,8 +518,10 @@ pub(super) fn start_session() -> Result<()> {
);
fs::remove_file(&path)?;
}
let listener = UnixListener::bind(&path)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
let listener = UnixListener::bind(&path)
.with_context(|| format!("Bind Steam APK bridge socket {}", path.display()))?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.with_context(|| format!("Protect Steam APK bridge socket {}", path.display()))?;
std::thread::spawn(move || {
let _lock = lock;
let workers = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+27 -5
View File
@@ -536,10 +536,16 @@ impl Agent {
if let Some(id)=views["screenshotClosed"].as_str().filter(|id|!id.is_empty()) {
self.screenshot.lock().unwrap().acknowledge(id);
}
if views["views"].is_object() {
self.core("host.ui.visibility",views["views"].clone())?;
if views.get("views").is_some() {
// Null is the startup state before the first VR frame:
// leave visibility unknown rather than reporting hidden.
ensure!(views["views"].is_null() || views["views"].is_object(), "Invalid native visibility snapshot");
if views["views"].is_object() {
self.core("host.ui.visibility",views["views"].clone())?;
}
if let Some(ids)=views["closedCasts"].as_array(){ensure!(ids.len()<=32,"Too many closed casts");for id in ids{if let Some(id)=id.as_str(){let _=self.receivers.lock().unwrap().control(id,"stop",json!({}));}}}
} else if views.is_object() {
ensure!(views.as_object().unwrap().values().all(Value::is_boolean), "Invalid legacy visibility report");
self.core("host.ui.visibility", views)?;
}
}
@@ -2520,7 +2526,7 @@ pub fn serve(
"UI session must not run as root"
);
crate::gamepad::session_started();
crate::apk::steam_session_started()?;
crate::apk::steam_session_started().context("Initialize Steam APK session bridge")?;
crate::apk::lifecycle_started();
let server = Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e.to_string()))?;
let port = server
@@ -2536,9 +2542,10 @@ pub fn serve(
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
let runtime = runtime.join(format!("framely-session-{}", std::process::id()));
fs::create_dir(&runtime)?;
fs::create_dir(&runtime).with_context(|| format!("Create UI runtime directory {}", runtime.display()))?;
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))
.with_context(|| format!("Protect UI runtime directory {}", runtime.display()))?;
let agent = Arc::new(Agent {
watch_access: Mutex::new(crate::cast::watch_access::Access {
token: ipc::call(&socket, "cast.watchAccess.get", json!({}))?["token"].as_str().map(str::to_owned),
@@ -4036,6 +4043,9 @@ mod tests {
let socket_worker = std::thread::spawn(move || {
let mut core = crate::service::Service::load(&root.path().join("state"), 1000).unwrap();
for expected in [
"host.notification.badge",
"status",
"launcher.gaze.calibration.get",
"host.ui.visibility",
"host.notification.badge",
"status",
@@ -4074,6 +4084,18 @@ mod tests {
.set("Cookie", &format!("framely={}", agent.web_key))
.send_json(json!({"menu":true}))
.is_err());
// The first native poll precedes the first VR visibility snapshot.
// It must succeed without treating envelope fields as boolean views.
let startup: Value = ureq::post(&url)
.set("X-Framely-Native", &agent.native_key)
.send_json(json!({"views":null,"closedCasts":[],"stopRecording":"","framingClosed":"","screenshotClosed":"","screenshotHotkey":"","radialAction":""}))
.unwrap().into_json().unwrap();
assert_eq!(startup["commands"],json!([framing]));
agent.commands.lock().unwrap().push_back(framing.clone());
for invalid in [json!({"views":[],"closedCasts":[]}),json!({"menu":[],"closedCasts":[]})] {
assert!(ureq::post(&url).set("X-Framely-Native", &agent.native_key)
.send_json(invalid).is_err());
}
let response = ureq::post(&url)
.set("X-Framely-Native", &agent.native_key)
.send_json(json!({"menu":true}))
+24
View File
@@ -0,0 +1,24 @@
"""Check the actual installed UI unit waits for the user runtime provider."""
import configparser
import pathlib
import subprocess
import unittest
class SessionUnitTests(unittest.TestCase):
def test_session_requires_and_orders_after_user_manager(self):
script = (pathlib.Path(__file__).parents[1] / 'packaging/install.sh').read_text()
body = script.split('cat > /etc/systemd/system/framely-session.service <<EOF\n', 1)[1].split('\nEOF', 1)[0]
result = subprocess.run(
['bash', '-eu', '-c', 'steam_uid=1000; steam_user=steamos; steam_home=/home/steamos; root=/var/lib/framely\ncat <<EOF\n' + body + '\nEOF\n'],
capture_output=True, text=True, check=True,
)
unit = configparser.ConfigParser(interpolation=None, strict=False)
unit.read_string(result.stdout)
for dependency in ['framely.service', f'user@{1000}.service']:
self.assertIn(dependency, unit['Unit']['Requires'].split())
self.assertIn(dependency, unit['Unit']['After'].split())
if __name__ == '__main__':
unittest.main()