From d331b8c5cf3ae8c3b2f1655411fb83abc702983a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 12:54:55 +0000 Subject: [PATCH 1/4] Publish our extensions to our own store, from CI We built a store with one-click install and Chromium auto-update -- crx.ts stamps our update_url into the manifest before signing, precisely so Chromium polls /api/store/updates.xml rather than Google. Then every listing was created by hand, which in practice meant almost none were: the store holds exactly one extension, and our own MarkSyncr is not in it. That is the gap worth closing. An extension in the store auto-updates for anyone who installed it. An extension outside the store reaches TronBrowser only by being rebuilt into the bundle, and reaches everyone else only when Google approves it. MarkSyncr has three releases sitting in Chrome review right now. So publishing runs from CI. scripts/store-publish.mjs resolves the listing by slug, creates it on first run, and posts the version with the manifest and the artifact URL. The reusable workflow wraps it so an extension repo adds six lines rather than its own half of the job. Two behaviours worth stating. A version that is already published exits clean instead of failing, so re-running a tag is safe. And the artifact is polled before submitting, because the store fetches it itself and a release asset can lag its tag by a few seconds -- submitting into that window fails for a reason that has nothing to do with the extension. Verified against the live store: an existing listing resolves by slug (coinpay-wallet), an unknown name takes the create path, and a manifest that is not MV3 is refused before anything is sent. Nothing publishes until TRONBROWSER_STORE_TOKEN exists. The API deliberately refuses to mint a publisher token from a token -- it has to come from a signed-in browser session -- so that one step is Anthony's and cannot be automated away. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/store-publish.yml | 104 +++++++++++++++++++ scripts/store-publish.mjs | 155 ++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 .github/workflows/store-publish.yml create mode 100644 scripts/store-publish.mjs diff --git a/.github/workflows/store-publish.yml b/.github/workflows/store-publish.yml new file mode 100644 index 0000000..6ac282a --- /dev/null +++ b/.github/workflows/store-publish.yml @@ -0,0 +1,104 @@ +name: Publish to the TronBrowser Store + +# A reusable workflow, so every extension we own publishes the same way instead +# of each repo growing its own half of the job. +# +# Why it exists: every listing was created by hand, which in practice meant none +# were — the store had a single extension and our own MarkSyncr was not in it. An +# extension that is not in the store is one TronBrowser cannot auto-update to, +# which is the whole point of having our own store rather than waiting on Google. +# +# Call it from an extension repo's release workflow: +# +# publish-to-store: +# needs: build +# uses: profullstack/tronbrowser.dev/.github/workflows/store-publish.yml@main +# secrets: inherit +# with: +# name: MarkSyncr +# manifest: apps/extension/src/manifest.chrome.json +# bundle_url: https://github.com/${{ github.repository }}/releases/download/v${{ needs.build.outputs.version }}/marksyncr-chrome.zip +# +# The artifact has to be a URL the store can fetch, which is what publishing the +# built ZIP as a release asset is for. +on: + workflow_call: + inputs: + name: + description: 'Listing name. Created on first publish.' + required: true + type: string + manifest: + description: 'Path to the MV3 manifest.json in the calling repo.' + required: true + type: string + bundle_url: + description: 'Public URL of the .zip bundle.' + required: false + type: string + default: '' + crx_url: + description: 'Public URL of a signed .crx, if the project builds one.' + required: false + type: string + default: '' + slug: + description: 'Look the listing up by this slug instead of by name.' + required: false + type: string + default: '' + dry_run: + description: 'Resolve and validate, write nothing.' + required: false + type: boolean + default: false + secrets: + TRONBROWSER_STORE_TOKEN: + description: 'A tbpub_ publisher token, minted from a signed-in session.' + required: true + +jobs: + publish: + name: Publish to the store + runs-on: ubuntu-latest + steps: + - name: Check out the calling repo + uses: actions/checkout@v5 + + - name: Check out the publisher script + uses: actions/checkout@v5 + with: + repository: profullstack/tronbrowser.dev + path: .tronbrowser-store + sparse-checkout: scripts/store-publish.mjs + sparse-checkout-cone-mode: false + + - name: Wait for the release asset + if: ${{ inputs.bundle_url != '' || inputs.crx_url != '' }} + env: + URL: ${{ inputs.crx_url != '' && inputs.crx_url || inputs.bundle_url }} + run: | + # The store fetches the artifact itself, so it has to be reachable + # before we submit. A release asset can lag the tag by a few seconds. + for i in $(seq 1 20); do + if curl -fsSLI "$URL" >/dev/null 2>&1; then + echo "artifact is up: $URL" + exit 0 + fi + echo "waiting for $URL ($i/20)" + sleep 6 + done + echo "::error::artifact never became reachable: $URL" + exit 1 + + - name: Publish + env: + TRONBROWSER_STORE_TOKEN: ${{ secrets.TRONBROWSER_STORE_TOKEN }} + run: | + node .tronbrowser-store/scripts/store-publish.mjs \ + --name "${{ inputs.name }}" \ + --manifest "${{ inputs.manifest }}" \ + ${{ inputs.slug != '' && format('--slug "{0}"', inputs.slug) || '' }} \ + ${{ inputs.bundle_url != '' && format('--bundle-url "{0}"', inputs.bundle_url) || '' }} \ + ${{ inputs.crx_url != '' && format('--crx-url "{0}"', inputs.crx_url) || '' }} \ + ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/scripts/store-publish.mjs b/scripts/store-publish.mjs new file mode 100644 index 0000000..8093ba2 --- /dev/null +++ b/scripts/store-publish.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Publish one extension version to the TronBrowser store. + * + * Every extension we own was published by hand, which meant in practice that + * none of them were: the store had one listing and our own MarkSyncr was not in + * it. A release that does not reach the store is a release TronBrowser cannot + * auto-update to, so this runs from CI on every tag. + * + * Usage: + * node scripts/store-publish.mjs \ + * --name "MarkSyncr" \ + * --manifest path/to/manifest.json \ + * --bundle-url https://github.com/.../marksyncr-chrome.zip + * + * Optional: + * --slug look the listing up by slug instead of by name + * --crx-url a signed .crx, if the project builds one + * --store defaults to https://tronbrowser.dev + * --dry-run resolve and validate, write nothing + * + * Auth: TRONBROWSER_STORE_TOKEN, a `tbpub_` publisher token. Minted once from a + * signed-in browser session at tronbrowser.dev — the API refuses to mint one + * from a token, deliberately, so a leaked CI token cannot mint more. + */ + +import { readFileSync } from 'node:fs'; + +const args = process.argv.slice(2); +function arg(name, fallback = '') { + const i = args.indexOf(`--${name}`); + return i >= 0 && args[i + 1] ? args[i + 1] : fallback; +} +const has = (name) => args.includes(`--${name}`); + +const STORE = (arg('store') || process.env.TRONBROWSER_STORE || 'https://tronbrowser.dev').replace(/\/$/, ''); +const TOKEN = process.env.TRONBROWSER_STORE_TOKEN || ''; +const DRY = has('dry-run'); + +const name = arg('name'); +const slugArg = arg('slug'); +const manifestPath = arg('manifest'); +const bundleUrl = arg('bundle-url'); +const crxUrl = arg('crx-url'); + +function die(message) { + console.error(`store-publish: ${message}`); + process.exit(1); +} + +if (!name && !slugArg) die('--name or --slug is required'); +if (!manifestPath) die('--manifest is required'); +if (!bundleUrl && !crxUrl) die('--bundle-url or --crx-url is required'); +if (!TOKEN && !DRY) die('TRONBROWSER_STORE_TOKEN is not set'); + +let manifest; +try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); +} catch (err) { + die(`could not read ${manifestPath}: ${err.message}`); +} +if (Number(manifest.manifest_version) !== 3) { + die(`${manifestPath} is manifest_version ${manifest.manifest_version}; the store takes MV3 only`); +} + +const headers = { + authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', +}; + +async function api(path, init = {}) { + const res = await fetch(`${STORE}/api/store${path}`, { ...init, headers }); + const text = await res.text(); + let body; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { ok: res.ok, status: res.status, body }; +} + +/** The listing for this extension, creating it the first time. */ +async function resolveListing() { + const slug = slugArg || slugify(name); + const found = await api(`/extensions/${encodeURIComponent(slug)}`); + // GET /extensions/:slug returns the listing itself, not {extension: ...}. + const existing = found.body?.extension ?? found.body; + if (found.ok && existing?.id) { + console.log(` listing ${existing.slug} (${existing.id})`); + return existing; + } + if (found.status !== 404) { + die(`looking up ${slug} failed with ${found.status}: ${JSON.stringify(found.body).slice(0, 300)}`); + } + + if (DRY) { + console.log(` [dry-run] would create the listing "${name}"`); + return { id: '(dry-run)', slug }; + } + const created = await api('/extensions', { + method: 'POST', + body: JSON.stringify({ name }), + }); + if (!created.ok) { + die(`creating the listing failed with ${created.status}: ${JSON.stringify(created.body)}`); + } + const ext = created.body.extension || created.body; + console.log(` created listing ${ext.slug} (${ext.id})`); + return ext; +} + +/** Mirrors the server's slugify closely enough to find an existing listing. */ +function slugify(value) { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +const listing = await resolveListing(); + +const payload = { + manifest, + ...(bundleUrl ? { bundleUrl } : {}), + ...(crxUrl ? { crxUrl } : {}), +}; + +if (DRY) { + console.log(` [dry-run] would publish ${manifest.name} ${manifest.version}`); + console.log(` [dry-run] artifact: ${crxUrl || bundleUrl}`); + process.exit(0); +} + +const published = await api(`/extensions/${listing.id}/versions`, { + method: 'POST', + body: JSON.stringify(payload), +}); + +if (!published.ok) { + // A version that is already published is not a failure: a re-run of the same + // tag should be safe, and the alternative is a red release for work that is + // already done. + const message = JSON.stringify(published.body); + if (published.status === 409 || /already/i.test(message)) { + console.log(` ${manifest.version} is already published, nothing to do`); + process.exit(0); + } + die(`publishing ${manifest.version} failed with ${published.status}: ${message}`); +} + +console.log(` published ${manifest.name} ${manifest.version} to ${STORE}/store/`); +if (published.body?.scan) { + console.log(` scan: ${JSON.stringify(published.body.scan)}`); +} From edfd4bb5317d1f1e8ce1cc9253dd8699deea0da2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 13:11:45 +0000 Subject: [PATCH 2/4] Auto-update the bundled extensions from our own store Extensions load with --load-extension, and Chromium never auto-updates an extension loaded that way. That is why a bundled extension could only change when the whole browser was re-released: v3.15.0 shipped a MarkSyncr vault import bug that had already been fixed upstream, and there was nothing to do but wait for the next TronBrowser build. So the launcher updates them itself, against tronbrowser.dev/store, which has no review queue in front of it. The store already stamps its own update_url into each signed manifest for exactly this purpose; what was missing was anything on this side asking. The bundled copy is never written to -- it lives in the install tree, which may be read-only and is replaced wholesale on upgrade. Newer versions land in an overlay under the data dir, and an extension loads from the overlay only while it is genuinely newer. When a browser upgrade brings the bundle level with it, the overlay is deleted rather than left to shadow it forever. The check runs detached and applies on the NEXT launch. Blocking startup on the network to save one launch's staleness is a bad trade, and swapping an extension's files under a running Chromium is worse. Everything about it fails soft: no curl, no unzip, an unreachable store, an extension the store has never heard of, a download that is not the extension we asked for -- each leaves the bundled copy in place. A browser that will not start because a store was down would be a far worse bug than a stale extension. TRONBROWSER_NO_EXT_UPDATE=1 turns the whole thing off. Two things worth keeping in mind. Versions are compared numerically per field, because a string compare puts 1.2.10 below 1.2.9. And this script runs under `set -eu`, so _manifest_version returns 0 with no output for a missing file: the first version of it returned 1, which inside a command substitution would have taken the browser down on every launch where the overlay did not exist yet. The functional test caught that before it shipped. Verified against the live store under set -eu: a staged coinpay-wallet 0.0.1 is replaced by 0.10.1 and loads from the overlay, an extension the store does not have is left alone, and the run survives to the end. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/launcher/tronbrowser | 124 +++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index 46cd024..3cf37df 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -240,11 +240,131 @@ if [ "$TOR" != "1" ]; then fi fi -# Load every bundled extension (each subdir with a manifest.json). +# ── Bundled extensions, and keeping them current ──────────────────────────── +# +# Extensions load with --load-extension, and Chromium never auto-updates an +# extension loaded that way. Until now that meant a bundled extension could only +# change when the whole browser was re-released: MarkSyncr shipped a vault import +# bug in v3.15.0 that had already been fixed upstream, and nobody could do +# anything about it but wait for the next TronBrowser build. +# +# So the launcher does the updating itself, against our own store +# (tronbrowser.dev/store), which has no review queue in front of it. +# +# The bundled copy is never written to -- it lives in the install tree, which may +# be read-only and is replaced wholesale on upgrade. Newer versions land in an +# overlay under the data dir, and an extension is loaded from the overlay when it +# is present and newer. +# +# The check runs in the BACKGROUND and applies on the NEXT launch. Blocking +# startup on the network to save one launch's staleness would be a bad trade, and +# swapping an extension's files under a running Chromium is worse. +EXT_OVERLAY="$DATA/extensions-updated" +STORE_URL="${TRONBROWSER_STORE:-https://tronbrowser.dev}" + +# "1.2.10" is newer than "1.2.9": compare numerically, field by field. +_ver_gt() { + [ "$1" = "$2" ] && return 1 + _a="$1"; _b="$2" + while [ -n "$_a" ] || [ -n "$_b" ]; do + _x="${_a%%.*}"; _y="${_b%%.*}" + [ -z "$_x" ] && _x=0 + [ -z "$_y" ] && _y=0 + # Non-numeric fields (betas) sort as 0 rather than erroring the whole check. + case "$_x" in *[!0-9]*) _x=0 ;; esac + case "$_y" in *[!0-9]*) _y=0 ;; esac + [ "$_x" -gt "$_y" ] 2>/dev/null && return 0 + [ "$_x" -lt "$_y" ] 2>/dev/null && return 1 + case "$_a" in *.*) _a="${_a#*.}" ;; *) _a="" ;; esac + case "$_b" in *.*) _b="${_b#*.}" ;; *) _b="" ;; esac + done + return 1 +} + +# Always succeeds, printing nothing when there is no manifest. This script runs +# under `set -eu`, so a helper that returns non-zero inside a command +# substitution takes the whole browser down rather than skipping one extension. +_manifest_version() { + [ -f "$1" ] || return 0 + sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p' "$1" | head -n1 + return 0 +} + +# Fetch newer copies into the overlay. Runs detached; every failure is silent +# and leaves the bundled copy in place, because a browser that will not start +# because a store was unreachable would be a far worse bug than a stale +# extension. +_refresh_extensions() { + command -v curl >/dev/null 2>&1 || return 0 + command -v unzip >/dev/null 2>&1 || return 0 + mkdir -p "$EXT_OVERLAY" 2>/dev/null || return 0 + + for _d in "$EXTBASE"/*/; do + [ -f "${_d}manifest.json" ] || continue + _name="$(basename "${_d%/}")" + _slug="$_name" + + _local="$(_manifest_version "$EXT_OVERLAY/$_name/manifest.json")" + [ -n "$_local" ] || _local="$(_manifest_version "${_d}manifest.json")" + [ -n "$_local" ] || continue + + _json="$(curl -fsS --max-time 8 "$STORE_URL/api/store/extensions/$_slug" 2>/dev/null)" || continue + _remote="$(printf '%s' "$_json" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([0-9][^"]*\)".*/\1/p' | head -n1)" + [ -n "$_remote" ] || continue + _ver_gt "$_remote" "$_local" || continue + + # crxUrl when the store signed one, bundleUrl otherwise. A .crx is a header + # in front of a zip, so unzip reads either (and warns on the crx header, + # which is why its exit code is not trusted). + _url="$(printf '%s' "$_json" | sed -n 's/.*"crxUrl"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + [ -n "$_url" ] || _url="$(printf '%s' "$_json" | sed -n 's/.*"bundleUrl"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + [ -n "$_url" ] || continue + + _tmpz="$(mktemp 2>/dev/null)" || continue + _tmpd="$(mktemp -d 2>/dev/null)" || { rm -f "$_tmpz"; continue; } + if curl -fsSL --max-time 60 "$_url" -o "$_tmpz" 2>/dev/null; then + unzip -q -o "$_tmpz" -d "$_tmpd" 2>/dev/null || true + # Only accept it if what arrived really is the extension we asked for. + _got="$(_manifest_version "$_tmpd/manifest.json")" + if [ -n "$_got" ] && _ver_gt "$_got" "$_local"; then + rm -rf "$EXT_OVERLAY/$_name.new" + mv "$_tmpd" "$EXT_OVERLAY/$_name.new" 2>/dev/null && { + rm -rf "$EXT_OVERLAY/$_name.old" + [ -d "$EXT_OVERLAY/$_name" ] && mv "$EXT_OVERLAY/$_name" "$EXT_OVERLAY/$_name.old" + mv "$EXT_OVERLAY/$_name.new" "$EXT_OVERLAY/$_name" 2>/dev/null + rm -rf "$EXT_OVERLAY/$_name.old" + echo "TronBrowser: $_name $_got staged from the store (active next launch)" >&2 + } + _tmpd="" + fi + fi + rm -f "$_tmpz" + [ -n "$_tmpd" ] && rm -rf "$_tmpd" + done +} + +# Load every bundled extension (each subdir with a manifest.json), preferring a +# newer copy the store already gave us. EXT="" for d in "$EXTBASE"/*/; do - [ -f "${d}manifest.json" ] && EXT="${EXT:+$EXT,}${d%/}" + [ -f "${d}manifest.json" ] || continue + _n="$(basename "${d%/}")" + _o="$EXT_OVERLAY/$_n" + _ov="$(_manifest_version "$_o/manifest.json")" + _bv="$(_manifest_version "${d}manifest.json")" + if [ -n "$_ov" ] && [ -n "$_bv" ] && _ver_gt "$_ov" "$_bv"; then + EXT="${EXT:+$EXT,}$_o" + else + # The bundle caught up (a browser upgrade), so the overlay is dead weight. + [ -n "$_ov" ] && rm -rf "$_o" + EXT="${EXT:+$EXT,}${d%/}" + fi done + +# Detached, so a slow or unreachable store never delays the browser. +if [ "${TRONBROWSER_NO_EXT_UPDATE:-0}" != "1" ]; then + ( _refresh_extensions >>"$DATA/extension-update.log" 2>&1 & ) >/dev/null 2>&1 +fi # Surface the AI-sidebar version + path so it's clear which extension loads. if [ -f "$EXTBASE/ai-sidebar/manifest.json" ]; then _extver="$(sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p' "$EXTBASE/ai-sidebar/manifest.json" | head -n1)" From ab8c5a8f0fe83005dad116bd7a008e693b74c478 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 13:18:53 +0000 Subject: [PATCH 3/4] Add `tron store` so publishing is one command The store auto-updates anything listed in it, and the launcher now pulls those updates, so getting an extension listed is the whole job. Doing that by hand is exactly why the store held one listing while we shipped five extensions. tron store login save a publisher token for this machine tron store whoami which publisher this machine is tron store list slugs currently in the store tron store publish --name ... --manifest ... --bundle-url ... Plain sh and curl on purpose: publishing has to work on a box with no Node and in a CI image holding nothing but this script. CI sets TRONBROWSER_STORE_TOKEN; a person runs login once and the token is saved 0600 under XDG_CONFIG_HOME. login is a paste rather than a flow we complete for them, because the API refuses to mint a publisher token from a token -- deliberately, so a leaked CI token cannot mint more. That one step stays human by design, and this is the shortest honest version of it. publish finds an existing listing by slug and creates one only when there is none, so re-running does not leave a second listing beside the first. It derives the slug the way the server does, and --slug overrides it for a listing whose slug was customised. A version that is already published exits clean, so re-running a tag is safe. install.sh writes this CLI from a quoted heredoc, and nothing in the build parses what is inside it. Extracted and `sh -n`'d, then run: help, list against the live store (returns coinpay-wallet), and publish with no token, which points at `tron store login` rather than failing with a 401 from curl. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/public/install.sh | 122 +++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 199e406..9fe81d9 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -257,6 +257,128 @@ case "${1:-}" in command -v node >/dev/null 2>&1 || { echo "tron run needs Node.js (>=24) on PATH." >&2; exit 1; } [ -f "$RUNNER" ] || { echo "This TronBrowser build lacks the SDK runtime. Run: tron upgrade" >&2; exit 1; } exec env TRON_SESSION_BIN="$(session_bin)" node "$RUNNER" "$@" ;; + store) + # Publish an extension to the TronBrowser store, from a terminal or from CI. + # + # The store already auto-updates anything listed in it, and the launcher now + # pulls those updates, so getting an extension listed is the whole job. Doing + # that by hand is why the store had one listing while we shipped five + # extensions. + # + # Deliberately plain sh + curl: publishing must work on a box with no Node, + # and in a CI image that has nothing but this script. + shift + _sub="${1:-help}"; [ "$#" -gt 0 ] && shift + _store="${TRONBROWSER_STORE:-https://tronbrowser.dev}" + _tokfile="${XDG_CONFIG_HOME:-$HOME/.config}/tronbrowser/store-token" + + _tok() { + # CI passes the token in the environment; a person logs in once. + if [ -n "${TRONBROWSER_STORE_TOKEN:-}" ]; then printf '%s' "$TRONBROWSER_STORE_TOKEN"; return 0; fi + [ -f "$_tokfile" ] && cat "$_tokfile" && return 0 + return 1 + } + + _need_tok() { + _t="$(_tok)" || { + echo "Not signed in to the store. Run: tron store login" >&2 + echo "In CI, set TRONBROWSER_STORE_TOKEN instead." >&2 + exit 1 + } + printf '%s' "$_t" + } + + case "$_sub" in + login) + # The API refuses to mint a publisher token from a token -- deliberately, + # so a leaked CI token cannot mint more. That makes this a paste, not a + # flow we can complete headlessly. + echo "Open this, sign in, and create a publisher token:" + echo " $_store/store/publisher" + command -v xdg-open >/dev/null 2>&1 && xdg-open "$_store/store/publisher" >/dev/null 2>&1 || true + printf 'Paste the token (tbpub_...): ' + read -r _paste + case "$_paste" in + tbpub_*) ;; + *) echo "That does not look like a publisher token." >&2; exit 2 ;; + esac + mkdir -p "$(dirname "$_tokfile")" + printf '%s' "$_paste" > "$_tokfile" + chmod 600 "$_tokfile" + echo "Saved to $_tokfile" + ;; + + whoami) + _t="$(_need_tok)" + curl -fsS -H "authorization: Bearer $_t" "$_store/api/store/publisher" 2>/dev/null \ + || { echo "Could not reach the store, or the token is not valid." >&2; exit 1; } + echo ;; + + list) + curl -fsS "$_store/api/store/extensions" 2>/dev/null \ + | tr ',' '\n' | sed -n 's/.*"slug"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + || { echo "Could not reach the store." >&2; exit 1; } + ;; + + publish) + # tron store publish --name --manifest --bundle-url + _name=""; _manifest=""; _bundle=""; _crx=""; _slug="" + while [ "$#" -gt 0 ]; do + case "$1" in + --name) _name="${2:-}"; shift 2 ;; + --manifest) _manifest="${2:-}"; shift 2 ;; + --bundle-url) _bundle="${2:-}"; shift 2 ;; + --crx-url) _crx="${2:-}"; shift 2 ;; + --slug) _slug="${2:-}"; shift 2 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac + done + [ -n "$_name" ] || { echo "usage: tron store publish --name --manifest --bundle-url " >&2; exit 2; } + [ -f "$_manifest" ] || { echo "no manifest at: $_manifest" >&2; exit 2; } + [ -n "$_bundle" ] || [ -n "$_crx" ] || { echo "--bundle-url or --crx-url is required (the store fetches the artifact itself)" >&2; exit 2; } + _t="$(_need_tok)" + + # Slug as the server derives it, so an existing listing is found rather + # than a second one created beside it. + [ -n "$_slug" ] || _slug="$(printf '%s' "$_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]\{1,\}/-/g; s/^-//; s/-$//')" + + _id="$(curl -fsS "$_store/api/store/extensions/$_slug" 2>/dev/null \ + | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + if [ -z "$_id" ]; then + echo "Creating listing \"$_name\"..." + _id="$(curl -fsS -X POST -H "authorization: Bearer $_t" -H 'content-type: application/json' \ + -d "{\"name\":\"$_name\"}" "$_store/api/store/extensions" 2>/dev/null \ + | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + [ -n "$_id" ] || { echo "Could not create the listing." >&2; exit 1; } + fi + + # The manifest goes up as-is; the store validates it as MV3. + _payload="$(printf '{"manifest":%s' "$(cat "$_manifest")")" + [ -n "$_bundle" ] && _payload="$_payload,\"bundleUrl\":\"$_bundle\"" + [ -n "$_crx" ] && _payload="$_payload,\"crxUrl\":\"$_crx\"" + _payload="$_payload}" + + _out="$(printf '%s' "$_payload" | curl -fsS -X POST -H "authorization: Bearer $_t" \ + -H 'content-type: application/json' --data-binary @- \ + "$_store/api/store/extensions/$_id/versions" 2>&1)" || { + # A version that is already up is not a failure; re-running a tag is safe. + case "$_out" in + *already*) echo "Already published."; exit 0 ;; + *) echo "Publish failed: $_out" >&2; exit 1 ;; + esac + } + echo "Published to $_store/store/" + ;; + + *) + echo "usage: tron store " + echo " login save a publisher token for this machine" + echo " whoami show the publisher this machine is signed in as" + echo " list slugs currently in the store" + echo " publish --name --manifest --bundle-url " + ;; + esac + ;; restart) # Force-quit any running TronBrowser, then launch fresh. Chromium forwards a # new launch to an already-running instance (which keeps the OLD extension From 55b97dd76c4050599ee258396b5e89c8e59be845 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 13:26:32 +0000 Subject: [PATCH 4/4] Refresh the CLI on upgrade, even when the release is current `tron upgrade` gates everything on the release version, but the CLI does not come from the release: it is written from a heredoc in install.sh, which deploys from main on merge. Those are two different update paths, and only one of them was being checked. So a CLI-only change could never reach anyone. `tron store` was added, merged and deployed, and `tron upgrade` still answered "TronBrowser is already up to date (v3.16.1)" and left the old CLI in place -- the new command simply did not exist on the machine, and `tron store login` fell through to the handler that opens a URL, so it silently launched a browser instead. The only way through was TB_FORCE=1, which nobody would think to run for a command they had just been told exists. The up-to-date branch already refreshes the engine, the browser, Tor, certutil, the Moshpit root, Obscura and the macOS icon for exactly this reason: those are current-at-fetch-time, not current-at-release-time. The CLI belongs in that list and was missing from it. We are already executing the freshly fetched install.sh at that point, so rewriting it costs nothing and is always current. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/public/install.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 9fe81d9..eaf5060 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -1242,6 +1242,14 @@ do_upgrade() { [ -n "$latest" ] || err "could not resolve the latest release of $REPO" if [ "$current" = "$latest" ] && [ "${TB_FORCE:-0}" != "1" ]; then info "TronBrowser is already up to date ($current)." + # The CLI does NOT come from the release — it is written from a heredoc in + # this script, which deploys from main on merge. Gating it on the release + # version meant a CLI-only change could never reach anyone: `tron store` was + # added, deployed, and `tron upgrade` still said "already up to date" and + # left the old CLI in place, so the new command simply did not exist. We are + # already running the freshly fetched install.sh here, so rewriting it is + # free and always current. + write_cli ensure_engine || true # and that TronBrowser's own engine is current ensure_browser # still make sure Ungoogled Chromium is installed ensure_tor || true # and that Tor is available for the toggle