fix: bind sync state to active vault

This commit is contained in:
mirivlad 2026-07-10 03:13:04 +08:00
parent b845e8b5e2
commit 18da09e06b
5 changed files with 311 additions and 14 deletions

View File

@ -108,6 +108,9 @@ func NewApp(
debug: debugEnabled,
activityEvents: make(map[string]bool),
}
if app.syncSvc == nil {
app.rebindSyncService()
}
app.ensureActivityProviderSubscriptions()
app.startFileWatcherForOpenVault()
return app
@ -714,6 +717,7 @@ func (a *App) CreateVault(path string) error {
if err := a.vault.CreateVault(path); err != nil {
return err
}
a.rebindSyncService()
a.secretsSession = nil
a.startFileWatcherForOpenVault()
return nil
@ -727,6 +731,7 @@ func (a *App) OpenVault(path string) error {
if err := a.vault.OpenVault(path); err != nil {
return err
}
a.rebindSyncService()
a.secretsSession = nil
a.startFileWatcherForOpenVault()
return nil
@ -741,6 +746,7 @@ func (a *App) CloseVault() error {
a.fileWatcher.Stop()
}
a.vault.CloseVault()
a.syncSvc = nil
a.secretsSession = nil
return nil
}
@ -1656,6 +1662,7 @@ func (a *App) SetCurrentVault(path string) string {
a.secretsSession = nil
// Save the actual vault path (normalized by OpenVault, includes VerstakVault/)
vaultPath := a.vault.GetVaultPath()
a.rebindSyncService()
if err := a.appSettings.SetCurrentVault(vaultPath); err != nil {
return fmt.Sprintf("failed to save app settings: %v", err)
}
@ -1685,6 +1692,16 @@ func (a *App) SetCurrentVault(path string) string {
return ""
}
func (a *App) rebindSyncService() {
if a == nil || a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
if a != nil {
a.syncSvc = nil
}
return
}
a.syncSvc = syncsvc.NewService(a.vault.GetVaultPath(), "")
}
func (a *App) startFileWatcherForOpenVault() {
if a == nil || a.vault == nil || a.eventBus == nil {
return
@ -2160,7 +2177,9 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) {
LastError: cfg.Sync.LastError,
}
if cfg.Sync.DeviceID != "" {
if deviceID := a.syncSvc.GetDeviceID(); deviceID != "" {
dto.DeviceID = deviceID
} else if cfg.Sync.DeviceID != "" {
dto.DeviceID = cfg.Sync.DeviceID
}
@ -2170,10 +2189,13 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) {
if deviceToken != "" {
client := newSyncClient(serverURL, "", "", vaultPath)
client.DeviceToken = deviceToken
if cfg.Sync.DeviceID != "" {
client.DeviceID = cfg.Sync.DeviceID
if dto.DeviceID != "" {
client.DeviceID = dto.DeviceID
}
if info, err := client.GetMe(); err == nil {
if info.DeviceID != "" {
_ = a.syncSvc.SetDeviceID(info.DeviceID)
}
dto.DeviceName = info.DeviceName
dto.DeviceID = info.DeviceID
dto.Connected = true
@ -2224,19 +2246,24 @@ func (a *App) syncConfigure(serverURL, username, password string) error {
if err := a.requireVault(); err != nil {
return err
}
meta := a.vault.GetVaultMeta()
if meta == nil || strings.TrimSpace(meta.VaultID) == "" {
return fmt.Errorf("vault ID is unavailable")
}
vaultPath := a.vaultPath()
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
client := newSyncClient(serverURL, "", "", vaultPath)
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2", meta.VaultID)
if err != nil {
return fmt.Errorf("pair: %w", err)
}
if err := syncsvc.SaveDeviceToken(vaultPath, deviceToken); err != nil {
return fmt.Errorf("save token: %w", err)
}
a.syncSvc = syncsvc.NewService(vaultPath, deviceID)
if err := a.syncSvc.SetState(serverURL, ""); err != nil {
return err
}
@ -2271,9 +2298,15 @@ func (a *App) syncDisconnect() error {
vaultPath := a.vaultPath()
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
cfg := a.appSettings.Get()
serverURL := cfg.Sync.ServerURL
if a.syncSvc != nil {
if currentServerURL, _, _, _, err := a.syncSvc.GetState(); err == nil && currentServerURL != "" {
serverURL = currentServerURL
}
}
if deviceToken != "" {
client := newSyncClient(cfg.Sync.ServerURL, "", "", vaultPath)
if deviceToken != "" && serverURL != "" {
client := newSyncClient(serverURL, "", "", vaultPath)
client.DeviceToken = deviceToken
_ = client.RevokeCurrent()
}
@ -2288,6 +2321,9 @@ func (a *App) syncDisconnect() error {
if err := a.appSettings.UpdateSync(cfg.Sync); err != nil {
return err
}
if a.syncSvc == nil {
return nil
}
return a.syncSvc.SetState("", "")
}
@ -2350,6 +2386,7 @@ func (a *App) syncResetKey() error {
return err
}
vaultPath := a.vaultPath()
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
if err := syncsvc.RemoveDeviceToken(vaultPath); err != nil && !os.IsNotExist(err) {
return err
}
@ -2361,7 +2398,14 @@ func (a *App) syncResetKey() error {
cfg.Sync.LastStatus = "disconnected"
cfg.Sync.LastError = ""
if a.syncSvc != nil {
if err := a.syncSvc.SetState(cfg.Sync.ServerURL, ""); err != nil {
serverURL, _, _, _, err := a.syncSvc.GetState()
if err != nil {
return err
}
if serverURL == "" && deviceToken != "" {
serverURL = cfg.Sync.ServerURL
}
if err := a.syncSvc.SetState(serverURL, ""); err != nil {
return err
}
}
@ -2384,6 +2428,12 @@ func (a *App) syncNow() (map[string]interface{}, error) {
return nil, err
}
vaultPath := a.vaultPath()
if a.syncSvc == nil {
a.rebindSyncService()
}
if a.syncSvc == nil {
return nil, fmt.Errorf("sync service not initialized")
}
serverURL, apiKey, lastPullSeq, _, err := a.syncSvc.GetState()
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
@ -2391,14 +2441,28 @@ func (a *App) syncNow() (map[string]interface{}, error) {
return nil, fmt.Errorf("sync not configured")
}
deviceID := ""
deviceID := a.syncSvc.GetDeviceID()
cfg := a.appSettings.Get()
if cfg.Sync.DeviceID != "" {
if deviceID == "" && deviceToken == "" && cfg.Sync.DeviceID != "" {
deviceID = cfg.Sync.DeviceID
}
client := newSyncClient(serverURL, apiKey, deviceID, vaultPath)
client.DeviceToken = deviceToken
if deviceID == "" && deviceToken != "" {
info, err := client.GetMe()
if err != nil {
return nil, fmt.Errorf("sync identity: %w", err)
}
if info.DeviceID == "" {
return nil, fmt.Errorf("sync identity: server returned an empty device ID")
}
if err := a.syncSvc.SetDeviceID(info.DeviceID); err != nil {
return nil, fmt.Errorf("save sync identity: %w", err)
}
deviceID = info.DeviceID
client.DeviceID = deviceID
}
unpushed, err := a.syncSvc.GetUnpushedOps()
if err != nil {
@ -2637,14 +2701,19 @@ func syncPayloadPath(op syncsvc.Op, payload syncFilePayload) string {
}
func (a *App) localSyncDeviceID() string {
if a.syncSvc != nil {
if deviceID := a.syncSvc.GetDeviceID(); deviceID != "" {
return deviceID
}
}
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen && syncsvc.LoadDeviceToken(a.vaultPath()) != "" {
return ""
}
if a.appSettings != nil {
if deviceID := a.appSettings.Get().Sync.DeviceID; deviceID != "" {
return deviceID
}
}
if a.syncSvc != nil {
return a.syncSvc.GetDeviceID()
}
return ""
}

View File

@ -1104,6 +1104,174 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
}
}
func TestSyncConfigurePairsCurrentVaultID(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
meta := app.vault.GetVaultMeta()
if meta == nil || meta.VaultID == "" {
t.Fatal("test vault must have an ID")
}
var pairedVaultID string
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/client/pair" {
http.NotFound(w, r)
return
}
var request struct {
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pairedVaultID = request.VaultID
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"device_id": "paired-device",
"device_token": "paired-token",
})
}))
defer server.Close()
if err := app.syncConfigure(server.URL, "alice", "secret"); err != nil {
t.Fatalf("syncConfigure: %v", err)
}
if pairedVaultID != meta.VaultID {
t.Fatalf("paired vault ID = %q, want %q", pairedVaultID, meta.VaultID)
}
}
func TestSyncNowHydratesLegacyVaultDeviceID(t *testing.T) {
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "wrong-global-device")
var pushedDeviceID string
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer vault-token" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/client/me":
_ = json.NewEncoder(w).Encode(map[string]string{"device_id": "vault-device"})
case "/api/v1/sync/push":
var request struct {
DeviceID string `json:"device_id"`
Ops []struct {
OpID string `json:"op_id"`
} `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pushedDeviceID = request.DeviceID
accepted := make([]string, 0, len(request.Ops))
for _, op := range request.Ops {
accepted = append(accepted, op.OpID)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"accepted": accepted,
"count": len(accepted),
"conflicts": []map[string]interface{}{},
})
case "/api/v1/sync/pull":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"server_sequence": 0,
"ops": []map[string]interface{}{},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
app.syncSvc = syncsvc.NewService(root, "")
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
t.Fatalf("SetState: %v", err)
}
if err := syncsvc.SaveDeviceToken(root, "vault-token"); err != nil {
t.Fatalf("SaveDeviceToken: %v", err)
}
if err := app.syncSvc.RecordOp(syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, map[string]string{"path": "Docs/one.txt"}); err != nil {
t.Fatalf("RecordOp: %v", err)
}
if _, err := app.syncNow(); err != nil {
t.Fatalf("syncNow: %v", err)
}
if pushedDeviceID != "vault-device" {
t.Fatalf("pushed device ID = %q, want vault-device", pushedDeviceID)
}
if app.syncSvc.GetDeviceID() != "vault-device" {
t.Fatalf("persisted device ID = %q, want vault-device", app.syncSvc.GetDeviceID())
}
}
func TestVaultTransitionsRebindSyncService(t *testing.T) {
settings := appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := settings.Load(); err != nil {
t.Fatalf("settings Load: %v", err)
}
app := &App{
capRegistry: capability.NewRegistry(),
vault: vault.NewVault(nil),
appSettings: settings,
}
if err := app.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
if app.syncSvc == nil {
t.Fatal("CreateVault must initialize the sync service")
}
vaultB := vault.NewVault(nil)
if err := vaultB.CreateVault(t.TempDir()); err != nil {
t.Fatalf("Create vault B: %v", err)
}
primeSyncState(t, vaultB.GetVaultPath(), "device-b", "https://sync-b.example.test", 22)
if err := app.OpenVault(vaultB.GetVaultPath()); err != nil {
t.Fatalf("OpenVault B: %v", err)
}
assertSyncState(t, app.syncSvc, "device-b", "https://sync-b.example.test", 22)
vaultC := vault.NewVault(nil)
if err := vaultC.CreateVault(t.TempDir()); err != nil {
t.Fatalf("Create vault C: %v", err)
}
primeSyncState(t, vaultC.GetVaultPath(), "device-c", "https://sync-c.example.test", 33)
if errStr := app.SetCurrentVault(vaultC.GetVaultPath()); errStr != "" {
t.Fatalf("SetCurrentVault C: %s", errStr)
}
assertSyncState(t, app.syncSvc, "device-c", "https://sync-c.example.test", 33)
}
func primeSyncState(t *testing.T, vaultRoot, deviceID, serverURL string, lastPullSeq int) {
t.Helper()
service := syncsvc.NewService(vaultRoot, deviceID)
if err := service.SetState(serverURL, ""); err != nil {
t.Fatalf("SetState: %v", err)
}
if err := service.SetLastPullSeq(lastPullSeq); err != nil {
t.Fatalf("SetLastPullSeq: %v", err)
}
}
func assertSyncState(t *testing.T, service *syncsvc.Service, wantDeviceID, wantServerURL string, wantLastPullSeq int) {
t.Helper()
if service == nil {
t.Fatal("sync service is nil")
}
serverURL, _, lastPullSeq, _, err := service.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if service.GetDeviceID() != wantDeviceID || serverURL != wantServerURL || lastPullSeq != wantLastPullSeq {
t.Fatalf("sync state = device=%q server=%q cursor=%d, want device=%q server=%q cursor=%d",
service.GetDeviceID(), serverURL, lastPullSeq, wantDeviceID, wantServerURL, wantLastPullSeq)
}
}
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent")

View File

@ -77,12 +77,13 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
}
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion, vaultID string) (deviceID, deviceToken string, err error) {
body := map[string]string{
"login": username,
"password": password,
"device_name": deviceName,
"client_version": clientVersion,
"vault_id": vaultID,
}
var resp struct {
DeviceID string `json:"device_id"`

View File

@ -86,3 +86,36 @@ func TestPushDoesNotRetryClientErrors(t *testing.T) {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
func TestPairDeviceSendsVaultID(t *testing.T) {
var pairedVaultID string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/client/pair" {
http.NotFound(w, r)
return
}
var request struct {
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pairedVaultID = request.VaultID
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"device_id": "device-1",
"device_token": "token-1",
})
}))
defer server.Close()
client := NewClient(server.URL, "", "", t.TempDir())
_, _, err := client.PairDevice(server.URL, "alice", "secret", "Desktop", "verstak-desktop/v2", "vault-123")
if err != nil {
t.Fatalf("PairDevice: %v", err)
}
if pairedVaultID != "vault-123" {
t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID)
}
}

View File

@ -60,7 +60,13 @@ type Service struct {
// NewService creates a sync service.
func NewService(vaultRoot, deviceID string) *Service {
return &Service{vaultRoot: vaultRoot, deviceID: deviceID}
service := &Service{vaultRoot: vaultRoot, deviceID: deviceID}
if deviceID == "" {
if state, err := service.loadState(); err == nil {
service.deviceID = state.DeviceID
}
}
return service
}
func (s *Service) syncDir() string {
@ -212,6 +218,9 @@ func (s *Service) SetState(serverURL, apiKey string) error {
}
st.ServerURL = serverURL
st.APIKey = apiKey
if s.deviceID != "" {
st.DeviceID = s.deviceID
}
return s.saveState(st)
}
@ -240,6 +249,23 @@ func (s *Service) GetDeviceID() string {
return s.deviceID
}
// SetDeviceID persists the device ID for this vault's sync state.
func (s *Service) SetDeviceID(deviceID string) error {
if err := s.ensureDir(); err != nil {
return err
}
st, err := s.loadState()
if err != nil {
st = &syncState{}
}
st.DeviceID = deviceID
if err := s.saveState(st); err != nil {
return err
}
s.deviceID = deviceID
return nil
}
// --- file helpers ---
func (s *Service) loadOps() ([]Op, error) {