mirror of
https://github.com/Mikescher/kpsync.git
synced 2025-10-14 08:45:08 +02:00
download kind works
This commit is contained in:
@@ -3,18 +3,29 @@ package app
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"fyne.io/systray"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/syncext"
|
||||
"mikescher.com/kpsync"
|
||||
"mikescher.com/kpsync/assets"
|
||||
"mikescher.com/kpsync/log"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
masterLock sync.Mutex
|
||||
|
||||
config kpsync.Config
|
||||
|
||||
trayReady bool
|
||||
sigStopChan chan bool
|
||||
sigErrChan chan error
|
||||
trayReady bool
|
||||
uploadRunning *syncext.AtomicBool
|
||||
|
||||
sigStopChan chan bool // keepass exited
|
||||
sigErrChan chan error // fatal error
|
||||
|
||||
sigSyncLoopStopChan chan bool // stop sync loop
|
||||
sigTermKeepassChan chan bool // stop keepass
|
||||
|
||||
dbFile string
|
||||
stateFile string
|
||||
@@ -25,10 +36,14 @@ func NewApplication() *Application {
|
||||
cfg := kpsync.LoadConfig()
|
||||
|
||||
return &Application{
|
||||
config: cfg,
|
||||
trayReady: false,
|
||||
sigStopChan: make(chan bool, 128),
|
||||
sigErrChan: make(chan error, 128),
|
||||
masterLock: sync.Mutex{},
|
||||
config: cfg,
|
||||
uploadRunning: syncext.NewAtomicBool(false),
|
||||
trayReady: false,
|
||||
sigStopChan: make(chan bool, 128),
|
||||
sigErrChan: make(chan error, 128),
|
||||
sigSyncLoopStopChan: make(chan bool, 128),
|
||||
sigTermKeepassChan: make(chan bool, 128),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +57,9 @@ func (app *Application) Run() {
|
||||
app.sigErrChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
app.setTrayStateDirect("Sleeping...", assets.IconDefault)
|
||||
|
||||
err = app.runSyncLoop()
|
||||
if err != nil {
|
||||
app.sigErrChan <- err
|
||||
@@ -55,14 +73,27 @@ func (app *Application) Run() {
|
||||
select {
|
||||
case <-sigTerm:
|
||||
|
||||
app.sigSyncLoopStopChan <- true
|
||||
app.sigTermKeepassChan <- true
|
||||
log.LogInfo("Stopping application (received SIGTERM signal)")
|
||||
|
||||
// TODO term
|
||||
|
||||
case _ = <-app.sigErrChan:
|
||||
case err := <-app.sigErrChan:
|
||||
|
||||
app.sigSyncLoopStopChan <- true
|
||||
app.sigTermKeepassChan <- true
|
||||
log.LogInfo("Stopping application (received ERROR)")
|
||||
log.LogError(err.Error(), err)
|
||||
|
||||
// TODO stop
|
||||
|
||||
case _ = <-app.sigStopChan:
|
||||
|
||||
app.sigSyncLoopStopChan <- true
|
||||
app.sigTermKeepassChan <- true
|
||||
log.LogInfo("Stopping application (received STOP)")
|
||||
|
||||
// TODO stop
|
||||
}
|
||||
|
||||
|
166
app/sync.go
166
app/sync.go
@@ -1,11 +1,18 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/exerr"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/timeext"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"mikescher.com/kpsync/assets"
|
||||
"mikescher.com/kpsync/log"
|
||||
)
|
||||
@@ -20,6 +27,11 @@ func (app *Application) initSync() error {
|
||||
app.dbFile = path.Join(app.config.WorkDir, path.Base(app.config.LocalFallback))
|
||||
app.stateFile = path.Join(app.config.WorkDir, "kpsync.state")
|
||||
|
||||
if isKeepassRunning() {
|
||||
log.LogError("keepassxc is already running!", nil)
|
||||
return exerr.New(exerr.TypeInternal, "keepassxc is already running").Build()
|
||||
}
|
||||
|
||||
state := app.readState()
|
||||
|
||||
needsDownload := true
|
||||
@@ -29,16 +41,18 @@ func (app *Application) initSync() error {
|
||||
if err != nil {
|
||||
log.LogError("Failed to calculate local database checksum", err)
|
||||
} else if localCS == state.Checksum {
|
||||
remoteETag, err := app.getRemoteETag()
|
||||
remoteETag, remoteLM, err := app.getRemoteETag()
|
||||
if err != nil {
|
||||
log.LogError("Failed to get remote ETag", err)
|
||||
} else if remoteETag == state.ETag {
|
||||
|
||||
log.LogInfo(fmt.Sprintf("Found local database matching remote database - skip initial download"))
|
||||
log.LogInfo(fmt.Sprintf("Checksum (cached) := %s", state.Checksum))
|
||||
log.LogInfo(fmt.Sprintf("Checksum (local) := %s", localCS))
|
||||
log.LogInfo(fmt.Sprintf("ETag (cached) := %s", state.ETag))
|
||||
log.LogInfo(fmt.Sprintf("ETag (remote) := %s", remoteETag))
|
||||
log.LogInfo(fmt.Sprintf("Checksum (cached) := %s", state.Checksum))
|
||||
log.LogInfo(fmt.Sprintf("Checksum (local) := %s", localCS))
|
||||
log.LogInfo(fmt.Sprintf("ETag (cached) := %s", state.ETag))
|
||||
log.LogInfo(fmt.Sprintf("ETag (remote) := %s", remoteETag))
|
||||
log.LogInfo(fmt.Sprintf("LastModified (cached) := %s", state.LastModified.Format(time.RFC3339)))
|
||||
log.LogInfo(fmt.Sprintf("LastModified (remote) := %s", remoteLM.Format(time.RFC3339)))
|
||||
needsDownload = false
|
||||
|
||||
}
|
||||
@@ -46,22 +60,152 @@ func (app *Application) initSync() error {
|
||||
}
|
||||
|
||||
if needsDownload {
|
||||
func() {
|
||||
fin := app.setTrayState("Downloading database", assets.IconDefault)
|
||||
err = func() error {
|
||||
fin := app.setTrayState("Downloading database", assets.IconDownload)
|
||||
defer fin()
|
||||
|
||||
log.LogInfo(fmt.Sprintf("Downloading remote database to %s", app.dbFile))
|
||||
|
||||
etag, err := app.downloadDatabase()
|
||||
etag, lm, sha, sz, err := app.downloadDatabase()
|
||||
if err != nil {
|
||||
log.LogError("Failed to download remote database", err)
|
||||
app.sigErrChan <- exerr.Wrap(err, "Failed to download remote database").Build()
|
||||
return
|
||||
return exerr.Wrap(err, "Failed to download remote database").Build()
|
||||
}
|
||||
|
||||
log.LogInfo(fmt.Sprintf("Downloaded remote database to %s", app.dbFile))
|
||||
log.LogInfo(fmt.Sprintf("Checksum := %s", sha))
|
||||
log.LogInfo(fmt.Sprintf("ETag := %s", etag))
|
||||
log.LogInfo(fmt.Sprintf("Size := %s", langext.FormatBytes(sz)))
|
||||
log.LogInfo(fmt.Sprintf("LastModified := %s", lm.Format(time.RFC3339)))
|
||||
|
||||
err = app.saveState(etag, lm, sha, sz)
|
||||
if err != nil {
|
||||
log.LogError("Failed to save state", err)
|
||||
return exerr.Wrap(err, "Failed to save state").Build()
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
return exerr.Wrap(err, "").Build()
|
||||
}
|
||||
} else {
|
||||
log.LogInfo(fmt.Sprintf("Skip download - use existing local database %s", app.dbFile))
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
log.LogInfo("Starting keepassxc...")
|
||||
|
||||
cmd := exec.Command("keepassxc", app.dbFile)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-app.sigTermKeepassChan:
|
||||
log.LogInfo("Received signal to terminate keepassxc")
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
log.LogInfo(fmt.Sprintf("Terminating keepassxc %d", cmd.Process.Pid))
|
||||
err := cmd.Process.Signal(syscall.SIGTERM)
|
||||
if err != nil {
|
||||
log.LogError("Failed to terminate keepassxc", err)
|
||||
} else {
|
||||
log.LogInfo("keepassxc terminated successfully")
|
||||
}
|
||||
} else {
|
||||
log.LogInfo("No keepassxc process to terminate")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
log.LogError("Failed to start keepassxc", err)
|
||||
app.sigErrChan <- exerr.Wrap(err, "Failed to start keepassxc").Build()
|
||||
return
|
||||
}
|
||||
|
||||
log.LogInfo(fmt.Sprintf("keepassxc started with PID %d", cmd.Process.Pid))
|
||||
|
||||
err = cmd.Wait()
|
||||
|
||||
exitErr := &exec.ExitError{}
|
||||
if errors.As(err, &exitErr) {
|
||||
|
||||
log.LogInfo(fmt.Sprintf("keepass exited with code %d", exitErr.ExitCode()))
|
||||
app.sigStopChan <- true
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.LogError("Failed to run keepassxc", err)
|
||||
app.sigErrChan <- exerr.Wrap(err, "Failed to run keepassxc").Build()
|
||||
return
|
||||
}
|
||||
|
||||
log.LogInfo("keepassxc exited successfully")
|
||||
app.sigStopChan <- true
|
||||
return
|
||||
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *Application) runSyncLoop() error {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return exerr.Wrap(err, "failed to init file-watcher").Build()
|
||||
}
|
||||
defer func() { _ = watcher.Close() }()
|
||||
|
||||
err = watcher.Add(app.config.WorkDir)
|
||||
if err != nil {
|
||||
return exerr.Wrap(err, "").Build()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-app.sigSyncLoopStopChan:
|
||||
log.LogInfo("Stopping sync loop (received signal)")
|
||||
return nil
|
||||
|
||||
case event := <-watcher.Events:
|
||||
log.LogInfo(fmt.Sprintf("inotify event: [%s] %s", event.Op.String(), event.Name))
|
||||
|
||||
if event.Has(fsnotify.Write) && event.Name == app.dbFile {
|
||||
func() {
|
||||
app.masterLock.Lock()
|
||||
app.uploadRunning.Wait(false)
|
||||
app.uploadRunning.Set(true)
|
||||
app.masterLock.Unlock()
|
||||
|
||||
defer app.uploadRunning.Set(false)
|
||||
|
||||
log.LogInfo("Database file was modified")
|
||||
log.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 {
|
||||
log.LogError("Failed to calculate local database checksum", err)
|
||||
return
|
||||
}
|
||||
|
||||
if localCS == state.Checksum {
|
||||
log.LogInfo("Local database still matches remote (via checksum) - no need to upload")
|
||||
log.LogInfo(fmt.Sprintf("Checksum (remote/cached) := %s", state.Checksum))
|
||||
log.LogInfo(fmt.Sprintf("Checksum (local) := %s", localCS))
|
||||
return
|
||||
}
|
||||
|
||||
//TODO upload with IfMatch
|
||||
}()
|
||||
}
|
||||
case err := <-watcher.Errors:
|
||||
log.LogError("Filewatcher reported an error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
38
app/tray.go
38
app/tray.go
@@ -18,3 +18,41 @@ func (app *Application) initTray() {
|
||||
|
||||
systray.Run(trayOnReady, nil)
|
||||
}
|
||||
|
||||
func (app *Application) setTrayState(txt string, icon []byte) func() {
|
||||
if !app.trayReady {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
app.masterLock.Lock()
|
||||
defer app.masterLock.Unlock()
|
||||
|
||||
systray.SetIcon(icon)
|
||||
systray.SetTooltip(txt)
|
||||
|
||||
fin := func() {
|
||||
app.masterLock.Lock()
|
||||
defer app.masterLock.Unlock()
|
||||
|
||||
if !app.trayReady {
|
||||
return
|
||||
}
|
||||
|
||||
systray.SetIcon(assets.IconDefault)
|
||||
systray.SetTooltip("Sleeping...")
|
||||
}
|
||||
|
||||
return fin
|
||||
}
|
||||
|
||||
func (app *Application) setTrayStateDirect(txt string, icon []byte) {
|
||||
if !app.trayReady {
|
||||
return
|
||||
}
|
||||
|
||||
app.masterLock.Lock()
|
||||
defer app.masterLock.Unlock()
|
||||
|
||||
systray.SetIcon(icon)
|
||||
systray.SetTooltip(txt)
|
||||
}
|
||||
|
59
app/utils.go
59
app/utils.go
@@ -3,9 +3,13 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/cryptext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/exerr"
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
"mikescher.com/kpsync/log"
|
||||
)
|
||||
|
||||
func fileExists(p string) bool {
|
||||
@@ -17,12 +21,16 @@ func fileExists(p string) bool {
|
||||
}
|
||||
|
||||
type State struct {
|
||||
ETag string `json:"etag"`
|
||||
Size int64 `json:"size"`
|
||||
Checksum string `json:"checksum"`
|
||||
ETag string `json:"etag"`
|
||||
Size int64 `json:"size"`
|
||||
Checksum string `json:"checksum"`
|
||||
LastModified time.Time `json:"lastModified"`
|
||||
}
|
||||
|
||||
func (app *Application) readState() *State {
|
||||
app.masterLock.Lock()
|
||||
defer app.masterLock.Unlock()
|
||||
|
||||
bin, err := os.ReadFile(app.stateFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -37,6 +45,30 @@ func (app *Application) readState() *State {
|
||||
return &state
|
||||
}
|
||||
|
||||
func (app *Application) saveState(eTag string, lastModified time.Time, checksum string, size int64) error {
|
||||
app.masterLock.Lock()
|
||||
defer app.masterLock.Unlock()
|
||||
|
||||
obj := State{
|
||||
ETag: eTag,
|
||||
Size: size,
|
||||
Checksum: checksum,
|
||||
LastModified: lastModified,
|
||||
}
|
||||
|
||||
bin, err := json.MarshalIndent(obj, "", " ")
|
||||
if err != nil {
|
||||
return exerr.Wrap(err, "Failed to marshal state").Build()
|
||||
}
|
||||
|
||||
err = os.WriteFile(app.stateFile, bin, 0644)
|
||||
if err != nil {
|
||||
return exerr.Wrap(err, "Failed to write state file").Build()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *Application) calcLocalChecksum() (string, error) {
|
||||
bin, err := os.ReadFile(app.dbFile)
|
||||
if err != nil {
|
||||
@@ -45,3 +77,24 @@ func (app *Application) calcLocalChecksum() (string, error) {
|
||||
|
||||
return cryptext.BytesSha256(bin), nil
|
||||
}
|
||||
|
||||
func isKeepassRunning() bool {
|
||||
proc, err := process.Processes()
|
||||
if err != nil {
|
||||
log.LogError("failed to query existing keepass process", err)
|
||||
return false
|
||||
}
|
||||
|
||||
for _, p := range proc {
|
||||
name, err := p.Name()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.ToLower(name) == "keepass" || strings.ToLower(name) == "keepassxc" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@@ -1,5 +1,99 @@
|
||||
package app
|
||||
|
||||
func (app *Application) initSync() {
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/cryptext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/exerr"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/timeext"
|
||||
)
|
||||
|
||||
func (app *Application) downloadDatabase() (string, time.Time, string, int64, error) {
|
||||
|
||||
client := http.Client{Timeout: 90 * time.Second}
|
||||
|
||||
req, err := http.NewRequest("GET", app.config.WebDAVURL, nil)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", 0, exerr.Wrap(err, "").Build()
|
||||
}
|
||||
|
||||
req.SetBasicAuth(app.config.WebDAVUser, app.config.WebDAVPass)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", 0, exerr.Wrap(err, "Failed to download remote database").Build()
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", time.Time{}, "", 0, exerr.New(exerr.TypeInternal, "Failed to download remote database").Int("sc", resp.StatusCode).Build()
|
||||
}
|
||||
|
||||
bin, err := io.ReadAll(resp.Body)
|
||||
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()
|
||||
}
|
||||
|
||||
lmStr := resp.Header.Get("Last-Modified")
|
||||
|
||||
lm, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", lmStr)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", 0, exerr.Wrap(err, "Failed to parse Last-Modified header").Build()
|
||||
}
|
||||
|
||||
lm = lm.In(timeext.TimezoneBerlin)
|
||||
|
||||
sha := cryptext.BytesSha256(bin)
|
||||
|
||||
sz := int64(len(bin))
|
||||
|
||||
err = os.WriteFile(app.dbFile, bin, 0644)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", 0, exerr.Wrap(err, "Failed to write database file").Build()
|
||||
}
|
||||
|
||||
return etag, lm, sha, sz, nil
|
||||
}
|
||||
|
||||
func (app *Application) getRemoteETag() (string, time.Time, error) {
|
||||
client := http.Client{Timeout: 90 * time.Second}
|
||||
|
||||
req, err := http.NewRequest("HEAD", app.config.WebDAVURL, nil)
|
||||
if err != nil {
|
||||
return "", time.Time{}, exerr.Wrap(err, "").Build()
|
||||
}
|
||||
|
||||
req.SetBasicAuth(app.config.WebDAVUser, app.config.WebDAVPass)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, exerr.Wrap(err, "Failed to download remote database").Build()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
lmStr := resp.Header.Get("Last-Modified")
|
||||
|
||||
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
|
||||
}
|
||||
|
Reference in New Issue
Block a user