From 52399acb42ef929a7b7fac582a5eb207798ae669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Wed, 20 Aug 2025 11:33:26 +0200 Subject: [PATCH] kinda works, upload, download etc --- TODO.md | 4 +- app/application.go | 23 ++++++--- app/config.go | 5 +- app/notifications.go | 4 +- app/reader.go | 45 ++++++++++++++++++ app/sync.go | 62 ++++++++++++++++++++---- app/tray.go | 59 ++++++++++++++++++++++- app/utils.go | 12 +++++ app/webdav.go | 111 ++++++++++++++++++++++++++++++------------- 9 files changed, 267 insertions(+), 58 deletions(-) create mode 100644 app/reader.go diff --git a/TODO.md b/TODO.md index 8d22c0f..c465cfc 100644 --- a/TODO.md +++ b/TODO.md @@ -16,4 +16,6 @@ - config via json + params override - colorful log - - download/upload progress log \ No newline at end of file + - download/upload progress log + + - logfile in workdir \ No newline at end of file diff --git a/app/application.go b/app/application.go index 4ae825d..b1d4e0e 100644 --- a/app/application.go +++ b/app/application.go @@ -33,6 +33,12 @@ type Application struct { dbFile string stateFile string + + currSysTrayTooltop string + + trayItemChecksum *systray.MenuItem + trayItemETag *systray.MenuItem + trayItemLastModified *systray.MenuItem } func NewApplication() *Application { @@ -68,15 +74,14 @@ func (app *Application) Run() { app.LogDebug(fmt.Sprintf("WebDAVPass := '%s'", app.config.WebDAVPass)) app.LogDebug(fmt.Sprintf("LocalFallback := '%s'", app.config.LocalFallback)) app.LogDebug(fmt.Sprintf("WorkDir := '%s'", app.config.WorkDir)) - app.LogDebug(fmt.Sprintf("ForceColors := %v", app.config.ForceColors)) - app.LogDebug(fmt.Sprintf("Debounce := %d", app.config.Debounce)) + app.LogDebug(fmt.Sprintf("Debounce := %d ms", app.config.Debounce)) app.LogDebug(fmt.Sprintf("ForceColors := %v", app.config.ForceColors)) app.LogLine() go func() { app.initTray() }() go func() { - app.syncLoopRunning = syncext.NewAtomicBool(true) + app.syncLoopRunning.Set(true) defer app.syncLoopRunning.Set(false) isr, err := app.initSync() @@ -91,7 +96,7 @@ func (app *Application) Run() { } else if isr == InitSyncResponseOkay { go func() { - app.keepassRunning = syncext.NewAtomicBool(true) + app.keepassRunning.Set(true) defer app.keepassRunning.Set(false) app.runKeepass(false) @@ -113,7 +118,7 @@ func (app *Application) Run() { app.LogDebug(fmt.Sprintf("DB-Path := '%s'", app.config.LocalFallback)) go func() { - app.keepassRunning = syncext.NewAtomicBool(true) + app.keepassRunning.Set(true) defer app.keepassRunning.Set(false) app.runKeepass(true) @@ -139,7 +144,9 @@ func (app *Application) Run() { app.stopBackgroundRoutines() - // TODO try final sync (?) + app.runFinalSync() + + return case err := <-app.sigErrChan: // fatal error @@ -165,7 +172,9 @@ func (app *Application) Run() { app.stopBackgroundRoutines() - // TODO try final sync + app.runFinalSync() + + return } } diff --git a/app/config.go b/app/config.go index bfcd366..d6305e5 100644 --- a/app/config.go +++ b/app/config.go @@ -3,9 +3,9 @@ package app import ( "encoding/json" "flag" - "fmt" "os" "os/user" + "path" "strings" "git.blackforestbytes.com/BlackForestBytes/goext/langext" @@ -57,10 +57,9 @@ func (app *Application) loadConfig() (Config, string) { if err != nil { app.LogFatalErr("Failed to query users home directory", err) } - fmt.Println(usr.HomeDir) configPath = strings.TrimPrefix(configPath, "~") - configPath = fmt.Sprintf("%s/%s", usr.HomeDir, configPath) + configPath = path.Join(usr.HomeDir, configPath) } if _, err := os.Stat(configPath); os.IsNotExist(err) && configPath != "" { diff --git a/app/notifications.go b/app/notifications.go index 0c7ed03..9b68f6b 100644 --- a/app/notifications.go +++ b/app/notifications.go @@ -40,7 +40,7 @@ func (app *Application) showSuccessNotification(msg string, body string) { res, err := cmdext. Runner("notify-send"). - Arg("--urgency=normal"). + Arg("--urgency=critical"). Arg("--app-name=kpsync"). Arg("--print-id"). Arg(msg). @@ -74,7 +74,7 @@ func (app *Application) showChoiceNotification(msg string, body string, options bldr = bldr.Arg("--action=" + kOpt + "=" + vOpt) } - bldr = bldr.Arg(msg) + bldr = bldr.Arg(msg).Arg(body) res, err := bldr.Run() if err != nil { diff --git a/app/reader.go b/app/reader.go new file mode 100644 index 0000000..e06f0ce --- /dev/null +++ b/app/reader.go @@ -0,0 +1,45 @@ +package app + +import ( + "io" + "sync" +) + +type teeReadCloser struct { + r io.Reader + c io.Closer +} + +func (t *teeReadCloser) Read(p []byte) (int, error) { return t.r.Read(p) } +func (t *teeReadCloser) Close() error { return t.c.Close() } + +type progressWriter struct { + sync.Mutex + + done int64 + total int64 + cb func(done, total int64) +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n := len(p) + + if pw.cb != nil { + pw.Lock() + defer pw.Unlock() + pw.done += int64(n) + pw.cb(pw.done, pw.total) + } + + return n, nil +} + +func ReadAllWithProgress(r io.Reader, totalBytes int64, onProgress func(done, total int64)) ([]byte, error) { + return io.ReadAll(NewProgressReader(r, totalBytes, onProgress)) +} + +func NewProgressReader(r io.Reader, totalBytes int64, onProgress func(done, total int64)) io.ReadCloser { + pw := &progressWriter{total: totalBytes, cb: onProgress} + + return &teeReadCloser{r: io.TeeReader(r, pw), c: io.NopCloser(r)} +} diff --git a/app/sync.go b/app/sync.go index 46f6eba..20f07f3 100644 --- a/app/sync.go +++ b/app/sync.go @@ -36,6 +36,7 @@ func (app *Application) initSync() (InitSyncResponse, error) { if app.isKeepassRunning() { app.LogError("keepassxc is already running!", nil) + app.showErrorNotification("KeePassSync: Error", "An keepassxc instance is already running!\nPlease close it before starting kpsync.") return "", exerr.New(exerr.TypeInternal, "keepassxc is already running").Build() } @@ -98,7 +99,7 @@ func (app *Application) initSync() (InitSyncResponse, error) { }() if err != nil { - r, err := app.showChoiceNotification("Failed to download remote database.\nUse local fallback?", map[string]string{"y": "Yes", "n": "Abort"}) + r, err := app.showChoiceNotification("KeePassSync", "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() @@ -134,14 +135,20 @@ func (app *Application) runKeepass(fallback bool) { cmd := exec.Command("keepassxc", filePath) + bgStop := make(chan bool, 128) + go func() { select { + case <-bgStop: + return case <-app.sigTermKeepassChan: app.LogInfo("Received signal to terminate keepassxc") if cmd != nil && cmd.Process != nil { app.LogInfo(fmt.Sprintf("Terminating keepassxc %d", cmd.Process.Pid)) err := cmd.Process.Signal(syscall.SIGTERM) - if err != nil { + if errors.Is(err, os.ErrProcessDone) { + app.LogInfo("keepassxc already terminated") + } else if err != nil { app.LogError("Failed to terminate keepassxc", err) } else { app.LogInfo("keepassxc terminated successfully") @@ -164,6 +171,8 @@ func (app *Application) runKeepass(fallback bool) { err = cmd.Wait() + bgStop <- true + exitErr := &exec.ExitError{} if errors.As(err, &exitErr) { @@ -208,8 +217,8 @@ func (app *Application) runSyncLoop() error { case event := <-watcher.Events: app.LogDebug(fmt.Sprintf("Received inotify event: [%s] %s", event.Op.String(), event.Name)) - if !event.Has(fsnotify.Write) { - app.LogDebug("Ignoring event - not a write event") + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { + app.LogDebug("Ignoring event - not a write|create event") app.LogLine() continue } @@ -240,9 +249,9 @@ func (app *Application) onDBFileChanged() { defer fin1() app.LogInfo("Database file was modified") - app.LogInfo(fmt.Sprintf("Sleeping for %d seconds", app.config.Debounce)) + app.LogInfo(fmt.Sprintf("Sleeping for %d ms", app.config.Debounce)) - time.Sleep(timeext.FromSeconds(app.config.Debounce)) + time.Sleep(timeext.FromMilliseconds(app.config.Debounce)) state := app.readState() localCS, err := app.calcLocalChecksum() @@ -259,6 +268,10 @@ func (app *Application) onDBFileChanged() { return } + app.doDBUpload(state, fin1, true) +} + +func (app *Application) doDBUpload(state *State, stateClear func(), allowConflictResolution bool) { app.LogInfo("Uploading database to remote") var eTagPtr *string = nil @@ -267,9 +280,9 @@ func (app *Application) onDBFileChanged() { } etag, lm, sha, sz, err := app.uploadDatabase(eTagPtr) - if errors.Is(err, ETagConflictError) { + if errors.Is(err, ETagConflictError) && allowConflictResolution { - fin1() + stateClear() fin2 := app.setTrayState("Uploading database (conflict", assets.IconUploadConflict) defer fin2() @@ -365,7 +378,38 @@ func (app *Application) onDBFileChanged() { return } - app.showSuccessNotification("KeePassSync: Error", "Uploaded database successfully") + app.showSuccessNotification("KeePassSync", "Uploaded database successfully") app.LogLine() } + +func (app *Application) runFinalSync() { + 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("Starting final sync...") + + 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.doDBUpload(state, fin1, false) +} diff --git a/app/tray.go b/app/tray.go index 08328dc..8856947 100644 --- a/app/tray.go +++ b/app/tray.go @@ -7,20 +7,61 @@ import ( func (app *Application) initTray() { + sigBGStop := make(chan bool, 128) + trayOnReady := func() { + app.masterLock.Lock() + defer app.masterLock.Unlock() + systray.SetIcon(assets.IconInit) systray.SetTitle("KeepassXC Sync") - systray.SetTooltip("Initializing...") + app.currSysTrayTooltop = "Initializing..." + systray.SetTooltip(app.currSysTrayTooltop) + + miSync := systray.AddMenuItem("Sync Now (checked)", "") + miSyncForce := systray.AddMenuItem("Sync Now (forced)", "") + miShowLog := systray.AddMenuItem("Show Log", "") + systray.AddMenuItem("", "") + app.trayItemChecksum = systray.AddMenuItem("Checksum: {...}", "") + app.trayItemETag = systray.AddMenuItem("ETag: {...}", "") + app.trayItemLastModified = systray.AddMenuItem("LastModified: {...}", "") + systray.AddMenuItem("", "") + miQuit := systray.AddMenuItem("Quit", "") app.LogDebug("SysTray initialized") app.LogLine() + go func() { + for { + select { + case <-miSync.ClickedCh: + app.LogDebug("SysTray: [Sync Now (checked)] clicked") + //TODO + case <-miSyncForce.ClickedCh: + app.LogDebug("SysTray: [Sync Now (forced)] clicked") + //TODO + case <-miShowLog.ClickedCh: + app.LogDebug("SysTray: [Show Log] clicked") + //TODO + case <-miQuit.ClickedCh: + app.LogDebug("SysTray: [Quit] clicked") + app.sigManualStopChan <- true + case <-sigBGStop: + app.LogDebug("SysTray: Click-Listener goroutine stopped") + return + + } + } + }() + app.trayReady.Set(true) } systray.Run(trayOnReady, nil) + sigBGStop <- true + app.LogDebug("SysTray stopped") app.LogLine() @@ -53,7 +94,8 @@ func (app *Application) setTrayState(txt string, icon []byte) func() { } systray.SetIcon(assets.IconDefault) - systray.SetTooltip("Sleeping...") + app.currSysTrayTooltop = "Sleeping..." + systray.SetTooltip(app.currSysTrayTooltop) finDone = true } @@ -72,3 +114,16 @@ func (app *Application) setTrayStateDirect(txt string, icon []byte) { systray.SetIcon(icon) systray.SetTooltip(txt) } + +func (app *Application) setTrayTooltip(txt string) { + if !app.trayReady.Get() { + return + } + + app.masterLock.Lock() + defer app.masterLock.Unlock() + + systray.SetTooltip(txt) + app.currSysTrayTooltop = txt + systray.SetTooltip(app.currSysTrayTooltop) +} diff --git a/app/utils.go b/app/utils.go index 5fc3a32..86b31fd 100644 --- a/app/utils.go +++ b/app/utils.go @@ -2,12 +2,14 @@ package app import ( "encoding/json" + "fmt" "os" "strings" "time" "git.blackforestbytes.com/BlackForestBytes/goext/cryptext" "git.blackforestbytes.com/BlackForestBytes/goext/exerr" + "git.blackforestbytes.com/BlackForestBytes/goext/timeext" "github.com/shirou/gopsutil/v3/process" ) @@ -65,6 +67,16 @@ func (app *Application) saveState(eTag string, lastModified time.Time, checksum return exerr.Wrap(err, "Failed to write state file").Build() } + if app.trayItemChecksum != nil { + app.trayItemChecksum.SetTitle(fmt.Sprintf("Checksum: %s", checksum)) + } + if app.trayItemETag != nil { + app.trayItemETag.SetTitle(fmt.Sprintf("ETag: %s", eTag)) + } + if app.trayItemLastModified != nil { + app.trayItemLastModified.SetTitle(fmt.Sprintf("LastModified: %s", lastModified.In(timeext.TimezoneBerlin).Format(time.RFC3339))) + } + return nil } diff --git a/app/webdav.go b/app/webdav.go index 6ead8aa..f37ffa0 100644 --- a/app/webdav.go +++ b/app/webdav.go @@ -1,8 +1,9 @@ package app import ( + "bytes" "errors" - "io" + "fmt" "net/http" "os" "strings" @@ -17,6 +18,9 @@ var ETagConflictError = errors.New("ETag conflict") func (app *Application) downloadDatabase() (string, time.Time, string, int64, error) { + prevTT := app.currSysTrayTooltop + defer app.setTrayTooltip(prevTT) + client := http.Client{Timeout: 90 * time.Second} req, err := http.NewRequest("GET", app.config.WebDAVURL, nil) @@ -26,6 +30,9 @@ func (app *Application) downloadDatabase() (string, time.Time, string, int64, er req.SetBasicAuth(app.config.WebDAVUser, app.config.WebDAVPass) + t0 := time.Now() + app.LogDebug(fmt.Sprintf("{HTTP} Starting WebDAV download...")) + resp, err := client.Do(req) if err != nil { return "", time.Time{}, "", 0, exerr.Wrap(err, "Failed to download remote database").Build() @@ -35,23 +42,26 @@ func (app *Application) downloadDatabase() (string, time.Time, string, int64, er return "", time.Time{}, "", 0, exerr.New(exerr.TypeInternal, "Failed to download remote database").Int("sc", resp.StatusCode).Build() } - bin, err := io.ReadAll(resp.Body) + currTT := "" + progressCallback := func(current int64, total int64) { + newTT := fmt.Sprintf("Downloading (%.0f%%)", float64(current)/float64(total)*100) + if currTT != newTT { + app.setTrayTooltip(newTT) + currTT = newTT + } + } + + bin, err := ReadAllWithProgress(resp.Body, resp.ContentLength, progressCallback) if err != nil { return "", time.Time{}, "", 0, exerr.Wrap(err, "Failed to read response body").Build() } - etag := resp.Header.Get("ETag") - if etag == "" { - return "", time.Time{}, "", 0, exerr.New(exerr.TypeInternal, "ETag header is missing").Build() - } - etag = strings.Trim(etag, "\"\r\n ") + app.LogDebug(fmt.Sprintf("{HTTP} Finished WebDAV download in %s", time.Since(t0))) - lmStr := resp.Header.Get("Last-Modified") - lm, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", lmStr) + etag, lm, err := app.parseHeader(resp) if err != nil { - return "", time.Time{}, "", 0, exerr.Wrap(err, "Failed to parse Last-Modified header").Build() + return "", time.Time{}, "", 0, exerr.Wrap(err, "").Build() } - lm = lm.In(timeext.TimezoneBerlin) sha := cryptext.BytesSha256(bin) @@ -75,35 +85,33 @@ func (app *Application) getRemoteETag() (string, time.Time, error) { req.SetBasicAuth(app.config.WebDAVUser, app.config.WebDAVPass) + t0 := time.Now() + app.LogDebug(fmt.Sprintf("{HTTP} Starting WebDAV HEAD-request...")) + resp, err := client.Do(req) if err != nil { return "", time.Time{}, exerr.Wrap(err, "Failed to download remote database").Build() } + app.LogDebug(fmt.Sprintf("{HTTP} Finished WebDAV request in %s", time.Since(t0))) + if resp.StatusCode != http.StatusOK { return "", time.Time{}, exerr.New(exerr.TypeInternal, "Failed to download remote database").Int("sc", resp.StatusCode).Build() } - etag := resp.Header.Get("ETag") - if etag == "" { - return "", time.Time{}, exerr.New(exerr.TypeInternal, "ETag header is missing").Build() - } - etag = strings.Trim(etag, "\"\r\n ") - - lmStr := resp.Header.Get("Last-Modified") - - lm, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", lmStr) + etag, lm, err := app.parseHeader(resp) if err != nil { - return "", time.Time{}, exerr.Wrap(err, "Failed to parse Last-Modified header").Build() + return "", time.Time{}, exerr.Wrap(err, "").Build() } - lm = lm.In(timeext.TimezoneBerlin) - return etag, lm, nil } func (app *Application) uploadDatabase(etagIfMatch *string) (string, time.Time, string, int64, error) { + prevTT := app.currSysTrayTooltop + defer app.setTrayTooltip(prevTT) + client := http.Client{Timeout: 90 * time.Second} req, err := http.NewRequest("PUT", app.config.WebDAVURL, nil) @@ -111,6 +119,8 @@ func (app *Application) uploadDatabase(etagIfMatch *string) (string, time.Time, return "", time.Time{}, "", 0, exerr.Wrap(err, "").Build() } + req.SetBasicAuth(app.config.WebDAVUser, app.config.WebDAVPass) + if etagIfMatch != nil { req.Header.Set("If-Match", "\""+*etagIfMatch+"\"") } @@ -124,7 +134,20 @@ func (app *Application) uploadDatabase(etagIfMatch *string) (string, time.Time, sz := int64(len(bin)) + currTT := "" + progressCallback := func(current int64, total int64) { + newTT := fmt.Sprintf("Uploading (%.0f%%)", float64(current)/float64(total)*100) + if currTT != newTT { + app.setTrayTooltip(newTT) + currTT = newTT + } + } + req.ContentLength = sz + req.Body = NewProgressReader(bytes.NewReader(bin), int64(len(bin)), progressCallback) + + t0 := time.Now() + app.LogDebug(fmt.Sprintf("{HTTP} Starting WebDAV upload...")) resp, err := client.Do(req) if err != nil { @@ -132,20 +155,14 @@ func (app *Application) uploadDatabase(etagIfMatch *string) (string, time.Time, } defer func() { _ = resp.Body.Close() }() + app.LogDebug(fmt.Sprintf("{HTTP} Finished WebDAV upload in %s", time.Since(t0))) + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusNoContent { - etag := resp.Header.Get("ETag") - if etag == "" { - return "", time.Time{}, "", 0, exerr.New(exerr.TypeInternal, "ETag header is missing").Build() - } - etag = strings.Trim(etag, "\"\r\n ") - - lmStr := resp.Header.Get("Last-Modified") - lm, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", lmStr) + etag, lm, err := app.parseHeader(resp) if err != nil { - return "", time.Time{}, "", 0, exerr.Wrap(err, "Failed to parse Last-Modified header").Build() + return "", time.Time{}, "", 0, exerr.Wrap(err, "").Build() } - lm = lm.In(timeext.TimezoneBerlin) return etag, lm, sha, sz, nil } @@ -154,5 +171,31 @@ func (app *Application) uploadDatabase(etagIfMatch *string) (string, time.Time, return "", time.Time{}, "", 0, ETagConflictError } - return "", time.Time{}, "", 0, exerr.New(exerr.TypeInternal, "Failed to upload remote database").Int("sc", resp.StatusCode).Build() + return "", time.Time{}, "", 0, exerr.New(exerr.TypeInternal, fmt.Sprintf("Failed to upload remote database (statuscode: %d)", resp.StatusCode)).Int("sc", resp.StatusCode).Build() +} + +func (app *Application) parseHeader(resp *http.Response) (string, time.Time, error) { + var err error + + etag := resp.Header.Get("ETag") + if etag == "" { + return "", time.Time{}, exerr.New(exerr.TypeInternal, "ETag header is missing").Build() + } + etag = strings.Trim(etag, "\"\r\n ") + + var lm time.Time + + lmStr := resp.Header.Get("Last-Modified") + if lmStr == "" { + lm = time.Now().In(timeext.TimezoneBerlin) + app.LogDebug("Last-Modified header is missing, using current time as fallback") + } else { + lm, err = time.Parse("Mon, 02 Jan 2006 15:04:05 MST", lmStr) + if err != nil { + return "", time.Time{}, exerr.Wrap(err, "Failed to parse Last-Modified header").Build() + } + lm = lm.In(timeext.TimezoneBerlin) + } + + return etag, lm, nil }