@@ -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.
5151KEY_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.
5762FIELD_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
6079def 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
377454def 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
397482def 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
0 commit comments