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.)" ;;