gui: embed compressed dist.zip in the binary for smaller, reproducible builds

Previously `make fetch-gui` extracted the GUI release into cmd/gui/dist/
and the unpacked tree was embedded uncompressed via `//go:embed dist`.

This commits and embeds the GUI bundle (dist.zip) and its release tag
(dist.tag) to the repo so:

- the rclone binary is smaller
- `go build` works on a fresh clone without first running fetch-gui
- a given commit pins an exact GUI version

The "Fetch GUI" step was removed from .github/workflows/build.yml.
This commit is contained in:
Nick Craig-Wood
2026-04-30 17:30:51 +01:00
parent 7400a811fd
commit 56b7d7500e
8 changed files with 45 additions and 45 deletions

View File

@@ -164,11 +164,6 @@ jobs:
printf "\n\nSystem environment:\n\n"
env
- name: Fetch GUI
run: make fetch-gui
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build rclone
run: |
make

View File

@@ -1,16 +1,22 @@
#!/bin/bash
# Fetch the latest GUI dist from rclone/rclone-web GitHub releases.
# Fetch the latest GUI dist.zip from rclone/rclone-web GitHub releases.
#
# Downloads dist.zip from the latest release and extracts it to
# cmd/gui/dist/. Skips the download if the local tag matches.
# Downloads dist.zip from the latest release to cmd/gui/dist.zip and
# records the tag in cmd/gui/dist.tag. Both files are committed to the
# repo so that builds are reproducible and `go build` works on a fresh
# clone without needing to fetch anything.
#
# Requires: curl, unzip
# Skips the download when both dist.zip and dist.tag exist and the tag
# matches the latest release.
#
# Requires: curl
set -euo pipefail
REPO="rclone/rclone-web"
DEST="cmd/gui/dist"
TAG_FILE="${DEST}/.tag"
DEST_DIR="cmd/gui"
ZIP_FILE="${DEST_DIR}/dist.zip"
TAG_FILE="${DEST_DIR}/dist.tag"
CURL_OPTS=(-fSs --retry 5 --retry-delay 2 --retry-all-errors)
@@ -49,14 +55,16 @@ sys.exit(1)
echo "Latest release: ${TAG}"
# Check if we already have this version
if [ -f "${TAG_FILE}" ] && [ "$(cat "${TAG_FILE}")" = "${TAG}" ]; then
# Skip only when both the zip and the tag are present and the tag matches.
# If only the tag exists (e.g. someone deleted the zip), force a re-download.
if [ -f "${ZIP_FILE}" ] && [ -f "${TAG_FILE}" ] && [ "$(cat "${TAG_FILE}")" = "${TAG}" ]; then
echo "Already up to date (${TAG})"
exit 0
fi
# Download dist.zip
TMPFILE=$(mktemp /tmp/rclone-gui-dist.XXXXXX.zip)
# Download dist.zip directly to its final location, via a temp file so a
# failed download doesn't leave a partial file behind.
TMPFILE=$(mktemp "${DEST_DIR}/.dist.zip.XXXXXX")
trap 'rm -f "${TMPFILE}"' EXIT
echo "Downloading dist.zip from ${TAG}..."
@@ -65,17 +73,8 @@ curl -L "${CURL_OPTS[@]}" "${AUTH_HEADER[@]}" -o "${TMPFILE}" "${ASSET_URL}" ||
exit 1
}
# Extract
echo "Extracting to ${DEST}/..."
rm -rf "${DEST}"
mkdir -p "${DEST}"
unzip -q "${TMPFILE}" -d "${DEST}"
# Restore marker files
git checkout "${DEST}"/.gitignore
git checkout "${DEST}"/README.md
# Write tag for cache comparison
mv "${TMPFILE}" "${ZIP_FILE}"
chmod 644 "${ZIP_FILE}"
echo -n "${TAG}" > "${TAG_FILE}"
echo "Done. GUI dist updated to ${TAG}"
echo "Done. ${ZIP_FILE} updated to ${TAG}"

1
cmd/gui/dist.tag Normal file
View File

@@ -0,0 +1 @@
1.1.7

BIN
cmd/gui/dist.zip Normal file
View File

Binary file not shown.

View File

@@ -1,4 +0,0 @@
# This directory gets the web gui release which we ignore
*
# Except this file which we use to keep the directory available
!.gitignore

View File

@@ -1 +0,0 @@
Use `make fetch-gui` to populate this directory.

View File

@@ -3,8 +3,9 @@ package gui
import (
"archive/zip"
"bytes"
"context"
"embed"
_ "embed"
"fmt"
iofs "io/fs"
"net/http"
@@ -24,8 +25,11 @@ import (
"github.com/spf13/cobra"
)
//go:embed dist
var assets embed.FS
//go:embed dist.zip
var distZip []byte
//go:embed dist.tag
var distTag string
var (
guiAddr []string
@@ -192,7 +196,11 @@ For more help see [the GUI docs](/gui/).
guiServer.Serve()
guiURL := guiServer.URLs()[0]
fs.Logf(nil, "Serving GUI on %s", guiURL)
guiSource := fmt.Sprintf("version %s", strings.TrimSpace(distTag))
if srcPath != "" {
guiSource = fmt.Sprintf("from %s", srcPath)
}
fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL)
// Open browser
loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth)
@@ -231,20 +239,20 @@ func originFromURL(rawURL string) string {
}
// guiSourceFS opens the GUI bundle at the given path. An empty path
// returns the embedded bundle. The returned cleanup func must be
// called on shutdown (no-op for embedded/DirFS, Close for the zip
// reader).
// returns the embedded bundle (read from the zip embedded in the binary).
// The returned cleanup func must be called on shutdown (no-op for the
// embedded bundle and DirFS, Close for an external zip reader).
func guiSourceFS(path string) (iofs.FS, func() error, error) {
noop := func() error { return nil }
if path == "" {
sub, err := iofs.Sub(assets, "dist")
zr, err := zip.NewReader(bytes.NewReader(distZip), int64(len(distZip)))
if err != nil {
return nil, nil, fmt.Errorf("embedded GUI dir not found: was `make fetch-gui` run before building?: %w", err)
return nil, nil, fmt.Errorf("failed to read embedded GUI zip: was `make fetch-gui` run before building?: %w", err)
}
if _, err := iofs.Stat(sub, "index.html"); err != nil {
return nil, nil, fmt.Errorf("embedded GUI not found: was `make fetch-gui` run before building?: %w", err)
if _, err := iofs.Stat(zr, "index.html"); err != nil {
return nil, nil, fmt.Errorf("embedded GUI has no index.html: was `make fetch-gui` run before building?: %w", err)
}
return sub, noop, nil
return zr, noop, nil
}
info, err := os.Stat(path)
if err != nil {

View File

@@ -120,4 +120,6 @@ Tailscale (all free).
## History
In v1.74 the GUI was redone and embedded within rclone for ease of use.
In v1.74 the GUI was redone and embedded within rclone for ease of
use. The GUI bundle ships as a compressed zip embedded in the rclone
binary and is served from the zip at runtime.