gui: new command to launch the https://github.com/rclone/rclone-web/ GUI

This adds a new gui command which runs an embedded copy of the GUI at

https://github.com/rclone/rclone-web/

The GUI release is fetched as part of the CI build.
This commit is contained in:
FTCHD
2026-04-10 11:39:50 +01:00
committed by GitHub
parent 082031cc85
commit acf887b464
7 changed files with 491 additions and 2 deletions
+3
View File
@@ -230,6 +230,9 @@ jobs:
go-version: '~1.26.0' go-version: '~1.26.0'
check-latest: true check-latest: true
cache: false cache: false
- name: Fetch GUI dist
run: make fetch-gui
- name: Cache - name: Cache
uses: actions/cache@v5 uses: actions/cache@v5
+2
View File
@@ -20,3 +20,5 @@ __pycache__
.DS_Store .DS_Store
resource_windows_*.syso resource_windows_*.syso
.devcontainer .devcontainer
# GUI dist is fetched at build time via `make fetch-gui`
cmd/gui/dist/
+5 -2
View File
@@ -45,9 +45,9 @@ LINTTAGS=--build-tags "$(GOTAGS)"
endif endif
LDFLAGS=--ldflags "-s -X github.com/rclone/rclone/fs.Version=$(TAG)" LDFLAGS=--ldflags "-s -X github.com/rclone/rclone/fs.Version=$(TAG)"
.PHONY: rclone test_all vars version .PHONY: rclone test_all vars version fetch-gui
rclone: rclone: fetch-gui
ifeq ($(GO_OS),windows) ifeq ($(GO_OS),windows)
go run bin/resource_windows.go -version $(TAG) -syso resource_windows_`go env GOARCH`.syso go run bin/resource_windows.go -version $(TAG) -syso resource_windows_`go env GOARCH`.syso
endif endif
@@ -59,6 +59,9 @@ endif
cp -av rclone`go env GOEXE` `go env GOPATH`/bin/rclone`go env GOEXE`.new cp -av rclone`go env GOEXE` `go env GOPATH`/bin/rclone`go env GOEXE`.new
mv -v `go env GOPATH`/bin/rclone`go env GOEXE`.new `go env GOPATH`/bin/rclone`go env GOEXE` mv -v `go env GOPATH`/bin/rclone`go env GOEXE`.new `go env GOPATH`/bin/rclone`go env GOEXE`
fetch-gui:
$(SHELL) ./bin/fetch-gui-dist.sh
test_all: test_all:
go install $(LDFLAGS) $(BUILDTAGS) $(BUILD_ARGS) github.com/rclone/rclone/fstest/test_all go install $(LDFLAGS) $(BUILDTAGS) $(BUILD_ARGS) github.com/rclone/rclone/fstest/test_all
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# Fetch the latest GUI dist 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.
#
# Requires: curl, unzip
set -euo pipefail
REPO="rclone/rclone-web"
DEST="cmd/gui/dist"
TAG_FILE="${DEST}/.tag"
CURL_OPTS=(-fSs --retry 5 --retry-delay 2 --retry-all-errors)
# Get the latest release info
echo "Checking latest release of ${REPO}..."
RELEASE_JSON=$(curl "${CURL_OPTS[@]}" \
"https://api.github.com/repos/${REPO}/releases/latest") || {
echo "Error: failed to fetch release info from GitHub API" >&2
exit 1
}
TAG=$(echo "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])")
ASSET_URL=$(echo "${RELEASE_JSON}" | python3 -c "
import sys, json
r = json.load(sys.stdin)
for a in r['assets']:
if a['name'] == 'dist.zip':
print(a['browser_download_url'])
sys.exit(0)
print('', file=sys.stderr)
sys.exit(1)
") || {
echo "Error: dist.zip asset not found in release ${TAG}" >&2
exit 1
}
echo "Latest release: ${TAG}"
# Check if we already have this version
if [ -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)
trap 'rm -f "${TMPFILE}"' EXIT
echo "Downloading dist.zip from ${TAG}..."
curl -L "${CURL_OPTS[@]}" -o "${TMPFILE}" "${ASSET_URL}" || {
echo "Error: failed to download dist.zip" >&2
exit 1
}
# Extract
echo "Extracting to ${DEST}/..."
rm -rf "${DEST}"
mkdir -p "${DEST}"
unzip -q "${TMPFILE}" -d "${DEST}"
# Write tag for cache comparison
echo -n "${TAG}" > "${TAG_FILE}"
echo "Done. GUI dist updated to ${TAG}"
+1
View File
@@ -31,6 +31,7 @@ import (
_ "github.com/rclone/rclone/cmd/genautocomplete" _ "github.com/rclone/rclone/cmd/genautocomplete"
_ "github.com/rclone/rclone/cmd/gendocs" _ "github.com/rclone/rclone/cmd/gendocs"
_ "github.com/rclone/rclone/cmd/gitannex" _ "github.com/rclone/rclone/cmd/gitannex"
_ "github.com/rclone/rclone/cmd/gui"
_ "github.com/rclone/rclone/cmd/hashsum" _ "github.com/rclone/rclone/cmd/hashsum"
_ "github.com/rclone/rclone/cmd/link" _ "github.com/rclone/rclone/cmd/link"
_ "github.com/rclone/rclone/cmd/listremotes" _ "github.com/rclone/rclone/cmd/listremotes"
+258
View File
@@ -0,0 +1,258 @@
// Package gui implements the "rclone gui" command.
package gui
import (
"context"
"embed"
"flag"
"fmt"
iofs "io/fs"
"net"
"net/http"
"net/url"
"strings"
"github.com/rclone/rclone/cmd"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/rc"
"github.com/rclone/rclone/fs/rc/rcserver"
libhttp "github.com/rclone/rclone/lib/http"
"github.com/rclone/rclone/lib/random"
"github.com/rclone/rclone/lib/systemd"
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
)
//go:embed dist
var assets embed.FS
var (
guiAddr []string
apiAddr []string
user string
pass string
noAuth bool
noOpenBrowser bool
enableMetrics bool
)
func init() {
cmd.Root.AddCommand(commandDefinition)
f := commandDefinition.Flags()
f.StringArrayVar(&guiAddr, "addr", nil, "IPaddress:Port for the GUI server (default auto-chosen localhost port)")
f.StringArrayVar(&apiAddr, "api-addr", nil, "IPaddress:Port for the RC API server (default auto-chosen localhost port)")
f.StringVar(&user, "user", "", "User name for RC authentication")
f.StringVar(&pass, "pass", "", "Password for RC authentication")
f.BoolVar(&noAuth, "no-auth", false, "Don't require auth for the RC API")
f.BoolVar(&noOpenBrowser, "no-open-browser", false, "Skip opening the browser automatically")
f.BoolVar(&enableMetrics, "enable-metrics", false, "Enable OpenMetrics/Prometheus compatible endpoint at /metrics")
}
var commandDefinition = &cobra.Command{
Use: "gui",
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
ports, generates login credentials automatically unless --no-auth
is specified, and opens the browser already authenticated.
rclone gui
Use --no-open-browser to skip opening the browser automatically:
rclone gui --no-open-browser
Use --addr to bind the GUI to a specific address:
rclone gui --addr localhost:5580
Use --user and --pass to set specific credentials:
rclone gui --user admin --pass secret
Use --no-auth to disable authentication entirely:
rclone gui --no-auth
`,
Annotations: map[string]string{
"versionIntroduced": "v1.74",
"groups": "RC",
},
Run: func(command *cobra.Command, args []string) {
cmd.CheckArgs(0, 0, command, args)
ctx := context.Background()
// --- 1. Create the GUI server (binds port eagerly, before Serve) ---
guiCfg := libhttp.DefaultCfg()
if command.Flags().Changed("addr") {
guiCfg.ListenAddr = guiAddr
} else {
guiCfg.ListenAddr = []string{"localhost:0"}
}
guiServer, err := libhttp.NewServer(ctx, libhttp.WithConfig(guiCfg))
if err != nil {
fs.Fatalf(nil, "Failed to create GUI server: %v", err)
}
// Read the GUI origin from the bound address (available before Serve).
guiOrigin := originFromURL(guiServer.URLs()[0])
// --- 2. Configure the RC API server ---
opt := rc.Opt // copy global defaults
opt.Enabled = true
opt.WebUI = false
opt.Serve = false
if command.Flags().Changed("api-addr") {
opt.HTTP.ListenAddr = apiAddr
} else {
port, err := freePort()
if err != nil {
fs.Fatalf(nil, "Failed to find a free port for RC: %v", err)
}
opt.HTTP.ListenAddr = []string{fmt.Sprintf("localhost:%d", port)}
}
// CORS: allow the GUI origin to make cross-port API requests.
opt.HTTP.AllowOrigin = guiOrigin
// Forward metrics flag to the RC server.
if command.Flags().Changed("enable-metrics") {
opt.EnableMetrics = enableMetrics
}
// --- 3. Generate credentials if needed ---
if command.Flags().Changed("user") {
opt.Auth.BasicUser = user
}
if command.Flags().Changed("pass") {
opt.Auth.BasicPass = pass
}
if command.Flags().Changed("no-auth") {
opt.NoAuth = noAuth
}
if !opt.NoAuth {
if opt.Auth.BasicUser == "" {
opt.Auth.BasicUser = "gui"
fs.Infof(nil, "No username specified. Using default username: %s", opt.Auth.BasicUser)
}
if opt.Auth.BasicPass == "" {
randomPass, err := random.Password(128)
if err != nil {
fs.Fatalf(nil, "Failed to make password: %v", err)
}
opt.Auth.BasicPass = randomPass
fs.Infof(nil, "No password specified. Using random password: %s", randomPass)
}
}
// --- 4. Start the RC server (unchanged rcserver.Start) ---
rcServer, err := rcserver.Start(ctx, &opt)
if err != nil {
fs.Fatalf(nil, "Failed to start RC server: %v", err)
}
if rcServer == nil {
fs.Fatal(nil, "RC server not configured")
}
// Build the RC URL from the address we configured (rcserver.Server
// does not expose URLs, and we know the address we passed in).
rcURL := "http://" + opt.HTTP.ListenAddr[0] + "/"
// --- 5. Mount the embedded GUI handler and start serving ---
spaHandler := guiHandler()
guiServer.Router().Get("/*", spaHandler.ServeHTTP)
guiServer.Router().Head("/*", spaHandler.ServeHTTP)
guiServer.Serve()
guiURL := guiServer.URLs()[0]
fs.Logf(nil, "Serving GUI on %s", guiURL)
// --- 6. Open browser ---
loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth)
fs.Logf(nil, "GUI available at %s", loginURL)
if flag.Lookup("test.v") == nil && !noOpenBrowser {
if err := open.Start(loginURL); err != nil {
fs.Errorf(nil, "Failed to open GUI in browser: %v", err)
}
}
// --- 7. Wait for either server to exit, then shut both down ---
defer systemd.Notify()()
done := make(chan struct{}, 2)
go func() { rcServer.Wait(); done <- struct{}{} }()
go func() { guiServer.Wait(); done <- struct{}{} }()
<-done
_ = rcServer.Shutdown()
_ = guiServer.Shutdown()
},
}
// freePort asks the OS for a free TCP port on localhost.
func freePort() (int, error) {
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
// originFromURL extracts the origin (scheme://host) from a URL string,
// stripping any path or trailing slash.
func originFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return strings.TrimRight(rawURL, "/")
}
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 {
sub, err := iofs.Sub(assets, "dist")
if err != nil {
panic("gui: embedded dist missing: " + err.Error())
}
fileServer := http.FileServer(http.FS(sub))
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 {
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback: serve index.html for unknown paths so that
// client-side routing (e.g. /login) works.
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
})
}
// guiBaseURL is the GUI server's URL. rcURL is the RC API server's URL.
// When auth is enabled it appends url, user, and pass as query
// parameters so the React app can discover the API endpoint and
// log in automatically.
func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u, err := url.Parse(guiBaseURL)
if err != nil {
return guiBaseURL
}
if noAuth {
return u.String()
}
u.Path = "/login"
q := u.Query()
q.Set("url", rcURL)
q.Set("user", user)
q.Set("pass", pass)
u.RawQuery = q.Encode()
return u.String()
}
+155
View File
@@ -0,0 +1,155 @@
package gui
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildLoginURL(t *testing.T) {
tests := []struct {
name string
guiURL string
rcURL string
user string
pass string
noAuth bool
want string
}{
{
name: "with credentials",
guiURL: "http://localhost:5580/",
rcURL: "http://localhost:5572/",
user: "gui",
pass: "secret",
noAuth: false,
want: "http://localhost:5580/login?pass=secret&url=http%3A%2F%2Flocalhost%3A5572%2F&user=gui",
},
{
name: "no auth",
guiURL: "http://localhost:5580/",
rcURL: "http://localhost:5572/",
user: "",
pass: "",
noAuth: true,
want: "http://localhost:5580/",
},
{
name: "no auth ignores credentials",
guiURL: "http://localhost:5580/",
rcURL: "http://localhost:5572/",
user: "gui",
pass: "secret",
noAuth: true,
want: "http://localhost:5580/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildLoginURL(tt.guiURL, tt.rcURL, tt.user, tt.pass, tt.noAuth)
assert.Equal(t, tt.want, got)
})
}
}
func TestOriginFromURL(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "with trailing slash",
url: "http://localhost:5580/",
want: "http://localhost:5580",
},
{
name: "with path",
url: "http://localhost:5580/some/path",
want: "http://localhost:5580",
},
{
name: "no trailing slash",
url: "http://localhost:5580",
want: "http://localhost:5580",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := originFromURL(tt.url)
assert.Equal(t, tt.want, got)
})
}
}
func TestFreePort(t *testing.T) {
port, err := freePort()
assert.NoError(t, err)
assert.Greater(t, port, 0)
assert.Less(t, port, 65536)
}
func TestHandlerServesIndexHTML(t *testing.T) {
h := guiHandler()
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, string(body), "<div id=\"root\"></div>")
}
func TestHandlerServesStaticAssets(t *testing.T) {
h := guiHandler()
req := httptest.NewRequest("GET", "/icon.svg", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.True(t, strings.Contains(string(body), "<svg"), "expected SVG content")
}
func TestHandlerSPAFallback(t *testing.T) {
h := guiHandler()
// /login is not a real file — it should fall back to index.html
req := httptest.NewRequest("GET", "/login", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, string(body), "<div id=\"root\"></div>",
"SPA fallback should serve index.html for unknown routes")
}
func TestHandlerSPAFallbackDeepPath(t *testing.T) {
h := guiHandler()
req := httptest.NewRequest("GET", "/some/deep/route", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, string(body), "<div id=\"root\"></div>")
}