mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
* Run programs, scripts and AppImages from the desktop Windows programs open with the Proton Steam has chosen, and a Steam game's own launch program is handed to Steam. x86-64 Linux programs and game scripts run under FEX, and an exec the kernel refuses goes to droiddeck-open through libblsession's binfmt.c. Programs on noexec shared storage pass KIO's checks; a folder of Linux programs there, and sharun AppImages, are added by the app once and run from the copy. Executable scripts run without KIO's prompt, mpv plays on the software desktop, FEXServer's socket moves under XDG_RUNTIME_DIR, and Proton runtimes no longer show up as games. * Switch to Desktop from Big Picture without Deck mode Big Picture's Switch to Desktop asks the client's SteamOS manager, which the client only has when started as SteamOS (Deck mode). Without it the request failed and the screen stayed on "Switching to Desktop..." for good. The live compat helper now sees the request on its way to the client and hands it to steamos-session-select, which opens the KDE desktop as Deck mode already did. * Run x86 programs from shared storage where they lie FEX asks for the guest's PROT_EXEC when it maps a program or library, which the kernel refuses on noexec shared storage; FEX never runs those pages itself, so libblsession maps them without it in FEX's processes. An x86 game, its libraries and its start script now run in place instead of being copied in; only native code on noexec storage is still copied. A Steam library's games are never copied, and a Linux game's launch program opened from the desktop goes to Steam like a Windows one.
307 lines
14 KiB
Python
307 lines
14 KiB
Python
import json
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
|
LIVE = runpy.run_path(str(BIN / "droiddeck-steam-compat"))
|
|
COMPAT = LIVE["COMPAT"]
|
|
TOOL = COMPAT["TOOL"]
|
|
TOOL_11 = COMPAT["TOOL_11"]
|
|
|
|
FAKE_STEAM = r"""
|
|
const dec = new TextDecoder(), enc = new TextEncoder();
|
|
function readVarint(b, at) { let r = 0n, s = 0n, x; do { x = b[at.i++]; r |= BigInt(x & 0x7f) << s; s += 7n; } while (x & 0x80); return r; }
|
|
function fields(b) {
|
|
const f = {}, at = { i: 0 };
|
|
while (at.i < b.length) {
|
|
const k = Number(readVarint(b, at)), n = k >> 3, w = k & 7; let v;
|
|
if (w === 0) v = readVarint(b, at);
|
|
else if (w === 1) { v = 0n; for (let j = 0; j < 8; j++) v |= BigInt(b[at.i + j]) << BigInt(8 * j); at.i += 8; }
|
|
else if (w === 2) { const len = Number(readVarint(b, at)); v = b.slice(at.i, at.i + len); at.i += len; }
|
|
else throw new Error("wire " + w);
|
|
(f[n] = f[n] || []).push(v);
|
|
}
|
|
return f;
|
|
}
|
|
function vint(n, out) { n = BigInt(n); do { let x = Number(n & 0x7fn); n >>= 7n; if (n) x |= 0x80; out.push(x); } while (n); }
|
|
function lenField(n, bytes, out) { vint(n * 8 + 2, out); vint(bytes.length, out); for (const x of bytes) out.push(x); }
|
|
function reply(eresult, jobid, body, name) {
|
|
const h = [];
|
|
if (jobid !== null) { vint(11 * 8 + 1, h); let n = jobid; for (let i = 0; i < 8; i++) { h.push(Number(n & 0xffn)); n >>= 8n; } }
|
|
if (name) lenField(12, enc.encode(name), h);
|
|
vint(13 * 8, h); vint(eresult, h);
|
|
const out = new Uint8Array(8 + h.length + body.length), dv = new DataView(out.buffer);
|
|
dv.setUint32(0, (0x80000000 | 147) >>> 0, true); dv.setUint32(4, h.length, true);
|
|
out.set(h, 8); out.set(body, 8 + h.length);
|
|
return out.buffer;
|
|
}
|
|
const state = { mapping: JSON.parse(process.argv[2]), known: JSON.parse(process.argv[3]), log: [], authed: false, notify: [] };
|
|
class FakeSocket {
|
|
constructor(url) { this.url = url; this.readyState = 0; state.socket = this; setTimeout(() => { this.readyState = 1; this.onopen(); }, 0); }
|
|
send(buffer) {
|
|
const b = new Uint8Array(buffer), dv = new DataView(buffer.buffer || buffer);
|
|
if (dv.getUint32(0, true) !== ((0x80000000 | 146) >>> 0)) throw new Error("bad emsg");
|
|
const hl = dv.getUint32(4, true), h = fields(b.slice(8, 8 + hl)), body = fields(b.slice(8 + hl));
|
|
const method = dec.decode(h[12][0]), jobid = h[10][0];
|
|
let out = [], result = 1;
|
|
if (method === "TransportAuth.Authenticate#1") {
|
|
state.authed = dec.decode(h[40][0]) === "secret" && dec.decode(body[1][0]) === "secret";
|
|
result = state.authed ? 1 : 5;
|
|
} else if (!state.authed) result = 5;
|
|
else if (method === "CompatManager.GetCompatTools#1") {
|
|
const app = String(body[1] ? body[1][0] : 0n);
|
|
for (const name of state.known) { const t = []; lenField(1, enc.encode(name), t); lenField(2, enc.encode(name + " shown"), t); lenField(1, t, out); }
|
|
lenField(3, enc.encode(state.mapping[app] || ""), out);
|
|
lenField(4, enc.encode("proton_experimental"), out);
|
|
} else if (method === "CompatManager.SpecifyCompatTool#1") {
|
|
const app = String(body[1] ? body[1][0] : 0n), name = body[2] ? dec.decode(body[2][0]) : "";
|
|
state.mapping[app] = name; state.log.push(app + "=" + name);
|
|
setTimeout(() => this.onmessage({ data: reply(1, null, new Uint8Array(0), "CompatManager.NotifyStateChanged#1") }), 0);
|
|
} else result = 2;
|
|
setTimeout(() => this.onmessage({ data: reply(result, jobid, Uint8Array.from(out)) }), 0);
|
|
}
|
|
close() {}
|
|
}
|
|
globalThis.window = globalThis;
|
|
globalThis.WebSocket = FakeSocket;
|
|
globalThis.SteamClient = { WebUITransport: { GetTransportInfo: async () => ({ portClientdll: 4242, authKeyClientdll: "secret" }) } };
|
|
window.droiddeckCompatChanged = (n) => state.notify.push(n);
|
|
const install = require("fs").readFileSync(process.argv[4], "utf8");
|
|
(async () => {
|
|
if (eval(install) !== "installed" || eval(install) !== "present") throw new Error("install is not idempotent");
|
|
const c = window.__droiddeckCompat;
|
|
const before = await c.get(0);
|
|
await c.set(0, "droiddeck-proton-11-arm64");
|
|
await c.set(42, "");
|
|
const after = await c.many(["0", "42", "43"]);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
console.log(JSON.stringify({ before, after, log: state.log, notify: state.notify, url: state.socket.url }));
|
|
})().catch((e) => { console.error(e.stack || String(e)); process.exit(1); });
|
|
"""
|
|
|
|
|
|
class FakePage:
|
|
def __init__(self, mapping, known):
|
|
self.mapping = dict(mapping)
|
|
self.known = list(known)
|
|
self.sets = []
|
|
|
|
def compat(self, call):
|
|
name, _, args = call.partition("(")
|
|
args = json.loads("[%s]" % args[:-1])
|
|
if name == "get":
|
|
return {"selected": self.mapping.get(str(args[0]), ""), "fallback": "proton_experimental", "tools": self.known}
|
|
if name == "many":
|
|
return {app: self.mapping.get(app, "") for app in args[0]}
|
|
if name == "set":
|
|
self.mapping[str(args[0])] = args[1]
|
|
self.sets.append((str(args[0]), args[1]))
|
|
return True
|
|
raise AssertionError(call)
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
|
class PageScriptTest(unittest.TestCase):
|
|
def test_transport_framing_auth_and_notifications(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
script = Path(tmp) / "install.js"
|
|
script.write_text(LIVE["INSTALL_JS"])
|
|
harness = Path(tmp) / "harness.js"
|
|
harness.write_text(FAKE_STEAM)
|
|
result = subprocess.run(["node", str(harness), json.dumps({"0": TOOL, "42": "proton_experimental"}),
|
|
json.dumps([TOOL, TOOL_11, "proton_experimental"]), str(script)],
|
|
capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
out = json.loads(result.stdout)
|
|
self.assertEqual(out["url"], "ws://127.0.0.1:4242/transportsocket/")
|
|
self.assertEqual(out["before"], {"selected": TOOL, "fallback": "proton_experimental", "tools": [TOOL, TOOL_11, "proton_experimental"]})
|
|
self.assertEqual(out["after"], {"0": TOOL_11, "42": "", "43": ""})
|
|
self.assertEqual(out["log"], ["0=" + TOOL_11, "42="])
|
|
self.assertEqual(out["notify"], ["1", "2"])
|
|
|
|
|
|
class DevtoolsHttpTest(unittest.TestCase):
|
|
def test_a_kept_alive_answer_is_read_to_its_length(self):
|
|
import socket
|
|
import threading
|
|
server = socket.socket()
|
|
server.bind(("127.0.0.1", 0))
|
|
server.listen(1)
|
|
port = server.getsockname()[1]
|
|
body = json.dumps([{"title": "other"}, {"title": "SharedJSContext", "webSocketDebuggerUrl": "ws://127.0.0.1:%d/devtools/page/AB" % port}]).encode()
|
|
done = threading.Event()
|
|
|
|
def answer():
|
|
conn, _ = server.accept()
|
|
conn.recv(4096)
|
|
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n" % len(body) + body)
|
|
done.wait(10)
|
|
conn.close()
|
|
server.close()
|
|
|
|
thread = threading.Thread(target=answer)
|
|
thread.start()
|
|
try:
|
|
self.assertEqual(LIVE["shared_context"](port), "/devtools/page/AB")
|
|
finally:
|
|
done.set()
|
|
thread.join()
|
|
|
|
|
|
class ReconcileTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
base = Path(self.tmp.name)
|
|
self.steam = base / "Steam"
|
|
for name in COMPAT["SOURCES"]:
|
|
(self.steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True)
|
|
for app in ("10", "11"):
|
|
(self.steam / ("steamapps/appmanifest_%s.acf" % app)).write_text("")
|
|
self.state = base / "compat"
|
|
self.state.mkdir()
|
|
COMPAT["main"].__globals__["COMPAT_DIR"] = str(self.state)
|
|
COMPAT["main"].__globals__["LABEL_FILE"] = str(base / "no-label")
|
|
self.addCleanup(COMPAT["main"].__globals__.__setitem__, "COMPAT_DIR", COMPAT["COMPAT_DIR"])
|
|
self.helper = LIVE["Helper"](str(self.steam), 1)
|
|
|
|
def reconcile(self, page):
|
|
self.helper.page = page
|
|
self.helper.reconcile()
|
|
return json.loads((self.state / "state.json").read_text())
|
|
|
|
def test_new_install_gets_the_default_live(self):
|
|
page = FakePage({"0": TOOL, "10": TOOL, "11": "proton_experimental"}, [TOOL, TOOL_11])
|
|
state = self.reconcile(page)
|
|
self.assertEqual(page.sets, [("11", TOOL)])
|
|
self.assertEqual(state["auto"], {"10": TOOL, "11": TOOL})
|
|
self.assertTrue(state["live"])
|
|
self.assertEqual(self.helper.installed, ["10", "11"])
|
|
|
|
def test_app_request_moves_the_default_and_followers(self):
|
|
page = FakePage({"0": TOOL, "10": TOOL, "11": "GE-Proton11-7"}, [TOOL, TOOL_11])
|
|
(self.state / "state.json").write_text(json.dumps({"default": TOOL, "auto": {"10": TOOL}}))
|
|
(self.state / "request.json").write_text(json.dumps({"seq": 7, "valve": True, "dir": "Proton 11.0 (ARM64)"}))
|
|
state = self.reconcile(page)
|
|
self.assertEqual(page.sets, [("0", TOOL_11), ("10", TOOL_11)])
|
|
self.assertEqual((state["default"], state["source"], state["applied"]), (TOOL_11, "app", 7))
|
|
self.assertEqual(page.mapping["11"], "GE-Proton11-7")
|
|
|
|
def test_a_steam_menu_change_is_mirrored(self):
|
|
page = FakePage({"0": TOOL_11, "10": TOOL}, [TOOL, TOOL_11])
|
|
(self.state / "state.json").write_text(json.dumps({"default": TOOL, "auto": {"10": TOOL}}))
|
|
state = self.reconcile(page)
|
|
self.assertEqual((state["default"], state["source"]), (TOOL_11, "steam"))
|
|
self.assertEqual(page.sets, [("10", TOOL_11), ("11", TOOL_11)])
|
|
|
|
def test_a_tool_the_client_has_not_loaded_waits_for_a_restart(self):
|
|
page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL])
|
|
(self.state / "request.json").write_text(json.dumps({"seq": 3, "valve": True, "dir": "Proton 11.0 (ARM64)"}))
|
|
state = self.reconcile(page)
|
|
self.assertEqual(page.sets, [])
|
|
self.assertEqual((state["pending"], state.get("applied", 0)), (TOOL_11, 0))
|
|
self.assertTrue(self.helper.request_waiting())
|
|
|
|
def test_file_mode_defers_to_the_live_helper(self):
|
|
(self.state / "live.pid").write_text("%d\n" % os.getpid())
|
|
self.assertTrue(COMPAT["live_helper_running"]())
|
|
LIVE["detach"]()
|
|
self.assertFalse(COMPAT["live_helper_running"]())
|
|
|
|
|
|
SWITCH_HARNESS = r"""
|
|
const sent = [], asked = [];
|
|
class FakeSocket { send(data) { sent.push(data); } }
|
|
globalThis.window = globalThis;
|
|
globalThis.WebSocket = FakeSocket;
|
|
window.droiddeckSwitchToDesktop = (x) => asked.push(x);
|
|
const install = require("fs").readFileSync(process.argv[2], "utf8");
|
|
const frame = (method) => {
|
|
const name = new TextEncoder().encode(method), h = [0x51, 1, 0, 0, 0, 0, 0, 0, 0, 0x62, name.length, ...name];
|
|
const out = new Uint8Array(8 + h.length), dv = new DataView(out.buffer);
|
|
dv.setUint32(0, (0x80000000 | 146) >>> 0, true); dv.setUint32(4, h.length, true); out.set(h, 8);
|
|
return out;
|
|
};
|
|
if (eval(install) !== "installed" || eval(install) !== "present") throw new Error("install is not idempotent");
|
|
const ws = new FakeSocket();
|
|
ws.send(frame("CompatManager.GetCompatTools#1"));
|
|
ws.send(frame("SteamOSManager.SwitchToDesktop#1").buffer);
|
|
ws.send(frame("SteamOSManager.SwitchToDesktop#1"));
|
|
ws.send("SteamOSManager.SwitchToDesktop#1");
|
|
console.log(JSON.stringify({ sent: sent.length, asked: asked.length }));
|
|
"""
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
|
class SwitchScriptTest(unittest.TestCase):
|
|
def test_the_request_is_reported_and_still_sent(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
script = Path(tmp) / "switch.js"
|
|
script.write_text(LIVE["SWITCH_JS"])
|
|
harness = Path(tmp) / "harness.js"
|
|
harness.write_text(SWITCH_HARNESS)
|
|
result = subprocess.run(["node", str(harness), str(script)], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(json.loads(result.stdout), {"sent": 4, "asked": 2})
|
|
|
|
|
|
class AttachPage:
|
|
def __init__(self, port, path):
|
|
self.calls, self.scripts = [], []
|
|
self.ws = mock.Mock()
|
|
|
|
def evaluate(self, expression, timeout=30):
|
|
self.scripts.append(expression)
|
|
return True
|
|
|
|
def call(self, method, params=None, timeout=30):
|
|
self.calls.append((method, (params or {}).get("name")))
|
|
return {}
|
|
|
|
def compat(self, call):
|
|
return {"selected": "", "fallback": "", "tools": []}
|
|
|
|
|
|
class SwitchToDesktopTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.globals = LIVE["Helper"].attach.__globals__
|
|
patcher = mock.patch.dict(self.globals, {"shared_context": lambda port: "/devtools/page/1", "Page": AttachPage})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def test_only_a_client_without_its_steamos_manager_is_watched(self):
|
|
helper = LIVE["Helper"]("/steam", 1, steamos=False)
|
|
helper.attach()
|
|
self.assertIn(("Runtime.addBinding", LIVE["SWITCH_BINDING"]), helper.page.calls)
|
|
self.assertIn(LIVE["SWITCH_JS"], helper.page.scripts)
|
|
helper = LIVE["Helper"]("/steam", 1, steamos=True)
|
|
helper.attach()
|
|
self.assertNotIn(("Runtime.addBinding", LIVE["SWITCH_BINDING"]), helper.page.calls)
|
|
self.assertNotIn(LIVE["SWITCH_JS"], helper.page.scripts)
|
|
|
|
def test_the_request_goes_to_steamos_session_select(self):
|
|
helper = LIVE["Helper"]("/steam", 1)
|
|
helper.page = mock.Mock()
|
|
helper.page.drain.return_value = [
|
|
{"method": "Runtime.bindingCalled", "params": {"name": LIVE["BINDING"], "payload": "1"}},
|
|
{"method": "Runtime.bindingCalled", "params": {"name": LIVE["SWITCH_BINDING"], "payload": ""}},
|
|
]
|
|
with mock.patch.object(self.globals["subprocess"], "call", return_value=0) as call:
|
|
events = helper.drain()
|
|
call.assert_called_once_with(["/usr/bin/steamos-session-select", "plasma"])
|
|
self.assertTrue(any(LIVE["called"](e, LIVE["BINDING"]) for e in events))
|
|
helper.page.drain.return_value = [{"method": "Runtime.bindingCalled", "params": {"name": LIVE["BINDING"]}}]
|
|
with mock.patch.object(self.globals["subprocess"], "call") as call:
|
|
helper.drain()
|
|
call.assert_not_called()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|