From 4cfc0871c25adfd80a44e6f7858bb22f58e2cd61 Mon Sep 17 00:00:00 2001 From: intergrav Date: Thu, 02 Jul 2026 14:40:23 +0000 Subject: [PATCH] feat: web editor --- README.md | 3 ++- cmd/restray/cli.go | 20 ++++++++++++++++++++ cmd/restray/config.go | 29 +++++++++++++++++++++++++++-- cmd/restray/templates/config.toml | 41 +++++++++++++++++++---------------------- cmd/restray/templates/webeditor.html | 408 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/restray/tray.go | 18 ++++++++++++++++++ cmd/restray/webeditor.go | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 2 +- go.mod | 1 + go.sum | 2 ++ nix/shared.nix | 2 +- 11 file(s) changed, 777 insertion(s)(+), 27 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ | `manage_restic` | bool | `false` | **Windows/Linux only.** If restic is not found in PATH, automatically downloads and updates a self-managed restic binary daily from GitHub. | | `notifications` | string | `"none"` | `"all"` (success + errors), `"errors"` (errors only), or `"none"` (silent) | | `icon` | string | platform | `"color"`, `"mono"`, `"white"`, or `"black"`. Default is `"mono"` on MacOS/Windows and `"color"` elsewhere. `"mono"` follows OS light/dark mode. | | `schedule_display` | string | `"description"` | `"description"` (human-readable, e.g. "every 6 hours"), `"cron"` (raw expression, e.g. "0 \* \* \* \*"), `"last"` (time since last backup, e.g. "2 hours ago"), or `"none"` | -| `terminal` | string | `""` | Terminal emulator used in "Shell" and "View Log". If unset, MacOS will use `Terminal.app`. Linux will use default. Not used on Windows. e.g. "Ghostty" | +| `terminal` | string | `""` | **MacOS/Linux only.** Terminal emulator used in "Shell" and "View Log". | ### `[[profiles]]` @@ -143,3 +143,4 @@ - [github.com/dustin/go-humanize](https://github.com/dustin/go-humanize) - human-readable relative timestamps - [github.com/lnquy/cron](https://github.com/lnquy/cron) - human-readable cron expressions - [github.com/thiagokokada/dark-mode-go](https://github.com/thiagokokada/dark-mode-go) - dark mode detection for icons - [github.com/urfave/cli](https://github.com/urfave/cli) - cli library +- [github.com/google/shlex](https://github.com/google/shlex) - shell-word splitting for web editor diff --git a/cmd/restray/cli.go b/cmd/restray/cli.go --- a/cmd/restray/cli.go +++ b/cmd/restray/cli.go @@ -47,6 +47,21 @@ } return "vi" } +func cliWeb(context.Context, *cli.Command) error { + ensureConfigFile() + url, err := ensureWebEditor() + if err != nil { + return cli.Exit(fmt.Sprintf("error: %v", err), 1) + } + fmt.Println(url) + openFile(url) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + return nil +} + func cliEdit(path string) error { cmd := exec.Command(editorCmd(), path) cmd.Stdin = os.Stdin @@ -386,6 +401,11 @@ Action: profileAction(func(p Profile) error { ensureEnvFile(p.EnvFile) return cliEdit(p.EnvFile) }), + }, + { + Name: "web", + Usage: "Start the config web editor and print its URL", + Action: cliWeb, }, }, }, diff --git a/cmd/restray/config.go b/cmd/restray/config.go --- a/cmd/restray/config.go +++ b/cmd/restray/config.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" _ "embed" "log" "os" @@ -78,9 +79,9 @@ return g.CheckUpdates == nil || *g.CheckUpdates } type Config struct { - General GUI `toml:"general"` // deprecated, use [gui] + General GUI `toml:"general,omitempty"` // deprecated, use [gui] GUI GUI `toml:"gui"` - Profile []Profile `toml:"profile"` // deprecated, use [[profiles]] + Profile []Profile `toml:"profile,omitempty"` // deprecated, use [[profiles]] Profiles []Profile `toml:"profiles"` } @@ -272,6 +273,30 @@ return } os.MkdirAll(filepath.Dir(p), 0700) os.WriteFile(p, []byte(defaultConfigContents()), 0600) +} + +func fileWritable(path string) bool { + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return !os.IsPermission(err) + } + f.Close() + return true +} + +func configWritable() bool { + return fileWritable(configPath()) +} + +func saveConfig(cfg Config) error { + var buf bytes.Buffer + enc := toml.NewEncoder(&buf) + if err := enc.Encode(cfg); err != nil { + return err + } + p := configPath() + os.MkdirAll(filepath.Dir(p), 0700) + return os.WriteFile(p, buf.Bytes(), 0600) } func ensureEnvFile(path string) { diff --git a/cmd/restray/templates/config.toml b/cmd/restray/templates/config.toml --- a/cmd/restray/templates/config.toml +++ b/cmd/restray/templates/config.toml @@ -2,27 +2,24 @@ # Restray configuration # Full configuration reference: https://tangled.org/devins.page/restray#configuration [gui] -# check_updates = true -# manage_restic = false -# notifications = "none" + # check_updates = true + # manage_restic = false + # notifications = "none" [[profiles]] -name = "default" -# env_file = "{{ENV_FILE}}" -# pre_hook = "" -# post_hook = "" - -[profiles.schedule] -# cron = "" -# on_battery = false -# backup = true -# prune = false -# check = false - -[profiles.backup] -paths = [] -# args = ["--exclude-caches", "--exclude", "*.tmp"] -# args_scheduled = ["--tag", "scheduled"] - -[profiles.prune] -# args = ["--keep-last", "4", "--keep-hourly", "24", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "12"] + name = "default" + # env_file = "{{ENV_FILE}}" + # pre_hook = "" + # post_hook = "" + [profiles.schedule] + # cron = "" + # on_battery = false + # backup = true + # prune = false + # check = false + [profiles.backup] + paths = [] + # args = ["--exclude-caches", "--exclude", "*.tmp"] + # args_scheduled = ["--tag", "scheduled"] + [profiles.prune] + # args = ["--keep-last", "4", "--keep-hourly", "24", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "12"] diff --git a/cmd/restray/templates/webeditor.html b/cmd/restray/templates/webeditor.html new file mode 100644 --- /dev/null +++ b/cmd/restray/templates/webeditor.html @@ -0,0 +1,408 @@ + + + + + Restray Web Editor + + + + {{$isGUITab := eq .ActiveTab "gui"}} + + {{range $i, $p := .Profiles}}{{$isThisTab := eq $.ActiveTab (printf + "profile-%d" $i)}} + {{end}} + +
+ + +
+
+
+
+

+ + MacOS/Windows only. Check for new Restray versions on startup + and periodically. +

+ +

+ + Windows/Linux only. If restic is not found in PATH, + automatically downloads and updates a self-managed restic + binary daily from GitHub. +

+ +

+ + "all" (success + errors), "errors" (errors only), or "none" + (silent) + +

+ +

+ + "color", "mono", "white", or "black". Default is "mono" on + MacOS/Windows and "color" elsewhere. "mono" follows OS + light/dark mode. + +

+ +

+ + "description" (human-readable, e.g. "every 6 hours"), "cron" + (raw expression), "last" (time since last backup), or + "none" + +

+ +

+ + MacOS/Linux only. Terminal emulator used in "Shell" and "View + Log". + +

+ + +
+
+
+ + {{range $i, $p := .Profiles}} +
+
+
+ Profile + + +

+ + Name for the profile + +

+ +

+ + Path to the environment file with RESTIC_REPOSITORY, + RESTIC_PASSWORD, etc. + +

+ +

+ + Path to an rclone config file, set as RCLONE_CONFIG for this + profile. Edit its contents with your regular editor/rclone; + this only stores the path. + +

+ +

+ + How long to retry acquiring a repository lock (e.g. "5m", + "30s"). Set to "0" to disable. + +

+ +

+ + Shell command to run before a scheduled run. + +

+ +

+ + Shell command to run after a scheduled run. + +

+ +
+ Schedule +

+ + Empty to disable schedule. Cron expression (see + crontab.guru). + +

+

+ + + + +

+
+ +
+ Backup +

+ + Paths to back up, one per line. + +

+

+ + Extra arguments passed to restic backup. + +

+

+ + Extra arguments appended (not replaced) when running from + schedule. + +

+
+ +
+ Prune +

+ + Extra arguments passed to restic forget --prune. + +

+
+ +
+ Check +

+ + Extra arguments passed to restic check. + +

+
+ +
+ Mount +

+ + Extra arguments passed to restic mount. + +

+
+ + + +
+
+ +
+
+ Env File + + + +
+
+
+ {{end}} +
+
+ + diff --git a/cmd/restray/tray.go b/cmd/restray/tray.go --- a/cmd/restray/tray.go +++ b/cmd/restray/tray.go @@ -202,6 +202,7 @@ systray.AddSeparator() mConfigure := systray.AddMenuItem("Configure", "Restray settings") mFDA := mConfigure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") mFDA.Hide() + mWebEditor := mConfigure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser") mSettings := mConfigure.AddSubMenuItem("Edit Config File", "Open config file in editor") mEnv := mConfigure.AddSubMenuItem("Edit Env File", "Open env file in editor") mFixPerms := mConfigure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open") @@ -235,6 +236,11 @@ if _, err := os.Stat(path); err != nil { m.SetTitle("Create " + label) } else { m.SetTitle("Edit " + label) + } + if fileWritable(path) { + m.Enable() + } else { + m.Disable() } } @@ -299,6 +305,10 @@ return prefix + ps.repoErr default: paused := !prof.Schedule.OnBattery && onBatteryPower() if prof.Schedule.Cron == "" { + if cfg.GUI.ScheduleDisplay == "last" { + last := formatLastBackup(getLastBackup(prof.displayName())) + return prefix + "Unscheduled, last " + last + } return prefix + "Unscheduled" } if paused { @@ -749,6 +759,14 @@ } case <-mSettings.ClickedCh: ensureConfigFile() openInEditor(configPath()) + case <-mWebEditor.ClickedCh: + ensureConfigFile() + url, err := ensureWebEditor() + if err != nil { + log.Printf("webeditor: %v", err) + continue + } + openFile(url) case <-mEnv.ClickedCh: prof := selectedProfile() ensureEnvFile(prof.EnvFile) diff --git a/cmd/restray/webeditor.go b/cmd/restray/webeditor.go new file mode 100644 --- /dev/null +++ b/cmd/restray/webeditor.go @@ -0,0 +1,278 @@ +package main + +import ( + _ "embed" + "errors" + "fmt" + "html/template" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/shlex" +) + +const webEditorIdleTimeout = 30 * time.Minute + +//go:embed templates/webeditor.html +var webEditorTemplateSource string + +var webEditorTemplate = template.Must(template.New("webeditor.html").Funcs(template.FuncMap{ + "joinLines": func(vals []string) string { return strings.Join(vals, "\n") }, +}).Parse(webEditorTemplateSource)) + +var webEditor struct { + mu sync.Mutex + addr string + idle *time.Timer +} + +func ensureWebEditor() (string, error) { + webEditor.mu.Lock() + defer webEditor.mu.Unlock() + if webEditor.addr != "" { + webEditor.idle.Reset(webEditorIdleTimeout) + return webEditor.addr, nil + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", err + } + + mux := http.NewServeMux() + mux.HandleFunc("/", webEditorIndex) + mux.HandleFunc("/save/gui", webEditorSaveGUI) + mux.HandleFunc("/save/profile", webEditorSaveProfile) + mux.HandleFunc("/profile/add", webEditorAddProfile) + mux.HandleFunc("/profile/delete", webEditorDeleteProfile) + mux.HandleFunc("/profile/env", webEditorSaveEnv) + + touch := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webEditor.mu.Lock() + webEditor.idle.Reset(webEditorIdleTimeout) + webEditor.mu.Unlock() + next.ServeHTTP(w, r) + }) + } + + webEditor.idle = time.AfterFunc(webEditorIdleTimeout, func() { + webEditor.mu.Lock() + defer webEditor.mu.Unlock() + ln.Close() + webEditor.addr = "" + }) + + go func() { + if err := http.Serve(ln, touch(mux)); err != nil && !errors.Is(err, net.ErrClosed) { + log.Printf("webeditor: %v", err) + } + }() + + webEditor.addr = "http://" + ln.Addr().String() + "/" + return webEditor.addr, nil +} + +func splitLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" { + out = append(out, line) + } + } + return out +} + +func splitArgs(s string) []string { + var out []string + for _, line := range splitLines(s) { + words, err := shlex.Split(line) + if err != nil { + out = append(out, line) + continue + } + out = append(out, words...) + } + return out +} + +type webEditorProfileView struct { + Profile + DisplayName string + EnvContents string +} + +func webEditorIndex(w http.ResponseWriter, r *http.Request) { + cfg := loadConfig() + views := make([]webEditorProfileView, len(cfg.Profiles)) + for i, p := range cfg.Profiles { + contents, err := os.ReadFile(p.EnvFile) + if err != nil { + contents = []byte(defaultEnvTemplate) + } + views[i] = webEditorProfileView{Profile: p, DisplayName: p.displayName(), EnvContents: string(contents)} + } + activeTab := r.URL.Query().Get("tab") + if activeTab == "" { + activeTab = "gui" + } + data := struct { + GUI GUI + Profiles []webEditorProfileView + Writable bool + ActiveTab string + NotificationOptions []string + IconOptions []string + ScheduleDisplayOptions []string + }{ + GUI: cfg.GUI, + Profiles: views, + Writable: configWritable(), + ActiveTab: activeTab, + NotificationOptions: []string{"none", "errors", "all"}, + IconOptions: []string{"", "color", "mono", "white", "black"}, + ScheduleDisplayOptions: []string{"description", "cron", "last", "none"}, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := webEditorTemplate.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func webEditorRequireWritable(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + if !configWritable() { + http.Error(w, "config file is read-only", http.StatusForbidden) + return false + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return false + } + return true +} + +func webEditorParseIndex(r *http.Request, cfg Config) (int, bool) { + idx, err := strconv.Atoi(r.FormValue("index")) + if err != nil || idx < 0 || idx >= len(cfg.Profiles) { + return 0, false + } + return idx, true +} + +func webEditorSaveAndRedirect(w http.ResponseWriter, r *http.Request, cfg Config, tab string) { + if err := saveConfig(cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + dest := "/" + if tab != "" { + dest = "/?tab=" + tab + } + http.Redirect(w, r, dest, http.StatusSeeOther) +} + +func webEditorSaveGUI(w http.ResponseWriter, r *http.Request) { + if !webEditorRequireWritable(w, r) { + return + } + cfg := loadConfig() + checkUpdates := r.FormValue("check_updates") == "on" + cfg.GUI.CheckUpdates = &checkUpdates + cfg.GUI.ManageRestic = r.FormValue("manage_restic") == "on" + cfg.GUI.Notifications = r.FormValue("notifications") + cfg.GUI.Icon = r.FormValue("icon") + cfg.GUI.ScheduleDisplay = r.FormValue("schedule_display") + cfg.GUI.Terminal = r.FormValue("terminal") + webEditorSaveAndRedirect(w, r, cfg, "gui") +} + +func webEditorSaveProfile(w http.ResponseWriter, r *http.Request) { + if !webEditorRequireWritable(w, r) { + return + } + cfg := loadConfig() + idx, ok := webEditorParseIndex(r, cfg) + if !ok { + http.Error(w, "profile not found", http.StatusNotFound) + return + } + p := &cfg.Profiles[idx] + p.Name = r.FormValue("name") + p.EnvFile = r.FormValue("env_file") + p.RcloneConfigFile = r.FormValue("rclone_config_file") + p.RetryLock = r.FormValue("retry_lock") + p.PreHook = r.FormValue("pre_hook") + p.PostHook = r.FormValue("post_hook") + p.Schedule.Cron = r.FormValue("cron") + p.Schedule.OnBattery = r.FormValue("on_battery") == "on" + backupEnabled := r.FormValue("sched_backup") == "on" + p.Schedule.Backup = &backupEnabled + p.Schedule.Prune = r.FormValue("sched_prune") == "on" + p.Schedule.Check = r.FormValue("sched_check") == "on" + p.Backup.Paths = splitLines(r.FormValue("backup_paths")) + p.Backup.Args = splitArgs(r.FormValue("backup_args")) + p.Backup.ArgsScheduled = splitArgs(r.FormValue("backup_args_scheduled")) + p.Prune.Args = splitArgs(r.FormValue("prune_args")) + p.Check.Args = splitArgs(r.FormValue("check_args")) + p.Mount.Args = splitArgs(r.FormValue("mount_args")) + webEditorSaveAndRedirect(w, r, cfg, fmt.Sprintf("profile-%d", idx)) +} + +func webEditorAddProfile(w http.ResponseWriter, r *http.Request) { + if !webEditorRequireWritable(w, r) { + return + } + cfg := loadConfig() + newIdx := len(cfg.Profiles) + name := fmt.Sprintf("profile-%d", newIdx+1) + cfg.Profiles = append(cfg.Profiles, Profile{Name: name}) + webEditorSaveAndRedirect(w, r, cfg, fmt.Sprintf("profile-%d", newIdx)) +} + +func webEditorDeleteProfile(w http.ResponseWriter, r *http.Request) { + if !webEditorRequireWritable(w, r) { + return + } + cfg := loadConfig() + idx, ok := webEditorParseIndex(r, cfg) + if !ok { + http.Error(w, "profile not found", http.StatusNotFound) + return + } + cfg.Profiles = append(cfg.Profiles[:idx], cfg.Profiles[idx+1:]...) + webEditorSaveAndRedirect(w, r, cfg, "") +} + +func webEditorSaveEnv(w http.ResponseWriter, r *http.Request) { + if !webEditorRequireWritable(w, r) { + return + } + cfg := loadConfig() + idx, ok := webEditorParseIndex(r, cfg) + if !ok { + http.Error(w, "profile not found", http.StatusNotFound) + return + } + envFile := cfg.Profiles[idx].EnvFile + if err := os.MkdirAll(filepath.Dir(envFile), 0700); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := os.WriteFile(envFile, []byte(r.FormValue("env_contents")), 0600); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/?tab=profile-%d", idx), http.StatusSeeOther) +} diff --git a/flake.nix b/flake.nix --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ packages.default = pkgs.buildGoModule { pname = "restray"; version = "0.19.5"; src = ./.; - vendorHash = "sha256-qplAplthqQqkS7zS7e+J2inFvyfnS+aIzm74GMgqAfk="; + vendorHash = "sha256-JdR/v3Ho0rd5uNZVCEo1wQ/OzXz1kFhp1DXG+84Vgw0="; subPackages = ["cmd/restray"]; ldflags = ["-s" "-w" "-X" "main.resticBuiltinPath=${pkgs.restic}/bin/restic"]; diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ github.com/distatus/battery v0.11.0 github.com/dustin/go-humanize v1.0.1 github.com/fsnotify/fsnotify v1.10.1 github.com/gen2brain/beeep v0.11.2 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/lnquy/cron v1.1.1 github.com/robfig/cron/v3 v3.0.1 github.com/thiagokokada/dark-mode-go v0.0.2 diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= diff --git a/nix/shared.nix b/nix/shared.nix --- a/nix/shared.nix +++ b/nix/shared.nix @@ -32,7 +32,7 @@ }; terminal = lib.mkOption { type = lib.types.str; default = ""; - description = ''Terminal emulator used in "Shell" and "View Log". If unset, MacOS will use Terminal.app. Linux will use default. Not used on Windows. e.g. "Ghostty"''; + description = ''MacOS/Linux only. Terminal emulator used in "Shell" and "View Log".''; }; }; }; -- tangled.sh