sync: overhaul sync system — device pairing, server_sequence, auto-sync, dashboards
BREAKING: replace legacy API keys with device tokens via pairing flow. - Server: /api/client/pair, revoke, me endpoints; server_sequence + tombstones + idempotency - Desktop client: PairDevice, GetMe, RevokeCurrent; auto-sync loop every 60s - Config: device_token stored in separate file (0600), not config.yml - Client DB: last_pull_seq migration for incremental pull - Frontend (Svelte): settings modal with connect/disconnect/interval - User dashboard (/dashboard): device list with status, revoke with password - Admin dashboard (/admin/dashboard): devices table from /admin/api/devices - CLI (cmd/verstak): updated for ServerSequence/GetState changes - Fix: autoSyncLoop falls back to SQLite sync_state for server URL - Fix: SyncSetInterval preserves server_url/device_id from SQLite
This commit is contained in:
+138
-16
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -51,6 +52,49 @@ func (a *App) startup(ctx context.Context) {
|
||||
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
||||
}
|
||||
})
|
||||
go a.autoSyncLoop()
|
||||
}
|
||||
|
||||
func (a *App) autoSyncLoop() {
|
||||
const checkInterval = 60 * time.Second
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("[autosync] started, vault=%s", a.vault)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
serverURL := ""
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err == nil {
|
||||
serverURL = cfg.Sync.ServerURL
|
||||
}
|
||||
// Fall back to SQLite sync_state if config doesn't have it.
|
||||
if serverURL == "" {
|
||||
sURL, _, _, _, _ := a.sync.GetState()
|
||||
serverURL = sURL
|
||||
}
|
||||
if serverURL == "" {
|
||||
log.Printf("[autosync] no server URL")
|
||||
continue
|
||||
}
|
||||
if cfg != nil && cfg.Sync.SyncInterval <= 0 {
|
||||
log.Printf("[autosync] interval=%d, skipping", cfg.Sync.SyncInterval)
|
||||
continue
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if deviceToken == "" {
|
||||
log.Printf("[autosync] no device token")
|
||||
continue
|
||||
}
|
||||
log.Printf("[autosync] running SyncNow...")
|
||||
if _, err := a.SyncNow(); err != nil {
|
||||
log.Printf("[autosync] SyncNow error: %v", err)
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
log.Printf("[autosync] stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -844,6 +888,10 @@ type SyncStatusDTO struct {
|
||||
Configured bool `json:"configured"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
Connected bool `json:"connected"`
|
||||
Revoked bool `json:"revoked"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
UnpushedOps int `json:"unpushedOps"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
@@ -854,65 +902,120 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
if err != nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
unpushed, _ := a.sync.GetUnpushedOps()
|
||||
cfg, _ := config.Load(a.vault)
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
dto := &SyncStatusDTO{
|
||||
Configured: serverURL != "" && apiKey != "",
|
||||
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||
ServerURL: serverURL,
|
||||
UnpushedOps: len(unpushed),
|
||||
LastSyncAt: lastSyncAt,
|
||||
UnpushedOps: 0,
|
||||
TokenStored: deviceToken != "",
|
||||
}
|
||||
if cfg != nil {
|
||||
dto.DeviceID = cfg.Sync.DeviceID
|
||||
dto.SyncInterval = cfg.Sync.SyncInterval
|
||||
}
|
||||
unpushed, _ := a.sync.GetUnpushedOps()
|
||||
dto.UnpushedOps = len(unpushed)
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
if cfg != nil {
|
||||
client.DeviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
if info, err := client.GetMe(); err == nil {
|
||||
dto.DeviceName = info.DeviceName
|
||||
dto.DeviceID = info.DeviceID
|
||||
dto.Connected = true
|
||||
if info.RevokedAt != "" {
|
||||
dto.Revoked = true
|
||||
dto.Connected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
// Register device on server with user credentials.
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
|
||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
|
||||
if err != nil {
|
||||
return fmt.Errorf("register: %w", err)
|
||||
return fmt.Errorf("pair: %w", err)
|
||||
}
|
||||
|
||||
if err := a.sync.SetState(serverURL, apiKey); err != nil {
|
||||
// Save token to separate file with 0600 perms.
|
||||
if err := config.SaveDeviceToken(a.vault, deviceToken); err != nil {
|
||||
return fmt.Errorf("save token: %w", err)
|
||||
}
|
||||
if err := a.sync.SetState(serverURL, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
// Persist to vault config.
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
return err
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
cfg.Sync.ServerURL = serverURL
|
||||
cfg.Sync.APIKey = apiKey
|
||||
cfg.Sync.DeviceID = deviceID
|
||||
cfg.Sync.APIKey = ""
|
||||
return config.Save(a.vault, cfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncDisconnect() error {
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
// Revoke token on server if we have one.
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
_ = client.RevokeCurrent()
|
||||
}
|
||||
config.RemoveDeviceToken(a.vault)
|
||||
cfg.Sync.ServerURL = ""
|
||||
cfg.Sync.DeviceID = ""
|
||||
cfg.Sync.APIKey = ""
|
||||
if err := config.Save(a.vault, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.sync.SetState("", "")
|
||||
}
|
||||
|
||||
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
|
||||
_, _, err := client.PairDevice(serverURL, username, password, "test-connection", "verstak-gui/v2")
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
return err
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
// If config lost the server URL, restore from sync_state.
|
||||
if cfg.Sync.ServerURL == "" {
|
||||
sURL, _, _, _, _ := a.sync.GetState()
|
||||
if sURL != "" {
|
||||
cfg.Sync.ServerURL = sURL
|
||||
}
|
||||
}
|
||||
if cfg.Sync.DeviceID == "" {
|
||||
cfg.Sync.DeviceID = a.sync.GetDeviceID()
|
||||
}
|
||||
cfg.Sync.SyncInterval = minutes
|
||||
return config.Save(a.vault, cfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
|
||||
if err != nil || serverURL == "" || apiKey == "" {
|
||||
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||
return nil, fmt.Errorf("sync not configured")
|
||||
}
|
||||
|
||||
@@ -922,6 +1025,7 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
|
||||
// Push unpushed ops.
|
||||
unpushed, err := a.sync.GetUnpushedOps()
|
||||
@@ -940,15 +1044,33 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
// Pull remote ops.
|
||||
pullResult, err := client.Pull(lastRev)
|
||||
pullResult, err := client.Pull(lastPullSeq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
|
||||
if len(pullResult.Ops) > 0 {
|
||||
// Apply pulled ops locally (record as remote ops, mark applied).
|
||||
for _, op := range pullResult.Ops {
|
||||
_ = a.sync.RecordRemoteOp(op)
|
||||
}
|
||||
opIDs := make([]string, len(pullResult.Ops))
|
||||
for i, op := range pullResult.Ops {
|
||||
opIDs[i] = op.OpID
|
||||
}
|
||||
_ = a.sync.MarkApplied(opIDs)
|
||||
}
|
||||
|
||||
// Update sync state.
|
||||
if pullResult.ServerSequence > lastPullSeq {
|
||||
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
|
||||
}
|
||||
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
return map[string]interface{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
"serverRevision": pullResult.ServerRevision,
|
||||
"serverSequence": pullResult.ServerSequence,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-Dk1pVsWM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DVpSwKcZ.css">
|
||||
<script type="module" crossorigin src="/assets/main-CvznySlT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bkv7FuGB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user