Sideloaded titles: use ids Steam's create-shortcut accepts

On the Frame, Steam refused to register titles whose id had a hyphen
(fc-smoke-exe) with "missing/invalid arguments", and registered the same
program as FCSmokeProbe (headset smoke test, 2026-09-27, BUILD_ID
20260922.6101926). Valve's client only allows ^[A-Za-z_][A-Za-z0-9_.]+$.

title_id now makes ids of letters, digits and _, not starting with a
digit, 2 to 64 long; new installs are checked against that, while titles
already on the Frame are still listed, launched and removed. Steam's error
text is trimmed before it's quoted, and the "install it again with Steam
running" hint only follows a Steam-not-running error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-27 17:40:09 +10:00
1 parent 10bf18fd50
commit ca2a991f03
4 files changed
+48 -19

No files matched your search

+10 -4
View File
@@ -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
+15 -3
View File
@@ -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')
+18 -9
View File
@@ -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
+5 -3
View File
@@ -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" };