diff --git a/apps/desktop/launcher/tron-doctor b/apps/desktop/launcher/tron-doctor index 98a1732..0bf0aa1 100644 --- a/apps/desktop/launcher/tron-doctor +++ b/apps/desktop/launcher/tron-doctor @@ -37,8 +37,10 @@ deleted, so the browser rebuilds it. It refuses to run under a live browser. from __future__ import annotations +import glob import json import os +import plistlib import re import shutil import socket @@ -53,6 +55,17 @@ FLATPAK_APPS = ( ) NATIVE_NAMES = ("ungoogled-chromium", "ungoogled-chromium-stable") +# The macOS app bundles the launcher will run, in its order of preference. On +# macOS TronBrowser has no engine of its own: it drives a Chromium.app found on +# disk, so if none of these resolves there is nothing to run and `tron` refuses +# to start. Caskroom entries are added at runtime — see macos_caskroom_apps(). +MAC_APPS = ( + "/Applications/Ungoogled Chromium.app", + "/Applications/Chromium.app", + "~/Applications/Ungoogled Chromium.app", + "~/Applications/Chromium.app", +) + # 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 = { @@ -167,6 +180,74 @@ def engine_candidates() -> list[dict]: found.append( {"source": f"flatpak {app}", "kind": "flatpak", "version": m.group(1).strip() if m else ""} ) + if sys.platform == "darwin": + found.extend(macos_candidates()) + return found + + +def mac_root() -> str: + """Prefix for the machine-wide search paths, so the tests can aim them at a + fixture. Unset in normal use; grants nothing TRONBROWSER_BROWSER does not.""" + return os.environ.get("TRONBROWSER_MAC_SEARCH_ROOT", "") + + +def macos_caskroom_apps() -> list[str]: + """Every Chromium.app the Homebrew ungoogled cask has staged, live or not. + + The cask MOVES the bundle to brew's `appdir` and leaves a symlink here, so + this is the only path that holds whatever `appdir` is set to. brew's prefix + is relocatable, so ask brew where it is; the two defaults cover brew not + being on PATH at all (/opt/homebrew is Apple Silicon, /usr/local is Intel). + Dangling links are returned too — naming that state is the whole point. + """ + root = mac_root() + rooms = [root + "/opt/homebrew/Caskroom", root + "/usr/local/Caskroom"] + brew = shutil.which("brew") + if brew: + rooms.insert(0, os.path.join(os.path.dirname(os.path.dirname(brew)), "Caskroom")) + out: list[str] = [] + for room in rooms: + for app in sorted(glob.glob(os.path.join(room, "ungoogled-chromium", "*", "Chromium.app"))): + if app not in out: + out.append(app) + return out + + +def app_is_runnable(app: str) -> bool: + """True when the bundle's inner binary is actually there and executable. + + This is what separates a working install from a Caskroom symlink pointing + at an .app that was deleted out from under brew — a state `brew list --cask` + still reports as installed. + """ + return os.access(os.path.join(app, "Contents", "MacOS", "Chromium"), os.X_OK) + + +def app_version(app: str) -> str: + """The bundle's version, read from Info.plist. + + Never by running the binary: launched from a shell rather than through + LaunchServices it SIGABRTs, which is also why the launcher uses `open -na`. + """ + try: + with open(os.path.join(app, "Contents", "Info.plist"), "rb") as fh: + return str(plistlib.load(fh).get("CFBundleShortVersionString", "") or "") + except (OSError, ValueError): + return "" + + +def macos_candidates() -> list[dict]: + """The macOS app bundles the launcher could run, newest-relevant first.""" + found: list[dict] = [] + seen: set[str] = set() + root = mac_root() + apps = [os.path.expanduser(a) if a.startswith("~") else root + a for a in MAC_APPS] + for app in apps + macos_caskroom_apps(): + real = os.path.realpath(app) + if real in seen or not app_is_runnable(app): + continue + seen.add(real) + found.append({"source": app, "kind": "macos-app", "version": app_version(app)}) return found @@ -201,7 +282,9 @@ def check_engine(rep: Report, data: str) -> 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", + "brew install --cask ungoogled-chromium" + if sys.platform == "darwin" + else "flatpak install -y flathub io.github.ungoogled_software.ungoogled_chromium", ) return @@ -282,6 +365,55 @@ def lock_state(data: str) -> dict: return state +def check_macos_browser(rep: Report) -> None: + """macOS only: report which Chromium.app will run, and why none does. + + On macOS TronBrowser ships no engine, so "no app" is not a degraded mode — + `tron` exits with "runs Ungoogled Chromium ONLY" and never opens a window. + The failure that prompted this check is quieter than it sounds: brew reports + the cask installed, the Caskroom symlink points at an /Applications app that + is no longer there, and nothing says so until the next launch. + """ + if sys.platform != "darwin": + return + # An explicit override is the engine; what is or is not in /Applications + # does not matter, and saying otherwise would be noise. + if os.environ.get("TRONBROWSER_BROWSER"): + return + apps = macos_candidates() + rep.facts["macos_apps"] = apps + stale = [a for a in macos_caskroom_apps() if not app_is_runnable(a)] + rep.facts["macos_stale_casks"] = stale + + if apps: + if stale: + rep.note( + "cask-stale", + "A Homebrew cask entry points at an app that is gone " + f"({stale[0]}); harmless while another app works, but " + "`brew reinstall --cask ungoogled-chromium` tidies it up.", + ) + return + + if stale: + rep.problem( + "cask-app-missing", + f"Homebrew reports the ungoogled-chromium cask installed, but the app it points at is " + f"gone ({os.path.realpath(stale[0])}). `brew list --cask` keeps saying installed, so " + "nothing notices until 'tron' refuses to start.", + # Not `install --force`: an intact install receipt makes that a + # no-op, and the app stays missing however many times you run it. + "brew reinstall --cask ungoogled-chromium", + ) + else: + rep.problem( + "no-macos-app", + "No de-googled Chromium.app was found, so 'tron' cannot start. Looked in " + "/Applications, ~/Applications and the Homebrew Caskroom.", + "brew install --cask ungoogled-chromium (or: curl -fsSL https://tronbrowser.dev/install.sh | sh)", + ) + + def check_locks(rep: Report, data: str) -> None: pids = running_pids(data) rep.facts["running"] = pids @@ -477,9 +609,11 @@ def diagnose(data: str) -> Report: 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_macos_browser(rep) check_engine(rep, data) check_disk(rep, data) return rep + check_macos_browser(rep) check_engine(rep, data) check_locks(rep, data) check_databases(rep, data) @@ -497,6 +631,11 @@ def print_report(rep: Report) -> None: print(f"Engine: {eng['version'] or 'unknown version'} [{eng['source']}]") else: print("Engine: none found") + for app in f.get("macos_apps") or []: + if not eng or app["source"] != eng["source"]: + print(f"Also here: {app['version'] or 'unknown version'} [{app['source']}]") + for app in f.get("macos_stale_casks") or []: + print(f"Stale cask entry: {app} -> {os.path.realpath(app)} (missing)") if f.get("last_version"): print(f"Profile last opened by: {f['last_version']}") if f.get("running"): diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index 3cf37df..714a6e5 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -139,14 +139,47 @@ fi # launch via `open -na` (LaunchServices), NOT the inner binary directly, which # SIGABRTs when run from a shell. MAC_APP="" +MAC_CASKROOM=0 # set when the app came out of the Homebrew cask (its own proof) +# Prefix for the machine-wide paths below, so the tests can point the whole +# search at a fixture directory. It grants nothing TRONBROWSER_BROWSER does not +# already grant — that runs any binary you name — and is unset in normal use. +MAC_ROOT="${TRONBROWSER_MAC_SEARCH_ROOT:-}" if [ -z "$BROWSER" ]; then for app in \ - "/Applications/Ungoogled Chromium.app/Contents/MacOS/Chromium" \ - "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "$MAC_ROOT/Applications/Ungoogled Chromium.app/Contents/MacOS/Chromium" \ + "$MAC_ROOT/Applications/Chromium.app/Contents/MacOS/Chromium" \ "$HOME/Applications/Ungoogled Chromium.app/Contents/MacOS/Chromium" \ "$HOME/Applications/Chromium.app/Contents/MacOS/Chromium"; do if [ -x "$app" ]; then BROWSER="$app"; MAC_APP="${app%/Contents/MacOS/*}"; break; fi done + + # The Homebrew cask MOVES Chromium.app to brew's `appdir` and leaves a symlink + # behind in the Caskroom. `appdir` is configurable (~/.config/homebrew or + # HOMEBREW_CASK_OPTS), so the four paths above can all miss an app that brew + # installed perfectly well — and then TronBrowser refuses to start with + # "install Ungoogled Chromium", which the user just did. The Caskroom path is + # the one that holds whatever `appdir` is, so follow it. Its prefix is where + # the two architectures differ: /opt/homebrew on Apple Silicon, /usr/local on + # Intel. A dangling symlink (the .app deleted out from under brew) fails the + # -x test and is correctly skipped. Newest version wins, as above. + if [ -z "$BROWSER" ]; then + # Ask brew where its Caskroom is rather than assuming: the prefix is + # relocatable, and the two defaults below are only the common cases. brew is + # usually NOT on PATH in a GUI/non-login launch, hence both. + _bp="$(command -v brew 2>/dev/null || true)" + for room in \ + ${_bp:+"$(dirname "$(dirname "$_bp")")/Caskroom"} \ + "$MAC_ROOT/opt/homebrew/Caskroom" "$MAC_ROOT/usr/local/Caskroom"; do + for cask in "$room"/ungoogled-chromium/*/Chromium.app; do + [ -x "$cask/Contents/MacOS/Chromium" ] || continue + v="$(ver_of "$(basename "$(dirname "$cask")")")" + if [ -z "$MAC_APP" ] || ver_gt "$v" "${BEST_VERSION:-}"; then + BROWSER="$cask/Contents/MacOS/Chromium"; MAC_APP="$cask" + MAC_CASKROOM=1; BEST_VERSION="$v" + fi + done + done + fi fi if [ -z "$BROWSER" ]; then @@ -581,6 +614,10 @@ esac if [ -n "$MAC_APP" ] && [ -z "${TRONBROWSER_BROWSER:-}" ]; then # Trust a build our own installer downloaded (marker records the verified app). _verified=0 + # Resolved through the Caskroom: that path only exists because the ungoogled + # cask put it there, which is the same thing `brew list --cask` is asked for + # below — except it cannot lie and does not need brew on PATH. + [ "$MAC_CASKROOM" = "1" ] && _verified=1 if [ -f "$DIR/.ungoogled-macos" ]; then _m="$(cat "$DIR/.ungoogled-macos" 2>/dev/null)" [ "$_m" = "$MAC_APP" ] && _verified=1 diff --git a/apps/desktop/test/doctor.test.ts b/apps/desktop/test/doctor.test.ts index c6568e5..c843a73 100644 --- a/apps/desktop/test/doctor.test.ts +++ b/apps/desktop/test/doctor.test.ts @@ -204,3 +204,93 @@ describe('tron repair', () => { expect(stdout).toContain('OLDER'); }); }); + +// --- macOS: which Chromium.app will run ------------------------------------- +// On macOS TronBrowser has no engine of its own — it drives a Chromium.app +// found on disk — so "no app" means `tron` never opens a window at all. The +// state worth naming is the quiet one: Homebrew reports the cask installed +// while the app it points at is gone, and nothing says so until the next +// launch. Only meaningful on macOS; the check is a no-op elsewhere. +// +// The Caskroom under test is reached through brew's own prefix (a stub `brew` +// on PATH), and TRONBROWSER_MAC_SEARCH_ROOT aims the machine-wide paths — +// /Applications and the default Caskrooms — at an empty fixture dir, so the +// host's own Chromium.app cannot decide the outcome. +const onMac = process.platform === 'darwin'; + +/** A Caskroom whose Chromium.app symlink points at `target` (gone, if absent). */ +function caskroom(env: Env, version: string, target?: string): string { + const brewBin = join(env.home, 'brewprefix', 'bin'); + mkdirSync(brewBin, { recursive: true }); + writeFileSync(join(brewBin, 'brew'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const room = join(env.home, 'brewprefix', 'Caskroom', 'ungoogled-chromium', version); + mkdirSync(room, { recursive: true }); + const link = join(room, 'Chromium.app'); + symlinkSync(target ?? join(env.home, 'deleted', 'Chromium.app'), link); + return link; +} + +/** Report with no TRONBROWSER_BROWSER override, so discovery actually runs. */ +function discoveredReport(env: Env): { problems: { code: string; text: string; hint: string }[] } { + const result = spawnSync('python3', [DOCTOR, 'doctor', '--json'], { + encoding: 'utf8', + env: { + PATH: `${join(env.home, 'brewprefix', 'bin')}:/usr/bin:/bin`, + HOME: env.home, + TRONBROWSER_DATA: env.profile, + TRONBROWSER_MAC_SEARCH_ROOT: join(env.home, 'root'), + }, + }); + expect([0, 1]).toContain(result.status); + return JSON.parse(result.stdout ?? '{}'); +} + +describe.skipIf(!onMac)('macOS browser discovery', () => { + it('names a cask whose app was deleted, and does not suggest install --force', () => { + // `brew install --cask --force` is a no-op while the install receipt is + // intact, so it can be re-run forever with the app still missing. + const env = setup(); + caskroom(env, '154.0.8037.97-1.1'); + const stale = discoveredReport(env).problems.find((p) => p.code === 'cask-app-missing'); + expect(stale).toBeDefined(); + expect(stale!.hint).toBe('brew reinstall --cask ungoogled-chromium'); + expect(stale!.text).toContain('keeps saying installed'); + }); + + it('runs the cask app the launcher would, reached through brew\'s prefix', () => { + const env = setup(); + const real = join(env.home, 'appdir', 'Chromium.app'); + mkdirSync(join(real, 'Contents', 'MacOS'), { recursive: true }); + writeFileSync(join(real, 'Contents', 'MacOS', 'Chromium'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + writeFileSync( + join(real, 'Contents', 'Info.plist'), + '' + + 'CFBundleShortVersionString154.0.8037.97', + ); + const link = caskroom(env, '154.0.8037.97-1.1', real); + const rep = discoveredReport(env) as { problems: { code: string }[]; engine?: { source: string; version: string } }; + expect(rep.problems.map((p) => p.code)).not.toContain('no-macos-app'); + expect(rep.engine?.source).toBe(link); + // Read from Info.plist, never by running the binary: launched from a shell + // rather than through LaunchServices, Chromium SIGABRTs. + expect(rep.engine?.version).toBe('154.0.8037.97'); + }); + + it('suggests brew, not Flatpak, when nothing is installed', () => { + const env = setup(); + const problems = discoveredReport(env).problems; + expect(problems.map((p) => p.code)).toContain('no-macos-app'); + for (const p of problems) { + expect(p.hint).toContain('brew'); + expect(p.hint).not.toContain('flatpak'); + } + }); + + it('stays quiet about app bundles when TRONBROWSER_BROWSER is set', () => { + // An explicit override IS the engine; /Applications is irrelevant then. + const env = setup(); + caskroom(env, '154.0.8037.97-1.1'); + expect(codes(env)).not.toContain('cask-app-missing'); + expect(codes(env)).not.toContain('no-macos-app'); + }); +}); diff --git a/apps/desktop/test/launcher.test.ts b/apps/desktop/test/launcher.test.ts index 0cdfd6f..ffbe4b2 100644 --- a/apps/desktop/test/launcher.test.ts +++ b/apps/desktop/test/launcher.test.ts @@ -4,7 +4,7 @@ // change to flag handling has to survive something before it ships. import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, chmodSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, chmodSync, existsSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -481,3 +481,113 @@ describe('profile written by a newer engine', () => { expect(run([], { home, version: 'Chromium 153.0.8010.36' }).stderr).not.toContain('OLDER'); }); }); + +// --- macOS: finding the Homebrew cask --------------------------------------- +// On macOS the browser is an .app bundle, and the Homebrew cask does not leave +// it where you would guess: it MOVES Chromium.app to brew's `appdir` and keeps +// only a symlink in the Caskroom. Probing /Applications alone therefore misses +// an app that brew installed perfectly well — and the launcher's response to +// "not found" is to refuse to start and tell the user to install the thing they +// just installed. So the Caskroom is searched too, through brew's own prefix. +// +// The Caskroom under test is the one reached through brew's own prefix (a stub +// `brew` under $HOME/bin), which is what makes a relocated Homebrew work. +// TRONBROWSER_MAC_SEARCH_ROOT points the machine-wide paths — /Applications and +// the two default Caskrooms — at an empty fixture dir, so these run the same +// whether or not the host has a Chromium.app of its own. + +/** A Chromium.app bundle at `appPath`, reached through a Caskroom symlink. */ +function caskApp(home: string, version: string): string { + const real = join(home, 'appdir', version, 'Chromium.app'); + mkdirSync(join(real, 'Contents', 'MacOS'), { recursive: true }); + writeFileSync(join(real, 'Contents', 'MacOS', 'Chromium'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const room = join(home, 'Caskroom', 'ungoogled-chromium', version); + mkdirSync(room, { recursive: true }); + symlinkSync(real, join(room, 'Chromium.app')); + return join(room, 'Chromium.app'); +} + +/** + * Run the launcher with no browser on PATH and no TRONBROWSER_BROWSER, so it + * has to discover one. `brew` and `open` are stubs under $HOME/bin: brew only + * has to EXIST, since its path is what locates the Caskroom ($HOME/Caskroom), + * and `open` is what the launcher execs on macOS instead of the inner binary. + */ +function runMac(home: string): { opened: string[]; stderr: string; status: number | null } { + const bin = join(home, 'bin'); + mkdirSync(bin, { recursive: true }); + const openArgv = join(home, 'open-argv.txt'); + writeFileSync(join(bin, 'brew'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + writeFileSync( + join(bin, 'open'), + ['#!/bin/sh', `: > "${openArgv}"`, `for a in "$@"; do printf '%s\\n' "$a" >> "${openArgv}"; done`, 'exit 0'].join('\n'), + { mode: 0o755 }, + ); + + const script = join(bin, 'tronbrowser'); + copyFileSync(LAUNCHER, script); + chmodSync(script, 0o755); + + const result = spawnSync('sh', [script], { + encoding: 'utf8', + env: { + PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, + HOME: home, + TRONBROWSER_MAC_SEARCH_ROOT: join(home, 'root'), + TRONBROWSER_DATA: join(home, 'profile'), + TRONBROWSER_VERBOSE: '1', + }, + }); + return { + opened: existsSync(openArgv) ? readFileSync(openArgv, 'utf8').split('\n').filter(Boolean) : [], + stderr: result.stderr ?? '', + status: result.status, + }; +} + +describe('macOS app discovery', () => { + it('runs the cask app through the Caskroom when appdir is not /Applications', () => { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + const app = caskApp(home, '154.0.8037.97-1.1'); + const { opened, stderr, status } = runMac(home); + expect(stderr).not.toContain('runs Ungoogled Chromium ONLY'); + expect(status).toBe(0); + // Launched via LaunchServices (`open -na`), not the inner binary. + expect(opened.slice(0, 3)).toEqual(['-na', app, '--args']); + }); + + it('asks no questions about a Caskroom app — the path is the proof', () => { + // Trust otherwise comes from `brew list --cask`, which needs brew on PATH + // (it usually is not, at launch) and keeps reporting the cask installed + // after the .app is gone. A live Caskroom path cannot be wrong either way. + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + caskApp(home, '154.0.8037.97-1.1'); + expect(runMac(home).stderr).not.toContain("Couldn't confirm"); + }); + + it('runs the newest cask version, not the first one found', () => { + // An older engine cannot open a profile a newer one wrote: every database + // fails at once ("Something went wrong when opening your profile"). + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + caskApp(home, '150.0.0.0-1.1'); + const newest = caskApp(home, '154.0.8037.97-1.1'); + expect(runMac(home).opened[1]).toBe(newest); + }); + + it('ignores a Caskroom symlink whose app was deleted out from under brew', () => { + // The state that prompted all this: brew reports the cask installed, the + // Caskroom symlink dangles, and nothing on disk can actually run. + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + const room = join(home, 'Caskroom', 'ungoogled-chromium', '154.0.8037.97-1.1'); + mkdirSync(room, { recursive: true }); + symlinkSync(join(home, 'appdir', 'gone', 'Chromium.app'), join(room, 'Chromium.app')); + const { opened, stderr, status } = runMac(home); + expect(opened).toEqual([]); + expect(stderr).toContain('runs Ungoogled Chromium ONLY'); + expect(status).toBe(1); + }); +}); diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 707fc62..9574416 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -569,13 +569,15 @@ download_ungoogled_macos() { # launcher_dir ldir="$1" case "$(uname -m)" in arm64|aarch64) uc_arch="arm64" ;; - x86_64|amd64) uc_arch="x86-64" ;; + x86_64|amd64) uc_arch="x86_64" ;; *) return 1 ;; esac ucrepo="ungoogled-software/ungoogled-chromium-macos" - fetch "https://api.github.com/repos/$ucrepo/releases/latest" /tmp/tb_uc.json || return 1 + ucjson="$(mktemp)" + fetch "https://api.github.com/repos/$ucrepo/releases/latest" "$ucjson" || { rm -f "$ucjson"; return 1; } # Pick the .dmg asset for our arch (assets look like *_arm64-macos.dmg). - url="$(sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*'"$uc_arch"'-macos\.dmg\)".*/\1/p' /tmp/tb_uc.json | head -n1)" + url="$(sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*'"$uc_arch"'-macos\.dmg\)".*/\1/p' "$ucjson" | head -n1)" + rm -f "$ucjson" [ -n "$url" ] || return 1 tmp="$(mktemp -d)"; mnt="$tmp/mnt"; mkdir -p "$mnt" @@ -588,7 +590,13 @@ download_ungoogled_macos() { # launcher_dir ok=0 if [ -n "$srcapp" ]; then mkdir -p "$HOME/Applications"; rm -rf "$dest" - cp -R "$srcapp" "$dest" 2>/dev/null && [ -d "$dest" ] && ok=1 + # ditto, not cp -R: it is the supported way to copy an .app, preserving the + # extended attributes and bundle metadata the code signature is computed + # over. On Apple Silicon a damaged signature is fatal (arm64 code must be + # signed), so the app would install and then be killed at launch. + if ditto "$srcapp" "$dest" 2>/dev/null || cp -R "$srcapp" "$dest" 2>/dev/null; then + [ -d "$dest" ] && ok=1 + fi fi hdiutil detach -quiet "$mnt" 2>/dev/null || true rm -rf "$tmp" @@ -602,6 +610,34 @@ download_ungoogled_macos() { # launcher_dir return 0 } +# Print the de-googled Chromium.app the LAUNCHER would pick, or nothing. This +# has to mirror the launcher's search exactly — an app the installer is happy +# with but the launcher cannot see is the same as no app at all. +# +# Note the Caskroom entries: the cask MOVES Chromium.app to brew's `appdir` +# (configurable) and leaves a symlink there, so the Caskroom is the only path +# that holds whatever `appdir` is. /opt/homebrew is Apple Silicon, /usr/local is +# Intel. Every candidate is tested through the inner binary, which is what makes +# a dangling Caskroom symlink — the .app deleted out from under brew — read as +# missing rather than present. +mac_app_path() { + # brew's prefix is relocatable, so ask it where the Caskroom is; the two + # defaults cover the case where brew is not on PATH (Apple Silicon / + # Intel). Newest-first is the launcher's job — here any usable app will do. + _bp="$(command -v brew 2>/dev/null || true)" + for _a in \ + "/Applications/Ungoogled Chromium.app" \ + "/Applications/Chromium.app" \ + "$HOME/Applications/Ungoogled Chromium.app" \ + "$HOME/Applications/Chromium.app" \ + ${_bp:+"$(dirname "$(dirname "$_bp")")"/Caskroom/ungoogled-chromium/*/Chromium.app} \ + /opt/homebrew/Caskroom/ungoogled-chromium/*/Chromium.app \ + /usr/local/Caskroom/ungoogled-chromium/*/Chromium.app; do + [ -x "$_a/Contents/MacOS/Chromium" ] && { printf '%s\n' "$_a"; return 0; } + done + return 1 +} + # TronBrowser runs Ungoogled Chromium ONLY (never regular Chrome/Chromium), so # we install it as part of setup. Skip with TB_NO_BROWSER_INSTALL=1. ensure_browser() { @@ -618,22 +654,9 @@ ensure_browser() { # Already have a build WE installed & verified (marker points at a live app)? if [ -n "$_ldir" ] && [ -f "$_ldir/.ungoogled-macos" ]; then _m="$(cat "$_ldir/.ungoogled-macos" 2>/dev/null)" - [ -n "$_m" ] && [ -d "$_m" ] && return 0 + [ -n "$_m" ] && [ -x "$_m/Contents/MacOS/Chromium" ] && return 0 fi - # The launcher needs the Chromium.app actually on disk AND proof it's the - # ungoogled build (the Homebrew cask, or our own marker). `brew list --cask` - # alone LIES — it reports the cask "installed" even after the .app was deleted - # out from under brew — so we also require the .app at a path the launcher checks. - app="" - for a in \ - "/Applications/Ungoogled Chromium.app" \ - "/Applications/Chromium.app" \ - "$HOME/Applications/Ungoogled Chromium.app" \ - "$HOME/Applications/Chromium.app"; do - [ -d "$a" ] && { app="$a"; break; } - done - # Homebrew usually ISN'T on PATH in the non-login shell that `tron upgrade` # spawns (Apple Silicon: /opt/homebrew, Intel: /usr/local), so probe the known # locations directly instead of trusting PATH — otherwise we'd never install. @@ -642,20 +665,49 @@ ensure_browser() { command -v "$b" >/dev/null 2>&1 && { brew="$b"; break; } done - if [ -n "$brew" ]; then - if [ -n "$app" ] && "$brew" list --cask ungoogled-chromium >/dev/null 2>&1; then return 0; fi + # The test that matters is not "did brew exit 0" but "can the launcher find + # the app" — those came apart in practice: brew reports the cask installed, + # the Caskroom holds a symlink to a /Applications/Chromium.app that is gone, + # and the install says "Installed TronBrowser" while `tron` dies on first + # launch with "install Ungoogled Chromium", which the user just did. So + # verify through the launcher's own search, after every attempt. + if [ -n "$brew" ] && [ -z "$(mac_app_path || true)" ]; then info "Installing Ungoogled Chromium (brew cask)…" # --force REPLACES any pre-existing regular Chromium.app rather than erroring, # and REINSTALLS when brew's metadata is stale but the .app is gone. - "$brew" install --cask ungoogled-chromium --force && return 0 - warn "brew cask install failed — falling back to a direct download…" + "$brew" install --cask ungoogled-chromium --force >/dev/null 2>&1 || true + # An intact install receipt makes `install --force` a no-op, so the app + # stays missing. `reinstall` re-stages the download and re-links it. + if [ -z "$(mac_app_path || true)" ]; then + "$brew" reinstall --cask ungoogled-chromium >/dev/null 2>&1 || true + fi + fi + + app="$(mac_app_path || true)" + if [ -n "$app" ]; then + # Record it ONLY when the path itself proves the build is de-googled: under + # a Caskroom (the cask put it there) or named "Ungoogled Chromium.app". + # A bare /Applications/Chromium.app could be anyone's plain Chromium, and + # the marker is a trust grant — the launcher runs a marked app with no + # further questions. Unproven paths are left to its own brew check. + case "$app" in + */Caskroom/ungoogled-chromium/*|*[Uu]ngoogled*) + [ -n "$_ldir" ] && printf '%s\n' "$app" > "$_ldir/.ungoogled-macos" 2>/dev/null || true ;; + esac + return 0 fi + [ -n "$brew" ] && warn "brew cask install did not leave a usable Chromium.app — falling back to a direct download…" # No Homebrew (or it failed): grab the official notarized build ourselves. # No brew, no sudo, nothing for the user to run by hand. if download_ungoogled_macos "$_ldir"; then return 0; fi - warn "Couldn't auto-install Ungoogled Chromium (check your network)." + # Say so plainly. Returning non-zero here would abort an install that has + # already put the launcher on disk, so this stays a warning — but it must + # never be silent: without the browser, `tron` cannot start at all. + warn "Could not install Ungoogled Chromium — 'tron' will not launch until it is present." + say " brew install --cask ungoogled-chromium" + say " then re-run: curl -fsSL $INSTALL_URL | sh" return 0 fi @@ -684,8 +736,12 @@ brand_macos_icon() { [ "${TB_NO_ICON:-0}" = "1" ] && return 0 command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1 || return 0 local png="$1"; [ -f "$png" ] || return 0 - local app="/Applications/Chromium.app"; [ -d "$app" ] || app="$HOME/Applications/Chromium.app" - [ -d "$app" ] || return 0 + # Brand whichever app the launcher actually runs. Checking only + # /Applications/Chromium.app silently skipped the direct-download path, which + # installs to ~/Applications/Ungoogled Chromium.app, and the Homebrew cask + # whenever its appdir is not /Applications. + local app; app="$(mac_app_path || true)" + [ -n "$app" ] || return 0 local iconfile; iconfile="$(defaults read "$app/Contents/Info" CFBundleIconFile 2>/dev/null || echo app)" case "$iconfile" in *.icns) ;; *) iconfile="$iconfile.icns" ;; esac