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:
2026-06-02 02:26:05 +08:00
parent 7fe02fc8df
commit 87c8dfcbea
15 changed files with 1002 additions and 253 deletions
+138 -16
View File
@@ -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
+2 -2
View File
@@ -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>