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
104 changes: 104 additions & 0 deletions .github/workflows/store-publish.yml
Original file line number Diff line number Diff line change
@@ -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' || '' }}
124 changes: 122 additions & 2 deletions apps/desktop/launcher/tronbrowser
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
130 changes: 130 additions & 0 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --manifest <path> --bundle-url <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 <name> --manifest <manifest.json> --bundle-url <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 <login|whoami|list|publish>"
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 <name> --manifest <path> --bundle-url <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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading