371 lines
9.9 KiB
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)
|
|
}
|