verstak/cmd/verstak-gui/bindings_sync.go

371 lines
9.9 KiB
Go

package main
import (
"fmt"
"log"
"os"
"time"
"verstak/internal/core/config"
syncsvc "verstak/internal/core/sync"
)
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"`
LastError string `json:"lastError"`
StatusLabel string `json:"statusLabel"` // human-readable status
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
if !a.IsReady() {
return &SyncStatusDTO{}, nil
}
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
if err != nil {
return &SyncStatusDTO{}, nil
}
appCfg, _ := config.LoadAppConfig()
deviceToken := config.LoadDeviceToken(a.vault)
dto := &SyncStatusDTO{
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
ServerURL: serverURL,
LastSyncAt: lastSyncAt,
UnpushedOps: 0,
TokenStored: deviceToken != "",
}
if appCfg != nil {
dto.DeviceID = appCfg.Vault.Sync.DeviceID
dto.SyncInterval = appCfg.Vault.Sync.SyncInterval
dto.LastError = appCfg.Vault.Sync.LastError
}
unpushed, _ := a.sync.GetUnpushedOps()
dto.UnpushedOps = len(unpushed)
if deviceToken != "" {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
client.DeviceToken = deviceToken
if appCfg != nil {
client.DeviceID = appCfg.Vault.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
}
}
}
// Build status label
switch {
case dto.Revoked:
dto.StatusLabel = "revoked"
case dto.Connected:
dto.StatusLabel = "connected"
case dto.Configured:
dto.StatusLabel = "disconnected"
default:
dto.StatusLabel = "disabled"
}
// Update config with latest status
if appCfg != nil {
changed := false
if dto.LastSyncAt != "" && appCfg.Vault.Sync.LastSyncAt != dto.LastSyncAt {
appCfg.Vault.Sync.LastSyncAt = dto.LastSyncAt
changed = true
}
if appCfg.Vault.Sync.LastStatus != dto.StatusLabel {
appCfg.Vault.Sync.LastStatus = dto.StatusLabel
changed = true
}
if changed {
_ = config.SaveAppConfig(appCfg)
}
}
return dto, nil
}
type SyncSettingsDTO struct {
Enabled bool `json:"enabled"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
SyncInterval int `json:"syncInterval"`
LastStatus string `json:"lastStatus"`
LastSyncAt string `json:"lastSyncAt"`
LastError string `json:"lastError"`
TokenStored bool `json:"tokenStored"`
}
func (a *App) GetSyncSettings() (*SyncSettingsDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
deviceToken := config.LoadDeviceToken(a.vault)
return &SyncSettingsDTO{
Enabled: appCfg.Vault.Sync.Enabled,
ServerURL: appCfg.Vault.Sync.ServerURL,
DeviceID: appCfg.Vault.Sync.DeviceID,
DeviceName: appCfg.Vault.Sync.DeviceName,
SyncInterval: appCfg.Vault.Sync.SyncInterval,
LastStatus: appCfg.Vault.Sync.LastStatus,
LastSyncAt: appCfg.Vault.Sync.LastSyncAt,
LastError: appCfg.Vault.Sync.LastError,
TokenStored: deviceToken != "",
}, nil
}
func (a *App) SyncConfigure(serverURL, username, password string) error {
if err := a.requireVault(); err != nil {
return err
}
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
client := syncsvc.NewClient(serverURL, "", "", a.vault)
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
if err != nil {
return fmt.Errorf("pair: %w", err)
}
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
}
// Update global config
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.Vault.Sync.Enabled = true
appCfg.Vault.Sync.ServerURL = serverURL
appCfg.Vault.Sync.DeviceID = deviceID
appCfg.Vault.Sync.DeviceName = hostname
appCfg.Vault.Sync.LastStatus = "connected"
_ = config.SaveAppConfig(appCfg)
return nil
}
func (a *App) SyncDisconnect() error {
if err := a.requireVault(); err != nil {
return err
}
deviceToken := config.LoadDeviceToken(a.vault)
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
if deviceToken != "" {
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", "", a.vault)
client.DeviceToken = deviceToken
_ = client.RevokeCurrent()
}
config.RemoveDeviceToken(a.vault)
appCfg.Vault.Sync.Enabled = false
appCfg.Vault.Sync.ServerURL = ""
appCfg.Vault.Sync.DeviceID = ""
appCfg.Vault.Sync.DeviceName = ""
appCfg.Vault.Sync.LastStatus = "disabled"
appCfg.Vault.Sync.LastError = ""
if err := config.SaveAppConfig(appCfg); err != nil {
return err
}
return a.sync.SetState("", "")
}
func (a *App) SyncTestConnection(serverURL, username, password string) error {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
return client.TestAuth(serverURL, username, password)
}
func (a *App) SyncSetInterval(minutes int) error {
if err := a.requireVault(); err != nil {
return err
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.Vault.Sync.SyncInterval = minutes
if appCfg.Vault.Sync.DeviceID == "" && a.sync != nil {
appCfg.Vault.Sync.DeviceID = a.sync.GetDeviceID()
}
return config.SaveAppConfig(appCfg)
}
func (a *App) SyncNow() (map[string]interface{}, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
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")
}
deviceID := ""
appCfg, _ := config.LoadAppConfig()
if appCfg != nil {
deviceID = appCfg.Vault.Sync.DeviceID
}
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
client.DeviceToken = deviceToken
unpushed, err := a.sync.GetUnpushedOps()
if err != nil {
return nil, fmt.Errorf("get ops: %w", err)
}
for i := range unpushed {
unpushed[i].LastSeenServerSeq = lastPullSeq
}
pushResult := &syncsvc.PushResponse{}
if len(unpushed) > 0 {
pushResult, err = client.Push(unpushed)
if err != nil {
_ = a.updateSyncError(fmt.Sprintf("push: %v", err))
return nil, fmt.Errorf("push: %w", err)
}
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
return nil, fmt.Errorf("mark pushed: %w", err)
}
}
pullResult, err := client.Pull(lastPullSeq)
if err != nil {
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
return nil, fmt.Errorf("pull: %w", err)
}
var applyErrors []string
for _, op := range pullResult.Ops {
if err := a.applyRemoteOp(op); err != nil {
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
}
_ = a.sync.RecordRemoteOp(op)
}
if len(pullResult.Ops) > 0 {
opIDs := make([]string, len(pullResult.Ops))
for i, op := range pullResult.Ops {
opIDs[i] = op.OpID
}
_ = a.sync.MarkApplied(opIDs)
}
if len(pushResult.Conflicts) > 0 {
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
for _, c := range pushResult.Conflicts {
log.Printf("[sync] conflict: op=%v entity=%v/%v",
c["op_id"], c["entity_type"], c["entity_id"])
}
}
if pullResult.ServerSequence > lastPullSeq {
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
}
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
// Update config with success
now := time.Now().UTC().Format(time.RFC3339)
a.updateSyncSuccess(now)
result := map[string]interface{}{
"pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops),
"serverSequence": pullResult.ServerSequence,
}
if len(applyErrors) > 0 {
result["applyErrors"] = applyErrors
}
if len(pushResult.Conflicts) > 0 {
result["conflicts"] = pushResult.Conflicts
}
return result, nil
}
func (a *App) updateSyncError(errMsg string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastError = errMsg
appCfg.Vault.Sync.LastStatus = "error"
return config.SaveAppConfig(appCfg)
}
func (a *App) updateSyncSuccess(lastSyncAt string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastError = ""
appCfg.Vault.Sync.LastStatus = "connected"
appCfg.Vault.Sync.LastSyncAt = lastSyncAt
return config.SaveAppConfig(appCfg)
}
// CheckSyncConnection tests the current sync connection.
func (a *App) CheckSyncConnection() (bool, string) {
if !a.IsReady() {
return false, "vault not open"
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil || !appCfg.Vault.Sync.Enabled {
return false, "sync not configured"
}
deviceToken := config.LoadDeviceToken(a.vault)
if deviceToken == "" {
return false, "no device token"
}
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", appCfg.Vault.Sync.DeviceID, a.vault)
client.DeviceToken = deviceToken
info, err := client.GetMe()
if err != nil {
return false, err.Error()
}
if info.RevokedAt != "" {
return false, "device revoked"
}
return true, ""
}
// ResetSyncKey clears the device token and resets sync state.
func (a *App) ResetSyncKey() error {
if err := a.requireVault(); err != nil {
return err
}
config.RemoveDeviceToken(a.vault)
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastStatus = "disabled"
appCfg.Vault.Sync.LastError = ""
return config.SaveAppConfig(appCfg)
}