powerful but friendly backup program that runs in your tray, powered by restic devins.page/restray
go restic system-tray
2

Configure Feed

Select the types of activity you want to include in your feed.

feat: web editor

intergrav (Jul 2, 2026, 10:40 AM EDT) 4cfc0871 136231ad

+777 -27
+2 -1
README.md
··· 63 63 | `notifications` | string | `"none"` | `"all"` (success + errors), `"errors"` (errors only), or `"none"` (silent) | 64 64 | `icon` | string | platform | `"color"`, `"mono"`, `"white"`, or `"black"`. Default is `"mono"` on MacOS/Windows and `"color"` elsewhere. `"mono"` follows OS light/dark mode. | 65 65 | `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"` | 66 - | `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" | 66 + | `terminal` | string | `""` | **MacOS/Linux only.** Terminal emulator used in "Shell" and "View Log". | 67 67 68 68 ### `[[profiles]]` 69 69 ··· 143 143 - [github.com/lnquy/cron](https://github.com/lnquy/cron) - human-readable cron expressions 144 144 - [github.com/thiagokokada/dark-mode-go](https://github.com/thiagokokada/dark-mode-go) - dark mode detection for icons 145 145 - [github.com/urfave/cli](https://github.com/urfave/cli) - cli library 146 + - [github.com/google/shlex](https://github.com/google/shlex) - shell-word splitting for web editor
+20
cmd/restray/cli.go
··· 47 47 return "vi" 48 48 } 49 49 50 + func cliWeb(context.Context, *cli.Command) error { 51 + ensureConfigFile() 52 + url, err := ensureWebEditor() 53 + if err != nil { 54 + return cli.Exit(fmt.Sprintf("error: %v", err), 1) 55 + } 56 + fmt.Println(url) 57 + openFile(url) 58 + 59 + sigCh := make(chan os.Signal, 1) 60 + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) 61 + <-sigCh 62 + return nil 63 + } 64 + 50 65 func cliEdit(path string) error { 51 66 cmd := exec.Command(editorCmd(), path) 52 67 cmd.Stdin = os.Stdin ··· 386 401 ensureEnvFile(p.EnvFile) 387 402 return cliEdit(p.EnvFile) 388 403 }), 404 + }, 405 + { 406 + Name: "web", 407 + Usage: "Start the config web editor and print its URL", 408 + Action: cliWeb, 389 409 }, 390 410 }, 391 411 },
+27 -2
cmd/restray/config.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "bytes" 5 6 _ "embed" 6 7 "log" 7 8 "os" ··· 78 79 } 79 80 80 81 type Config struct { 81 - General GUI `toml:"general"` // deprecated, use [gui] 82 + General GUI `toml:"general,omitempty"` // deprecated, use [gui] 82 83 GUI GUI `toml:"gui"` 83 - Profile []Profile `toml:"profile"` // deprecated, use [[profiles]] 84 + Profile []Profile `toml:"profile,omitempty"` // deprecated, use [[profiles]] 84 85 Profiles []Profile `toml:"profiles"` 85 86 } 86 87 ··· 272 273 } 273 274 os.MkdirAll(filepath.Dir(p), 0700) 274 275 os.WriteFile(p, []byte(defaultConfigContents()), 0600) 276 + } 277 + 278 + func fileWritable(path string) bool { 279 + f, err := os.OpenFile(path, os.O_WRONLY, 0) 280 + if err != nil { 281 + return !os.IsPermission(err) 282 + } 283 + f.Close() 284 + return true 285 + } 286 + 287 + func configWritable() bool { 288 + return fileWritable(configPath()) 289 + } 290 + 291 + func saveConfig(cfg Config) error { 292 + var buf bytes.Buffer 293 + enc := toml.NewEncoder(&buf) 294 + if err := enc.Encode(cfg); err != nil { 295 + return err 296 + } 297 + p := configPath() 298 + os.MkdirAll(filepath.Dir(p), 0700) 299 + return os.WriteFile(p, buf.Bytes(), 0600) 275 300 } 276 301 277 302 func ensureEnvFile(path string) {
+19 -22
cmd/restray/templates/config.toml
··· 2 2 # Full configuration reference: https://tangled.org/devins.page/restray#configuration 3 3 4 4 [gui] 5 - # check_updates = true 6 - # manage_restic = false 7 - # notifications = "none" 5 + # check_updates = true 6 + # manage_restic = false 7 + # notifications = "none" 8 8 9 9 [[profiles]] 10 - name = "default" 11 - # env_file = "{{ENV_FILE}}" 12 - # pre_hook = "" 13 - # post_hook = "" 14 - 15 - [profiles.schedule] 16 - # cron = "" 17 - # on_battery = false 18 - # backup = true 19 - # prune = false 20 - # check = false 21 - 22 - [profiles.backup] 23 - paths = [] 24 - # args = ["--exclude-caches", "--exclude", "*.tmp"] 25 - # args_scheduled = ["--tag", "scheduled"] 26 - 27 - [profiles.prune] 28 - # args = ["--keep-last", "4", "--keep-hourly", "24", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "12"] 10 + name = "default" 11 + # env_file = "{{ENV_FILE}}" 12 + # pre_hook = "" 13 + # post_hook = "" 14 + [profiles.schedule] 15 + # cron = "" 16 + # on_battery = false 17 + # backup = true 18 + # prune = false 19 + # check = false 20 + [profiles.backup] 21 + paths = [] 22 + # args = ["--exclude-caches", "--exclude", "*.tmp"] 23 + # args_scheduled = ["--tag", "scheduled"] 24 + [profiles.prune] 25 + # args = ["--keep-last", "4", "--keep-hourly", "24", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "12"]
+408
cmd/restray/templates/webeditor.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>Restray Web Editor</title> 6 + <style> 7 + :root { color-scheme: light dark; } 8 + body { margin: 0; font-family: system-ui, sans-serif; font-size: 13px; line-height: 1.5; } 9 + input[type=text], select, textarea { width: 100%; box-sizing: border-box; } 10 + textarea { min-height: 6rem; } 11 + main label, main small { display: block; } 12 + fieldset { background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } 13 + fieldset > fieldset { margin-block: 1rem; } 14 + 15 + .layout { display: flex; min-height: 100vh; } 16 + nav { flex: 0 0 8rem; padding: 1rem; border-inline-end: 1px solid light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.25)); background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); display: flex; flex-direction: column; gap: .5rem; } 17 + nav h1, nav h2 { font-size: 1rem; margin: 0; } 18 + nav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .5rem; } 19 + nav label { display: block; padding-inline: .5rem; cursor: pointer; background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); border: 1px solid light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.25)); } 20 + nav button { width: 100%; } 21 + main { flex: 1; min-width: 0; padding: 1rem; } 22 + 23 + input.tab { display: none; } 24 + .pane { display: none; flex-direction: column; gap: 1rem; } 25 + 26 + #tab-gui:checked ~ .layout #pane-gui { display: flex; } 27 + #tab-gui:checked ~ .layout label[for=tab-gui] { background: light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.25)); } 28 + {{range $i, $p := .Profiles}} 29 + #tab-profile-{{$i}}:checked ~ .layout #pane-profile-{{$i}} { display: flex; } 30 + #tab-profile-{{$i}}:checked ~ .layout label[for=tab-profile-{{$i}}] { background: light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.25)); } 31 + {{end}} 32 + </style> 33 + </head> 34 + <body> 35 + {{$isGUITab := eq .ActiveTab "gui"}} 36 + <input 37 + type="radio" 38 + name="tab" 39 + id="tab-gui" 40 + class="tab" 41 + {{if 42 + $isGUITab}}checked{{end}} 43 + /> 44 + {{range $i, $p := .Profiles}}{{$isThisTab := eq $.ActiveTab (printf 45 + "profile-%d" $i)}}<input 46 + type="radio" 47 + name="tab" 48 + id="tab-profile-{{$i}}" 49 + class="tab" 50 + {{if 51 + $isThisTab}}checked{{end}} 52 + /> 53 + {{end}} 54 + 55 + <div class="layout"> 56 + <nav> 57 + <h1>Restray Web Editor</h1> 58 + {{if not .Writable}} 59 + <p>Your config file is read-only, so editing is disabled.</p> 60 + {{end}} 61 + <ul> 62 + <li><label for="tab-gui">GUI</label></li> 63 + </ul> 64 + <h2>Profiles</h2> 65 + <ul> 66 + {{range $i, $p := .Profiles}} 67 + <li><label for="tab-profile-{{$i}}">{{$p.DisplayName}}</label></li> 68 + {{end}} 69 + </ul> 70 + {{if .Writable}} 71 + <form method="post" action="/profile/add"> 72 + <button type="submit">Add Profile</button> 73 + </form> 74 + {{end}} 75 + </nav> 76 + 77 + <main> 78 + <div class="pane" id="pane-gui"> 79 + <form method="post" action="/save/gui"> 80 + <fieldset {{if not .Writable}}disabled{{end}}> 81 + <p> 82 + <label 83 + ><input 84 + type="checkbox" 85 + name="check_updates" 86 + {{if 87 + .GUI.UpdatesEnabled}}checked{{end}} 88 + /> 89 + Check Updates</label 90 + > 91 + <small 92 + >MacOS/Windows only. Check for new Restray versions on startup 93 + and periodically.</small 94 + > 95 + </p> 96 + 97 + <p> 98 + <label 99 + ><input 100 + type="checkbox" 101 + name="manage_restic" 102 + {{if 103 + .GUI.ManageRestic}}checked{{end}} 104 + /> 105 + Manage Restic</label 106 + > 107 + <small 108 + >Windows/Linux only. If restic is not found in PATH, 109 + automatically downloads and updates a self-managed restic 110 + binary daily from GitHub.</small 111 + > 112 + </p> 113 + 114 + <p> 115 + <label for="notifications">Notifications</label> 116 + <small 117 + >"all" (success + errors), "errors" (errors only), or "none" 118 + (silent)</small 119 + > 120 + <select name="notifications" id="notifications"> 121 + {{$val := .GUI.Notifications}} {{range .NotificationOptions}} 122 + <option value="{{.}}" {{if eq . $val}}selected{{end}}> 123 + {{.}} 124 + </option> 125 + {{end}} 126 + </select> 127 + </p> 128 + 129 + <p> 130 + <label for="icon">Icon</label> 131 + <small 132 + >"color", "mono", "white", or "black". Default is "mono" on 133 + MacOS/Windows and "color" elsewhere. "mono" follows OS 134 + light/dark mode.</small 135 + > 136 + <select name="icon" id="icon"> 137 + {{$val := .GUI.Icon}} {{range .IconOptions}} 138 + <option value="{{.}}" {{if eq . $val}}selected{{end}}> 139 + {{if eq . ""}}(default){{else}}{{.}}{{end}} 140 + </option> 141 + {{end}} 142 + </select> 143 + </p> 144 + 145 + <p> 146 + <label for="schedule_display">Schedule Display</label> 147 + <small 148 + >"description" (human-readable, e.g. "every 6 hours"), "cron" 149 + (raw expression), "last" (time since last backup), or 150 + "none"</small 151 + > 152 + <select name="schedule_display" id="schedule_display"> 153 + {{$val := .GUI.ScheduleDisplay}} {{range 154 + .ScheduleDisplayOptions}} 155 + <option value="{{.}}" {{if eq . $val}}selected{{end}}> 156 + {{.}} 157 + </option> 158 + {{end}} 159 + </select> 160 + </p> 161 + 162 + <p> 163 + <label for="terminal">Terminal</label> 164 + <small 165 + >MacOS/Linux only. Terminal emulator used in "Shell" and "View 166 + Log".</small 167 + > 168 + <input 169 + type="text" 170 + name="terminal" 171 + id="terminal" 172 + value="{{.GUI.Terminal}}" 173 + /> 174 + </p> 175 + 176 + <button type="submit">Save GUI</button> 177 + </fieldset> 178 + </form> 179 + </div> 180 + 181 + {{range $i, $p := .Profiles}} 182 + <div class="pane" id="pane-profile-{{$i}}"> 183 + <form method="post" action="/save/profile"> 184 + <fieldset {{if not $.Writable}}disabled{{end}}> 185 + <legend>Profile</legend> 186 + <input type="hidden" name="index" value="{{$i}}" /> 187 + 188 + <p> 189 + <label for="name-{{$i}}">Name</label> 190 + <small>Name for the profile</small> 191 + <input 192 + type="text" 193 + name="name" 194 + id="name-{{$i}}" 195 + value="{{$p.Name}}" 196 + /> 197 + </p> 198 + 199 + <p> 200 + <label for="env_file-{{$i}}">Env File</label> 201 + <small 202 + >Path to the environment file with RESTIC_REPOSITORY, 203 + RESTIC_PASSWORD, etc.</small 204 + > 205 + <input 206 + type="text" 207 + name="env_file" 208 + id="env_file-{{$i}}" 209 + value="{{$p.EnvFile}}" 210 + /> 211 + </p> 212 + 213 + <p> 214 + <label for="rclone_config_file-{{$i}}" 215 + >Rclone Config File</label 216 + > 217 + <small 218 + >Path to an rclone config file, set as RCLONE_CONFIG for this 219 + profile. Edit its contents with your regular editor/rclone; 220 + this only stores the path.</small 221 + > 222 + <input 223 + type="text" 224 + name="rclone_config_file" 225 + id="rclone_config_file-{{$i}}" 226 + value="{{$p.RcloneConfigFile}}" 227 + /> 228 + </p> 229 + 230 + <p> 231 + <label for="retry_lock-{{$i}}">Retry Lock</label> 232 + <small 233 + >How long to retry acquiring a repository lock (e.g. "5m", 234 + "30s"). Set to "0" to disable.</small 235 + > 236 + <input 237 + type="text" 238 + name="retry_lock" 239 + id="retry_lock-{{$i}}" 240 + value="{{$p.RetryLock}}" 241 + /> 242 + </p> 243 + 244 + <p> 245 + <label for="pre_hook-{{$i}}">Pre-Hook</label> 246 + <small>Shell command to run before a scheduled run.</small> 247 + <input 248 + type="text" 249 + name="pre_hook" 250 + id="pre_hook-{{$i}}" 251 + value="{{$p.PreHook}}" 252 + /> 253 + </p> 254 + 255 + <p> 256 + <label for="post_hook-{{$i}}">Post-Hook</label> 257 + <small>Shell command to run after a scheduled run.</small> 258 + <input 259 + type="text" 260 + name="post_hook" 261 + id="post_hook-{{$i}}" 262 + value="{{$p.PostHook}}" 263 + /> 264 + </p> 265 + 266 + <fieldset> 267 + <legend>Schedule</legend> 268 + <p> 269 + <label for="cron-{{$i}}">Cron</label> 270 + <small 271 + >Empty to disable schedule. Cron expression (see 272 + crontab.guru).</small 273 + > 274 + <input 275 + type="text" 276 + name="cron" 277 + id="cron-{{$i}}" 278 + value="{{$p.Schedule.Cron}}" 279 + /> 280 + </p> 281 + <p> 282 + <label 283 + ><input 284 + type="checkbox" 285 + name="on_battery" 286 + {{if 287 + $p.Schedule.OnBattery}}checked{{end}} 288 + /> 289 + On Battery</label 290 + > 291 + <label 292 + ><input 293 + type="checkbox" 294 + name="sched_backup" 295 + {{if 296 + $p.Schedule.BackupEnabled}}checked{{end}} 297 + /> 298 + Backup</label 299 + > 300 + <label 301 + ><input 302 + type="checkbox" 303 + name="sched_prune" 304 + {{if 305 + $p.Schedule.Prune}}checked{{end}} 306 + /> 307 + Prune</label 308 + > 309 + <label 310 + ><input 311 + type="checkbox" 312 + name="sched_check" 313 + {{if 314 + $p.Schedule.Check}}checked{{end}} 315 + /> 316 + Check</label 317 + > 318 + </p> 319 + </fieldset> 320 + 321 + <fieldset> 322 + <legend>Backup</legend> 323 + <p> 324 + <label for="backup_paths-{{$i}}">Paths</label> 325 + <small>Paths to back up, one per line.</small> 326 + <textarea name="backup_paths" id="backup_paths-{{$i}}"> 327 + {{joinLines $p.Backup.Paths}}</textarea> 328 + </p> 329 + <p> 330 + <label for="backup_args-{{$i}}">Args</label> 331 + <small>Extra arguments passed to restic backup.</small> 332 + <textarea name="backup_args" id="backup_args-{{$i}}"> 333 + {{joinLines $p.Backup.Args}}</textarea> 334 + </p> 335 + <p> 336 + <label for="backup_args_scheduled-{{$i}}" 337 + >Scheduled Args</label 338 + > 339 + <small 340 + >Extra arguments appended (not replaced) when running from 341 + schedule.</small 342 + > 343 + <textarea 344 + name="backup_args_scheduled" 345 + id="backup_args_scheduled-{{$i}}" 346 + > 347 + {{joinLines $p.Backup.ArgsScheduled}}</textarea> 348 + </p> 349 + </fieldset> 350 + 351 + <fieldset> 352 + <legend>Prune</legend> 353 + <p> 354 + <label for="prune_args-{{$i}}">Args</label> 355 + <small 356 + >Extra arguments passed to restic forget --prune.</small 357 + > 358 + <textarea name="prune_args" id="prune_args-{{$i}}"> 359 + {{joinLines $p.Prune.Args}}</textarea> 360 + </p> 361 + </fieldset> 362 + 363 + <fieldset> 364 + <legend>Check</legend> 365 + <p> 366 + <label for="check_args-{{$i}}">Args</label> 367 + <small>Extra arguments passed to restic check.</small> 368 + <textarea name="check_args" id="check_args-{{$i}}"> 369 + {{joinLines $p.Check.Args}}</textarea> 370 + </p> 371 + </fieldset> 372 + 373 + <fieldset> 374 + <legend>Mount</legend> 375 + <p> 376 + <label for="mount_args-{{$i}}">Args</label> 377 + <small>Extra arguments passed to restic mount.</small> 378 + <textarea name="mount_args" id="mount_args-{{$i}}"> 379 + {{joinLines $p.Mount.Args}}</textarea> 380 + </p> 381 + </fieldset> 382 + 383 + <button type="submit">Save Profile</button> 384 + <button 385 + type="submit" 386 + formaction="/profile/delete" 387 + onclick="return confirm('Delete profile {{$p.DisplayName}}?');" 388 + > 389 + Delete Profile 390 + </button> 391 + </fieldset> 392 + </form> 393 + 394 + <form method="post" action="/profile/env"> 395 + <fieldset {{if not $.Writable}}disabled{{end}}> 396 + <legend>Env File</legend> 397 + <input type="hidden" name="index" value="{{$i}}" /> 398 + <textarea name="env_contents" rows="10"> 399 + {{$p.EnvContents}}</textarea> 400 + <button type="submit">Save Env File</button> 401 + </fieldset> 402 + </form> 403 + </div> 404 + {{end}} 405 + </main> 406 + </div> 407 + </body> 408 + </html>
+18
cmd/restray/tray.go
··· 202 202 mConfigure := systray.AddMenuItem("Configure", "Restray settings") 203 203 mFDA := mConfigure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") 204 204 mFDA.Hide() 205 + mWebEditor := mConfigure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser") 205 206 mSettings := mConfigure.AddSubMenuItem("Edit Config File", "Open config file in editor") 206 207 mEnv := mConfigure.AddSubMenuItem("Edit Env File", "Open env file in editor") 207 208 mFixPerms := mConfigure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open") ··· 235 236 m.SetTitle("Create " + label) 236 237 } else { 237 238 m.SetTitle("Edit " + label) 239 + } 240 + if fileWritable(path) { 241 + m.Enable() 242 + } else { 243 + m.Disable() 238 244 } 239 245 } 240 246 ··· 299 305 default: 300 306 paused := !prof.Schedule.OnBattery && onBatteryPower() 301 307 if prof.Schedule.Cron == "" { 308 + if cfg.GUI.ScheduleDisplay == "last" { 309 + last := formatLastBackup(getLastBackup(prof.displayName())) 310 + return prefix + "Unscheduled, last " + last 311 + } 302 312 return prefix + "Unscheduled" 303 313 } 304 314 if paused { ··· 749 759 case <-mSettings.ClickedCh: 750 760 ensureConfigFile() 751 761 openInEditor(configPath()) 762 + case <-mWebEditor.ClickedCh: 763 + ensureConfigFile() 764 + url, err := ensureWebEditor() 765 + if err != nil { 766 + log.Printf("webeditor: %v", err) 767 + continue 768 + } 769 + openFile(url) 752 770 case <-mEnv.ClickedCh: 753 771 prof := selectedProfile() 754 772 ensureEnvFile(prof.EnvFile)
+278
cmd/restray/webeditor.go
··· 1 + package main 2 + 3 + import ( 4 + _ "embed" 5 + "errors" 6 + "fmt" 7 + "html/template" 8 + "log" 9 + "net" 10 + "net/http" 11 + "os" 12 + "path/filepath" 13 + "strconv" 14 + "strings" 15 + "sync" 16 + "time" 17 + 18 + "github.com/google/shlex" 19 + ) 20 + 21 + const webEditorIdleTimeout = 30 * time.Minute 22 + 23 + //go:embed templates/webeditor.html 24 + var webEditorTemplateSource string 25 + 26 + var webEditorTemplate = template.Must(template.New("webeditor.html").Funcs(template.FuncMap{ 27 + "joinLines": func(vals []string) string { return strings.Join(vals, "\n") }, 28 + }).Parse(webEditorTemplateSource)) 29 + 30 + var webEditor struct { 31 + mu sync.Mutex 32 + addr string 33 + idle *time.Timer 34 + } 35 + 36 + func ensureWebEditor() (string, error) { 37 + webEditor.mu.Lock() 38 + defer webEditor.mu.Unlock() 39 + if webEditor.addr != "" { 40 + webEditor.idle.Reset(webEditorIdleTimeout) 41 + return webEditor.addr, nil 42 + } 43 + 44 + ln, err := net.Listen("tcp", "127.0.0.1:0") 45 + if err != nil { 46 + return "", err 47 + } 48 + 49 + mux := http.NewServeMux() 50 + mux.HandleFunc("/", webEditorIndex) 51 + mux.HandleFunc("/save/gui", webEditorSaveGUI) 52 + mux.HandleFunc("/save/profile", webEditorSaveProfile) 53 + mux.HandleFunc("/profile/add", webEditorAddProfile) 54 + mux.HandleFunc("/profile/delete", webEditorDeleteProfile) 55 + mux.HandleFunc("/profile/env", webEditorSaveEnv) 56 + 57 + touch := func(next http.Handler) http.Handler { 58 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 + webEditor.mu.Lock() 60 + webEditor.idle.Reset(webEditorIdleTimeout) 61 + webEditor.mu.Unlock() 62 + next.ServeHTTP(w, r) 63 + }) 64 + } 65 + 66 + webEditor.idle = time.AfterFunc(webEditorIdleTimeout, func() { 67 + webEditor.mu.Lock() 68 + defer webEditor.mu.Unlock() 69 + ln.Close() 70 + webEditor.addr = "" 71 + }) 72 + 73 + go func() { 74 + if err := http.Serve(ln, touch(mux)); err != nil && !errors.Is(err, net.ErrClosed) { 75 + log.Printf("webeditor: %v", err) 76 + } 77 + }() 78 + 79 + webEditor.addr = "http://" + ln.Addr().String() + "/" 80 + return webEditor.addr, nil 81 + } 82 + 83 + func splitLines(s string) []string { 84 + var out []string 85 + for _, line := range strings.Split(s, "\n") { 86 + line = strings.TrimSpace(line) 87 + if line != "" { 88 + out = append(out, line) 89 + } 90 + } 91 + return out 92 + } 93 + 94 + func splitArgs(s string) []string { 95 + var out []string 96 + for _, line := range splitLines(s) { 97 + words, err := shlex.Split(line) 98 + if err != nil { 99 + out = append(out, line) 100 + continue 101 + } 102 + out = append(out, words...) 103 + } 104 + return out 105 + } 106 + 107 + type webEditorProfileView struct { 108 + Profile 109 + DisplayName string 110 + EnvContents string 111 + } 112 + 113 + func webEditorIndex(w http.ResponseWriter, r *http.Request) { 114 + cfg := loadConfig() 115 + views := make([]webEditorProfileView, len(cfg.Profiles)) 116 + for i, p := range cfg.Profiles { 117 + contents, err := os.ReadFile(p.EnvFile) 118 + if err != nil { 119 + contents = []byte(defaultEnvTemplate) 120 + } 121 + views[i] = webEditorProfileView{Profile: p, DisplayName: p.displayName(), EnvContents: string(contents)} 122 + } 123 + activeTab := r.URL.Query().Get("tab") 124 + if activeTab == "" { 125 + activeTab = "gui" 126 + } 127 + data := struct { 128 + GUI GUI 129 + Profiles []webEditorProfileView 130 + Writable bool 131 + ActiveTab string 132 + NotificationOptions []string 133 + IconOptions []string 134 + ScheduleDisplayOptions []string 135 + }{ 136 + GUI: cfg.GUI, 137 + Profiles: views, 138 + Writable: configWritable(), 139 + ActiveTab: activeTab, 140 + NotificationOptions: []string{"none", "errors", "all"}, 141 + IconOptions: []string{"", "color", "mono", "white", "black"}, 142 + ScheduleDisplayOptions: []string{"description", "cron", "last", "none"}, 143 + } 144 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 145 + if err := webEditorTemplate.Execute(w, data); err != nil { 146 + http.Error(w, err.Error(), http.StatusInternalServerError) 147 + } 148 + } 149 + 150 + func webEditorRequireWritable(w http.ResponseWriter, r *http.Request) bool { 151 + if r.Method != http.MethodPost { 152 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 153 + return false 154 + } 155 + if !configWritable() { 156 + http.Error(w, "config file is read-only", http.StatusForbidden) 157 + return false 158 + } 159 + if err := r.ParseForm(); err != nil { 160 + http.Error(w, err.Error(), http.StatusBadRequest) 161 + return false 162 + } 163 + return true 164 + } 165 + 166 + func webEditorParseIndex(r *http.Request, cfg Config) (int, bool) { 167 + idx, err := strconv.Atoi(r.FormValue("index")) 168 + if err != nil || idx < 0 || idx >= len(cfg.Profiles) { 169 + return 0, false 170 + } 171 + return idx, true 172 + } 173 + 174 + func webEditorSaveAndRedirect(w http.ResponseWriter, r *http.Request, cfg Config, tab string) { 175 + if err := saveConfig(cfg); err != nil { 176 + http.Error(w, err.Error(), http.StatusInternalServerError) 177 + return 178 + } 179 + dest := "/" 180 + if tab != "" { 181 + dest = "/?tab=" + tab 182 + } 183 + http.Redirect(w, r, dest, http.StatusSeeOther) 184 + } 185 + 186 + func webEditorSaveGUI(w http.ResponseWriter, r *http.Request) { 187 + if !webEditorRequireWritable(w, r) { 188 + return 189 + } 190 + cfg := loadConfig() 191 + checkUpdates := r.FormValue("check_updates") == "on" 192 + cfg.GUI.CheckUpdates = &checkUpdates 193 + cfg.GUI.ManageRestic = r.FormValue("manage_restic") == "on" 194 + cfg.GUI.Notifications = r.FormValue("notifications") 195 + cfg.GUI.Icon = r.FormValue("icon") 196 + cfg.GUI.ScheduleDisplay = r.FormValue("schedule_display") 197 + cfg.GUI.Terminal = r.FormValue("terminal") 198 + webEditorSaveAndRedirect(w, r, cfg, "gui") 199 + } 200 + 201 + func webEditorSaveProfile(w http.ResponseWriter, r *http.Request) { 202 + if !webEditorRequireWritable(w, r) { 203 + return 204 + } 205 + cfg := loadConfig() 206 + idx, ok := webEditorParseIndex(r, cfg) 207 + if !ok { 208 + http.Error(w, "profile not found", http.StatusNotFound) 209 + return 210 + } 211 + p := &cfg.Profiles[idx] 212 + p.Name = r.FormValue("name") 213 + p.EnvFile = r.FormValue("env_file") 214 + p.RcloneConfigFile = r.FormValue("rclone_config_file") 215 + p.RetryLock = r.FormValue("retry_lock") 216 + p.PreHook = r.FormValue("pre_hook") 217 + p.PostHook = r.FormValue("post_hook") 218 + p.Schedule.Cron = r.FormValue("cron") 219 + p.Schedule.OnBattery = r.FormValue("on_battery") == "on" 220 + backupEnabled := r.FormValue("sched_backup") == "on" 221 + p.Schedule.Backup = &backupEnabled 222 + p.Schedule.Prune = r.FormValue("sched_prune") == "on" 223 + p.Schedule.Check = r.FormValue("sched_check") == "on" 224 + p.Backup.Paths = splitLines(r.FormValue("backup_paths")) 225 + p.Backup.Args = splitArgs(r.FormValue("backup_args")) 226 + p.Backup.ArgsScheduled = splitArgs(r.FormValue("backup_args_scheduled")) 227 + p.Prune.Args = splitArgs(r.FormValue("prune_args")) 228 + p.Check.Args = splitArgs(r.FormValue("check_args")) 229 + p.Mount.Args = splitArgs(r.FormValue("mount_args")) 230 + webEditorSaveAndRedirect(w, r, cfg, fmt.Sprintf("profile-%d", idx)) 231 + } 232 + 233 + func webEditorAddProfile(w http.ResponseWriter, r *http.Request) { 234 + if !webEditorRequireWritable(w, r) { 235 + return 236 + } 237 + cfg := loadConfig() 238 + newIdx := len(cfg.Profiles) 239 + name := fmt.Sprintf("profile-%d", newIdx+1) 240 + cfg.Profiles = append(cfg.Profiles, Profile{Name: name}) 241 + webEditorSaveAndRedirect(w, r, cfg, fmt.Sprintf("profile-%d", newIdx)) 242 + } 243 + 244 + func webEditorDeleteProfile(w http.ResponseWriter, r *http.Request) { 245 + if !webEditorRequireWritable(w, r) { 246 + return 247 + } 248 + cfg := loadConfig() 249 + idx, ok := webEditorParseIndex(r, cfg) 250 + if !ok { 251 + http.Error(w, "profile not found", http.StatusNotFound) 252 + return 253 + } 254 + cfg.Profiles = append(cfg.Profiles[:idx], cfg.Profiles[idx+1:]...) 255 + webEditorSaveAndRedirect(w, r, cfg, "") 256 + } 257 + 258 + func webEditorSaveEnv(w http.ResponseWriter, r *http.Request) { 259 + if !webEditorRequireWritable(w, r) { 260 + return 261 + } 262 + cfg := loadConfig() 263 + idx, ok := webEditorParseIndex(r, cfg) 264 + if !ok { 265 + http.Error(w, "profile not found", http.StatusNotFound) 266 + return 267 + } 268 + envFile := cfg.Profiles[idx].EnvFile 269 + if err := os.MkdirAll(filepath.Dir(envFile), 0700); err != nil { 270 + http.Error(w, err.Error(), http.StatusInternalServerError) 271 + return 272 + } 273 + if err := os.WriteFile(envFile, []byte(r.FormValue("env_contents")), 0600); err != nil { 274 + http.Error(w, err.Error(), http.StatusInternalServerError) 275 + return 276 + } 277 + http.Redirect(w, r, fmt.Sprintf("/?tab=profile-%d", idx), http.StatusSeeOther) 278 + }
+1 -1
flake.nix
··· 25 25 pname = "restray"; 26 26 version = "0.19.5"; 27 27 src = ./.; 28 - vendorHash = "sha256-qplAplthqQqkS7zS7e+J2inFvyfnS+aIzm74GMgqAfk="; 28 + vendorHash = "sha256-JdR/v3Ho0rd5uNZVCEo1wQ/OzXz1kFhp1DXG+84Vgw0="; 29 29 subPackages = ["cmd/restray"]; 30 30 31 31 ldflags = ["-s" "-w" "-X" "main.resticBuiltinPath=${pkgs.restic}/bin/restic"];
+1
go.mod
··· 9 9 github.com/dustin/go-humanize v1.0.1 10 10 github.com/fsnotify/fsnotify v1.10.1 11 11 github.com/gen2brain/beeep v0.11.2 12 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 12 13 github.com/lnquy/cron v1.1.1 13 14 github.com/robfig/cron/v3 v3.0.1 14 15 github.com/thiagokokada/dark-mode-go v0.0.2
+2
go.sum
··· 22 22 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 23 23 github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= 24 24 github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 25 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 26 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 25 27 github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= 26 28 github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= 27 29 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+1 -1
nix/shared.nix
··· 32 32 terminal = lib.mkOption { 33 33 type = lib.types.str; 34 34 default = ""; 35 - 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"''; 35 + description = ''MacOS/Linux only. Terminal emulator used in "Shell" and "View Log".''; 36 36 }; 37 37 }; 38 38 };