From 3e002b30d3c146f48c2aea922e26f2053236dcff Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 15:13:40 +0000 Subject: [PATCH] fix(launcher): parse Exec the way the desktop does, single quotes included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two releases of tron-pwa reported "no TronBrowser web apps" on a machine with eleven of them. They were in the directory it scans, under names it now matches, carrying the --app-id and --user-data-dir it looks for. It could not read them. Flatpak's flextop exports every argument single-quoted: Exec=flatpak 'run' '--command=/app/bin/chromium' '--app-id=abc…' The desktop-entry spec defines only double quotes, and the parser followed the spec -- so `'--app-id=abc…'` came out as one literal token with an apostrophe on the front, every startswith() test failed, and the file was classified as not a web app and skipped whole. GLib's g_shell_parse_argv, which is what actually launches these entries, honours single quotes; being stricter than the launcher meant not seeing files that work fine for everyone else. Parse like GLib: backslash escapes outside quotes, single quotes literal, double quotes as before. That also makes the launch failure legible. The flextop command carries --user-data-dir=~/.tronbrowser but no --filesystem grant, so the sandbox cannot read the profile directory: Chromium starts, cannot open the profile, and exits a few seconds later. The launcher passes --filesystem="$DATA", which is why the same app opens from the address bar and dies from its icon -- and why routing the shortcut through the launcher is the fix rather than a tidy-up. Verified against the reported file byte-for-byte: found, rewritten to run the launcher with the app id and profile intact and the flatpak wrapper dropped, and reverted back to the original exactly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SZXtxiVkXd7rFmvrMYV7Ut --- apps/desktop/launcher/tron-pwa | 64 ++++++++++++++------- apps/desktop/test/pwa.test.ts | 100 +++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 20 deletions(-) diff --git a/apps/desktop/launcher/tron-pwa b/apps/desktop/launcher/tron-pwa index 95b8be9..b285e96 100755 --- a/apps/desktop/launcher/tron-pwa +++ b/apps/desktop/launcher/tron-pwa @@ -77,40 +77,64 @@ KEEP_SWITCHES = ( def unescape_exec(value: str) -> list[str]: - """Split a desktop-entry Exec value into argv. + """Split a desktop-entry Exec value into argv, the way the desktop does. - Quoting is the desktop spec's own: double quotes group, and inside them a - backslash escapes `"`, `` ` ``, `$` and `\\`. Splitting on whitespace alone - would break every profile path that has a space in it. + Follow GLib's g_shell_parse_argv rather than the desktop-entry spec. The + spec defines only double quotes, but GLib is what actually launches these + entries, and it honours SINGLE quotes too -- so writers use them. Flatpak's + flextop exports every argument single-quoted: + + Exec=flatpak 'run' '--command=/app/bin/chromium' '--app-id=abc…' + + Parsed to the spec, `'--app-id=abc…'` is one literal token with an + apostrophe on the front, so every test for a switch fails and the whole file + reads as "not a web app". Being stricter than the launcher here means not + seeing files that work perfectly well for everyone else. + + Rules: outside quotes a backslash escapes the next character; single quotes + are literal to the next single quote with no escapes inside; double quotes + are literal except that a backslash escapes `"`, `` ` ``, `$` and `\\`. """ args: list[str] = [] cur = "" - in_quotes = False started = False i = 0 while i < len(value): c = value[i] - if in_quotes: - if c == "\\" and i + 1 < len(value) and value[i + 1] in '"`$\\': - cur += value[i + 1] - i += 2 - continue - if c == '"': - in_quotes = False + if c == "'": + started = True + i += 1 + while i < len(value) and value[i] != "'": + cur += value[i] i += 1 - continue - cur += c - elif c == '"': - in_quotes = True + i += 1 # closing quote (or end of string, if unbalanced) + continue + if c == '"': started = True - elif c.isspace(): + i += 1 + while i < len(value) and value[i] != '"': + if value[i] == "\\" and i + 1 < len(value) and value[i + 1] in '"`$\\': + cur += value[i + 1] + i += 2 + continue + cur += value[i] + i += 1 + i += 1 + continue + if c == "\\" and i + 1 < len(value): + cur += value[i + 1] + started = True + i += 2 + continue + if c.isspace(): if started: args.append(cur) cur = "" started = False - else: - cur += c - started = True + i += 1 + continue + cur += c + started = True i += 1 if started: args.append(cur) diff --git a/apps/desktop/test/pwa.test.ts b/apps/desktop/test/pwa.test.ts index f24eda0..0db8cfd 100644 --- a/apps/desktop/test/pwa.test.ts +++ b/apps/desktop/test/pwa.test.ts @@ -397,6 +397,106 @@ describe('Flatpak flextop exports', () => { }); }); +describe('single-quoted Exec values', () => { + // Verbatim from a real Flathub install. The desktop-entry spec defines only + // double quotes, but GLib -- which is what actually launches these -- honours + // single quotes, so writers use them. Parsed to the spec, every argument here + // is a literal token with an apostrophe on the front, so every switch test + // fails and the file reads as "not a web app". That is how eleven working + // shortcuts stayed invisible to two releases of this helper. + const REAL_EXEC = + "flatpak 'run' '--command=/app/bin/chromium' " + + "'io.github.ungoogled_software.ungoogled_chromium' " + + `'--user-data-dir=PROFILE' '--profile-directory=Default' '--app-id=${APP_ID}'`; + + function writeReal(env: Env): string { + const file = `io.github.ungoogled_software.ungoogled_chromium.flextop.chrome-${APP_ID}-Default.desktop`; + writeFileSync( + join(env.apps, file), + [ + '[Desktop Entry]', + 'Version=1.0', + 'Terminal=false', + 'Type=Application', + 'Name=Sulata Note', + `Exec=${REAL_EXEC.replace('PROFILE', env.profile)}`, + `Icon=chrome-${APP_ID}-Default`, + `StartupWMClass=crx_${APP_ID}`, + 'X-Flatpak-Part-Of=io.github.ungoogled_software.ungoogled_chromium', + 'TryExec=/var/lib/flatpak/exports/bin/io.github.ungoogled_software.ungoogled_chromium', + '', + ].join('\n'), + ); + return file; + } + + it('recognises a single-quoted --app-id as a web app at all', () => { + const env = setup(); + writeReal(env); + + expect(run(env, ['list']).stdout).toContain('Sulata Note'); + }); + + it('rewrites it, keeping the app and profile and dropping the flatpak wrapper', () => { + const env = setup(); + const file = writeReal(env); + + run(env, ['sync']); + + const [exec] = execLines(shortcut(file)(env)); + expect(exec.split(' ')[0]).toBe(CLI); + expect(exec).toContain(`--app-id=${APP_ID}`); + expect(exec).toContain(`--user-data-dir=${env.profile}`); + expect(exec).toContain('--profile-directory=Default'); + expect(exec).not.toContain('--command='); + expect(exec).not.toContain("'"); + }); + + it('reverts byte-for-byte, single quotes and all', () => { + const env = setup(); + const file = writeReal(env); + const before = shortcut(file)(env); + + run(env, ['sync']); + run(env, ['revert']); + + expect(shortcut(file)(env)).toBe(before); + }); + + it('still parses double quotes and backslash escapes', () => { + const env = setup(); + const profile = join(env.home, 'a b'); + mkdirSync(profile, { recursive: true }); + const file = `chrome-${APP_ID}-Default.desktop`; + writeFileSync( + join(env.apps, file), + [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Mixed', + `Exec=/app/chromium/chrome "--user-data-dir=${profile}" '--app-id=${APP_ID}'`, + '', + ].join('\n'), + ); + + const result = spawnSync('python3', [TRON_PWA, 'sync'], { + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: env.home, + XDG_DATA_HOME: join(env.home, '.local', 'share'), + TRONBROWSER_DATA: profile, + TRONBROWSER_CLI: CLI, + }, + }); + expect(result.status).toBe(0); + + const [exec] = execLines(shortcut(file)(env)); + expect(exec).toContain(`"--user-data-dir=${profile}"`); + expect(exec).toContain(`--app-id=${APP_ID}`); + }); +}); + describe('the launcher runs the sync itself', () => { // A repair nobody invokes is not a fix. The engine rewrites these shortcuts // behind us, so the browser has to re-run this on every start — which means