diff --git a/Makefile b/Makefile index 19c90d2..f27f55b 100644 --- a/Makefile +++ b/Makefile @@ -32,11 +32,6 @@ package: GOARCH=386 GOOS=linux go build -o _out/kpsync_linux-386 ./cmd/cli # Linux - 32 bit GOARCH=amd64 GOOS=linux go build -o _out/kpsync_linux-amd64 ./cmd/cli # Linux - 64 bit GOARCH=arm64 GOOS=linux go build -o _out/kpsync_linux-arm64 ./cmd/cli # Linux - ARM - GOARCH=386 GOOS=windows go build -o _out/kpsync_win-386.exe -tags timetzdata -ldflags "-w -s" ./cmd/cli # Windows - 32 bit - GOARCH=amd64 GOOS=windows go build -o _out/kpsync_win-amd64.exe -tags timetzdata -ldflags "-w -s" ./cmd/cli # Windows - 64 bit - GOARCH=arm64 GOOS=windows go build -o _out/kpsync_win-arm64.exe -tags timetzdata -ldflags "-w -s" ./cmd/cli # Windows - ARM - GOARCH=amd64 GOOS=darwin go build -o _out/kpsync_macos-amd64 ./cmd/cli # macOS - 32 bit - GOARCH=amd64 GOOS=darwin go build -o _out/kpsync_macos-amd64 ./cmd/cli # macOS - 64 bit GOARCH=amd64 GOOS=openbsd go build -o _out/kpsync_openbsd-amd64 ./cmd/cli # OpenBSD - 64 bit GOARCH=arm64 GOOS=openbsd go build -o _out/kpsync_openbsd-arm64 ./cmd/cli # OpenBSD - ARM GOARCH=amd64 GOOS=freebsd go build -o _out/kpsync_freebsd-amd64 ./cmd/cli # FreeBSD - 64 bit diff --git a/app/application.go b/app/application.go index 5b8ec96..4ae825d 100644 --- a/app/application.go +++ b/app/application.go @@ -24,8 +24,9 @@ type Application struct { syncLoopRunning *syncext.AtomicBool keepassRunning *syncext.AtomicBool - sigStopChan chan bool // keepass exited - sigErrChan chan error // fatal error + sigKPExitChan chan bool // keepass exited + sigManualStopChan chan bool // manual stop + sigErrChan chan error // fatal error sigSyncLoopStopChan chan bool // stop sync loop sigTermKeepassChan chan bool // stop keepass @@ -42,7 +43,8 @@ func NewApplication() *Application { trayReady: syncext.NewAtomicBool(false), syncLoopRunning: syncext.NewAtomicBool(false), keepassRunning: syncext.NewAtomicBool(false), - sigStopChan: make(chan bool, 128), + sigKPExitChan: make(chan bool, 128), + sigManualStopChan: make(chan bool, 128), sigErrChan: make(chan error, 128), sigSyncLoopStopChan: make(chan bool, 128), sigTermKeepassChan: make(chan bool, 128), @@ -77,28 +79,54 @@ func (app *Application) Run() { app.syncLoopRunning = syncext.NewAtomicBool(true) defer app.syncLoopRunning.Set(false) - err := app.initSync() + isr, err := app.initSync() if err != nil { app.sigErrChan <- err return } - go func() { - app.keepassRunning = syncext.NewAtomicBool(true) - defer app.keepassRunning.Set(false) + if isr == InitSyncResponseAbort { + app.sigManualStopChan <- true + return + } else if isr == InitSyncResponseOkay { - app.runKeepass() - }() + go func() { + app.keepassRunning = syncext.NewAtomicBool(true) + defer app.keepassRunning.Set(false) - time.Sleep(1 * time.Second) + app.runKeepass(false) + }() - app.setTrayStateDirect("Sleeping...", assets.IconDefault) + time.Sleep(1 * time.Second) - err = app.runSyncLoop() - if err != nil { - app.sigErrChan <- err + app.setTrayStateDirect("Sleeping...", assets.IconDefault) + + err = app.runSyncLoop() + if err != nil { + app.sigErrChan <- err + return + } + + } else if isr == InitSyncResponseFallback { + + app.LogInfo(fmt.Sprintf("Starting KeepassXC with local fallback database (without sync loop!)")) + app.LogDebug(fmt.Sprintf("DB-Path := '%s'", app.config.LocalFallback)) + + go func() { + app.keepassRunning = syncext.NewAtomicBool(true) + defer app.keepassRunning.Set(false) + + app.runKeepass(true) + }() + + app.setTrayStateDirect("Sleeping...", assets.IconDefault) + + } else { + app.LogError("Unknown InitSyncResponse: "+string(isr), nil) + app.sigErrChan <- fmt.Errorf("unknown InitSyncResponse: %s", isr) return } + }() sigTerm := make(chan os.Signal, 1) @@ -111,7 +139,7 @@ func (app *Application) Run() { app.stopBackgroundRoutines() - // TODO try final sync + // TODO try final sync (?) case err := <-app.sigErrChan: // fatal error @@ -121,9 +149,17 @@ func (app *Application) Run() { app.LogError("Stopped due to error: "+err.Error(), nil) - // TODO stop? + return - case _ = <-app.sigStopChan: // keepass exited + case <-app.sigManualStopChan: // manual + + app.LogInfo("Stopping application (manual)") + + app.stopBackgroundRoutines() + + return + + case _ = <-app.sigKPExitChan: // keepass exited app.LogInfo("Stopping application (received STOP)") diff --git a/app/notifications.go b/app/notifications.go index b13c1f8..0c7ed03 100644 --- a/app/notifications.go +++ b/app/notifications.go @@ -1,9 +1,90 @@ package app -func (app *Application) showErrorNotification(msg string) { - //TODO +import ( + "fmt" + "strings" + + "git.blackforestbytes.com/BlackForestBytes/goext/cmdext" + "git.blackforestbytes.com/BlackForestBytes/goext/exerr" +) + +func (app *Application) showErrorNotification(msg string, body string) { + app.LogDebug("{notify-send} " + msg) + + res, err := cmdext. + Runner("notify-send"). + Arg("--urgency=critical"). + Arg("--app-name=kpsync"). + Arg("--print-id"). + Arg(msg). + Arg(body). + Run() + if err != nil { + app.LogError("Failed to show notification", err) + return + } + + if res.ExitCode != 0 { + app.LogError("Failed to show notification", nil) + app.LogDebug(fmt.Sprintf("ExitCode: %d", res.ExitCode)) + app.LogDebug(fmt.Sprintf("Stdout: %s", res.StdOut)) + app.LogDebug(fmt.Sprintf("Stderr: %s", res.StdErr)) + return + } + + app.LogDebug(fmt.Sprintf("Displayed notification with id %s", res.StdOut)) } -func (app *Application) showSuccessNotification(msg string) { - //TODO +func (app *Application) showSuccessNotification(msg string, body string) { + app.LogDebug("{notify-send} " + msg) + + res, err := cmdext. + Runner("notify-send"). + Arg("--urgency=normal"). + Arg("--app-name=kpsync"). + Arg("--print-id"). + Arg(msg). + Arg(body). + Run() + if err != nil { + app.LogError("Failed to show notification", err) + return + } + + if res.ExitCode != 0 { + app.LogError("Failed to show notification", nil) + app.LogDebug(fmt.Sprintf("ExitCode: %d", res.ExitCode)) + app.LogDebug(fmt.Sprintf("Stdout: %s", res.StdOut)) + app.LogDebug(fmt.Sprintf("Stderr: %s", res.StdErr)) + return + } + + app.LogDebug(fmt.Sprintf("Displayed notification with id %s", res.StdOut)) +} + +func (app *Application) showChoiceNotification(msg string, body string, options map[string]string) (string, error) { + app.LogDebug(fmt.Sprintf("{notify-send} %s {%d choices}", msg, len(options))) + + bldr := cmdext. + Runner("notify-send"). + Arg("--wait"). + Arg("--app-name=kpsync") + + for kOpt, vOpt := range options { + bldr = bldr.Arg("--action=" + kOpt + "=" + vOpt) + } + + bldr = bldr.Arg(msg) + + res, err := bldr.Run() + if err != nil { + app.LogError("Failed to show choice-notification", err) + return "", exerr.Wrap(err, "").Build() + } + + if res.ExitCode != 0 { + return "", nil + } + + return strings.TrimSpace(res.StdOut), nil } diff --git a/app/sync.go b/app/sync.go index 37ec5a3..46f6eba 100644 --- a/app/sync.go +++ b/app/sync.go @@ -16,11 +16,19 @@ import ( "mikescher.com/kpsync/assets" ) -func (app *Application) initSync() error { +type InitSyncResponse string //@enum:type + +const ( + InitSyncResponseOkay InitSyncResponse = "OKAY" + InitSyncResponseFallback InitSyncResponse = "FALLBACK" + InitSyncResponseAbort InitSyncResponse = "ABORT" +) + +func (app *Application) initSync() (InitSyncResponse, error) { err := os.MkdirAll(app.config.WorkDir, os.ModePerm) if err != nil { - return exerr.Wrap(err, "").Build() + return "", exerr.Wrap(err, "").Build() } app.dbFile = path.Join(app.config.WorkDir, path.Base(app.config.LocalFallback)) @@ -28,7 +36,7 @@ func (app *Application) initSync() error { if app.isKeepassRunning() { app.LogError("keepassxc is already running!", nil) - return exerr.New(exerr.TypeInternal, "keepassxc is already running").Build() + return "", exerr.New(exerr.TypeInternal, "keepassxc is already running").Build() } state := app.readState() @@ -89,20 +97,42 @@ func (app *Application) initSync() error { return nil }() if err != nil { - return exerr.Wrap(err, "").Build() + + r, err := app.showChoiceNotification("Failed to download remote database.\nUse local fallback?", map[string]string{"y": "Yes", "n": "Abort"}) + if err != nil { + app.LogError("Failed to show choice notification", err) + return "", exerr.Wrap(err, "Failed to show choice notification").Build() + } + + if r == "y" { + return InitSyncResponseFallback, nil + } else if r == "n" { + return InitSyncResponseAbort, nil + } else { + return "", exerr.Wrap(err, "").Build() + } + } + + return InitSyncResponseOkay, nil + } else { app.LogInfo(fmt.Sprintf("Skip download - use existing local database %s", app.dbFile)) app.LogLine() - } - return nil + return InitSyncResponseOkay, nil + } } -func (app *Application) runKeepass() { +func (app *Application) runKeepass(fallback bool) { app.LogInfo("Starting keepassxc...") - cmd := exec.Command("keepassxc", app.dbFile) + filePath := app.dbFile + if fallback { + filePath = app.config.LocalFallback + } + + cmd := exec.Command("keepassxc", filePath) go func() { select { @@ -138,7 +168,7 @@ func (app *Application) runKeepass() { if errors.As(err, &exitErr) { app.LogInfo(fmt.Sprintf("keepass exited with code %d", exitErr.ExitCode())) - app.sigStopChan <- true + app.sigKPExitChan <- true return } @@ -152,7 +182,7 @@ func (app *Application) runKeepass() { app.LogInfo("keepassxc exited successfully") app.LogLine() - app.sigStopChan <- true + app.sigKPExitChan <- true return } @@ -190,65 +220,152 @@ func (app *Application) runSyncLoop() error { continue } - func() { - app.masterLock.Lock() - app.uploadRunning.Wait(false) - app.uploadRunning.Set(true) - app.masterLock.Unlock() - - defer app.uploadRunning.Set(false) - - app.LogInfo("Database file was modified") - app.LogInfo(fmt.Sprintf("Sleeping for %d seconds", app.config.Debounce)) - - time.Sleep(timeext.FromSeconds(app.config.Debounce)) - - state := app.readState() - localCS, err := app.calcLocalChecksum() - if err != nil { - app.LogError("Failed to calculate local database checksum", err) - app.showErrorNotification("Failed to calculate local database checksum") - return - } - - if localCS == state.Checksum { - app.LogInfo("Local database still matches remote (via checksum) - no need to upload") - app.LogInfo(fmt.Sprintf("Checksum (remote/cached) := %s", state.Checksum)) - app.LogInfo(fmt.Sprintf("Checksum (local) := %s", localCS)) - return - } - - etag, lm, sha, sz, err := app.uploadDatabase(langext.Ptr(state.ETag)) - if errors.Is(err, ETagConflictError) { - - //TODO - choice notification - - } else if err != nil { - app.LogError("Failed to upload remote database", err) - app.showErrorNotification("Failed to upload remote database") - return - } - - app.LogInfo(fmt.Sprintf("Uploaded database to remote")) - app.LogDebug(fmt.Sprintf("Checksum := %s", sha)) - app.LogDebug(fmt.Sprintf("ETag := %s", etag)) - app.LogDebug(fmt.Sprintf("Size := %s (%d)", langext.FormatBytes(sz), sz)) - app.LogDebug(fmt.Sprintf("LastModified := %s", lm.Format(time.RFC3339))) - - err = app.saveState(etag, lm, sha, sz) - if err != nil { - app.LogError("Failed to save state", err) - app.showErrorNotification("Failed to save state") - return - } - - app.showSuccessNotification("Uploaded database successfully") - - app.LogLine() - }() + app.onDBFileChanged() case err := <-watcher.Errors: app.LogError("Filewatcher reported an error", err) } } } + +func (app *Application) onDBFileChanged() { + app.masterLock.Lock() + app.uploadRunning.Wait(false) + app.uploadRunning.Set(true) + app.masterLock.Unlock() + + defer app.uploadRunning.Set(false) + + fin1 := app.setTrayState("Uploading database", assets.IconUpload) + defer fin1() + + app.LogInfo("Database file was modified") + app.LogInfo(fmt.Sprintf("Sleeping for %d seconds", app.config.Debounce)) + + time.Sleep(timeext.FromSeconds(app.config.Debounce)) + + state := app.readState() + localCS, err := app.calcLocalChecksum() + if err != nil { + app.LogError("Failed to calculate local database checksum", err) + app.showErrorNotification("KeePassSync: Error", "Failed to calculate local database checksum") + return + } + + if state != nil && localCS == state.Checksum { + app.LogInfo("Local database still matches remote (via checksum) - no need to upload") + app.LogInfo(fmt.Sprintf("Checksum (remote/cached) := %s", state.Checksum)) + app.LogInfo(fmt.Sprintf("Checksum (local) := %s", localCS)) + return + } + + app.LogInfo("Uploading database to remote") + + var eTagPtr *string = nil + if state != nil { + eTagPtr = langext.Ptr(state.ETag) + } + + etag, lm, sha, sz, err := app.uploadDatabase(eTagPtr) + if errors.Is(err, ETagConflictError) { + + fin1() + fin2 := app.setTrayState("Uploading database (conflict", assets.IconUploadConflict) + defer fin2() + + r, err := app.showChoiceNotification("KeePassSync: Upload failed", "Conflict with remote file.\n[1] Overwrite remote file\n[2] Download remote and sync manually", map[string]string{"o": "Overwrite", "d": "Download", "a": "Abort"}) + if err != nil { + app.LogError("Failed to show choice notification", err) + return + } + + if r == "o" { + + app.LogInfo("Uploading database to remote (unchecked)") + + etag, lm, sha, sz, err := app.uploadDatabase(nil) // unchecked upload + if err != nil { + app.LogError("Failed to upload remote database", err) + app.showErrorNotification("KeePassSync: Error", "Failed to upload remote database") + return + } + + app.LogInfo(fmt.Sprintf("Uploaded database to remote")) + app.LogDebug(fmt.Sprintf("Checksum := %s", sha)) + app.LogDebug(fmt.Sprintf("ETag := %s", etag)) + app.LogDebug(fmt.Sprintf("Size := %s (%d)", langext.FormatBytes(sz), sz)) + app.LogDebug(fmt.Sprintf("LastModified := %s", lm.Format(time.RFC3339))) + + err = app.saveState(etag, lm, sha, sz) + if err != nil { + app.LogError("Failed to save state", err) + app.showErrorNotification("KeePassSync: Error", "Failed to save state") + return + } + + app.showSuccessNotification("KeePassSync", "Uploaded database successfully (overwrite remote)") + + app.LogLine() + + return + + } else if r == "d" { + + app.LogInfo(fmt.Sprintf("Re-Downloading remote database to %s", app.dbFile)) + + etag, lm, sha, sz, err := app.downloadDatabase() + if err != nil { + app.LogError("Failed to download remote database", err) + return + } + + app.LogInfo(fmt.Sprintf("Downloaded remote database to %s", app.dbFile)) + app.LogInfo(fmt.Sprintf("Checksum := %s", sha)) + app.LogInfo(fmt.Sprintf("ETag := %s", etag)) + app.LogInfo(fmt.Sprintf("Size := %s (%d)", langext.FormatBytes(sz), sz)) + app.LogInfo(fmt.Sprintf("LastModified := %s", lm.Format(time.RFC3339))) + + err = app.saveState(etag, lm, sha, sz) + if err != nil { + app.LogError("Failed to save state", err) + return + } + + app.showSuccessNotification("KeePassSync", "Re-Downloaded database successfully") + + app.LogLine() + + } else if r == "a" { + + app.sigManualStopChan <- true + return + + } else { + app.LogError("Unknown choice in notification: '"+r+"'", nil) + app.showErrorNotification("KeePassSync: Error", "Unknown choice in notification: '"+r+"'") + return + } + + } else if err != nil { + app.LogError("Failed to upload remote database", err) + app.showErrorNotification("KeePassSync: Error", "Failed to upload remote database") + return + } + + app.LogInfo(fmt.Sprintf("Uploaded database to remote")) + app.LogDebug(fmt.Sprintf("Checksum := %s", sha)) + app.LogDebug(fmt.Sprintf("ETag := %s", etag)) + app.LogDebug(fmt.Sprintf("Size := %s (%d)", langext.FormatBytes(sz), sz)) + app.LogDebug(fmt.Sprintf("LastModified := %s", lm.Format(time.RFC3339))) + + err = app.saveState(etag, lm, sha, sz) + if err != nil { + app.LogError("Failed to save state", err) + app.showErrorNotification("KeePassSync: Error", "Failed to save state") + return + } + + app.showSuccessNotification("KeePassSync: Error", "Uploaded database successfully") + + app.LogLine() +} diff --git a/app/tray.go b/app/tray.go index 3dfda2d..08328dc 100644 --- a/app/tray.go +++ b/app/tray.go @@ -38,16 +38,24 @@ func (app *Application) setTrayState(txt string, icon []byte) func() { systray.SetIcon(icon) systray.SetTooltip(txt) + var finDone = false + fin := func() { app.masterLock.Lock() defer app.masterLock.Unlock() + if finDone { + return + } + if !app.trayReady.Get() { return } systray.SetIcon(assets.IconDefault) systray.SetTooltip("Sleeping...") + + finDone = true } return fin diff --git a/assets/assets.go b/assets/assets.go index 05ce50a..18ae701 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -12,3 +12,9 @@ var IconDefault []byte //go:embed iconDownload.png var IconDownload []byte + +//go:embed iconUpload.png +var IconUpload []byte + +//go:embed iconUploadConflict.png +var IconUploadConflict []byte diff --git a/assets/iconUpload.png b/assets/iconUpload.png new file mode 100644 index 0000000..be2f92d Binary files /dev/null and b/assets/iconUpload.png differ diff --git a/assets/iconUploadConflict.png b/assets/iconUploadConflict.png new file mode 100644 index 0000000..7fa0b0b Binary files /dev/null and b/assets/iconUploadConflict.png differ