From d1068843dacf73e981d9357282eac7ef9a3306ed Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 14:23:50 +0000 Subject: [PATCH] fix(launcher): installed web apps launch through TronBrowser, not the raw engine Installing a web app writes a freedesktop shortcut, and the engine writes it against ITSELF -- `Exec=` is the Chromium binary plus `--app-id=`. That is the one launch path in TronBrowser that never comes through launcher/tronbrowser, so an app started from its desktop icon gets none of what the launcher sets up: no bundled extensions, no --class, and no GPU mode. Under the Flathub engine the recorded path is the in-sandbox /app/... one, which does not exist on the host at all, so the icon cannot launch anything. That is why "install app" from the address bar works and the same app dies from its icon: the address bar hands the request to a browser the launcher already configured, while the icon starts a fresh one it never touched. launcher/tron-pwa repoints those shortcuts at the launcher, keeping every Chromium switch they carried -- the app id, the profile, the user data dir and the shortcuts-menu switches -- so each icon still opens the app it named, in the profile it was installed in. It hands back the shortcut's own StartupWMClass as --class, because the launcher stamps --class=TronBrowser on everything it starts and a window whose class does not match never binds to its taskbar entry. The shim runs it on every start rather than once at install: the engine rewrites these files whenever an app's manifest or icon changes, which puts the engine path straight back. `tron pwa` is the manual handle and `tron pwa list` the diagnostic; `tron remove` reverts, so uninstalling does not strand every icon on a launcher that is about to be deleted. Only shortcuts whose --user-data-dir is a TronBrowser profile are touched. A real Chrome's web apps are left byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SZXtxiVkXd7rFmvrMYV7Ut --- apps/desktop/launcher/tron-pwa | 485 ++++++++++++++++++++++++++ apps/desktop/launcher/tronbrowser | 21 ++ apps/desktop/scripts/build-release.sh | 4 + apps/desktop/test/pwa.test.ts | 363 +++++++++++++++++++ apps/web/public/install.sh | 26 ++ 5 files changed, 899 insertions(+) create mode 100755 apps/desktop/launcher/tron-pwa create mode 100644 apps/desktop/test/pwa.test.ts diff --git a/apps/desktop/launcher/tron-pwa b/apps/desktop/launcher/tron-pwa new file mode 100755 index 0000000..b9abdee --- /dev/null +++ b/apps/desktop/launcher/tron-pwa @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""Keep installed-PWA desktop shortcuts pointing at the TronBrowser launcher. + +Installing a web app writes a freedesktop shortcut, and Chromium writes it +against ITSELF: `Exec=` is the engine binary plus `--app-id=`. That is the one +launch path in TronBrowser that never goes through `launcher/tronbrowser`, so +none of what the launcher supplies is there — no bundled extensions, no +`--class=TronBrowser`, and no GPU mode. It is why "install app" works from the +address bar and the same app dies from its desktop icon: the address bar hands +the request to a browser the launcher already configured, while the icon starts +a fresh one it never touched. + +Two ways that presents: + + * Flatpak (the usual Ungoogled Chromium on Linux). The recorded path is the + in-sandbox one, `/app/...`, which does not exist on the host. Nothing can + launch it and the desktop just reports a failure. + * A system Ungoogled Chromium. It launches, but with the stock backend — so a + machine kept usable by `tron gpu safe` or `tron gpu off` gets, in its PWA + windows only, exactly the GPU process that made those settings necessary. + That reads as the app crashing on open. + +So we rewrite `Exec=` to run the launcher instead, keeping every Chromium switch +the shortcut already carried. Chromium rewrites these files whenever an app's +manifest or icon changes, which would undo this — hence the launcher runs +`sync` on every start rather than this being a one-time repair. + +Only shortcuts whose `--user-data-dir` is a TronBrowser profile are touched. A +shortcut from someone's real Chrome is left exactly as it is. + + tron pwa [list] show every web-app shortcut and who owns it + tron pwa sync point TronBrowser's shortcuts at the launcher + tron pwa revert hand them back to the engine binary (used by uninstall) + +`--dry-run` reports what sync/revert would change without writing. +""" + +from __future__ import annotations + +import os +import sys +import tempfile + +# Our own keys in the shortcut. PATCHED records that the file is ours to revert; +# ENGINE remembers the binary Chromium wrote, so revert restores that and not a +# guess. Freedesktop reserves the X- prefix for exactly this. +KEY_PATCHED = "X-TronBrowser-Launcher" +KEY_ENGINE = "X-TronBrowser-Engine" +# The --class we added, so revert removes exactly that and not a --class the +# shortcut arrived with. +KEY_CLASS = "X-TronBrowser-Class" + +# Field codes are the desktop spec's argument placeholders. A web-app shortcut +# is launched with no arguments, so they expand to nothing -- but a stray %U +# left on the line would let a file manager pass a path through to the launcher, +# which forwards unrecognised arguments to the browser as URLs. +FIELD_CODES = {"%f", "%F", "%u", "%U", "%i", "%c", "%k", "%v", "%m", "%d", "%D", "%n", "%N"} + + +def unescape_exec(value: str) -> list[str]: + """Split a desktop-entry Exec value into argv. + + 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. + """ + 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 + i += 1 + continue + cur += c + elif c == '"': + in_quotes = True + started = True + elif c.isspace(): + if started: + args.append(cur) + cur = "" + started = False + else: + cur += c + started = True + i += 1 + if started: + args.append(cur) + return args + + +def escape_exec(args: list[str]) -> str: + """Render argv back into an Exec value, quoting only what needs it.""" + out = [] + for a in args: + if a and not any(c in a for c in ' \t\n"\'\\><~|&;$*?#()`'): + out.append(a) + continue + escaped = a + for c in ("\\", '"', "`", "$"): + escaped = escaped.replace(c, "\\" + c) + out.append('"' + escaped + '"') + return " ".join(out) + + +def applications_dir() -> str: + base = os.environ.get("XDG_DATA_HOME") or os.path.join( + os.path.expanduser("~"), ".local", "share" + ) + return os.path.join(base, "applications") + + +def profile_dirs() -> list[str]: + """Every directory that could be a TronBrowser profile on this machine. + + Mirrors the launcher: `$TRONBROWSER_DATA` when set, `~/.tronbrowser` + normally, and `~/TronBrowser` under a snap-confined browser (which can only + read non-hidden files in $HOME). + """ + home = os.path.expanduser("~") + dirs = [os.environ.get("TRONBROWSER_DATA") or os.path.join(home, ".tronbrowser")] + dirs.append(os.path.join(home, "TronBrowser")) + seen: list[str] = [] + for d in dirs: + if d and d not in seen: + seen.append(d) + return seen + + +def same_path(a: str, b: str) -> bool: + """Compare two paths that need not exist yet. + + A profile directory is created on first launch, so realpath() is not always + available on both sides; normalising and expanding covers the rest. + """ + def norm(p: str) -> str: + p = os.path.expanduser(os.path.expandvars(p)) + p = os.path.abspath(p) + return os.path.realpath(p) if os.path.exists(p) else os.path.normpath(p) + + return norm(a) == norm(b) + + +def cli_from_browser_desktop() -> str | None: + """The `tron` path TronBrowser's own desktop entry was installed with. + + This is the authoritative answer: the installer picked that prefix and wrote + it into tronbrowser.desktop, and it is a stable path across upgrades — the + versioned launcher directory this script sits in is not. + """ + dirs = [applications_dir(), "/usr/local/share/applications", "/usr/share/applications"] + for d in dirs: + p = os.path.join(d, "tronbrowser.desktop") + try: + with open(p, encoding="utf-8") as f: + for line in f: + if line.startswith("Exec="): + argv = unescape_exec(line[len("Exec=") :].strip()) + if argv and os.path.isabs(argv[0]) and os.access(argv[0], os.X_OK): + return argv[0] + break + except (OSError, UnicodeDecodeError): + continue + return None + + +def launcher_cli() -> str: + """Absolute path to put in Exec. + + It has to stay valid across upgrades and it has to be found without a login + shell: a desktop icon launches us with whatever PATH the session manager + has, which on KDE frequently does not include ~/.local/bin. So PATH is only + one of the places we look, and never the first. + """ + override = os.environ.get("TRONBROWSER_CLI") + if override: + return override + from_desktop = cli_from_browser_desktop() + if from_desktop: + return from_desktop + home = os.path.expanduser("~") + dirs = (os.environ.get("PATH") or "").split(os.pathsep) + [ + os.path.join(home, ".local", "bin"), + "/usr/local/bin", + "/usr/bin", + ] + for name in ("tron", "tronbrowser"): + for d in dirs: + if not d: + continue + cand = os.path.join(d, name) + if os.path.isfile(cand) and os.access(cand, os.X_OK): + return os.path.abspath(cand) + # Last resort: the shim beside this script. Correct today, but it lives in a + # versioned directory, so say so rather than letting a shortcut silently + # rot at the next upgrade. + here = os.path.dirname(os.path.abspath(__file__)) + fallback = os.path.join(here, "tronbrowser") + print( + f"tron pwa: no 'tron' on PATH — using {fallback}, which an upgrade will move.\n" + " Re-run 'tron pwa sync' after upgrading, or put 'tron' on PATH.", + file=sys.stderr, + ) + return fallback + + +class Shortcut: + """One chrome-*.desktop file, parsed just enough to rewrite its Exec lines.""" + + def __init__(self, path: str, lines: list[str]): + self.path = path + self.lines = lines + + @classmethod + def load(cls, path: str) -> "Shortcut | None": + try: + with open(path, encoding="utf-8") as f: + return cls(path, f.read().splitlines()) + except (OSError, UnicodeDecodeError): + return None + + def exec_lines(self) -> list[tuple[int, list[str]]]: + """Every Exec= line, as (index, argv). + + A web-app shortcut can carry several: the entry itself, plus one per + [Desktop Action] when the app declares a shortcuts menu. All of them + launch the browser, so all of them need the same treatment. + """ + found = [] + for i, line in enumerate(self.lines): + if line.startswith("Exec="): + found.append((i, unescape_exec(line[len("Exec=") :]))) + return found + + def get(self, key: str) -> str | None: + prefix = key + "=" + for line in self.lines: + if line.startswith(prefix): + return line[len(prefix) :].strip() + return None + + def set(self, key: str, value: str) -> None: + prefix = key + "=" + for i, line in enumerate(self.lines): + if line.startswith(prefix): + self.lines[i] = prefix + value + return + # Land it in [Desktop Entry], not in whichever group happens to be last. + for i, line in enumerate(self.lines): + if line.strip() == "[Desktop Entry]": + self.lines.insert(i + 1, prefix + value) + return + self.lines.append(prefix + value) + + def unset(self, key: str) -> None: + prefix = key + "=" + self.lines = [l for l in self.lines if not l.startswith(prefix)] + + def user_data_dir(self) -> str | None: + for _, argv in self.exec_lines(): + for a in argv: + if a.startswith("--user-data-dir="): + return a[len("--user-data-dir=") :] + return None + + def app_id(self) -> str | None: + for _, argv in self.exec_lines(): + for a in argv: + if a.startswith("--app-id="): + return a[len("--app-id=") :] + return None + + def is_web_app(self) -> bool: + for _, argv in self.exec_lines(): + for a in argv: + if a.startswith("--app-id=") or a.startswith("--app="): + return True + return False + + def patched(self) -> bool: + return (self.get(KEY_PATCHED) or "").strip() == "1" + + def engine(self) -> str | None: + """The program each Exec line runs today.""" + for _, argv in self.exec_lines(): + if argv: + return argv[0] + return None + + def write(self) -> None: + """Replace the file atomically. + + A half-written shortcut is one the desktop cannot launch at all, and we + rewrite these on every browser start -- so the window where a crash + could leave one truncated has to be closed. + """ + d = os.path.dirname(self.path) or "." + fd, tmp = tempfile.mkstemp(dir=d, prefix=".tron-pwa-") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write("\n".join(self.lines) + "\n") + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, 0o644) + os.replace(tmp, self.path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def rewrite_argv(argv: list[str], cli: str, wm_class: str | None) -> list[str]: + """Point one Exec argv at the launcher, keeping the switches it carried. + + Everything Chromium put on the line stays: --app-id, --profile-directory, + --user-data-dir and the shortcuts-menu switches. The launcher drops its own + copy of any switch the caller also passes, so what is here wins -- which is + what keeps a shortcut opening the profile the app was actually installed in. + + --class is the exception we add. The launcher stamps every window it starts + --class=TronBrowser, which stock Chromium does not do, and a window whose + class does not match the shortcut's StartupWMClass never binds to its + taskbar entry -- it shows up beside the app as a second, unnamed window with + the browser's icon. Passing the shortcut's own declared class back in makes + the two agree by construction. + """ + kept = [a for a in argv[1:] if a not in FIELD_CODES] + if wm_class and not any(a == "--class" or a.startswith("--class=") for a in kept): + kept.append("--class=" + wm_class) + return [cli] + kept + + +def find_shortcuts(apps_dir: str) -> list[Shortcut]: + try: + names = sorted(os.listdir(apps_dir)) + except OSError: + return [] + out = [] + for name in names: + if not name.endswith(".desktop"): + continue + # Chromium names every installed web app chrome--.desktop + # regardless of which Chromium wrote it. + if not name.startswith("chrome-"): + continue + sc = Shortcut.load(os.path.join(apps_dir, name)) + if sc and sc.is_web_app(): + out.append(sc) + return out + + +def ours(sc: Shortcut) -> bool: + """Is this shortcut for an app installed in a TronBrowser profile? + + Chromium copies the running --user-data-dir into the shortcut, so the + profile path is the attribution. Anything without one came from a browser + running its default profile, which TronBrowser never does -- so it belongs + to some other Chrome or Chromium and we leave it alone. + """ + udd = sc.user_data_dir() + if not udd: + return False + return any(same_path(udd, p) for p in profile_dirs()) + + +def cmd_list(apps_dir: str) -> int: + shortcuts = find_shortcuts(apps_dir) + if not shortcuts: + print(f"No web-app shortcuts in {apps_dir}") + return 0 + print(f"Web-app shortcuts in {apps_dir}:\n") + for sc in shortcuts: + name = sc.get("Name") or os.path.basename(sc.path) + if not ours(sc): + udd = sc.user_data_dir() or "default profile" + print(f" {name}\n not TronBrowser's ({udd}) — left alone") + continue + if sc.patched(): + state = "launches via TronBrowser" + else: + state = f"launches the engine directly ({sc.engine()}) — run 'tron pwa sync'" + print(f" {name}\n {state}\n {os.path.basename(sc.path)}") + return 0 + + +def cmd_sync(apps_dir: str, dry_run: bool) -> int: + cli = launcher_cli() + changed = 0 + for sc in find_shortcuts(apps_dir): + if not ours(sc): + continue + engine = sc.engine() + lines = sc.exec_lines() + if not lines: + continue + # The shortcut's own StartupWMClass, or the class Chromium would give an + # app window if the shortcut never declared one. + wm_class = sc.get("StartupWMClass") + if not wm_class: + app_id = sc.app_id() + wm_class = "crx_" + app_id if app_id else None + new_lines = {i: rewrite_argv(argv, cli, wm_class) for i, argv in lines} + if all(escape_exec(new) == escape_exec(dict(lines)[i]) for i, new in new_lines.items()): + continue + name = sc.get("Name") or os.path.basename(sc.path) + if dry_run: + print(f"would rewrite {name} ({os.path.basename(sc.path)})") + changed += 1 + continue + for i, new in new_lines.items(): + sc.lines[i] = "Exec=" + escape_exec(new) + # Remember the engine only the first time, so re-running after Chromium + # has rewritten the file does not record our own CLI as the engine and + # make revert a no-op. + if not sc.get(KEY_ENGINE) and engine: + sc.set(KEY_ENGINE, engine) + if wm_class and not any( + a == "--class" or a.startswith("--class=") for _, argv in lines for a in argv[1:] + ): + sc.set(KEY_CLASS, wm_class) + sc.set(KEY_PATCHED, "1") + sc.write() + print(f"TronBrowser: {name} now launches through the TronBrowser launcher.") + changed += 1 + return 0 if changed or dry_run else 0 + + +def cmd_revert(apps_dir: str, dry_run: bool) -> int: + for sc in find_shortcuts(apps_dir): + if not sc.patched(): + continue + engine = sc.get(KEY_ENGINE) + if not engine: + print(f"{os.path.basename(sc.path)}: no engine recorded, skipping", file=sys.stderr) + continue + name = sc.get("Name") or os.path.basename(sc.path) + if dry_run: + print(f"would restore {name} to {engine}") + continue + added_class = sc.get(KEY_CLASS) + for i, argv in sc.exec_lines(): + restored = [engine] + [ + a for a in argv[1:] if not (added_class and a == "--class=" + added_class) + ] + sc.lines[i] = "Exec=" + escape_exec(restored) + sc.unset(KEY_PATCHED) + sc.unset(KEY_ENGINE) + sc.unset(KEY_CLASS) + sc.write() + print(f"Restored {name} to {engine}.") + return 0 + + +def main(argv: list[str]) -> int: + args = [a for a in argv if a != "--dry-run"] + dry_run = "--dry-run" in argv + cmd = args[0] if args else "list" + apps_dir = applications_dir() + + if cmd in ("-h", "--help", "help"): + print(__doc__.strip()) + return 0 + if cmd == "list": + return cmd_list(apps_dir) + if cmd == "sync": + return cmd_sync(apps_dir, dry_run) + if cmd == "revert": + return cmd_revert(apps_dir, dry_run) + print(f"tron pwa: unknown command '{cmd}'. Try: list, sync, revert", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index 5d53393..2f831fc 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -441,6 +441,27 @@ if [ "$TOR" != "1" ]; then printf '%s\n' "$ENGINE" > "$ENGINE_MARK" 2>/dev/null || true fi +# --- Installed web apps (PWAs) --------------------------------------------- +# Installing a web app writes a freedesktop shortcut, and the browser writes it +# against ITSELF — `Exec=` is the engine binary — so clicking that icon is the +# one launch path that never comes through here. The app then starts with none +# of what this script sets up: no bundled extensions, no --class, and no GPU +# mode, which is why an app opens fine from the address bar and dies from its +# desktop icon. Under the Flathub engine it is worse: the recorded path is the +# in-sandbox /app/... one, which does not exist on the host at all. +# +# tron-pwa repoints those shortcuts at this launcher. It runs on every start +# rather than once at install because the browser rewrites the files whenever an +# app's manifest or icon changes, which puts the engine path back. Only +# shortcuts whose --user-data-dir is a TronBrowser profile are touched, so a +# real Chrome's web apps are left alone. Best-effort and quiet: needs python3, +# and a desktop that cannot be repaired must not stop the browser opening. +if [ "$TOR" != "1" ] && [ -z "$MAC_APP" ] && [ -f "$DIR/tron-pwa" ]; then + if command -v python3 >/dev/null 2>&1; then + TRONBROWSER_DATA="$DATA" python3 "$DIR/tron-pwa" sync >&2 || true + fi +fi + # --- GPU backend ----------------------------------------------------------- # A crashing GPU process does not look like a crash. The window stays up and the # page keeps whatever it had already rasterized — a logo, a header — while diff --git a/apps/desktop/scripts/build-release.sh b/apps/desktop/scripts/build-release.sh index 83fec9c..b2e53da 100755 --- a/apps/desktop/scripts/build-release.sh +++ b/apps/desktop/scripts/build-release.sh @@ -64,6 +64,10 @@ stage() { # dest dir # On-demand Tor control helper for the in-browser 🧅 Tor toggle (the launcher # starts it; it starts Tor only when the toggle asks). install -m 0755 "$DESKTOP/launcher/tron-tor-helper" "$s/tron-tor-helper" + # Repoints installed-web-app desktop icons at the launcher. The shim runs it + # on every start (the engine rewrites those files behind us); `tron pwa` is + # the manual handle. + install -m 0755 "$DESKTOP/launcher/tron-pwa" "$s/tron-pwa" # Managed-session engine for `tron browser …` / `tron open` (PRD M3.1). Sits # next to the shim; the `tron` dispatcher resolves it relative to $CURRENT. install -m 0755 "$DESKTOP/launcher/tron-session" "$s/tron-session" diff --git a/apps/desktop/test/pwa.test.ts b/apps/desktop/test/pwa.test.ts new file mode 100644 index 0000000..cb2a7b0 --- /dev/null +++ b/apps/desktop/test/pwa.test.ts @@ -0,0 +1,363 @@ +// tron-pwa rewrites files the desktop reads to launch apps. Two things have to +// hold every time: a shortcut belonging to some other Chrome is never touched, +// and a shortcut we do touch stays launchable. Both are the kind of thing that +// is invisible until someone's app menu breaks, so they are pinned here. + +import { spawnSync } from 'node:child_process'; +import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const TRON_PWA = join(HERE, '..', 'launcher', 'tron-pwa'); + +const CLI = '/opt/tron/bin/tron'; +const APP_ID = 'abcdefghijklmnopabcdefghijklmnop'; +const OTHER_ID = 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'; + +type Env = { home: string; apps: string; profile: string }; + +function setup(): Env { + const home = mkdtempSync(join(tmpdir(), 'tron-pwa-')); + const apps = join(home, '.local', 'share', 'applications'); + const profile = join(home, '.tronbrowser'); + mkdirSync(apps, { recursive: true }); + mkdirSync(profile, { recursive: true }); + return { home, apps, profile }; +} + +function run(env: Env, args: string[]): { stdout: string; stderr: string; status: number } { + const result = spawnSync('python3', [TRON_PWA, ...args], { + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: env.home, + XDG_DATA_HOME: join(env.home, '.local', 'share'), + TRONBROWSER_DATA: env.profile, + TRONBROWSER_CLI: CLI, + }, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', status: result.status ?? -1 }; +} + +const shortcut = (file: string) => (env: Env) => readFileSync(join(env.apps, file), 'utf8'); +const execLines = (text: string) => + text + .split('\n') + .filter((l) => l.startsWith('Exec=')) + .map((l) => l.slice('Exec='.length)); + +/** A web app installed through TronBrowser, written the way the engine writes it. */ +function writeTronApp(env: Env, opts: { engine?: string; withAction?: boolean } = {}): string { + const engine = opts.engine ?? '/app/chromium/chrome'; + const file = `chrome-${APP_ID}-Default.desktop`; + const base = `${engine} --user-data-dir=${env.profile} --profile-directory=Default --app-id=${APP_ID}`; + const lines = [ + '[Desktop Entry]', + 'Version=1.0', + 'Type=Application', + 'Name=Excalidraw', + `Exec=${base}`, + `Icon=chrome-${APP_ID}-Default`, + `StartupWMClass=crx_${APP_ID}`, + ]; + if (opts.withAction) { + lines.push( + 'Actions=New', + '', + '[Desktop Action New]', + 'Name=New board', + `Exec=${base} --app-launch-url-for-shortcuts-menu-item=https://excalidraw.com/new`, + ); + } + writeFileSync(join(env.apps, file), `${lines.join('\n')}\n`); + return file; +} + +describe('tron pwa sync', () => { + it('repoints a TronBrowser web app at the launcher, keeping its switches', () => { + const env = setup(); + const file = writeTronApp(env); + + expect(run(env, ['sync']).status).toBe(0); + + const [exec] = execLines(shortcut(file)(env)); + // The launcher runs, not the engine — that is the whole fix. + expect(exec.split(' ')[0]).toBe(CLI); + // Everything that identifies WHICH app in WHICH profile has to survive, or + // the icon opens the wrong thing (or a plain browser window). + expect(exec).toContain(`--app-id=${APP_ID}`); + expect(exec).toContain('--profile-directory=Default'); + expect(exec).toContain(`--user-data-dir=${env.profile}`); + // Without a class matching StartupWMClass the window does not bind to its + // taskbar entry: it shows up as a stray TronBrowser window. + expect(exec).toContain(`--class=crx_${APP_ID}`); + }); + + it('rewrites shortcut-menu actions too, not just the main entry', () => { + const env = setup(); + const file = writeTronApp(env, { withAction: true }); + + run(env, ['sync']); + + const lines = execLines(shortcut(file)(env)); + expect(lines).toHaveLength(2); + expect(lines.every((l) => l.startsWith(CLI))).toBe(true); + expect(lines[1]).toContain('--app-launch-url-for-shortcuts-menu-item=https://excalidraw.com/new'); + }); + + it('leaves another browser\'s web app completely alone', () => { + const env = setup(); + const file = `chrome-${OTHER_ID}-Default.desktop`; + const original = [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Google Docs', + `Exec=/opt/google/chrome/google-chrome --profile-directory=Default --app-id=${OTHER_ID}`, + '', + ].join('\n'); + writeFileSync(join(env.apps, file), original); + + run(env, ['sync']); + + expect(shortcut(file)(env)).toBe(original); + }); + + it('is idempotent — a second sync changes nothing', () => { + const env = setup(); + const file = writeTronApp(env); + + run(env, ['sync']); + const once = shortcut(file)(env); + const second = run(env, ['sync']); + + expect(shortcut(file)(env)).toBe(once); + expect(second.stdout.trim()).toBe(''); + }); + + it('re-repairs a shortcut the engine has rewritten underneath us', () => { + // The engine rewrites these files whenever an app's manifest or icon + // changes, putting its own path back and dropping our keys with it. That is + // why sync runs on every launch instead of once at install. + const env = setup(); + const file = writeTronApp(env); + run(env, ['sync']); + writeTronApp(env, { engine: '/usr/bin/ungoogled-chromium' }); + + run(env, ['sync']); + + const [exec] = execLines(shortcut(file)(env)); + expect(exec.split(' ')[0]).toBe(CLI); + // The engine it records is the one it just displaced, so revert still lands + // somewhere launchable. + run(env, ['revert']); + expect(execLines(shortcut(file)(env))[0].split(' ')[0]).toBe('/usr/bin/ungoogled-chromium'); + }); + + it('never records its own CLI as the engine to revert to', () => { + // Syncing an already-synced shortcut reads an Exec that names the launcher. + // Taking that as "the engine" would make revert a no-op and strand the + // shortcut on a launcher that uninstall is about to delete. + const env = setup(); + const file = writeTronApp(env); + + run(env, ['sync']); + run(env, ['sync']); + run(env, ['sync']); + run(env, ['revert']); + + const [exec] = execLines(shortcut(file)(env)); + expect(exec.split(' ')[0]).toBe('/app/chromium/chrome'); + expect(exec).not.toContain(CLI); + }); + + it('handles a profile path containing spaces', () => { + const env = setup(); + const profile = join(env.home, 'my profile'); + mkdirSync(profile, { recursive: true }); + const file = `chrome-${APP_ID}-Default.desktop`; + writeFileSync( + join(env.apps, file), + [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Spaced', + `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.split(' ')[0]).toBe(CLI); + // Re-quoted, so the desktop still parses it as one argument. + expect(exec).toContain(`"--user-data-dir=${profile}"`); + }); + + it('passes back the class the shortcut itself declares', () => { + // Taskbar binding is StartupWMClass == the window's WM_CLASS. The launcher + // stamps --class=TronBrowser on everything it starts, so the shortcut's own + // declared class has to be handed back or the app window never binds. + const env = setup(); + const file = `chrome-${APP_ID}-Default.desktop`; + writeFileSync( + join(env.apps, file), + [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Renamed', + `Exec=/app/chromium/chrome --user-data-dir=${env.profile} --app-id=${APP_ID}`, + 'StartupWMClass=some.other.Class', + '', + ].join('\n'), + ); + + run(env, ['sync']); + + expect(execLines(shortcut(file)(env))[0]).toContain('--class=some.other.Class'); + }); + + it('keeps a --class the shortcut already had, and revert leaves it', () => { + const env = setup(); + const file = `chrome-${APP_ID}-Default.desktop`; + writeFileSync( + join(env.apps, file), + [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Classed', + `Exec=/app/chromium/chrome --user-data-dir=${env.profile} --app-id=${APP_ID} --class=mine`, + `StartupWMClass=crx_${APP_ID}`, + '', + ].join('\n'), + ); + + run(env, ['sync']); + expect(execLines(shortcut(file)(env))[0]).toContain('--class=mine'); + expect(execLines(shortcut(file)(env))[0]).not.toContain(`--class=crx_${APP_ID}`); + + run(env, ['revert']); + expect(execLines(shortcut(file)(env))[0]).toContain('--class=mine'); + }); + + it('drops field codes so a file manager cannot pass a path through', () => { + const env = setup(); + const file = `chrome-${APP_ID}-Default.desktop`; + writeFileSync( + join(env.apps, file), + [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Fielded', + `Exec=/app/chromium/chrome --user-data-dir=${env.profile} --app-id=${APP_ID} %U`, + '', + ].join('\n'), + ); + + run(env, ['sync']); + + expect(execLines(shortcut(file)(env))[0]).not.toContain('%U'); + }); +}); + +describe('tron pwa list', () => { + it('names the engine a broken shortcut still points at', () => { + const env = setup(); + writeTronApp(env); + + const out = run(env, ['list']).stdout; + + expect(out).toContain('Excalidraw'); + expect(out).toContain('/app/chromium/chrome'); + expect(out).toContain('tron pwa sync'); + }); + + it('says which shortcuts are not ours', () => { + const env = setup(); + writeFileSync( + join(env.apps, `chrome-${OTHER_ID}-Default.desktop`), + [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Google Docs', + `Exec=/opt/google/chrome/google-chrome --app-id=${OTHER_ID}`, + '', + ].join('\n'), + ); + + expect(run(env, ['list']).stdout).toContain('left alone'); + }); +}); + +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 + // the wiring in the shim is part of the fix, not an optimisation. + it('repairs a broken shortcut on browser start', () => { + const env = setup(); + const file = writeTronApp(env); + + // Stage the shim and the helper together: the shim resolves helpers + // relative to its own directory. + const bin = join(env.home, 'bin'); + mkdirSync(bin, { recursive: true }); + const shim = join(bin, 'tronbrowser'); + copyFileSync(join(HERE, '..', 'launcher', 'tronbrowser'), shim); + copyFileSync(TRON_PWA, join(bin, 'tron-pwa')); + chmodSync(shim, 0o755); + chmodSync(join(bin, 'tron-pwa'), 0o755); + + // Named ungoogled-chromium so the shim's de-googled check stays quiet. + const browser = join(bin, 'ungoogled-chromium'); + writeFileSync( + browser, + ['#!/bin/sh', 'if [ "${1:-}" = "--version" ]; then echo "Chromium 100.0.0.0"; fi', 'exit 0'].join('\n'), + { mode: 0o755 }, + ); + + const result = spawnSync('sh', [shim], { + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: env.home, + XDG_DATA_HOME: join(env.home, '.local', 'share'), + TRONBROWSER_BROWSER: browser, + TRONBROWSER_DATA: env.profile, + TRONBROWSER_CLI: CLI, + TRONBROWSER_VERBOSE: '1', + }, + }); + expect(result.status).toBe(0); + + expect(execLines(shortcut(file)(env))[0].split(' ')[0]).toBe(CLI); + }); +}); + +describe('tron pwa revert', () => { + it('hands a shortcut back to the engine, class flag and all', () => { + const env = setup(); + const file = writeTronApp(env); + run(env, ['sync']); + + run(env, ['revert']); + + const text = shortcut(file)(env); + const [exec] = execLines(text); + expect(exec.split(' ')[0]).toBe('/app/chromium/chrome'); + expect(exec).not.toContain('--class='); + expect(text).not.toContain('X-TronBrowser-Launcher'); + }); +}); diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index a4e37dd..645976d 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -101,6 +101,10 @@ Usage: (the new-tab box is set in TronBrowser Settings) tron gpu [mode] Show or set the GPU backend: on | safe | off (use when pages render blank or the window freezes) + tron pwa [list] Show installed web apps and how their icons launch + tron pwa sync Repoint their desktop icons at TronBrowser + (use when an installed app dies from its icon but + opens fine from the address bar) tron remove Uninstall TronBrowser (keeps your profile data) tron version Print the installed version tron help Show this help @@ -341,7 +345,29 @@ case "${1:-}" in printf '%s\n' "$_want" > "$_d/gpu-mode" done echo "GPU set to '$_want'. Restart TronBrowser to apply ('tron restart')." ;; + pwa) + # Installed web apps get a desktop icon written by the ENGINE, pointing at + # the engine — the one launch path that skips the launcher, and so the one + # that starts without our extensions, window class or GPU mode. Under the + # Flatpak engine the recorded path (/app/...) does not exist outside the + # sandbox at all. The launcher re-syncs these on every start; this is the + # manual handle, and `list` is the diagnostic. + shift + _ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")" + ENTRY="$_ld/tron-pwa" + command -v python3 >/dev/null 2>&1 || { echo "tron pwa needs python3 on PATH." >&2; exit 1; } + [ -f "$ENTRY" ] || { echo "This TronBrowser build lacks the PWA helper. Run: tron upgrade" >&2; exit 1; } + # Name ourselves explicitly: this CLI is the stable path across upgrades, + # and a desktop icon runs with the session's PATH, which need not have it. + exec env TRONBROWSER_CLI="$PREFIX/bin/tron" python3 "$ENTRY" "$@" ;; remove|uninstall) + # Hand the web-app icons back to the engine before the launcher they point + # at disappears — otherwise uninstalling TronBrowser silently breaks every + # app the user installed through it. + _ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")" + if [ -f "$_ld/tron-pwa" ] && command -v python3 >/dev/null 2>&1; then + python3 "$_ld/tron-pwa" revert || true + fi rm -rf "$APP_DIR" rm -f "$PREFIX/bin/tron" "$PREFIX/bin/tronbrowser" "$PREFIX/share/applications/tronbrowser.desktop" echo "Removed TronBrowser. (Profile data kept; delete ~/.tronbrowser and ~/TronBrowser to wipe it.)" ;;