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) }