Skip to content

Commit 01a8966

Browse files
ralyodioclaude
andauthored
fix(launcher): find Flatpak web-app shortcuts, and give them the right profile (#88)
The first pass at this missed every shortcut it was meant to fix on a Flatpak engine, which is the common Linux install. Three bugs, found by looking at a machine where it had changed nothing: Shortcuts were matched by filename prefix `chrome-`. That is what Chromium names its own, but a Flatpak exports web apps to the host through flextop, as `<flatpak-app-id>.flextop.chrome-<app-id>-<profile>.desktop`. Eleven shortcuts on the reporting machine were invisible to the scan. What makes a file a web-app shortcut is `--app-id` on its Exec line, so ask that instead of the name. Ownership was decided by `--user-data-dir` matching a TronBrowser profile. A flextop export carries no `--user-data-dir` at all, so every Flatpak web app read as "somebody else's" and was skipped. The profile itself records what it has installed, under `Web Applications/Manifest Resources/<app-id>`, and that answer does not depend on what the engine chose to write. Either rule now establishes ownership; matching neither still means hands off. That missing `--user-data-dir` is also the failure the user sees. Without it the shortcut opens the Flatpak's OWN default profile, where the app is not installed -- so the browser starts, finds nothing and exits a few seconds later. It reads as a crash but it is the right browser opening the wrong profile. Sync now fills in the profile the app is actually installed in. Switches are carried over by allowlist rather than "everything after the program", because the program is not always the engine: a flextop Exec is `flatpak run --branch=… --command=… <app-id>`, and forwarding those tokens would hand the `tron` CLI a bare `run`, which is one of its own subcommands. Since that deliberately drops tokens, revert can no longer rebuild the line -- it restored `/usr/bin/flatpak` with no `run --command=…` and left a shortcut that launched nothing. Each original Exec is now recorded verbatim and restored as-is; shortcuts patched by 3.9.9 still revert via the old reconstruction. Claude-Session: https://claude.ai/code/session_01SZXtxiVkXd7rFmvrMYV7Ut Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 68d1894 commit 01a8966

2 files changed

Lines changed: 248 additions & 38 deletions

File tree

‎apps/desktop/launcher/tron-pwa‎

Lines changed: 153 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,32 @@ KEY_ENGINE = "X-TronBrowser-Engine"
4949
# The --class we added, so revert removes exactly that and not a --class the
5050
# shortcut arrived with.
5151
KEY_CLASS = "X-TronBrowser-Class"
52+
# Each original Exec line, verbatim, as X-TronBrowser-OrigExec<N> in file order.
53+
# Revert restores these rather than rebuilding a command line: we deliberately
54+
# drop tokens we do not understand (a `flatpak run --command=… <app-id>`
55+
# wrapper), and nothing that drops information can reconstruct it afterwards.
56+
KEY_ORIG_EXEC = "X-TronBrowser-OrigExec"
5257

5358
# Field codes are the desktop spec's argument placeholders. A web-app shortcut
5459
# is launched with no arguments, so they expand to nothing -- but a stray %U
5560
# left on the line would let a file manager pass a path through to the launcher,
5661
# which forwards unrecognised arguments to the browser as URLs.
5762
FIELD_CODES = {"%f", "%F", "%u", "%U", "%i", "%c", "%k", "%v", "%m", "%d", "%D", "%n", "%N"}
5863

64+
# The only switches worth carrying from a shortcut onto the launcher's command
65+
# line: they say which app, in which profile, under which window class. An
66+
# allowlist rather than "everything after the program" because the program is
67+
# not always the engine -- a flextop export wraps it in `flatpak run`, whose own
68+
# arguments would otherwise be forwarded to the `tron` CLI as if they were ours.
69+
KEEP_SWITCHES = (
70+
"--app-id=",
71+
"--app=",
72+
"--profile-directory=",
73+
"--user-data-dir=",
74+
"--app-launch-url-for-shortcuts-menu-item=",
75+
"--class=",
76+
)
77+
5978

6079
def unescape_exec(value: str) -> list[str]:
6180
"""Split a desktop-entry Exec value into argv.
@@ -320,22 +339,39 @@ class Shortcut:
320339
raise
321340

322341

323-
def rewrite_argv(argv: list[str], cli: str, wm_class: str | None) -> list[str]:
324-
"""Point one Exec argv at the launcher, keeping the switches it carried.
342+
def rewrite_argv(
343+
argv: list[str], cli: str, wm_class: str | None, user_data_dir: str | None
344+
) -> list[str]:
345+
"""Point one Exec argv at the launcher, carrying over the switches that matter.
346+
347+
We keep an ALLOWLIST rather than everything after the program, because the
348+
program is not always the engine. Flatpak exports its web apps through
349+
flextop, whose Exec is a `flatpak run --branch=… --command=… <app-id>`
350+
wrapper: passing those tokens through would hand the `tron` CLI a bare
351+
`run`, which is one of its own subcommands. Only the switches that say WHICH
352+
app in WHICH profile mean anything here; the launcher supplies the rest.
325353
326-
Everything Chromium put on the line stays: --app-id, --profile-directory,
327-
--user-data-dir and the shortcuts-menu switches. The launcher drops its own
328-
copy of any switch the caller also passes, so what is here wins -- which is
329-
what keeps a shortcut opening the profile the app was actually installed in.
354+
--user-data-dir is filled in when the shortcut has none. A flextop entry
355+
does not carry one, so it opens the Flatpak's own default profile -- where
356+
the app is not installed, so the browser starts, finds nothing, and exits.
357+
That is the crash: not a crash at all, but the right browser opening the
358+
wrong profile.
330359
331-
--class is the exception we add. The launcher stamps every window it starts
360+
--class is added because the launcher stamps every window it starts
332361
--class=TronBrowser, which stock Chromium does not do, and a window whose
333362
class does not match the shortcut's StartupWMClass never binds to its
334-
taskbar entry -- it shows up beside the app as a second, unnamed window with
335-
the browser's icon. Passing the shortcut's own declared class back in makes
336-
the two agree by construction.
363+
taskbar entry.
337364
"""
338-
kept = [a for a in argv[1:] if a not in FIELD_CODES]
365+
kept = []
366+
for a in argv[1:]:
367+
if a in FIELD_CODES:
368+
continue
369+
for keep in KEEP_SWITCHES:
370+
if a.startswith(keep):
371+
kept.append(a)
372+
break
373+
if user_data_dir and not any(a.startswith("--user-data-dir=") for a in kept):
374+
kept.insert(0, "--user-data-dir=" + user_data_dir)
339375
if wm_class and not any(a == "--class" or a.startswith("--class=") for a in kept):
340376
kept.append("--class=" + wm_class)
341377
return [cli] + kept
@@ -350,44 +386,93 @@ def find_shortcuts(apps_dir: str) -> list[Shortcut]:
350386
for name in names:
351387
if not name.endswith(".desktop"):
352388
continue
353-
# Chromium names every installed web app chrome-<app-id>-<profile>.desktop
354-
# regardless of which Chromium wrote it.
355-
if not name.startswith("chrome-"):
356-
continue
389+
# Do NOT filter by filename. Chromium names its own web apps
390+
# chrome-<app-id>-<profile>.desktop, but that is not the only writer: a
391+
# Flatpak exports them through flextop as
392+
# <flatpak-app-id>.flextop.chrome-<app-id>-<profile>.desktop, and a
393+
# chrome- prefix match silently skips every one of those. What makes a
394+
# file a web-app shortcut is --app-id on its Exec line, so ask that.
357395
sc = Shortcut.load(os.path.join(apps_dir, name))
358396
if sc and sc.is_web_app():
359397
out.append(sc)
360398
return out
361399

362400

363-
def ours(sc: Shortcut) -> bool:
364-
"""Is this shortcut for an app installed in a TronBrowser profile?
401+
def installed_apps() -> dict[str, str]:
402+
"""Map every app id installed in a TronBrowser profile to that profile.
403+
404+
Chromium keeps one directory per installed web app under
405+
<user-data-dir>/<profile>/Web Applications/Manifest Resources/<app-id>, so
406+
the profile itself is the register of what TronBrowser has installed. This
407+
is the attribution that holds when the shortcut carries no --user-data-dir
408+
to compare against -- which is exactly the case for a flextop export.
409+
"""
410+
found: dict[str, str] = {}
411+
for data in profile_dirs():
412+
try:
413+
profiles = os.listdir(data)
414+
except OSError:
415+
continue
416+
for profile in profiles:
417+
manifests = os.path.join(data, profile, "Web Applications", "Manifest Resources")
418+
try:
419+
for app_id in os.listdir(manifests):
420+
if os.path.isdir(os.path.join(manifests, app_id)):
421+
found.setdefault(app_id, data)
422+
except OSError:
423+
continue
424+
return found
425+
426+
427+
def ours(sc: Shortcut, installed: dict[str, str]) -> str | None:
428+
"""The TronBrowser profile this shortcut belongs to, or None.
429+
430+
Two ways to establish it, and both are needed:
431+
432+
* The shortcut names a --user-data-dir that is one of ours. This is what
433+
Chromium writes when it records the running command line.
434+
* The app id is installed in one of our profiles. A flextop export carries
435+
no --user-data-dir at all, so the first rule alone reads every Flatpak
436+
web app as "somebody else's" and skips it -- which is precisely how a
437+
whole machine's worth of shortcuts stayed broken. The profile knows what
438+
it has installed, and that answer does not depend on what the engine
439+
chose to write into the file.
365440
366-
Chromium copies the running --user-data-dir into the shortcut, so the
367-
profile path is the attribution. Anything without one came from a browser
368-
running its default profile, which TronBrowser never does -- so it belongs
369-
to some other Chrome or Chromium and we leave it alone.
441+
A shortcut matching neither is another browser's, and is left alone.
370442
"""
371443
udd = sc.user_data_dir()
372-
if not udd:
373-
return False
374-
return any(same_path(udd, p) for p in profile_dirs())
444+
if udd:
445+
for p in profile_dirs():
446+
if same_path(udd, p):
447+
return p
448+
app_id = sc.app_id()
449+
if app_id and app_id in installed:
450+
return installed[app_id]
451+
return None
375452

376453

377454
def cmd_list(apps_dir: str) -> int:
378455
shortcuts = find_shortcuts(apps_dir)
379456
if not shortcuts:
380457
print(f"No web-app shortcuts in {apps_dir}")
381458
return 0
459+
installed = installed_apps()
382460
print(f"Web-app shortcuts in {apps_dir}:\n")
383461
for sc in shortcuts:
384462
name = sc.get("Name") or os.path.basename(sc.path)
385-
if not ours(sc):
386-
udd = sc.user_data_dir() or "default profile"
463+
profile = ours(sc, installed)
464+
if not profile:
465+
udd = sc.user_data_dir() or "no profile recorded, and not installed in ours"
387466
print(f" {name}\n not TronBrowser's ({udd}) — left alone")
388467
continue
389468
if sc.patched():
390469
state = "launches via TronBrowser"
470+
elif not sc.user_data_dir():
471+
state = (
472+
f"launches {sc.engine()} with NO profile — it opens the browser's default "
473+
f"profile, where this app is not installed, so it exits on startup. "
474+
f"Run 'tron pwa sync'"
475+
)
391476
else:
392477
state = f"launches the engine directly ({sc.engine()}) — run 'tron pwa sync'"
393478
print(f" {name}\n {state}\n {os.path.basename(sc.path)}")
@@ -396,21 +481,25 @@ def cmd_list(apps_dir: str) -> int:
396481

397482
def cmd_sync(apps_dir: str, dry_run: bool) -> int:
398483
cli = launcher_cli()
484+
installed = installed_apps()
399485
changed = 0
400486
for sc in find_shortcuts(apps_dir):
401-
if not ours(sc):
487+
profile = ours(sc, installed)
488+
if not profile:
402489
continue
403490
engine = sc.engine()
404491
lines = sc.exec_lines()
405492
if not lines:
406493
continue
494+
# Verbatim Exec values, before we touch anything, keyed by line index.
495+
original_text = {i: sc.lines[i][len("Exec=") :] for i, _ in lines}
407496
# The shortcut's own StartupWMClass, or the class Chromium would give an
408497
# app window if the shortcut never declared one.
409498
wm_class = sc.get("StartupWMClass")
410499
if not wm_class:
411500
app_id = sc.app_id()
412501
wm_class = "crx_" + app_id if app_id else None
413-
new_lines = {i: rewrite_argv(argv, cli, wm_class) for i, argv in lines}
502+
new_lines = {i: rewrite_argv(argv, cli, wm_class, profile) for i, argv in lines}
414503
if all(escape_exec(new) == escape_exec(dict(lines)[i]) for i, new in new_lines.items()):
415504
continue
416505
name = sc.get("Name") or os.path.basename(sc.path)
@@ -425,10 +514,11 @@ def cmd_sync(apps_dir: str, dry_run: bool) -> int:
425514
# make revert a no-op.
426515
if not sc.get(KEY_ENGINE) and engine:
427516
sc.set(KEY_ENGINE, engine)
428-
if wm_class and not any(
429-
a == "--class" or a.startswith("--class=") for _, argv in lines for a in argv[1:]
430-
):
431-
sc.set(KEY_CLASS, wm_class)
517+
# Record the originals only the first time, for the same reason as the
518+
# engine: a re-sync reads Exec lines we already wrote.
519+
if not sc.get(KEY_ORIG_EXEC + "0"):
520+
for n, (i, _) in enumerate(lines):
521+
sc.set(KEY_ORIG_EXEC + str(n), original_text[i])
432522
sc.set(KEY_PATCHED, "1")
433523
sc.write()
434524
print(f"TronBrowser: {name} now launches through the TronBrowser launcher.")
@@ -448,15 +538,40 @@ def cmd_revert(apps_dir: str, dry_run: bool) -> int:
448538
if dry_run:
449539
print(f"would restore {name} to {engine}")
450540
continue
451-
added_class = sc.get(KEY_CLASS)
452-
for i, argv in sc.exec_lines():
453-
restored = [engine] + [
454-
a for a in argv[1:] if not (added_class and a == "--class=" + added_class)
455-
]
456-
sc.lines[i] = "Exec=" + escape_exec(restored)
541+
originals = []
542+
n = 0
543+
while True:
544+
v = sc.get(KEY_ORIG_EXEC + str(n))
545+
if v is None:
546+
break
547+
originals.append(v)
548+
n += 1
549+
lines = sc.exec_lines()
550+
if originals and len(originals) == len(lines):
551+
for (i, _), text in zip(lines, originals):
552+
sc.lines[i] = "Exec=" + text
553+
elif originals:
554+
print(
555+
f"{os.path.basename(sc.path)}: {len(lines)} Exec lines but "
556+
f"{len(originals)} recorded, skipping",
557+
file=sys.stderr,
558+
)
559+
continue
560+
else:
561+
# Patched by a build that recorded only the engine (3.9.9). Rebuild
562+
# the line: correct whenever the engine ran the shortcut directly,
563+
# which is every case that build was able to touch.
564+
added_class = sc.get(KEY_CLASS)
565+
for i, argv in lines:
566+
sc.lines[i] = "Exec=" + escape_exec(
567+
[engine]
568+
+ [a for a in argv[1:] if not (added_class and a == "--class=" + added_class)]
569+
)
457570
sc.unset(KEY_PATCHED)
458571
sc.unset(KEY_ENGINE)
459572
sc.unset(KEY_CLASS)
573+
for k in range(len(originals)):
574+
sc.unset(KEY_ORIG_EXEC + str(k))
460575
sc.write()
461576
print(f"Restored {name} to {engine}.")
462577
return 0

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,101 @@ describe('tron pwa list', () => {
302302
});
303303
});
304304

305+
describe('Flatpak flextop exports', () => {
306+
// What a Flathub TronBrowser actually produces, and what the first version of
307+
// this helper missed entirely: the filename does not start with chrome-, the
308+
// Exec is a `flatpak run` wrapper, and there is no --user-data-dir at all --
309+
// so the shortcut opens the Flatpak's own default profile, where the app is
310+
// not installed, and the browser exits a few seconds after starting.
311+
const FLATPAK = 'io.github.ungoogled_software.ungoogled_chromium';
312+
const flextopName = `${FLATPAK}.flextop.chrome-${APP_ID}-Default.desktop`;
313+
314+
function writeFlextop(env: Env, opts: { installed?: boolean } = {}): string {
315+
if (opts.installed !== false) {
316+
mkdirSync(join(env.profile, 'Default', 'Web Applications', 'Manifest Resources', APP_ID), {
317+
recursive: true,
318+
});
319+
}
320+
writeFileSync(
321+
join(env.apps, flextopName),
322+
[
323+
'[Desktop Entry]',
324+
'Type=Application',
325+
'Name=Reeleel',
326+
`Exec=/usr/bin/flatpak run --branch=stable --arch=x86_64 --command=/app/bin/chromium ${FLATPAK} --profile-directory=Default --app-id=${APP_ID}`,
327+
`StartupWMClass=crx_${APP_ID}`,
328+
'',
329+
].join('\n'),
330+
);
331+
return flextopName;
332+
}
333+
334+
it('finds a shortcut whose filename does not start with chrome-', () => {
335+
const env = setup();
336+
const file = writeFlextop(env);
337+
338+
run(env, ['sync']);
339+
340+
expect(execLines(shortcut(file)(env))[0].split(' ')[0]).toBe(CLI);
341+
});
342+
343+
it('claims it by the app being installed in the profile, with no --user-data-dir to go on', () => {
344+
const env = setup();
345+
writeFlextop(env);
346+
347+
expect(run(env, ['list']).stdout).toContain('Reeleel');
348+
expect(run(env, ['list']).stdout).not.toContain('left alone');
349+
});
350+
351+
it('fills in the profile the app is actually installed in', () => {
352+
const env = setup();
353+
const file = writeFlextop(env);
354+
355+
run(env, ['sync']);
356+
357+
// Without this the shortcut opens the browser's default profile, which is
358+
// the entire bug: right browser, wrong profile, no such app, exit.
359+
expect(execLines(shortcut(file)(env))[0]).toContain(`--user-data-dir=${env.profile}`);
360+
});
361+
362+
it('drops the flatpak wrapper tokens instead of forwarding them to the CLI', () => {
363+
const env = setup();
364+
const file = writeFlextop(env);
365+
366+
run(env, ['sync']);
367+
368+
// `run` is a tron subcommand. Forwarding it would run a script, not a browser.
369+
const [exec] = execLines(shortcut(file)(env));
370+
expect(exec).not.toContain(' run ');
371+
expect(exec).not.toContain('--branch=');
372+
expect(exec).not.toContain('--command=');
373+
expect(exec).not.toContain(FLATPAK + ' ');
374+
});
375+
376+
it('restores the flatpak wrapper exactly on revert', () => {
377+
const env = setup();
378+
const file = writeFlextop(env);
379+
const before = execLines(shortcut(file)(env))[0];
380+
381+
run(env, ['sync']);
382+
run(env, ['revert']);
383+
384+
// Rebuilding this line is impossible once the wrapper tokens are dropped,
385+
// so it has to have been recorded verbatim.
386+
expect(execLines(shortcut(file)(env))[0]).toBe(before);
387+
});
388+
389+
it('leaves a flextop app that is NOT in our profile alone', () => {
390+
const env = setup();
391+
const file = writeFlextop(env, { installed: false });
392+
const before = shortcut(file)(env);
393+
394+
run(env, ['sync']);
395+
396+
expect(shortcut(file)(env)).toBe(before);
397+
});
398+
});
399+
305400
describe('the launcher runs the sync itself', () => {
306401
// A repair nobody invokes is not a fix. The engine rewrites these shortcuts
307402
// behind us, so the browser has to re-run this on every start — which means

0 commit comments

Comments
 (0)