diff --git a/apps/desktop/launcher/tron-doctor b/apps/desktop/launcher/tron-doctor new file mode 100644 index 0000000..98a1732 --- /dev/null +++ b/apps/desktop/launcher/tron-doctor @@ -0,0 +1,633 @@ +#!/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. + * 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. + * 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))}). " + "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 + # 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.\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-"))] + 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/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 fc30847..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) @@ -105,6 +110,13 @@ 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; 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 @@ -134,7 +146,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 +377,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 +841,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=" @@ -872,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)