gui: allow serving from a local zip file or an unpacked directory

This helps with local development and allows users to try older and
newer releases of rclone-web.
This commit is contained in:
Nick Craig-Wood
2026-04-11 13:04:55 +01:00
parent a08b48adaa
commit 55afa13921
2 changed files with 218 additions and 18 deletions

View File

@@ -2,6 +2,7 @@
package gui
import (
"archive/zip"
"context"
"embed"
"flag"
@@ -10,6 +11,7 @@ import (
"net"
"net/http"
"net/url"
"os"
"strings"
"github.com/rclone/rclone/cmd"
@@ -49,17 +51,29 @@ func init() {
}
var commandDefinition = &cobra.Command{
Use: "gui",
Use: "gui [path]",
Short: `Open the web based GUI.`,
Long: `This command starts an embedded web GUI for rclone and opens it in
your default browser.
It starts an RC API server and a GUI server on separate localhost
This starts an RC API server and a GUI server on separate localhost
ports, generates login credentials automatically unless --no-auth
is specified, and opens the browser already authenticated.
rclone gui
By default rclone gui serves the web GUI that was embedded into the
rclone binary at build time from https://github.com/rclone/rclone-web/
You can override this by passing a path to either an unpacked GUI
directory or a dist.zip archive (e.g. one downloaded from the
rclone-web releases page):
rclone gui ./my-dist/
rclone gui ./dist.zip
This is useful for iterating on the GUI locally without rebuilding
rclone, or for serving a different GUI release than the one embedded.
Use --no-open-browser to skip opening the browser automatically:
rclone gui --no-open-browser
@@ -81,9 +95,21 @@ Use --no-auth to disable authentication entirely:
"groups": "RC",
},
RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(0, 0, command, args)
cmd.CheckArgs(0, 1, command, args)
ctx := context.Background()
// Resolve the GUI source (embedded, directory, or .zip)
// before binding any sockets so errors surface immediately.
var srcPath string
if len(args) == 1 {
srcPath = args[0]
}
srcFS, cleanupSrc, err := guiSourceFS(srcPath)
if err != nil {
return err
}
defer func() { _ = cleanupSrc() }()
// Create the GUI server (binds port eagerly, before Serve)
guiCfg := libhttp.DefaultCfg()
if command.Flags().Changed("addr") {
@@ -159,8 +185,8 @@ Use --no-auth to disable authentication entirely:
// does not expose URLs, and we know the address we passed in).
rcURL := "http://" + opt.HTTP.ListenAddr[0] + "/"
// Mount the embedded GUI handler and start serving
spaHandler, err := guiHandler()
// Mount the GUI handler and start serving
spaHandler, err := guiHandler(srcFS)
if err != nil || spaHandler == nil {
return fmt.Errorf("failed to start GUI handler: %w", err)
}
@@ -213,24 +239,53 @@ func originFromURL(rawURL string) string {
return u.Scheme + "://" + u.Host
}
// guiHandler returns an http.Handler that serves the embedded GUI bundle
// with SPA fallback: paths that don't match a real file return index.html.
func guiHandler() (http.Handler, error) {
sub, err := iofs.Sub(assets, "dist")
if err != nil {
return nil, fmt.Errorf("embedded GUI dir not found: was `make fetch-gui` run before building?: %w", err)
// 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).
func guiSourceFS(path string) (iofs.FS, func() error, error) {
noop := func() error { return nil }
if path == "" {
sub, err := iofs.Sub(assets, "dist")
if err != nil {
return nil, nil, fmt.Errorf("embedded GUI dir not found: 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)
}
return sub, noop, nil
}
_, err = iofs.Stat(sub, "index.html")
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("embedded GUI not found: was `make fetch-gui` run before building?: %w", err)
return nil, nil, fmt.Errorf("failed to stat GUI source %q: %w", path, err)
}
fileServer := http.FileServer(http.FS(sub))
if info.IsDir() {
return os.DirFS(path), noop, nil
}
if info.Mode().IsRegular() && strings.HasSuffix(strings.ToLower(path), ".zip") {
zr, err := zip.OpenReader(path)
if err != nil {
return nil, nil, fmt.Errorf("failed to open GUI zip %q: %w", path, err)
}
return zr, zr.Close, nil
}
return nil, nil, fmt.Errorf("GUI source must be a directory or a .zip file: %q", path)
}
// guiHandler returns an http.Handler that serves the GUI bundle from
// srcFS with SPA fallback: paths that don't match a real file return
// index.html.
func guiHandler(srcFS iofs.FS) (http.Handler, error) {
if _, err := iofs.Stat(srcFS, "index.html"); err != nil {
return nil, fmt.Errorf("GUI bundle has no index.html: %w", err)
}
fileServer := http.FileServer(http.FS(srcFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := iofs.Stat(sub, path); err == nil {
if _, err := iofs.Stat(srcFS, path); err == nil {
fileServer.ServeHTTP(w, r)
return
}

View File

@@ -1,9 +1,13 @@
package gui
import (
"archive/zip"
"io"
iofs "io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -11,6 +15,11 @@ import (
"github.com/stretchr/testify/require"
)
const (
testIndexHTML = `<!doctype html><html><body><div id="root"></div></body></html>`
testIconSVG = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`
)
func TestBuildLoginURL(t *testing.T) {
tests := []struct {
name string
@@ -94,17 +103,153 @@ func TestFreePort(t *testing.T) {
assert.Less(t, port, 65536)
}
// newTestHandler returns a guiHandler, or skips the test if the embedded
// GUI bundle is not present (i.e. `make fetch-gui` has not been run).
// newTestHandler returns a guiHandler backed by the embedded GUI
// bundle, or skips the test if it is not present (i.e. `make fetch-gui`
// has not been run).
func newTestHandler(t *testing.T) http.Handler {
t.Helper()
h, err := guiHandler()
srcFS, cleanup, err := guiSourceFS("")
if err != nil {
t.Skipf("skipping: GUI dist not embedded (run `make fetch-gui`): %v", err)
}
t.Cleanup(func() { _ = cleanup() })
h, err := guiHandler(srcFS)
require.NoError(t, err)
return h
}
// writeTestDir creates a temp directory containing a fake GUI bundle
// (index.html + icon.svg) and returns its path.
func writeTestDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte(testIndexHTML), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "icon.svg"), []byte(testIconSVG), 0644))
return dir
}
// writeTestZip creates a temp .zip file containing a fake GUI bundle
// and returns its path.
func writeTestZip(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "dist.zip")
f, err := os.Create(path)
require.NoError(t, err)
zw := zip.NewWriter(f)
for name, content := range map[string]string{
"index.html": testIndexHTML,
"icon.svg": testIconSVG,
} {
w, err := zw.Create(name)
require.NoError(t, err)
_, err = w.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, zw.Close())
require.NoError(t, f.Close())
return path
}
func TestGuiSourceFS(t *testing.T) {
t.Run("empty path returns embedded", func(t *testing.T) {
srcFS, cleanup, err := guiSourceFS("")
if err != nil {
t.Skipf("skipping: GUI dist not embedded (run `make fetch-gui`): %v", err)
}
defer func() { _ = cleanup() }()
_, err = iofs.Stat(srcFS, "index.html")
assert.NoError(t, err)
})
t.Run("directory path", func(t *testing.T) {
dir := writeTestDir(t)
srcFS, cleanup, err := guiSourceFS(dir)
require.NoError(t, err)
defer func() { _ = cleanup() }()
data, err := iofs.ReadFile(srcFS, "index.html")
require.NoError(t, err)
assert.Equal(t, testIndexHTML, string(data))
})
t.Run("zip path", func(t *testing.T) {
path := writeTestZip(t)
srcFS, cleanup, err := guiSourceFS(path)
require.NoError(t, err)
defer func() { _ = cleanup() }()
data, err := iofs.ReadFile(srcFS, "index.html")
require.NoError(t, err)
assert.Equal(t, testIndexHTML, string(data))
})
t.Run("nonexistent path", func(t *testing.T) {
_, _, err := guiSourceFS(filepath.Join(t.TempDir(), "nope"))
assert.Error(t, err)
})
t.Run("regular file without zip suffix", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "notazip.txt")
require.NoError(t, os.WriteFile(path, []byte("hi"), 0644))
_, _, err := guiSourceFS(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "directory or a .zip file")
})
}
// handlerForSource builds a handler from a temp directory or zip
// source. Used by parameterised tests that need to verify the handler
// works against all FS implementations, not just the embedded one.
func handlerForSource(t *testing.T, srcPath string) http.Handler {
t.Helper()
srcFS, cleanup, err := guiSourceFS(srcPath)
require.NoError(t, err)
t.Cleanup(func() { _ = cleanup() })
h, err := guiHandler(srcFS)
require.NoError(t, err)
return h
}
func TestHandlerAllSources(t *testing.T) {
dir := writeTestDir(t)
zipPath := writeTestZip(t)
sources := []struct {
name string
path string
}{
{"directory", dir},
{"zip", zipPath},
}
for _, src := range sources {
t.Run(src.name, func(t *testing.T) {
h := handlerForSource(t, src.path)
// index.html
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Result().StatusCode)
body, _ := io.ReadAll(w.Result().Body)
assert.Contains(t, string(body), `<div id="root"></div>`)
// static asset
req = httptest.NewRequest("GET", "/icon.svg", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Result().StatusCode)
body, _ = io.ReadAll(w.Result().Body)
assert.Contains(t, string(body), "<svg")
// SPA fallback
req = httptest.NewRequest("GET", "/login", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Result().StatusCode)
body, _ = io.ReadAll(w.Result().Body)
assert.Contains(t, string(body), `<div id="root"></div>`,
"SPA fallback should serve index.html for unknown routes")
})
}
}
func TestHandlerServesIndexHTML(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest("GET", "/", nil)