diff --git a/docs/sideloading.md b/docs/sideloading.md index 6ff60e4..53a0ad8 100644 --- a/docs/sideloading.md +++ b/docs/sideloading.md @@ -21,7 +21,8 @@ desktop app, which knows where a dropped folder lives; in a plain browser, zip it.) A dialog shows: - **Name**: what Steam shows. Steam uses the title id as the name, so it's - limited to letters, digits, `_` and `-`; the dialog shows the result. + limited to letters, digits and `_`, and can't start with a digit; the + dialog shows the result. - **Launches**: the program picked to start the game, with the other candidates in the list. - **Runtime**: picked from the program, see below. Windows programs can switch @@ -133,10 +134,15 @@ splits that string is **not checked**. with the same rule, because `scp -r` would follow a link out of the folder and upload whatever it points at. - Installs run one at a time, and Remove is refused while one runs. -- The title id is limited to `[A-Za-z0-9_-]`, at most 64 characters. Valve's - scripts pass it to a shell (`steamos-delete` runs `rm -r` on it). Valve's +- The title id is limited to letters, digits and `_`, doesn't start with a + digit (one that would gets `_` in front), and is 2 to 64 characters. That's + what Steam's `create-shortcut` accepts: on the Frame it refused + `fc-smoke-exe` with `missing/invalid arguments` and registered the same + program as `FCSmokeProbe` (2026-09-27, BUILD_ID 20260922.6101926), and + Valve's client only allows `^[A-Za-z_][A-Za-z0-9_.]+$`. Valve's scripts + also pass the id to a shell (`steamos-delete` runs `rm -r` on it). Valve's reserved sideload names (`steam`, `steamvr`, and their `deckard` forms, - which would replace the Steam client itself) get `-game` added. + which would replace the Steam client itself) get `_game` added. - Nothing needs `sudo`; everything goes to your home folder on the Frame. - In the app, a dropped folder is read from its local path by the app's own server, which only accepts requests from its own page (see diff --git a/tests/test_frame_titles.py b/tests/test_frame_titles.py index a46b508..55d080d 100644 --- a/tests/test_frame_titles.py +++ b/tests/test_frame_titles.py @@ -347,14 +347,26 @@ class Zips(unittest.TestCase): class Names(unittest.TestCase): def test_title_id(self): self.assertEqual(frame_titles.title_id('Hollow Knight: Silksong!'), 'Hollow_Knight_Silksong') - self.assertEqual(frame_titles.title_id('steam'), 'steam-game') # Valve's reserved sideload names + self.assertEqual(frame_titles.title_id('steam'), 'steam_game') # Valve's reserved sideload names self.assertEqual(frame_titles.title_id('Devkit Steam'), 'Devkit_Steam') - self.assertEqual(frame_titles.title_id('devkit-steam'), 'devkit-steam-game') # the trampoline file - self.assertEqual(frame_titles.title_id('--rm -rf /'), 'rm_-rf') + self.assertEqual(frame_titles.title_id('--rm -rf /'), 'rm_rf') self.assertEqual(len(frame_titles.title_id('x' * 200)), 64) with self.assertRaises(FrameError): frame_titles.title_id('!!!') + def test_title_id_is_one_steam_accepts(self): + # The Frame's Steam refused "fc-smoke-exe" ("missing/invalid arguments") and took + # "FCSmokeProbe" (2026-09-27): Valve's client allows ^[A-Za-z_][A-Za-z0-9_.]+$ only. + self.assertEqual(frame_titles.title_id('Half-Life 2'), 'Half_Life_2') + self.assertEqual(frame_titles.title_id('fc-smoke-exe'), 'fc_smoke_exe') + self.assertEqual(frame_titles.title_id('2048'), '_2048') + self.assertEqual(frame_titles.title_id('X'), 'X_game') + self.assertEqual(frame_titles.title_id('devkit-steam'), 'devkit_steam') + for name in ('Half-Life 2', '2048', 'X', 'steam', 'a' * 90, 'Ünïcödé game', '9' * 70): + gid = frame_titles.title_id(name) + self.assertRegex(gid, r'^[A-Za-z_][A-Za-z0-9_.]+$', name) + self.assertTrue(frame_titles.NEW_ID_RE.match(gid) and frame_titles.ID_RE.match(gid), gid) + def test_display_name(self): self.assertEqual(frame_titles.display_name('MyGame-linux-arm64.zip'), 'MyGame') self.assertEqual(frame_titles.display_name('Portal 2.zip'), 'Portal 2') diff --git a/ui/frame_titles.py b/ui/frame_titles.py index bff6b56..bc208a0 100644 --- a/ui/frame_titles.py +++ b/ui/frame_titles.py @@ -35,7 +35,13 @@ PY = 'python3 ~/' + UTILS + '/' # devkit-steam is the trampoline file that switches SteamOS to a sideloaded client # (select_steam.sh); a folder there breaks Valve's devkit tools. RESERVED_IDS = ('steam', 'steamdeckard', 'steamvr', 'steamvrdeckard', 'devkit-steam') -ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$') +# Any title folder on the Frame we'll list, launch or remove: safe in a shell and a path. +ID_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$') +# What a new install may use. Steam's create-shortcut refused "fc-smoke-exe" with +# "missing/invalid arguments" and took the same program as "FCSmokeProbe" (headset +# smoke test, 2026-09-27, build 20260922.6101926); Valve's client only allows +# GAMEID_ALLOWED_PATTERN, ^[A-Za-z_][A-Za-z0-9_.]+$ (devkit_client/gui2). No dots here. +NEW_ID_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{1,63}$') DIR_RE = re.compile(r'^/[A-Za-z0-9_./-]+$') # Zip limits: well above any real game, well below a zip bomb. @@ -124,13 +130,15 @@ def _pe(f, head): def title_id(name): - """The Devkit Game id Steam shows as the title's name: [A-Za-z0-9_-], at most 64.""" - s = re.sub(r'[^A-Za-z0-9_-]+', '_', str(name or '').strip()) - s = re.sub(r'_+', '_', s).strip('_-')[:64].strip('_-') + """The Devkit Game id Steam shows as the title's name, in the form Steam accepts + (NEW_ID_RE): letters, digits and _, not starting with a digit, 2 to 64 long.""" + s = re.sub(r'[^A-Za-z0-9]+', '_', str(name or '').strip()).strip('_')[:64].strip('_') if not s: raise FrameError('the title needs a name with some letters or digits') - if s.lower() in RESERVED_IDS: - s += '-game' + if s[0].isdigit(): + s = '_' + s[:63] + if len(s) < 2 or s.lower() in RESERVED_IDS: + s += '_game' return s @@ -653,7 +661,7 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None): def _install(plan, step): gid = plan['id'] - if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS: + if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS: raise FrameError(f'bad title id {gid!r}') step("Syncing Valve's devkit tools to the Frame", 0.02) ensure_utils() @@ -683,8 +691,9 @@ def _install(plan, step): ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) registered = True # the files stay: Steam registers them once it's running if 'error' in reply: - raise FrameError(f"Uploaded, but Steam didn't register it: {reply['error']}. " - "With Steam running on the Frame, install it again.") + err = str(reply['error']).strip().rstrip('.') + hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else '' + raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}") step('Done', 1.0) meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip()) return meta diff --git a/ui/index.html b/ui/index.html index e980e69..66e4d0b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1433,9 +1433,11 @@ async function sendFiles(files, dirs = new Set()) { // ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ---- const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"]; function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows - const trim = x => x.replace(/^[_-]+|[_-]+$/g, ""); - let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_")).slice(0, 64)); - if (RESERVED_IDS.includes(s.toLowerCase())) s += "-game"; + const trim = x => x.replace(/^_+|_+$/g, ""); + let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9]+/g, "_")).slice(0, 64)); + if (!s) return s; + if (/^[0-9]/.test(s)) s = "_" + s.slice(0, 63); + if (s.length < 2 || RESERVED_IDS.includes(s.toLowerCase())) s += "_game"; return s; } const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };