From 55afa13921cb59260ebf4431275da441f4e370d3 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Sat, 11 Apr 2026 13:04:55 +0100 Subject: [PATCH] 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. --- cmd/gui/gui.go | 85 ++++++++++++++++++++----- cmd/gui/gui_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 218 insertions(+), 18 deletions(-) diff --git a/cmd/gui/gui.go b/cmd/gui/gui.go index 4e9a56b3b..434ab4aa5 100644 --- a/cmd/gui/gui.go +++ b/cmd/gui/gui.go @@ -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 } diff --git a/cmd/gui/gui_test.go b/cmd/gui/gui_test.go index 6fc562aa0..0a9978e46 100644 --- a/cmd/gui/gui_test.go +++ b/cmd/gui/gui_test.go @@ -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 = `
` + testIconSVG = `` +) + 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), `
`) + + // 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), "`, + "SPA fallback should serve index.html for unknown routes") + }) + } +} + func TestHandlerServesIndexHTML(t *testing.T) { h := newTestHandler(t) req := httptest.NewRequest("GET", "/", nil)