Skip to content

Commit 3e002b3

Browse files
ralyodioclaude
andcommitted
fix(launcher): parse Exec the way the desktop does, single quotes included
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZXtxiVkXd7rFmvrMYV7Ut
1 parent 01a8966 commit 3e002b3

2 files changed

Lines changed: 144 additions & 20 deletions

File tree

‎apps/desktop/launcher/tron-pwa‎

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -77,40 +77,64 @@ KEEP_SWITCHES = (
7777

7878

7979
def unescape_exec(value: str) -> list[str]:
80-
"""Split a desktop-entry Exec value into argv.
80+
"""Split a desktop-entry Exec value into argv, the way the desktop does.
8181
82-
Quoting is the desktop spec's own: double quotes group, and inside them a
83-
backslash escapes `"`, `` ` ``, `$` and `\\`. Splitting on whitespace alone
84-
would break every profile path that has a space in it.
82+
Follow GLib's g_shell_parse_argv rather than the desktop-entry spec. The
83+
spec defines only double quotes, but GLib is what actually launches these
84+
entries, and it honours SINGLE quotes too -- so writers use them. Flatpak's
85+
flextop exports every argument single-quoted:
86+
87+
Exec=flatpak 'run' '--command=/app/bin/chromium' '--app-id=abc…'
88+
89+
Parsed to the spec, `'--app-id=abc…'` is one literal token with an
90+
apostrophe on the front, so every test for a switch fails and the whole file
91+
reads as "not a web app". Being stricter than the launcher here means not
92+
seeing files that work perfectly well for everyone else.
93+
94+
Rules: outside quotes a backslash escapes the next character; single quotes
95+
are literal to the next single quote with no escapes inside; double quotes
96+
are literal except that a backslash escapes `"`, `` ` ``, `$` and `\\`.
8597
"""
8698
args: list[str] = []
8799
cur = ""
88-
in_quotes = False
89100
started = False
90101
i = 0
91102
while i < len(value):
92103
c = value[i]
93-
if in_quotes:
94-
if c == "\\" and i + 1 < len(value) and value[i + 1] in '"`$\\':
95-
cur += value[i + 1]
96-
i += 2
97-
continue
98-
if c == '"':
99-
in_quotes = False
104+
if c == "'":
105+
started = True
106+
i += 1
107+
while i < len(value) and value[i] != "'":
108+
cur += value[i]
100109
i += 1
101-
continue
102-
cur += c
103-
elif c == '"':
104-
in_quotes = True
110+
i += 1 # closing quote (or end of string, if unbalanced)
111+
continue
112+
if c == '"':
105113
started = True
106-
elif c.isspace():
114+
i += 1
115+
while i < len(value) and value[i] != '"':
116+
if value[i] == "\\" and i + 1 < len(value) and value[i + 1] in '"`$\\':
117+
cur += value[i + 1]
118+
i += 2
119+
continue
120+
cur += value[i]
121+
i += 1
122+
i += 1
123+
continue
124+
if c == "\\" and i + 1 < len(value):
125+
cur += value[i + 1]
126+
started = True
127+
i += 2
128+
continue
129+
if c.isspace():
107130
if started:
108131
args.append(cur)
109132
cur = ""
110133
started = False
111-
else:
112-
cur += c
113-
started = True
134+
i += 1
135+
continue
136+
cur += c
137+
started = True
114138
i += 1
115139
if started:
116140
args.append(cur)

‎apps/desktop/test/pwa.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,106 @@ describe('Flatpak flextop exports', () => {
397397
});
398398
});
399399

400+
describe('single-quoted Exec values', () => {
401+
// Verbatim from a real Flathub install. The desktop-entry spec defines only
402+
// double quotes, but GLib -- which is what actually launches these -- honours
403+
// single quotes, so writers use them. Parsed to the spec, every argument here
404+
// is a literal token with an apostrophe on the front, so every switch test
405+
// fails and the file reads as "not a web app". That is how eleven working
406+
// shortcuts stayed invisible to two releases of this helper.
407+
const REAL_EXEC =
408+
"flatpak 'run' '--command=/app/bin/chromium' " +
409+
"'io.github.ungoogled_software.ungoogled_chromium' " +
410+
`'--user-data-dir=PROFILE' '--profile-directory=Default' '--app-id=${APP_ID}'`;
411+
412+
function writeReal(env: Env): string {
413+
const file = `io.github.ungoogled_software.ungoogled_chromium.flextop.chrome-${APP_ID}-Default.desktop`;
414+
writeFileSync(
415+
join(env.apps, file),
416+
[
417+
'[Desktop Entry]',
418+
'Version=1.0',
419+
'Terminal=false',
420+
'Type=Application',
421+
'Name=Sulata Note',
422+
`Exec=${REAL_EXEC.replace('PROFILE', env.profile)}`,
423+
`Icon=chrome-${APP_ID}-Default`,
424+
`StartupWMClass=crx_${APP_ID}`,
425+
'X-Flatpak-Part-Of=io.github.ungoogled_software.ungoogled_chromium',
426+
'TryExec=/var/lib/flatpak/exports/bin/io.github.ungoogled_software.ungoogled_chromium',
427+
'',
428+
].join('\n'),
429+
);
430+
return file;
431+
}
432+
433+
it('recognises a single-quoted --app-id as a web app at all', () => {
434+
const env = setup();
435+
writeReal(env);
436+
437+
expect(run(env, ['list']).stdout).toContain('Sulata Note');
438+
});
439+
440+
it('rewrites it, keeping the app and profile and dropping the flatpak wrapper', () => {
441+
const env = setup();
442+
const file = writeReal(env);
443+
444+
run(env, ['sync']);
445+
446+
const [exec] = execLines(shortcut(file)(env));
447+
expect(exec.split(' ')[0]).toBe(CLI);
448+
expect(exec).toContain(`--app-id=${APP_ID}`);
449+
expect(exec).toContain(`--user-data-dir=${env.profile}`);
450+
expect(exec).toContain('--profile-directory=Default');
451+
expect(exec).not.toContain('--command=');
452+
expect(exec).not.toContain("'");
453+
});
454+
455+
it('reverts byte-for-byte, single quotes and all', () => {
456+
const env = setup();
457+
const file = writeReal(env);
458+
const before = shortcut(file)(env);
459+
460+
run(env, ['sync']);
461+
run(env, ['revert']);
462+
463+
expect(shortcut(file)(env)).toBe(before);
464+
});
465+
466+
it('still parses double quotes and backslash escapes', () => {
467+
const env = setup();
468+
const profile = join(env.home, 'a b');
469+
mkdirSync(profile, { recursive: true });
470+
const file = `chrome-${APP_ID}-Default.desktop`;
471+
writeFileSync(
472+
join(env.apps, file),
473+
[
474+
'[Desktop Entry]',
475+
'Type=Application',
476+
'Name=Mixed',
477+
`Exec=/app/chromium/chrome "--user-data-dir=${profile}" '--app-id=${APP_ID}'`,
478+
'',
479+
].join('\n'),
480+
);
481+
482+
const result = spawnSync('python3', [TRON_PWA, 'sync'], {
483+
encoding: 'utf8',
484+
env: {
485+
PATH: process.env.PATH ?? '/usr/bin:/bin',
486+
HOME: env.home,
487+
XDG_DATA_HOME: join(env.home, '.local', 'share'),
488+
TRONBROWSER_DATA: profile,
489+
TRONBROWSER_CLI: CLI,
490+
},
491+
});
492+
expect(result.status).toBe(0);
493+
494+
const [exec] = execLines(shortcut(file)(env));
495+
expect(exec).toContain(`"--user-data-dir=${profile}"`);
496+
expect(exec).toContain(`--app-id=${APP_ID}`);
497+
});
498+
});
499+
400500
describe('the launcher runs the sync itself', () => {
401501
// A repair nobody invokes is not a fix. The engine rewrites these shortcuts
402502
// behind us, so the browser has to re-run this on every start — which means

0 commit comments

Comments
 (0)