diff --git a/.gitignore b/.gitignore index 74d2b988..f54dc39c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ resticui resticui-* dist +resticui.exe \ No newline at end of file diff --git a/README.md b/README.md index 40ea7323..23d2de9b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ The goals of this project are: * Easy to pull back the curtain: all common operations should be possible from the UI, but it should be easy to drop down to the command line and use restic directly if needed. * Lightweight: your backup orchestration should blend into the background. The web UI binary is fully self contained as a single executable and the binary is <20 MB with very light memory overhead at runtime. +OS Support + + * Linux + * MacOS (Darwin) + * Windows (note: must be run as administrator on first execution to install the restic binary in Program Files). + # Getting Started ## Running diff --git a/go.mod b/go.mod index c9709b0e..8654104a 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.21.3 require ( github.com/GeertJohan/go.rice v1.0.3 github.com/gitploy-io/cronexpr v0.2.2 - github.com/google/renameio v1.0.1 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 github.com/hashicorp/go-multierror v1.1.1 github.com/mattn/go-colorable v0.1.13 + github.com/natefinch/atomic v1.0.1 go.etcd.io/bbolt v1.3.8 go.uber.org/zap v1.26.0 google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 @@ -20,6 +20,7 @@ require ( require ( github.com/daaku/go.zipexe v1.0.2 // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/renameio v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 69eb8831..2a9193ea 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/config/environment.go b/internal/config/environment.go index 86b7a191..ccd3faee 100644 --- a/internal/config/environment.go +++ b/internal/config/environment.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path" + "runtime" "strings" ) @@ -14,16 +15,20 @@ var ( EnvVarBinPath = "RESTICUI_RESTIC_BIN_PATH" ) +// ConfigFilePath +// - *nix systems use $XDG_CONFIG_HOME/resticui/config.json +// - windows uses %APPDATA%/resticui/config.json func ConfigFilePath() string { if val := os.Getenv(EnvVarConfigPath); val != "" { return val } - if val := os.Getenv("XDG_CONFIG_HOME"); val != "" { - return path.Join(val, "resticui/config.json") - } - return path.Join(getHomeDir(), ".config/resticui/config.json") + + return path.Join(getConfigDir(), "resticui/config.json") } +// DataDir +// - *nix systems use $XDG_DATA_HOME/resticui +// - windows uses %APPDATA%/resticui/data func DataDir() string { if val := os.Getenv(EnvVarDataDir); val != "" { return val @@ -31,6 +36,10 @@ func DataDir() string { if val := os.Getenv("XDG_DATA_HOME"); val != "" { return path.Join(val, "resticui") } + + if runtime.GOOS == "windows" { + return path.Join(getConfigDir(), "resticui/data") + } return path.Join(getHomeDir(), ".local/share/resticui") } @@ -58,3 +67,11 @@ func getHomeDir() string { } return home } + +func getConfigDir() string { + cfgDir, err := os.UserConfigDir() + if err != nil { + panic(fmt.Errorf("couldn't determine config directory: %v", err)) + } + return cfgDir +} diff --git a/internal/config/jsonstore.go b/internal/config/jsonstore.go index 01e55641..b5012258 100644 --- a/internal/config/jsonstore.go +++ b/internal/config/jsonstore.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "errors" "fmt" "os" @@ -8,13 +9,13 @@ import ( "sync" v1 "github.com/garethgeorge/resticui/gen/go/v1" - "github.com/google/renameio" + "github.com/natefinch/atomic" "google.golang.org/protobuf/encoding/protojson" ) type JsonFileStore struct { Path string - mu sync.Mutex + mu sync.Mutex } var _ ConfigStore = &JsonFileStore{} @@ -32,7 +33,7 @@ func (f *JsonFileStore) Get() (*v1.Config, error) { } var config v1.Config - + if err = protojson.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } @@ -53,8 +54,8 @@ func (f *JsonFileStore) Update(config *v1.Config) error { } data, err := protojson.MarshalOptions{ - Indent: " ", - Multiline: true, + Indent: " ", + Multiline: true, EmitUnpopulated: true, }.Marshal(config) if err != nil { @@ -66,7 +67,7 @@ func (f *JsonFileStore) Update(config *v1.Config) error { return fmt.Errorf("failed to create config directory: %w", err) } - err = renameio.WriteFile(f.Path, data, 0644) + err = atomic.WriteFile(f.Path, bytes.NewReader(data)) if err != nil { return fmt.Errorf("failed to write config file: %w", err) } diff --git a/internal/resticinstaller/flock.go b/internal/resticinstaller/flock.go index 1a9f54fc..908f3844 100644 --- a/internal/resticinstaller/flock.go +++ b/internal/resticinstaller/flock.go @@ -1,3 +1,6 @@ +//go:build linux || darwin +// +build linux darwin + package resticinstaller import ( diff --git a/internal/resticinstaller/flockwindows.go b/internal/resticinstaller/flockwindows.go new file mode 100644 index 00000000..42e97d86 --- /dev/null +++ b/internal/resticinstaller/flockwindows.go @@ -0,0 +1,9 @@ +//go:build windows +// +build windows + +package resticinstaller + +func withFlock(lock string, do func() error) error { + // TODO: windows file locking. Not a major issue as locking is only needed for test runs. + return do() +} diff --git a/internal/resticinstaller/resticinstaller.go b/internal/resticinstaller/resticinstaller.go index 9cc5adda..d0c0feae 100644 --- a/internal/resticinstaller/resticinstaller.go +++ b/internal/resticinstaller/resticinstaller.go @@ -1,6 +1,8 @@ package resticinstaller import ( + "archive/zip" + "bytes" "compress/bzip2" "errors" "fmt" @@ -28,10 +30,13 @@ var ( ) func resticDownloadURL(version string) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/restic_%v_windows_%v.zip", version, version, runtime.GOARCH) + } return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/restic_%v_%v_%v.bz2", version, version, runtime.GOOS, runtime.GOARCH) } -func downloadFile(url string, path string) error { +func downloadFile(url string, downloadPath string) error { // Download ur as a file and save it to path resp, err := http.Get(url) if err != nil { @@ -42,19 +47,38 @@ func downloadFile(url string, path string) error { var body io.Reader = resp.Body if strings.HasSuffix(url, ".bz2") { body = bzip2.NewReader(resp.Body) + } else if strings.HasSuffix(url, ".zip") { + var fullBody bytes.Buffer + _, err := io.Copy(&fullBody, resp.Body) + if err != nil { + return fmt.Errorf("copy response body to buffer: %w", err) + } + + archive, err := zip.NewReader(bytes.NewReader(fullBody.Bytes()), int64(fullBody.Len())) + if err != nil { + return fmt.Errorf("open zip archive: %w", err) + } + + if len(archive.File) != 1 { + return fmt.Errorf("expected zip archive to contain exactly one file, got %v", len(archive.File)) + } + body, err = archive.File[0].Open() + if err != nil { + return fmt.Errorf("open zip archive file %v: %w", archive.File[0].Name, err) + } } - out, err := os.Create(path) + out, err := os.Create(downloadPath) if err != nil { - return fmt.Errorf("create file %v: %w", path, err) + return fmt.Errorf("create file %v: %w", downloadPath, err) } defer out.Close() if err != nil { - return fmt.Errorf("create file %v: %w", path, err) + return fmt.Errorf("create file %v: %w", downloadPath, err) } _, err = io.Copy(out, body) if err != nil { - return fmt.Errorf("copy response body to file %v: %w", path, err) + return fmt.Errorf("copy response body to file %v: %w", downloadPath, err) } return nil @@ -115,6 +139,13 @@ func FindOrInstallResticBinary() (string, error) { // Check for restic installation in data directory. resticInstallPath := path.Join(config.DataDir(), fmt.Sprintf("restic-%v", RequiredResticVersion)) + if runtime.GOOS == "windows" { + programFiles := os.Getenv("programfiles(x86)") + if programFiles == "" { + programFiles = os.Getenv("programfiles") + } + resticInstallPath = path.Join(programFiles, "restic", fmt.Sprintf("restic-%v.exe", RequiredResticVersion)) + } if _, err := os.Stat(resticInstallPath); err != nil { if !errors.Is(err, os.ErrNotExist) { return "", fmt.Errorf("could not stat restic binary at %v: %w", resticBin, err) @@ -125,7 +156,7 @@ func FindOrInstallResticBinary() (string, error) { } didTryInstall = true - zap.S().Infof("Installing restic %v...", RequiredResticVersion) + zap.S().Infof("Installing restic %v to %v...", resticInstallPath, RequiredResticVersion) if err := installResticIfNotExists(resticInstallPath); err != nil { return "", fmt.Errorf("install restic: %w", err) } diff --git a/pkg/restic/restic.go b/pkg/restic/restic.go index f8f27873..49141ee0 100644 --- a/pkg/restic/restic.go +++ b/pkg/restic/restic.go @@ -467,7 +467,12 @@ func WithEnv(env ...string) GenericOption { } } -var EnvToPropagate = []string{"PATH", "HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"} +var EnvToPropagate = []string{ + // *nix systems + "PATH", "HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + // windows + "APPDATA", "LOCALAPPDATA", +} func WithPropagatedEnvVars(extras ...string) GenericOption { var extension []string diff --git a/webui/package.json b/webui/package.json index 6a750283..9c2298b6 100644 --- a/webui/package.json +++ b/webui/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "", "scripts": { - "start": "RESTICUI_BUILD_VERSION=$(git describe --tags --abbrev=0) parcel serve src/index.html", + "start": "parcel serve src/index.html", "build": "RESTICUI_BUILD_VERSION=$(git describe --tags --abbrev=0) parcel build src/index.html", "test": "echo \"Error: no test specified\" && exit 1" },