diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60a5ef2..31740e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,6 +120,7 @@ jobs: Copy-Item apps/desktop/launcher/tronbrowser.cmd "$stage/tronbrowser.cmd" Copy-Item apps/desktop/launcher/tron-tor-helper "$stage/tron-tor-helper" Copy-Item apps/desktop/launcher/tron-windows.py "$stage/tron-windows.py" + Copy-Item apps/desktop/launcher/tron-w3bs.ps1 "$stage/tron-w3bs.ps1" Copy-Item -Recurse apps/desktop/extensions/ai-sidebar "$stage/extensions/ai-sidebar" # Same wholesale-copy problem build-release.sh has: the vitest files # next to the extension sources ride along into the zip. Chrome never diff --git a/.github/workflows/w3bs-scheme.yml b/.github/workflows/w3bs-scheme.yml new file mode 100644 index 0000000..5ff8c85 --- /dev/null +++ b/.github/workflows/w3bs-scheme.yml @@ -0,0 +1,48 @@ +name: w3bs:// handler + +# The OS-level half of w3bs:// support: each platform's real URL dispatch +# (xdg-open, LaunchServices, ShellExecute) has to reach the launcher, which +# opens the w3bs.org resolver. Nothing here can be checked from Linux alone. +on: + push: + branches: [main] + paths: + - 'apps/desktop/launcher/**' + - 'apps/desktop/test/w3bs-scheme/**' + - '.github/workflows/w3bs-scheme.yml' + pull_request: + paths: + - 'apps/desktop/launcher/**' + - 'apps/desktop/test/w3bs-scheme/**' + - '.github/workflows/w3bs-scheme.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + posix: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - name: Install xdg-utils and gio + if: runner.os == 'Linux' + run: | + command -v xdg-open && command -v gio \ + || (sudo apt-get update -qq && sudo apt-get install -y -qq xdg-utils libglib2.0-bin) + - name: OS opens w3bs:// in TronBrowser + run: sh apps/desktop/test/w3bs-scheme/posix.sh + + windows: + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - name: Windows opens w3bs:// in TronBrowser + shell: pwsh + run: ./apps/desktop/test/w3bs-scheme/windows.ps1 diff --git a/apps/desktop/launcher/tron-w3bs-register b/apps/desktop/launcher/tron-w3bs-register new file mode 100755 index 0000000..73ba732 --- /dev/null +++ b/apps/desktop/launcher/tron-w3bs-register @@ -0,0 +1,95 @@ +#!/bin/sh +# Register TronBrowser as the handler for w3bs:// URIs with the OS, so a W3BS +# link from another app, or one typed in the address bar (Chromium hands any +# scheme it cannot load to the OS), comes back to `tron`, which opens it +# through the w3bs.org resolver. +# +# tron-w3bs-register +# +# is what the OS runs with the URI as its one argument: the launcher +# passes its own path. Idempotent, quiet and best-effort: the launcher runs it +# in the background on every start, so upgrades and moved installs heal +# themselves. A handler someone else registered (the w3bs.org inspector, say) is +# left alone. Windows registers from tron-w3bs.ps1 instead. +set -u + +TARGET="${TRONBROWSER_W3BS_HANDLER_TARGET:-${1:-}}" +[ -n "$TARGET" ] || { echo "usage: tron-w3bs-register " >&2; exit 2; } + +linux() { + command -v xdg-mime >/dev/null 2>&1 || return 0 + _cur="$(xdg-mime query default x-scheme-handler/w3bs 2>/dev/null || true)" + # xdg-mime reports tronbrowser.desktop merely because it declares the scheme, + # while GIO (GNOME, and what xdg-open defers to there) only honours an + # explicit default. So write ours unless another app holds it. + case "$_cur" in ''|tronbrowser.desktop) ;; *) return 0 ;; esac + # Only claim the scheme through a desktop entry that declares it; install.sh, + # the .deb/.rpm and the AppImage all write one named tronbrowser.desktop. + for _d in "${XDG_DATA_HOME:-$HOME/.local/share}" $(printf '%s' "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" | tr ':' ' '); do + if grep -qs 'x-scheme-handler/w3bs' "$_d/applications/tronbrowser.desktop"; then + xdg-mime default tronbrowser.desktop x-scheme-handler/w3bs 2>/dev/null || true + return 0 + fi + done +} + +# macOS routes a URL scheme to an app bundle that declares it, never to a +# script, and Chromium.app cannot be the one: it would hand w3bs:// straight +# back to the OS. So build a small faceless applet that declares w3bs and runs +# $TARGET with the URI. +macos() { + APP="${TRONBROWSER_W3BS_APP:-$HOME/Applications/TronBrowser Links.app}" + ID="dev.tronbrowser.links" + STAMP="$APP/Contents/Resources/tron-target" + if [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" = "$TARGET" ]; then + return 0 + fi + command -v osacompile >/dev/null 2>&1 || return 0 + PB=/usr/libexec/PlistBuddy + LSREG=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister + + _src="$(mktemp -t tron-w3bs)" || return 0 + # AppleScript string literal: escape backslashes and double quotes. + _t="$(printf '%s' "$TARGET" | sed 's/\\/\\\\/g; s/"/\\"/g')" + cat > "$_src" </dev/null 2>&1 &" +end open location +on run +end run +EOF + mkdir -p "$(dirname "$APP")" + rm -rf "$APP" + osacompile -o "$APP" "$_src" || { rm -f "$_src"; return 0; } + rm -f "$_src" + + _plist="$APP/Contents/Info.plist" + "$PB" -c "Set :CFBundleIdentifier $ID" "$_plist" 2>/dev/null || "$PB" -c "Add :CFBundleIdentifier string $ID" "$_plist" + "$PB" -c "Set :CFBundleName TronBrowser Links" "$_plist" 2>/dev/null || true + "$PB" -c "Add :LSUIElement bool true" "$_plist" 2>/dev/null || true + "$PB" -c "Delete :CFBundleURLTypes" "$_plist" 2>/dev/null || true + "$PB" -c "Add :CFBundleURLTypes array" "$_plist" + "$PB" -c "Add :CFBundleURLTypes:0 dict" "$_plist" + "$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLName string W3BS resource" "$_plist" + "$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$_plist" + "$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string w3bs" "$_plist" + printf '%s' "$TARGET" > "$STAMP" + # Editing Info.plist breaks the applet's ad-hoc signature, and Apple silicon + # refuses to run an app with a broken one. + codesign --force --deep --sign - "$APP" >/dev/null 2>&1 || true + [ -x "$LSREG" ] && "$LSREG" -f "$APP" >/dev/null 2>&1 || true + + # Become the default only when no other app has claimed w3bs. + osascript -l JavaScript >/dev/null 2>&1 < open a W3BS URI through the w3bs.org resolver +# +# The OS runs this script, not tronbrowser.cmd, for a w3bs:// link: a URI from +# a web page must never be parsed by cmd.exe, where & and % mean something. +# tronbrowser.cmd runs -Register in the background on every start, so scoop, +# choco, winget and the ZIP all register, and an upgrade that moved the files +# re-points the handler. A handler someone else registered is left alone. +param( + [Parameter(Position = 0)][string]$Uri = '', + [switch]$Register +) +$ErrorActionPreference = 'Stop' +$script = $MyInvocation.MyCommand.Path +$here = Split-Path -Parent $script +$key = 'HKCU:\Software\Classes\w3bs' + +if ($Register) { + $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $command = '"{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "{1}" "%1"' -f $ps, $script + $current = $null + if (Test-Path "$key\shell\open\command") { + $current = (Get-Item "$key\shell\open\command").GetValue('') + } + if ($current -eq $command) { exit 0 } + if ($current -and $current -notmatch 'tron-w3bs\.ps1') { exit 0 } + New-Item -Path "$key\shell\open\command" -Force | Out-Null + Set-Item -Path $key -Value 'URL:W3BS resource' + New-ItemProperty -Path $key -Name 'URL Protocol' -Value '' -PropertyType String -Force | Out-Null + Set-Item -Path "$key\shell\open\command" -Value $command + exit 0 +} + +$gateway = if ($env:TRONBROWSER_W3BS_GATEWAY) { $env:TRONBROWSER_W3BS_GATEWAY } else { 'https://w3bs.org/browse?q=' } +$u = $Uri.Trim() +# W3BS-URI-1 identifiers are lowercase letters, digits, - . / @ and the scheme: +# nothing a query string or cmd.exe needs escaped, so a well-formed one goes +# through as written. Anything else opens the resolver without it rather than +# smuggling % or & into the command line below. +if ($u -match '^(?i)w3bs://[a-z0-9][a-z0-9./@-]{0,500}$') { + $url = $gateway + $u +} else { + $url = $gateway -replace '\?q=$', '' +} +& (Join-Path $here 'tronbrowser.cmd') $url +exit $LASTEXITCODE diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index b454b78..ddc4c07 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -50,7 +50,13 @@ while [ "$_i" -lt "$_argc" ]; do _a="$1"; shift; _i=$((_i + 1)) case "$_a" in w3bs:*|W3BS:*) - _a="$W3BS_GATEWAY$(printf '%s' "$_a" | od -An -v -tx1 | tr -d ' \n' | sed 's/\(..\)/%\1/g')" ;; + # Percent-encode like encodeURIComponent: unreserved characters stay. + _a="$W3BS_GATEWAY$(printf '%s' "$_a" | LC_ALL=C awk ' + BEGIN { for (i = 1; i < 256; i++) ord[sprintf("%c", i)] = i } + { for (i = 1; i <= length($0); i++) { + c = substr($0, i, 1) + if (c ~ /[A-Za-z0-9._~!*()\047-]/) printf "%s", c; else printf "%%%02X", ord[c] + } }')" ;; esac set -- "$@" "$_a" done @@ -774,6 +780,14 @@ if [ "$TOR" != "1" ] && [ -z "$MAC_APP" ] && [ -f "$DIR/tron-pwa" ]; then fi fi +# w3bs:// from other apps and the address bar reaches the OS, which must hand it +# back to this launcher (see the w3bs:// rewrite above). On every start, in the +# background, so package-manager installs that never ran install.sh register +# too and an upgrade that moved us re-points the handler. +if [ -f "$DIR/tron-w3bs-register" ]; then + sh "$DIR/tron-w3bs-register" "$DIR/$(basename "$SELF")" >/dev/null 2>&1 { const url = new URL(opened[0]!); expect(url.origin + url.pathname).toBe('https://w3bs.org/browse'); expect(url.searchParams.get('q')).toBe('w3bs://prompt/w3bs/research@1'); + // Encoded the way encodeURIComponent does it, so the address bar stays readable. + expect(opened[0]).toBe('https://w3bs.org/browse?q=w3bs%3A%2F%2Fprompt%2Fw3bs%2Fresearch%401'); expect(opened[1]).toBe('https://tronbrowser.dev'); }); diff --git a/apps/desktop/test/w3bs-scheme/posix.sh b/apps/desktop/test/w3bs-scheme/posix.sh new file mode 100644 index 0000000..5d1de3a --- /dev/null +++ b/apps/desktop/test/w3bs-scheme/posix.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# End to end on Linux and macOS: register w3bs:// the way the launcher does, ask +# the OS to open a W3BS URI (xdg-open / open, the same path a link from another +# app or the address bar takes), and check the engine was handed the w3bs.org +# resolver URL. The engine is a stub that records its argv, and HOME and the XDG +# dirs are scratch, so nothing on the machine running this is touched except, +# on macOS, LaunchServices learning about the scratch applet. +set -eu + +REPO="$(CDPATH= cd -- "$(dirname -- "$0")/../../../.." && pwd)" +W="$(mktemp -d)" +REAL_HOME="$HOME" +export HOME="$W/home" +export XDG_DATA_HOME="$HOME/.local/share" XDG_CONFIG_HOME="$HOME/.config" +mkdir -p "$W/bin" "$XDG_DATA_HOME/applications" "$XDG_CONFIG_HOME" +cp "$REPO/apps/desktop/launcher/tronbrowser" "$REPO/apps/desktop/launcher/tron-w3bs-register" "$W/bin/" +ARGV="$W/argv" +URI='w3bs://prompt/w3bs/research@1' +WANT='https://w3bs.org/browse?q=w3bs%3A%2F%2Fprompt%2Fw3bs%2Fresearch%401' +# LaunchServices will not route a URL to an app in a temporary directory, so on +# macOS the applet goes where real installs put it, under a test-only name. +# Same bundle id as the real one: run this on a disposable machine (CI). +APP="$REAL_HOME/Applications/TronBrowser Links (test).app" + +# Named ungoogled-chromium so the launcher's de-googled check stays quiet. +cat > "$W/bin/ungoogled-chromium" < "$ARGV.tmp" && mv "$ARGV.tmp" "$ARGV" +EOF +# What the OS runs. LaunchServices and xdg-open start it with their own bare +# environment, so the stub engine and scratch profile are set here. +cat > "$W/bin/tron" <&2; exit 1; } + +case "$(uname -s)" in + Darwin) + TRONBROWSER_W3BS_APP="$APP" sh "$W/bin/tron-w3bs-register" "$W/bin/tron" + PLIST="$APP/Contents/Info.plist" + [ -f "$PLIST" ] || fail "no handler app at $APP" + [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleURLTypes:0:CFBundleURLSchemes:0' "$PLIST")" = w3bs ] \ + || fail "handler app does not declare w3bs" + codesign --verify "$APP" || fail "handler app signature is broken" + # LaunchServices learns about the app asynchronously. + i=0 + until open "$URI" 2>/dev/null; do + i=$((i + 1)) + if [ "$i" -ge 20 ]; then + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -dump \ + | grep -i -B3 -A3 'w3bs\|tronbrowser.links' >&2 || true + open "$URI" || true + fail "LaunchServices has no handler for w3bs" + fi + sleep 1 + done + ;; + *) + command -v xdg-open >/dev/null 2>&1 || fail "xdg-open is not installed" + cat > "$XDG_DATA_HOME/applications/tronbrowser.desktop" < "$XDG_DATA_HOME/applications/other.desktop" + xdg-mime default other.desktop x-scheme-handler/w3bs + sh "$W/bin/tron-w3bs-register" "$W/bin/tron" + [ "$(xdg-mime query default x-scheme-handler/w3bs)" = other.desktop ] \ + || fail "replaced another app's w3bs handler" + # ...and with none set, TronBrowser claims the scheme. + rm -f "$XDG_CONFIG_HOME/mimeapps.list" "$XDG_DATA_HOME/applications/other.desktop" + sh "$W/bin/tron-w3bs-register" "$W/bin/tron" + [ "$(xdg-mime query default x-scheme-handler/w3bs)" = tronbrowser.desktop ] \ + || fail "tronbrowser.desktop is not the w3bs handler" + # As on a GNOME desktop: xdg-open hands the URI to `gio open`, which looks + # the handler up in mimeapps.list. Generic mode is not used because + # xdg-utils 1.1.x (Ubuntu 24.04) misreads any scheme with a digit, like + # w3bs, as a file path. DISPLAY only has to be set; nothing connects to it. + command -v gio >/dev/null 2>&1 || fail "gio is not installed" + DISPLAY="${DISPLAY:-:99}" XDG_CURRENT_DESKTOP=GNOME xdg-open "$URI" + ;; +esac + +i=0 +while [ ! -s "$ARGV" ] && [ "$i" -lt 60 ]; do sleep 1; i=$((i + 1)); done +[ -s "$ARGV" ] || fail "the engine was never started for $URI" +grep -qxF "$WANT" "$ARGV" || { cat "$ARGV" >&2; fail "the engine was not handed $WANT"; } +echo "ok: $URI -> $WANT" +if [ -d "$APP" ]; then + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -u "$APP" 2>/dev/null || true + rm -rf "$APP" +fi +rm -rf "$W" diff --git a/apps/desktop/test/w3bs-scheme/windows.ps1 b/apps/desktop/test/w3bs-scheme/windows.ps1 new file mode 100644 index 0000000..28abd2d --- /dev/null +++ b/apps/desktop/test/w3bs-scheme/windows.ps1 @@ -0,0 +1,66 @@ +# End to end on Windows: register w3bs:// the way tronbrowser.cmd does, ask the +# shell to open a W3BS URI (Start-Process, the same ShellExecute path a link +# from another app or the address bar takes), and check the engine was handed +# the w3bs.org resolver URL. The engine is a stub .cmd that records its argv. +# Writes HKCU\Software\Classes\w3bs; meant for a disposable CI runner. +$ErrorActionPreference = 'Stop' +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path +$w = Join-Path ([IO.Path]::GetTempPath()) ("tron-w3bs-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Force $w | Out-Null +Copy-Item (Join-Path $repo 'apps\desktop\launcher\tronbrowser.cmd') $w +Copy-Item (Join-Path $repo 'apps\desktop\launcher\tron-w3bs.ps1') $w +$argv = Join-Path $w 'argv.txt' +$stub = Join-Path $w 'stub-browser.cmd' +Set-Content -Encoding ascii $stub "@echo off`r`n>>`"%TB_ARGV%`" echo(%*`r`n" +$env:TRONBROWSER_BROWSER = $stub +$env:TRONBROWSER_DATA = Join-Path $w 'profile' +$env:TB_ARGV = $argv +$key = 'HKCU:\Software\Classes\w3bs' +$uri = 'w3bs://prompt/w3bs/research@1' +$want = 'https://w3bs.org/browse?q=w3bs://prompt/w3bs/research@1' +$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + +function Wait-Argv([string]$expect, [string]$what) { + for ($i = 0; $i -lt 60 -and -not (Test-Path $argv); $i++) { Start-Sleep -Seconds 1 } + if (-not (Test-Path $argv)) { throw "FAIL ($what): the engine was never started" } + Start-Sleep -Milliseconds 500 + $got = Get-Content -Raw $argv + Remove-Item $argv + if (-not $got.Contains($expect)) { throw "FAIL ($what): wanted $expect, engine got: $got" } + Write-Host "ok ($what): $expect" +} +function Handler { if (Test-Path "$key\shell\open\command") { (Get-Item "$key\shell\open\command").GetValue('') } } + +Remove-Item -Recurse -Force $key -ErrorAction SilentlyContinue + +# A normal launch registers the scheme, in the background. +& cmd.exe /d /c "`"$w\tronbrowser.cmd`" https://example.invalid/" +Wait-Argv 'https://example.invalid/' 'plain launch' +for ($i = 0; $i -lt 60 -and -not (Handler); $i++) { Start-Sleep -Seconds 1 } +if ((Handler) -notlike "*$w\tron-w3bs.ps1*") { throw "FAIL: launch did not register w3bs (handler: $(Handler))" } +if ($null -eq (Get-Item $key).GetValue('URL Protocol')) { throw 'FAIL: no URL Protocol value' } +Write-Host "ok (register): $(Handler)" + +# The shell hands a w3bs:// URI to TronBrowser. +Start-Process $uri +Wait-Argv $want 'Start-Process' + +# `tron w3bs://...` from a terminal. +& cmd.exe /d /c "`"$w\tronbrowser.cmd`" $uri" +Wait-Argv $want 'tron w3bs://' + +# A malformed URI with cmd.exe metacharacters opens the resolver without it. +$marker = Join-Path $w 'pwned.txt' +& $ps -NoProfile -ExecutionPolicy Bypass -File "$w\tron-w3bs.ps1" "w3bs://x&echo pwned>`"$marker`"&" +Wait-Argv 'https://w3bs.org/browse' 'hostile URI' +if (Test-Path $marker) { throw 'FAIL: a w3bs:// URI ran a command' } + +# Someone else's handler is left alone. +Set-Item "$key\shell\open\command" -Value '"C:\Other\other.exe" "%1"' +& $ps -NoProfile -ExecutionPolicy Bypass -File "$w\tron-w3bs.ps1" -Register +if ((Handler) -ne '"C:\Other\other.exe" "%1"') { throw "FAIL: replaced another app's handler: $(Handler)" } +Write-Host 'ok (other handler kept)' + +Remove-Item -Recurse -Force $key +Remove-Item -Recurse -Force $w -ErrorAction SilentlyContinue +Write-Host 'all w3bs:// Windows checks passed'