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/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)" diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 199e406..eaf5060 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 @@ -1120,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 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)}`); +}