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:
@@ -230,6 +230,9 @@ jobs:
|
||||
go-version: '~1.26.0'
|
||||
check-latest: true
|
||||
cache: false
|
||||
|
||||
- name: Fetch GUI dist
|
||||
run: make fetch-gui
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v5
|
||||
|
||||
@@ -20,3 +20,5 @@ __pycache__
|
||||
.DS_Store
|
||||
resource_windows_*.syso
|
||||
.devcontainer
|
||||
# GUI dist is fetched at build time via `make fetch-gui`
|
||||
cmd/gui/dist/
|
||||
@@ -45,9 +45,9 @@ LINTTAGS=--build-tags "$(GOTAGS)"
|
||||
endif
|
||||
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)
|
||||
go run bin/resource_windows.go -version $(TAG) -syso resource_windows_`go env GOARCH`.syso
|
||||
endif
|
||||
@@ -59,6 +59,9 @@ endif
|
||||
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`
|
||||
|
||||
fetch-gui:
|
||||
$(SHELL) ./bin/fetch-gui-dist.sh
|
||||
|
||||
test_all:
|
||||
go install $(LDFLAGS) $(BUILDTAGS) $(BUILD_ARGS) github.com/rclone/rclone/fstest/test_all
|
||||
|
||||
|
||||
Executable
+67
@@ -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}"
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
_ "github.com/rclone/rclone/cmd/genautocomplete"
|
||||
_ "github.com/rclone/rclone/cmd/gendocs"
|
||||
_ "github.com/rclone/rclone/cmd/gitannex"
|
||||
_ "github.com/rclone/rclone/cmd/gui"
|
||||
_ "github.com/rclone/rclone/cmd/hashsum"
|
||||
_ "github.com/rclone/rclone/cmd/link"
|
||||
_ "github.com/rclone/rclone/cmd/listremotes"
|
||||
|
||||
+258
@@ -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()
|
||||
}
|
||||
@@ -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>")
|
||||
}
|
||||
Reference in New Issue
Block a user