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"` } func (a *App) SyncStatus() (*SyncStatusDTO, error) { serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState() if err != nil { return &SyncStatusDTO{}, nil } cfg, _ := config.Load(a.vault) deviceToken := config.LoadDeviceToken(a.vault) dto := &SyncStatusDTO{ Configured: serverURL != "" && (apiKey != "" || deviceToken != ""), ServerURL: serverURL, 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 { 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 } cfg, err := config.Load(a.vault) if err != nil { cfg = &config.Config{} } cfg.Sync.ServerURL = serverURL 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{} } 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) return client.TestAuth(serverURL, username, password) } func (a *App) SyncSetInterval(minutes int) error { cfg, err := config.Load(a.vault) if err != nil { cfg = &config.Config{} } 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, lastPullSeq, _, err := a.sync.GetState() deviceToken := config.LoadDeviceToken(a.vault) if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") { return nil, fmt.Errorf("sync not configured") } deviceID := "" if cfg, err := config.Load(a.vault); err == nil { deviceID = cfg.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 { 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 { 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)) 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 }