From 7cf5b8779c8906b0945716a421fa5aa78ea6d09c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 04:30:48 +0000 Subject: [PATCH 1/2] fix(launcher): run the newest engine, and add `tron doctor` / `tron repair` "Something went wrong when opening your profile. Some features may be unavailable." is Chromium's profile-error dialog: one database it could not open, reported once per feature that needed it, so a single broken `Web Data` file arrives as ten dialogs. No TronBrowser release causes it and no reinstall fixes it, because the engine, the locks and the databases all live on the user's machine. The launcher used to run the first Ungoogled Chromium it found, native before Flatpak, regardless of version. A profile carries the schema of whichever build wrote it last, so a distro package appearing beside the self-updating Flatpak could downgrade the engine underneath the profile with nothing in TronBrowser changing. It now runs the newest candidate, and warns when the engine is older than the `Last Version` that wrote the profile. `tron doctor` reports the engine and every candidate, the profile's last writer, running instances and SingletonLock, each database's integrity and schema version, disk space, permissions, and the database lines from the engine's log. `tron repair` removes a stale lock and moves a database that no longer opens aside (never deletes it) so the browser rebuilds it; it refuses under a running browser. Also: the once-daily background upgrade now waits 30s so its cache prune can see the browser it was launched beside, and the prune re-checks for a running browser after the slow sizing pass, right before unlinking. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y9BHV9dFe6Q43CPUbvFyef --- apps/desktop/launcher/tron-doctor | 618 ++++++++++++++++++++++++++ apps/desktop/launcher/tronbrowser | 58 ++- apps/desktop/scripts/build-release.sh | 3 + apps/web/public/install.sh | 27 ++ 4 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/launcher/tron-doctor diff --git a/apps/desktop/launcher/tron-doctor b/apps/desktop/launcher/tron-doctor new file mode 100644 index 0000000..def6262 --- /dev/null +++ b/apps/desktop/launcher/tron-doctor @@ -0,0 +1,618 @@ +#!/usr/bin/env python3 +"""Diagnose, and where it is safe to, repair a TronBrowser profile that will not open. + +The dialog this exists for reads "Something went wrong when opening your +profile. Some features may be unavailable." It is Chromium's profile-error +dialog, and it is shown once per consumer of the database that failed. The +autofill, keyword, token, payments and account services all sit on the one +`Web Data` file, so a single database that cannot be opened arrives as ten +identical dialogs. Nothing in TronBrowser's own history need have changed for +this to start one morning, and reinstalling TronBrowser never touches the +profile, so it cannot fix it either. The causes all live on the machine: + + * The engine went BACKWARDS. TronBrowser ships no engine; it drives whatever + Ungoogled Chromium is installed. A profile carries the schema of whichever + build wrote it last, and an older build refuses every database at once. + The Flathub Flatpak updates itself, and a distro package appearing beside + it used to be preferred by the launcher regardless of version. + * A lock is stale: a killed instance left SingletonLock behind, or an + instance is still shutting down. + * A database is corrupt: a hard kill mid-write, a full disk, a bad shutdown. + * The disk is full, or the profile is not writable. + +`doctor` reports all of it and exits non-zero when something is wrong. +`repair` does what can be done without losing data it cannot get back: a +stale lock is removed, a database that no longer opens is MOVED aside, never +deleted, so the browser rebuilds it. It refuses to run under a live browser. + + tron doctor report on the engine, the locks, the databases, the disk + tron doctor --json the same, for a script + tron repair fix what the report found, with the browser closed + tron repair --dry-run say what repair would do and touch nothing +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import socket +import sqlite3 +import subprocess +import sys +import time + +FLATPAK_APPS = ( + "io.github.ungoogled_software.ungoogled_chromium", + "com.github.Eloston.UngoogledChromium", +) +NATIVE_NAMES = ("ungoogled-chromium", "ungoogled-chromium-stable") + +# The databases the profile-error dialog can be about, and what each holds, so +# a repair says in plain words what it is taking away rather than a filename. +DATABASES = { + "Web Data": "autofill entries, saved addresses and cards, and custom search engines", + "History": "browsing history and the address bar's visited-site suggestions", + "Login Data": "SAVED PASSWORDS", + "Cookies": "every site login (you will be signed out everywhere)", + "Favicons": "site icons (rebuilt as you browse)", + "Top Sites": "new-tab thumbnails (rebuilt as you browse)", + "Shortcuts": "address-bar shortcuts (rebuilt as you browse)", + "Affiliation Database": "password-manager site groupings (rebuilt on demand)", +} +STATE_FILES = ("Local State", "Default/Preferences", "Default/Secure Preferences") +LOCK_FILES = ("SingletonLock", "SingletonSocket", "SingletonCookie") + +# Lines in the engine's log that explain a profile failure. Everything else in +# there is the usual Chromium noise. +LOG_PATTERN = re.compile( + r"sqlite|\bsql\b|database|Failed to (open|init|create)|Unable to open|" + r"No space left|Permission denied|read-only|readonly|too new|profile_error|" + r"ProfileError|WebDatabase|HistoryBackend", + re.IGNORECASE, +) + +MB = 1024 * 1024 +LOW_DISK_MB = 200 +BIG_LOG_MB = 200 + + +class Report: + def __init__(self) -> None: + self.problems: list[dict] = [] + self.notes: list[dict] = [] + self.facts: dict = {} + + def problem(self, code: str, text: str, hint: str = "") -> None: + self.problems.append({"code": code, "text": text, "hint": hint}) + + def note(self, code: str, text: str) -> None: + self.notes.append({"code": code, "text": text}) + + +# --- Where things are ------------------------------------------------------- + + +def data_dir() -> str: + d = os.environ.get("TRONBROWSER_DATA") + if d: + return d + home = os.path.expanduser("~") + hidden = os.path.join(home, ".tronbrowser") + visible = os.path.join(home, "TronBrowser") # the snap-shaped install + if not os.path.isdir(hidden) and os.path.isdir(visible): + return visible + return hidden + + +def version_tuple(text: str) -> tuple[int, ...]: + """The first dotted number in a version string, as integers. + + "Chromium 152.0.7977.82" and the Flatpak's "152.0.7977.82-1" both give + (152, 0, 7977, 82); a build suffix never takes part in the comparison. + """ + m = re.search(r"\d+(?:\.\d+)+", text or "") + if not m: + return () + return tuple(int(p) for p in m.group(0).split(".")) + + +def dotted(v: tuple[int, ...]) -> str: + return ".".join(str(p) for p in v) + + +def run(cmd: list[str], timeout: float = 8.0) -> str: + try: + out = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False + ) + except (OSError, subprocess.SubprocessError): + return "" + return out.stdout if out.returncode == 0 else "" + + +# --- The engine -------------------------------------------------------------- + + +def engine_candidates() -> list[dict]: + """Every Ungoogled Chromium the launcher could pick, with its version. + + Mirrors the launcher: an explicit TRONBROWSER_BROWSER, else a native + binary on PATH, else the Flathub Flatpak. When more than one is installed + the launcher runs the newest, so this lists them all. + """ + found: list[dict] = [] + override = os.environ.get("TRONBROWSER_BROWSER") + if override: + ver = run([override, "--version"]).strip().splitlines() + found.append({"source": override, "kind": "override", "version": ver[0] if ver else ""}) + return found + for name in NATIVE_NAMES: + path = shutil.which(name) + if not path: + continue + ver = run([path, "--version"]).strip().splitlines() + found.append({"source": path, "kind": "native", "version": ver[0] if ver else ""}) + if shutil.which("flatpak"): + for app in FLATPAK_APPS: + info = run(["flatpak", "info", app]) + if not info: + continue + m = re.search(r"^\s*Version:\s*(.+)$", info, re.MULTILINE) + found.append( + {"source": f"flatpak {app}", "kind": "flatpak", "version": m.group(1).strip() if m else ""} + ) + return found + + +def pick_engine(cands: list[dict]) -> dict | None: + """The one the launcher runs: the newest, first-found on a tie.""" + best = None + for c in cands: + if best is None or version_tuple(c["version"]) > version_tuple(best["version"]): + best = c + return best + + +def check_engine(rep: Report, data: str) -> None: + cands = engine_candidates() + rep.facts["engines"] = cands + engine = pick_engine(cands) + rep.facts["engine"] = engine + last = "" + try: + with open(os.path.join(data, "Last Version"), encoding="utf-8", errors="replace") as f: + last = f.read().strip() + except OSError: + pass + rep.facts["last_version"] = last + try: + with open(os.path.join(data, ".tron-engine"), encoding="utf-8", errors="replace") as f: + rep.facts["last_engine"] = f.read().strip() + except OSError: + rep.facts["last_engine"] = "" + + if engine is None: + rep.problem( + "no-engine", + "No Ungoogled Chromium is installed, so there is nothing to run the profile with.", + "flatpak install -y flathub io.github.ungoogled_software.ungoogled_chromium", + ) + return + + have = version_tuple(engine["version"]) + wrote = version_tuple(last) + if wrote and have and have < wrote: + newer = [c for c in cands if version_tuple(c["version"]) >= wrote] + hint = ( + f"Run the newer one: TRONBROWSER_BROWSER is the override, and the launcher " + f"now prefers the newest engine on its own; {newer[0]['source']} is {newer[0]['version']}." + if newer + else "Update Ungoogled Chromium (flatpak update, or your package manager) back " + "to at least that version. There is no safe way to take the profile backwards." + ) + rep.problem( + "engine-downgrade", + f"The engine that would run ({dotted(have)}, {engine['source']}) is OLDER than the " + f"{dotted(wrote)} that last opened this profile. Chromium cannot open a profile written " + "by a newer build: every database fails, which is the profile-error dialog, once per feature.", + hint, + ) + if len(cands) > 1: + others = ", ".join(f"{c['version'] or '?'} ({c['source']})" for c in cands if c is not engine) + rep.note("engines", f"More than one engine is installed; the launcher runs the newest. Also here: {others}") + + +# --- Running instances and locks -------------------------------------------- + + +def running_pids(data: str) -> list[int]: + """Processes holding this profile open, by the switch on their command line.""" + needle = f"--user-data-dir={data}" + me = os.getpid() + pids: list[int] = [] + if os.path.isdir("/proc"): + for entry in os.listdir("/proc"): + if not entry.isdigit() or int(entry) == me: + continue + try: + with open(f"/proc/{entry}/cmdline", "rb") as f: + argv = f.read().split(b"\0") + except OSError: + continue + if any(a.decode("utf-8", "replace").startswith(needle) for a in argv): + pids.append(int(entry)) + return sorted(pids) + out = run(["ps", "ax", "-o", "pid=,command="]) + for line in out.splitlines(): + parts = line.strip().split(None, 1) + if len(parts) == 2 and needle in parts[1] and int(parts[0]) != me: + pids.append(int(parts[0])) + return sorted(pids) + + +def lock_state(data: str) -> dict: + """What SingletonLock says: which host and pid claim the profile.""" + path = os.path.join(data, "SingletonLock") + state = {"present": os.path.lexists(path), "host": "", "pid": 0, "alive": None} + if not state["present"]: + return state + try: + target = os.readlink(path) + except OSError: + return state + host, sep, pid = target.rpartition("-") + if sep and pid.isdigit(): + state["host"] = host + state["pid"] = int(pid) + try: + os.kill(int(pid), 0) + state["alive"] = True + except ProcessLookupError: + state["alive"] = False + except PermissionError: + state["alive"] = True + except OSError: + state["alive"] = None + return state + + +def check_locks(rep: Report, data: str) -> None: + pids = running_pids(data) + rep.facts["running"] = pids + lock = lock_state(data) + rep.facts["lock"] = lock + if pids: + rep.note("running", f"TronBrowser is running on this profile (pid {', '.join(map(str, pids))}).") + return + if not lock["present"]: + return + # Nothing has the profile open, yet a lock claims it. That is what a killed + # instance leaves behind. Chromium clears it when the pid is dead on THIS + # host; a lock naming another host, or a pid that is alive but is not a + # browser, is the one it cannot see through. + host = socket.gethostname() + if lock["host"] and lock["host"] != host: + rep.problem( + "lock-other-host", + f"SingletonLock says the profile is open on '{lock['host']}' (this machine is '{host}'). " + "Chromium treats that as another computer using the profile and will not open it here. " + "A hostname change does this too.", + "tron repair removes the lock (nothing is running on the profile).", + ) + else: + rep.problem( + "lock-stale", + f"SingletonLock points at pid {lock['pid'] or '?'} but nothing has the profile open. " + "A previous instance was killed rather than closed.", + "tron repair removes the lock.", + ) + + +# --- Databases and state files ---------------------------------------------- + + +def check_db(path: str, running: bool) -> dict: + """Open one SQLite file read-only and ask it whether it is intact.""" + info = {"name": os.path.basename(path), "path": path, "size_mb": 0.0, "status": "ok", "detail": "", "version": ""} + try: + info["size_mb"] = round(os.path.getsize(path) / MB, 1) + except OSError as e: + info["status"] = "unreadable" + info["detail"] = str(e) + return info + if not os.access(path, os.R_OK | os.W_OK): + info["status"] = "not-writable" + info["detail"] = "the file is not writable by this user" + return info + uri = "file:" + path.replace("?", "%3f").replace("#", "%23") + "?mode=ro" + try: + con = sqlite3.connect(uri, uri=True, timeout=1.0) + try: + rows = con.execute("PRAGMA quick_check").fetchall() + if rows and rows[0][0] != "ok": + info["status"] = "corrupt" + info["detail"] = "; ".join(str(r[0]) for r in rows[:3]) + try: + meta = dict(con.execute("SELECT key, value FROM meta").fetchall()) + info["version"] = str(meta.get("version", "")) + if meta.get("last_compatible_version"): + info["version"] += f" (compatible back to {meta['last_compatible_version']})" + except sqlite3.Error: + pass + finally: + con.close() + except sqlite3.DatabaseError as e: + text = str(e) + if "locked" in text or "busy" in text: + info["status"] = "busy" if running else "locked" + elif "not a database" in text or "malformed" in text or "corrupt" in text: + info["status"] = "corrupt" + elif "unable to open" in text: + info["status"] = "unreadable" + elif "disk I/O" in text or "I/O error" in text: + info["status"] = "io-error" + else: + info["status"] = "error" + info["detail"] = text + return info + + +def check_databases(rep: Report, data: str) -> None: + running = bool(rep.facts.get("running")) + profile = os.path.join(data, "Default") + results = [] + for name in DATABASES: + path = os.path.join(profile, name) + if not os.path.exists(path): + continue + r = check_db(path, running) + results.append(r) + if r["status"] == "ok": + continue + if r["status"] == "busy": + rep.note("db-busy", f"{name} is in use by the running browser, so it was not checked.") + continue + if r["status"] == "locked": + rep.problem( + "db-locked", + f"{name} is locked, but nothing has the profile open: a process is still holding it, " + f"or a lock file outlived its owner. ({r['detail']})", + "Wait a few seconds and run tron doctor again; then tron repair.", + ) + continue + holds = DATABASES[name] + rep.problem( + f"db-{r['status']}", + f"{name} cannot be opened ({r['status']}: {r['detail'] or 'no detail'}). It holds {holds}.", + "tron repair moves it aside so the browser rebuilds it; the original is kept in the profile.", + ) + rep.facts["databases"] = results + + +def check_state_files(rep: Report, data: str) -> None: + for rel in STATE_FILES: + path = os.path.join(data, rel) + if not os.path.exists(path): + continue + try: + with open(path, encoding="utf-8") as f: + json.load(f) + except (OSError, ValueError) as e: + rep.problem( + "state-unparseable", + f"{rel} does not parse as JSON ({e}). Chromium starts with defaults when this happens, " + "which reads as every setting gone.", + "Restore it from a backup if you have one; the launcher never overwrites a file it cannot read.", + ) + + +# --- Disk, permissions, log -------------------------------------------------- + + +def check_disk(rep: Report, data: str) -> None: + probe = data if os.path.isdir(data) else os.path.dirname(data) or "." + try: + usage = shutil.disk_usage(probe) + except OSError: + return + free_mb = usage.free // MB + rep.facts["disk_free_mb"] = free_mb + if free_mb < LOW_DISK_MB: + rep.problem( + "disk-full", + f"Only {free_mb}MB free on the disk holding the profile. SQLite cannot write its journal " + "on a full disk, and every database then fails to open.", + "Free space (tron clean clears the browser caches), then start again.", + ) + for rel in ("", "Default"): + path = os.path.join(data, rel) + if os.path.isdir(path) and not os.access(path, os.W_OK): + rep.problem( + "not-writable", + f"{path} is not writable by this user, so the profile cannot be opened for use.", + f"chmod u+rwx '{path}' (and check who owns it: ls -ld '{path}').", + ) + + +def check_log(rep: Report, data: str) -> None: + path = os.environ.get("TRONBROWSER_LOG") or os.path.join(data, "tron.log") + rep.facts["log"] = path + try: + size = os.path.getsize(path) + except OSError: + rep.facts["log_lines"] = [] + return + rep.facts["log_mb"] = round(size / MB, 1) + if size > BIG_LOG_MB * MB: + rep.note("log-big", f"{path} is {size // MB}MB; it only ever grows. Safe to delete while the browser is closed.") + try: + with open(path, "rb") as f: + f.seek(max(0, size - 2 * MB)) + tail = f.read().decode("utf-8", "replace").splitlines() + except OSError: + rep.facts["log_lines"] = [] + return + hits = [line for line in tail if LOG_PATTERN.search(line)] + rep.facts["log_lines"] = hits[-15:] + + +# --- Reporting --------------------------------------------------------------- + + +def diagnose(data: str) -> Report: + rep = Report() + rep.facts["profile"] = data + rep.facts["profile_exists"] = os.path.isdir(data) + if not os.path.isdir(data): + rep.note("no-profile", f"{data} does not exist yet; the first launch creates it.") + check_engine(rep, data) + check_disk(rep, data) + return rep + check_engine(rep, data) + check_locks(rep, data) + check_databases(rep, data) + check_state_files(rep, data) + check_disk(rep, data) + check_log(rep, data) + return rep + + +def print_report(rep: Report) -> None: + f = rep.facts + eng = f.get("engine") + print(f"Profile: {f['profile']}" + ("" if f.get("profile_exists") else " (not created yet)")) + if eng: + print(f"Engine: {eng['version'] or 'unknown version'} [{eng['source']}]") + else: + print("Engine: none found") + if f.get("last_version"): + print(f"Profile last opened by: {f['last_version']}") + if f.get("running"): + print(f"Running: yes (pid {', '.join(map(str, f['running']))})") + else: + print("Running: no") + if "disk_free_mb" in f: + print(f"Disk: {f['disk_free_mb']}MB free") + dbs = f.get("databases") or [] + if dbs: + print("Databases:") + for d in dbs: + ver = f" schema {d['version']}" if d["version"] else "" + flag = "" if d["status"] == "ok" else f" <-- {d['status']}" + print(f" {d['name']:<22} {d['size_mb']:>8}MB{ver}{flag}") + print() + if rep.problems: + print(f"{len(rep.problems)} problem(s):") + for p in rep.problems: + print(f" * {p['text']}") + if p["hint"]: + print(f" -> {p['hint']}") + else: + print("No problems found in the profile, its locks, the databases or the disk.") + if eng: + print( + "If the profile-error dialog still appears, the cause is not in the profile files: " + "quit every TronBrowser window, wait ten seconds, and start it again. If it persists, " + f"the engine's own log is the next place to look: {f.get('log', '')}" + ) + for n in rep.notes: + print(f" - {n['text']}") + lines = f.get("log_lines") or [] + if lines: + print() + print(f"From {f.get('log')} (lines about databases and the profile, newest last):") + for line in lines: + print(f" {line[:200]}") + + +# --- Repair ------------------------------------------------------------------ + + +def repair(data: str, dry_run: bool) -> int: + rep = diagnose(data) + if rep.facts.get("running"): + pids = ", ".join(map(str, rep.facts["running"])) + print(f"TronBrowser is running on this profile (pid {pids}). Quit it first, then run tron repair.", file=sys.stderr) + return 1 + + fixable = [p for p in rep.problems if p["code"].startswith(("lock-", "db-"))] + unfixable = [p for p in rep.problems if p not in fixable] + if not fixable: + print("Nothing for repair to do.") + for p in unfixable: + print(f" * {p['text']}") + if p["hint"]: + print(f" -> {p['hint']}") + return 0 if not unfixable else 1 + + verb = "Would" if dry_run else "Will" + stamp = time.strftime("%Y%m%d-%H%M%S") + backup = os.path.join(data, f"tron-repair-{stamp}") + moved = False + + if any(p["code"].startswith("lock-") for p in fixable): + for name in LOCK_FILES: + path = os.path.join(data, name) + if not os.path.lexists(path): + continue + print(f"{verb} remove stale {name}") + if not dry_run: + try: + os.unlink(path) + except OSError as e: + print(f" could not remove {path}: {e}", file=sys.stderr) + + for d in rep.facts.get("databases") or []: + if d["status"] in ("ok", "busy"): + continue + if d["status"] == "locked": + print(f"{d['name']} is still locked; nothing has the profile open, so this should clear on its own. Not touching it.") + continue + holds = DATABASES.get(d["name"], "its data") + print(f"{verb} move {d['name']} ({d['status']}) into {backup}/ so the browser rebuilds it.") + print(f" This gives up {holds}. The original stays in that folder; nothing is deleted.") + if dry_run: + continue + os.makedirs(backup, exist_ok=True) + for suffix in ("", "-journal", "-wal", "-shm"): + src = d["path"] + suffix + if os.path.lexists(src): + shutil.move(src, os.path.join(backup, d["name"] + suffix)) + moved = True + + for p in unfixable: + print(f"Not something repair can fix: {p['text']}") + if p["hint"]: + print(f" -> {p['hint']}") + + if dry_run: + print("Dry run: nothing was changed.") + return 0 + if moved: + print(f"Originals kept in {backup}. Delete that folder once the browser is back to normal.") + print("Done. Start TronBrowser again.") + return 0 + + +def main(argv: list[str]) -> int: + mode = argv[1] if len(argv) > 1 and not argv[1].startswith("-") else "doctor" + flags = set(a for a in argv[1:] if a.startswith("-")) + if mode not in ("doctor", "repair") or flags - {"--json", "--dry-run"}: + print(__doc__.strip().splitlines()[0], file=sys.stderr) + print("usage: tron doctor [--json] | tron repair [--dry-run]", file=sys.stderr) + return 2 + data = data_dir() + if mode == "repair": + return repair(data, "--dry-run" in flags) + rep = diagnose(data) + if "--json" in flags: + print(json.dumps({"problems": rep.problems, "notes": rep.notes, **rep.facts}, indent=2)) + else: + print_report(rep) + return 1 if rep.problems else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index 697c538..ea692a1 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -42,6 +42,26 @@ BROWSER="" # a binary path, or "flatpak" FLATPAK_APP="" IS_SNAP=0 +# The first dotted number in a version string: "Chromium 152.0.7977.82" and the +# Flatpak's "152.0.7977.82-1" both give 152.0.7977.82. A build suffix never +# takes part in a comparison. +ver_of() { + printf '%s\n' "$1" | sed -n 's/^[^0-9]*\([0-9][0-9]*\(\.[0-9][0-9]*\)*\).*/\1/p' | head -n1 +} +# ver_gt A B: true when A is a strictly newer dotted version than B. An empty +# side is the oldest possible, so a candidate whose version could not be read +# never displaces one whose version could. +ver_gt() { + awk -v a="$1" -v b="$2" 'BEGIN { + na = split(a, x, "."); nb = split(b, y, "."); + n = (na > nb) ? na : nb; + for (i = 1; i <= n; i++) { + p = (i <= na) ? x[i] + 0 : 0; q = (i <= nb) ? y[i] + 0 : 0; + if (p > q) exit 0; if (p < q) exit 1; + } + exit 1 }' +} + if [ -n "${TRONBROWSER_BROWSER:-}" ]; then BROWSER="$TRONBROWSER_BROWSER" elif [ -x "$DIR/chrome" ]; then @@ -49,14 +69,32 @@ elif [ -x "$DIR/chrome" ]; then else # Ungoogled Chromium ONLY. Never regular Chromium/Chrome, never snap (snap # chromium is regular AND can't be isolated). No fallbacks. + # + # More than one can be installed — a distro package beside the Flathub + # Flatpak — and they are rarely the same version. Run the NEWEST. A profile + # carries the schema of whichever build wrote it last, and an older build + # opening it fails every database at once: that is the "Something went wrong + # when opening your profile" dialog, once per feature, ten times over. + # Taking the first candidate found meant a package install could downgrade + # the engine underneath the profile with nothing in TronBrowser changing. + # A tie keeps the earlier candidate, so a native binary still beats the + # Flatpak of the same version. + BEST_VERSION="" for c in ungoogled-chromium ungoogled-chromium-stable; do p="$(command -v "$c" 2>/dev/null)" || continue - BROWSER="$p"; break + v="$(ver_of "$("$p" --version 2>/dev/null | head -n1)")" + if [ -z "$BROWSER" ] || ver_gt "$v" "$BEST_VERSION"; then + BROWSER="$p"; FLATPAK_APP=""; BEST_VERSION="$v" + fi done # Flatpak Ungoogled Chromium (Flathub) — fully de-googled. - if [ -z "$BROWSER" ] && command -v flatpak >/dev/null 2>&1; then + if command -v flatpak >/dev/null 2>&1; then for app in io.github.ungoogled_software.ungoogled_chromium com.github.Eloston.UngoogledChromium; do - if flatpak info "$app" >/dev/null 2>&1; then BROWSER="flatpak"; FLATPAK_APP="$app"; break; fi + flatpak info "$app" >/dev/null 2>&1 || continue + v="$(ver_of "$(flatpak info "$app" 2>/dev/null | sed -n 's/^[[:space:]]*Version:[[:space:]]*//p' | head -n1)")" + if [ -z "$BROWSER" ] || ver_gt "$v" "$BEST_VERSION"; then + BROWSER="flatpak"; FLATPAK_APP="$app"; BEST_VERSION="$v" + fi done fi fi @@ -439,6 +477,20 @@ if [ "$TOR" != "1" ]; then echo " If the browser now feels different (scrolling, video, GPU), start here." >&2 fi printf '%s\n' "$ENGINE" > "$ENGINE_MARK" 2>/dev/null || true + + # Chromium records which build last opened the profile. An engine older than + # that cannot open it: the databases carry a newer schema, every one of them + # fails, and the user sees "Something went wrong when opening your profile" + # once per feature. Selection above already prefers the newest engine, so + # this fires when the newer build has actually gone — an uninstalled package, + # a Flatpak rolled back — and says what happened before the dialogs do. + LAST_VERSION="$(cat "$DATA/Last Version" 2>/dev/null | tr -d '[:space:]' || true)" + _engine_num="$(ver_of "$ENGINE_VERSION")" + if [ -n "$LAST_VERSION" ] && [ -n "$_engine_num" ] && ver_gt "$(ver_of "$LAST_VERSION")" "$_engine_num"; then + echo "TronBrowser: engine $_engine_num is OLDER than the $LAST_VERSION that last opened this profile." >&2 + echo " Chromium cannot open a profile written by a newer build. Expect 'Something went wrong" >&2 + echo " when opening your profile' on every start until that build is back. See 'tron doctor'." >&2 + fi fi # --- Installed web apps (PWAs) --------------------------------------------- diff --git a/apps/desktop/scripts/build-release.sh b/apps/desktop/scripts/build-release.sh index b2e53da..558580f 100755 --- a/apps/desktop/scripts/build-release.sh +++ b/apps/desktop/scripts/build-release.sh @@ -68,6 +68,9 @@ stage() { # dest dir # 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" + # `tron doctor` / `tron repair`: the profile-error dialog explained and, where + # safe, fixed. Python, like tron-pwa; the CLI resolves it next to the shim. + install -m 0755 "$DESKTOP/launcher/tron-doctor" "$s/tron-doctor" # 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/web/public/install.sh b/apps/web/public/install.sh index fc30847..ab86cd2 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -105,6 +105,11 @@ Usage: 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 doctor Check the engine, locks, databases and disk + (use when "Something went wrong when opening your + profile" appears; --json for machine output) + tron repair Fix what doctor found, with the browser closed + (--dry-run to see what it would do) tron remove Uninstall TronBrowser (keeps your profile data) tron version Print the installed version tron help Show this help @@ -134,7 +139,12 @@ maybe_auto_upgrade() { [ "$((now - last))" -lt 86400 ] && return 0 printf '%s\n' "$now" > "$AUTO_UPGRADE_STAMP" 2>/dev/null || return 0 + # This fires as the browser is starting, and the upgrade begins by clearing + # oversized caches if nothing has the profile open. Chromium takes a moment + # to appear on the process list, so without the pause that check can pass + # and the caches get pulled out from under a browser that is now running. ( + sleep 30 TRONBROWSER_AUTO_UPGRADE=0 sh -c "curl -fsSL '$INSTALL_URL' | sh -s -- upgrade" ) >/dev/null 2>&1 & } @@ -360,6 +370,17 @@ case "${1:-}" in # 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" "$@" ;; + doctor|repair) + # "Something went wrong when opening your profile" is Chromium reporting + # one database it could not open, shown once per feature that needed it — + # ten dialogs from one file. No TronBrowser release causes it and no + # reinstall fixes it: the engine, the locks and the databases all live on + # this machine. So this is the diagnosis, and `repair` the safe fixes. + _ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")" + ENTRY="$_ld/tron-doctor" + command -v python3 >/dev/null 2>&1 || { echo "tron $1 needs python3 on PATH." >&2; exit 1; } + [ -f "$ENTRY" ] || { echo "This TronBrowser build lacks the doctor. Run: tron upgrade" >&2; exit 1; } + exec 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 @@ -813,6 +834,12 @@ prune_profile_caches() { if [ "$_total" -eq 0 ]; then continue fi + # Sizing gigabytes of cache takes a while; a browser started in the + # meantime has these directories open. Ask again right before unlinking. + if command -v pgrep >/dev/null 2>&1 && pgrep -f "user-data-dir=$_data" >/dev/null 2>&1; then + warn "TronBrowser started while measuring — leaving $_data alone. Quit it, then run 'tron clean'." + continue + fi info "Clearing ${_total}MB of browser cache from $_data (bookmarks, passwords, history and logins are untouched)." _old_ifs="$IFS"; IFS=" From 55f839ff043c14f693536b366f571f93c6db6f11 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 04:34:07 +0000 Subject: [PATCH 2/2] test(launcher): pin engine selection, the downgrade warning, and tron doctor Document `TB_FORCE=1 tron upgrade` in the CLI help: it reinstalls the current release and stops every TronBrowser process first, which is what cleared the profile-error dialog on a machine where a windowless instance was holding the databases. The doctor now names that case when it finds the profile in use. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y9BHV9dFe6Q43CPUbvFyef --- apps/desktop/launcher/tron-doctor | 19 ++- apps/desktop/test/doctor.test.ts | 206 +++++++++++++++++++++++++++++ apps/desktop/test/launcher.test.ts | 110 +++++++++++++++ apps/web/public/install.sh | 12 +- 4 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/test/doctor.test.ts diff --git a/apps/desktop/launcher/tron-doctor b/apps/desktop/launcher/tron-doctor index def6262..98a1732 100644 --- a/apps/desktop/launcher/tron-doctor +++ b/apps/desktop/launcher/tron-doctor @@ -15,6 +15,10 @@ profile, so it cannot fix it either. The causes all live on the machine: build wrote it last, and an older build refuses every database at once. The Flathub Flatpak updates itself, and a distro package appearing beside it used to be preferred by the launcher regardless of version. + * An instance is stuck. A browser process with no window — a crashed + shutdown, an orphaned app window, a managed session — still holds the + databases, and every new launch fails to open them. Killing it is the + fix; `TB_FORCE=1 tron upgrade` and `tron restart` both do that. * A lock is stale: a killed instance left SingletonLock behind, or an instance is still shutting down. * A database is corrupt: a hard kill mid-write, a full disk, a bad shutdown. @@ -284,7 +288,13 @@ def check_locks(rep: Report, data: str) -> None: lock = lock_state(data) rep.facts["lock"] = lock if pids: - rep.note("running", f"TronBrowser is running on this profile (pid {', '.join(map(str, pids))}).") + rep.note( + "running", + f"TronBrowser is running on this profile (pid {', '.join(map(str, pids))}). " + "If you have no TronBrowser window open, that is a stuck instance holding the " + "databases, and the profile-error dialog is what every new launch gets. " + "'tron restart' or 'TB_FORCE=1 tron upgrade' stops it.", + ) return if not lock["present"]: return @@ -534,7 +544,12 @@ def repair(data: str, dry_run: bool) -> int: rep = diagnose(data) if rep.facts.get("running"): pids = ", ".join(map(str, rep.facts["running"])) - print(f"TronBrowser is running on this profile (pid {pids}). Quit it first, then run tron repair.", file=sys.stderr) + print( + f"TronBrowser is running on this profile (pid {pids}). Quit it first, then run tron repair.\n" + "No window open? Then it is a stuck instance, and stopping it is the whole fix: " + "'tron restart', or 'TB_FORCE=1 tron upgrade'.", + file=sys.stderr, + ) return 1 fixable = [p for p in rep.problems if p["code"].startswith(("lock-", "db-"))] diff --git a/apps/desktop/test/doctor.test.ts b/apps/desktop/test/doctor.test.ts new file mode 100644 index 0000000..c6568e5 --- /dev/null +++ b/apps/desktop/test/doctor.test.ts @@ -0,0 +1,206 @@ +// tron-doctor is what a user runs when the browser shows "Something went wrong +// when opening your profile". Two things have to hold: the report names the +// real cause among the ones that produce that dialog, and repair never takes a +// step that loses data it cannot get back. Both are pinned here against +// synthetic profiles, with the engine stubbed. + +import { spawnSync, spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DOCTOR = join(HERE, '..', 'launcher', 'tron-doctor'); + +type Env = { home: string; profile: string; engine: string }; + +function setup(engineVersion = 'Chromium 151.0.7922.173'): Env { + const home = mkdtempSync(join(tmpdir(), 'tron-doctor-')); + const profile = join(home, '.tronbrowser'); + mkdirSync(join(profile, 'Default'), { recursive: true }); + const bin = join(home, 'bin'); + mkdirSync(bin); + const engine = join(bin, 'ungoogled-chromium'); + writeFileSync(engine, `#!/bin/sh\necho "${engineVersion}"\n`, { mode: 0o755 }); + return { home, profile, engine }; +} + +function run(env: Env, args: string[] = []): { stdout: string; stderr: string; status: number } { + const result = spawnSync('python3', [DOCTOR, ...args], { + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: env.home, + TRONBROWSER_DATA: env.profile, + TRONBROWSER_BROWSER: env.engine, + }, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', status: result.status ?? -1 }; +} + +function report(env: Env): { problems: { code: string; text: string }[]; [k: string]: unknown } { + const { stdout, status } = run(env, ['doctor', '--json']); + expect([0, 1]).toContain(status); + return JSON.parse(stdout); +} + +const codes = (env: Env) => report(env).problems.map((p) => p.code); + +/** Write a real SQLite file with Chromium's meta table, the way the engine would. */ +function sqliteDb(path: string, version = 140): void { + const result = spawnSync( + 'python3', + [ + '-c', + [ + 'import sqlite3, sys', + 'c = sqlite3.connect(sys.argv[1])', + "c.execute('CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR)')", + "c.execute('INSERT INTO meta VALUES(?, ?)', ('version', sys.argv[2]))", + 'c.commit(); c.close()', + ].join('\n'), + path, + String(version), + ], + { encoding: 'utf8' }, + ); + if (result.status !== 0) throw new Error(result.stderr); +} + +const lastVersion = (env: Env, v: string) => writeFileSync(join(env.profile, 'Last Version'), `${v}\n`); + +const children: ChildProcess[] = []; +afterEach(() => { + for (const c of children) c.kill(); + children.length = 0; +}); + +describe('tron doctor', () => { + it('finds nothing wrong with a healthy profile', () => { + const env = setup(); + sqliteDb(join(env.profile, 'Default', 'Web Data')); + sqliteDb(join(env.profile, 'Default', 'History')); + lastVersion(env, '151.0.7922.173'); + const { stdout, status } = run(env, ['doctor']); + expect(status).toBe(0); + expect(stdout).toContain('No problems found'); + expect(stdout).toContain('Web Data'); + expect(stdout).toContain('schema 140'); + }); + + it('reports the engine as the cause when the profile was written by a newer build', () => { + // The case this exists for: the Flatpak updated itself, then a distro + // package (or a rollback) put an older build in front of it. + const env = setup('Chromium 151.0.7922.173'); + sqliteDb(join(env.profile, 'Default', 'Web Data')); + lastVersion(env, '152.0.7977.82'); + const r = report(env); + expect(r.problems.map((p) => p.code)).toEqual(['engine-downgrade']); + expect(r.problems[0]!.text).toContain('152.0.7977.82'); + expect(r.problems[0]!.text).toContain('OLDER'); + }); + + it('does not call a same-version engine a downgrade, whatever the build suffix', () => { + const env = setup('Chromium 152.0.7977.82'); + lastVersion(env, '152.0.7977.82'); + expect(codes(env)).toEqual([]); + }); + + it('reports a database that is not a database, and says what it holds', () => { + const env = setup(); + writeFileSync(join(env.profile, 'Default', 'Web Data'), 'this is not a database\n'); + const r = report(env); + const p = r.problems.find((x) => x.code === 'db-corrupt'); + expect(p).toBeDefined(); + expect(p!.text).toContain('Web Data'); + expect(p!.text).toContain('autofill'); + }); + + it('reports a lock left behind by a killed instance', () => { + const env = setup(); + symlinkSync('somehost-2147483000', join(env.profile, 'SingletonLock')); + const found = codes(env); + expect(found.length).toBe(1); + expect(['lock-stale', 'lock-other-host']).toContain(found[0]); + }); + + it('surfaces the database lines from the engine log and nothing else', () => { + const env = setup(); + writeFileSync( + join(env.profile, 'tron.log'), + [ + '[1:1:0913/101010.000:ERROR:sql/database.cc] Web Data sqlite error 26: file is not a database', + '[1:1:0913/101010.001:ERROR:mojo] widget host noise', + '', + ].join('\n'), + ); + const { stdout } = run(env, ['doctor']); + expect(stdout).toContain('file is not a database'); + expect(stdout).not.toContain('widget host noise'); + }); + + it('rejects a mode it does not know', () => { + const env = setup(); + expect(run(env, ['bogus']).status).toBe(2); + }); +}); + +describe('tron repair', () => { + it('moves a broken database aside rather than deleting it, and removes the stale lock', () => { + const env = setup(); + writeFileSync(join(env.profile, 'Default', 'Web Data'), 'garbage'); + writeFileSync(join(env.profile, 'Default', 'Web Data-journal'), 'garbage'); + sqliteDb(join(env.profile, 'Default', 'History')); + symlinkSync('somehost-2147483000', join(env.profile, 'SingletonLock')); + + const { stdout, status } = run(env, ['repair']); + expect(status).toBe(0); + expect(stdout).toContain('SingletonLock'); + expect(stdout).toContain('Web Data'); + + expect(existsSync(join(env.profile, 'SingletonLock'))).toBe(false); + expect(existsSync(join(env.profile, 'Default', 'Web Data'))).toBe(false); + expect(existsSync(join(env.profile, 'Default', 'History'))).toBe(true); + const backup = readdirSync(env.profile).find((f) => f.startsWith('tron-repair-')); + expect(backup).toBeDefined(); + expect(readdirSync(join(env.profile, backup!)).sort()).toEqual(['Web Data', 'Web Data-journal']); + + expect(codes(env)).toEqual([]); + }); + + it('touches nothing on a dry run', () => { + const env = setup(); + writeFileSync(join(env.profile, 'Default', 'Web Data'), 'garbage'); + const { stdout, status } = run(env, ['repair', '--dry-run']); + expect(status).toBe(0); + expect(stdout).toContain('Would move Web Data'); + expect(existsSync(join(env.profile, 'Default', 'Web Data'))).toBe(true); + expect(readdirSync(env.profile).some((f) => f.startsWith('tron-repair-'))).toBe(false); + }); + + it('refuses while a browser has the profile open', async () => { + const env = setup(); + writeFileSync(join(env.profile, 'Default', 'Web Data'), 'garbage'); + // Anything with the profile's --user-data-dir on its command line is the + // browser as far as the process list is concerned. + const child = spawn('python3', ['-c', 'import time; time.sleep(30)', `--user-data-dir=${env.profile}`]); + children.push(child); + await new Promise((r) => setTimeout(r, 300)); + + const { stderr, status } = run(env, ['repair']); + expect(status).toBe(1); + expect(stderr).toContain('running'); + expect(existsSync(join(env.profile, 'Default', 'Web Data'))).toBe(true); + }); + + it('has nothing to do for a downgrade, and says what to do instead', () => { + const env = setup('Chromium 141.0.0.0'); + lastVersion(env, '152.0.7977.82'); + const { stdout, status } = run(env, ['repair']); + expect(status).toBe(1); + expect(stdout).toContain('Nothing for repair to do'); + expect(stdout).toContain('OLDER'); + }); +}); diff --git a/apps/desktop/test/launcher.test.ts b/apps/desktop/test/launcher.test.ts index b9cdb87..0cdfd6f 100644 --- a/apps/desktop/test/launcher.test.ts +++ b/apps/desktop/test/launcher.test.ts @@ -371,3 +371,113 @@ describe('engine reporting', () => { expect(second.stderr).not.toContain('CHANGED'); }); }); + +/** + * A Flatpak stub beside the native stub. `info` answers only for the Flathub + * app id, with the version given; `run` records everything after it the way + * the native stub records its argv. `null` means no Flatpak is installed. + */ +function flatpakStub(home: string, version: string | null): string { + const dir = join(home, 'bin'); + mkdirSync(dir, { recursive: true }); + const out = join(home, 'argv-flatpak.txt'); + writeFileSync( + join(dir, 'flatpak'), + [ + '#!/bin/sh', + 'APP=io.github.ungoogled_software.ungoogled_chromium', + 'case "$1" in', + ' info)', + ' [ "$2" = "--show-commit" ] && shift', + ' [ "$2" = "$APP" ] || exit 1', + version === null ? ' exit 1 ;;' : ` echo "Version: ${version}"; echo "Commit: abc123def456"; exit 0 ;;`, + ' run)', + ` : > "${out}"`, + ` for a in "$@"; do printf '%s\\n' "$a" >> "${out}"; done`, + ' exit 0 ;;', + 'esac', + 'exit 1', + ].join('\n'), + { mode: 0o755 }, + ); + return out; +} + +/** Run with auto-detection instead of TRONBROWSER_BROWSER: the stubs on PATH decide. */ +function runDetecting(home: string, nativeVersion: string): Run { + return run([], { + home, + version: nativeVersion, + env: { TRONBROWSER_BROWSER: '', PATH: `${join(home, 'bin')}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); +} + +describe('engine selection', () => { + // The profile carries the schema of whichever build wrote it last, and an + // older build then fails every database at once. So when more than one + // Ungoogled Chromium is installed, the newest runs — not the first found. + it('runs the Flatpak when it is newer than the native binary', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + const flatpakArgv = flatpakStub(home, '152.0.7977.82-1'); + const { argv, stderr } = runDetecting(home, 'Chromium 141.0.7390.54'); + expect(argv).toEqual([]); // the native stub never ran + const ran = readFileSync(flatpakArgv, 'utf8').split('\n').filter(Boolean); + expect(ran).toContain('io.github.ungoogled_software.ungoogled_chromium'); + expect(ran.some((a) => a.startsWith('--user-data-dir='))).toBe(true); + expect(stderr).toContain('engine 152.0.7977.82-1'); + }); + + it('runs the native binary when it is newer than the Flatpak', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + const flatpakArgv = flatpakStub(home, '141.0.7390.54-1'); + const { argv, stderr } = runDetecting(home, 'Chromium 152.0.7977.82'); + expect(valueOf(argv, '--user-data-dir')).toEqual([join(home, 'profile')]); + expect(existsSync(flatpakArgv)).toBe(false); + expect(stderr).toContain('engine Chromium 152.0.7977.82'); + }); + + it('keeps the native binary on a tie, whatever the build suffix', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + const flatpakArgv = flatpakStub(home, '152.0.7977.82-1'); + const { argv } = runDetecting(home, 'Chromium 152.0.7977.82'); + expect(valueOf(argv, '--user-data-dir')).toEqual([join(home, 'profile')]); + expect(existsSync(flatpakArgv)).toBe(false); + }); + + it('still runs the only engine there is', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + flatpakStub(home, null); + const { argv } = runDetecting(home, 'Chromium 141.0.7390.54'); + expect(valueOf(argv, '--user-data-dir')).toEqual([join(home, 'profile')]); + }); +}); + +describe('profile written by a newer engine', () => { + const seedLastVersion = (home: string, version: string) => { + mkdirSync(join(home, 'profile'), { recursive: true }); + writeFileSync(join(home, 'profile', 'Last Version'), `${version}\n`); + }; + + it('warns that the engine is older than the build that last opened the profile', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + seedLastVersion(home, '152.0.7977.82'); + const { argv, stderr } = run([], { home, version: 'Chromium 141.0.7390.54' }); + expect(stderr).toContain('engine 141.0.7390.54 is OLDER than the 152.0.7977.82'); + expect(stderr).toContain('tron doctor'); + // A warning, not a refusal: the browser still starts. + expect(valueOf(argv, '--user-data-dir')).toEqual([join(home, 'profile')]); + }); + + it('stays quiet when the engine is the same build or newer', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + seedLastVersion(home, '152.0.7977.82'); + expect(run([], { home, version: 'Chromium 152.0.7977.82' }).stderr).not.toContain('OLDER'); + expect(run([], { home, version: 'Chromium 153.0.8010.36' }).stderr).not.toContain('OLDER'); + }); +}); diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index ab86cd2..63e8b32 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -95,7 +95,12 @@ Usage: tron mcp Run a local MCP server over stdio (--headless) tron trace start|stop Record commands into a .trontrace bundle tron replay Replay a recorded trace against the session - tron upgrade Update to the latest release + tron upgrade Update to the latest release ('tron update' works too) + TB_FORCE=1 tron upgrade + Reinstall the current release. Stops every TronBrowser + process first, so it also clears "Something went wrong + when opening your profile" when a stuck instance is + holding the profile's databases (see 'tron doctor') tron clean Clear browser caches (keeps bookmarks, logins, history) tron search [engine] Show or set the ADDRESS BAR's search engine (the new-tab box is set in TronBrowser Settings) @@ -109,7 +114,9 @@ Usage: (use when "Something went wrong when opening your profile" appears; --json for machine output) tron repair Fix what doctor found, with the browser closed - (--dry-run to see what it would do) + (--dry-run to see what it would do; if doctor says + the browser is running and you see no window, + 'tron restart' or TB_FORCE=1 tron upgrade) tron remove Uninstall TronBrowser (keeps your profile data) tron version Print the installed version tron help Show this help @@ -899,6 +906,7 @@ Usage: curl -fsSL $INSTALL_URL | sh [-s -- ] Commands: install Download and install the latest TronBrowser (default) upgrade Update an existing install to the latest release + (TB_FORCE=1 reinstalls the current one; stops TronBrowser first) clean Clear the profile's browser caches (keeps bookmarks/logins). 'clean --if-large' only acts past TRONBROWSER_CACHE_LIMIT_MB remove Uninstall TronBrowser (keeps your profile data)