Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion apps/desktop/launcher/tron-doctor
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"):
Expand Down
41 changes: 39 additions & 2 deletions apps/desktop/launcher/tronbrowser
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions apps/desktop/test/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
'<?xml version="1.0"?><!DOCTYPE plist><plist version="1.0"><dict>' +
'<key>CFBundleShortVersionString</key><string>154.0.8037.97</string></dict></plist>',
);
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');
});
});
Loading
Loading