Refine v2 plugin API and sync flow

This commit is contained in:
2026-06-27 12:36:31 +08:00
parent 03175aa46d
commit 24444a8588
14 changed files with 1013 additions and 282 deletions
+287 -139
View File
@@ -18,7 +18,6 @@ import (
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/notes"
"github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
@@ -41,7 +40,6 @@ type App struct {
vault *vault.Vault
storage *storage.Storage
files *corefiles.Service
notes *notes.Service
appSettings *appsettings.Manager
pluginState *pluginstate.Manager
workbench *coreworkbench.Router
@@ -60,7 +58,6 @@ func NewApp(
vaultService *vault.Vault,
storageService *storage.Storage,
filesService *corefiles.Service,
notesService *notes.Service,
appSettingsMgr *appsettings.Manager,
pluginStateMgr *pluginstate.Manager,
workspaceMgr *workspace.Manager,
@@ -76,7 +73,6 @@ func NewApp(
vault: vaultService,
storage: storageService,
files: filesService,
notes: notesService,
appSettings: appSettingsMgr,
pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
@@ -349,7 +345,6 @@ func (a *App) ReloadPlugins() (int, string) {
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notes/v1",
}
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
@@ -653,9 +648,23 @@ func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string,
if a.files == nil {
return "files service not initialized"
}
opType := syncsvc.OpUpdate
if _, err := a.files.GetVaultFileMetadata(relativePath); err != nil {
if isSyncNotFound(err) {
opType = syncsvc.OpCreate
} else {
return err.Error()
}
}
if err := a.files.WriteVaultTextFile(relativePath, content, options); err != nil {
return err.Error()
}
if err := a.recordFileSyncOp(syncsvc.EntityFile, relativePath, opType, map[string]string{
"path": relativePath,
"content": content,
}); err != nil {
return err.Error()
}
return ""
}
@@ -670,6 +679,11 @@ func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
if err := a.files.CreateVaultFolder(relativePath); err != nil {
return err.Error()
}
if err := a.recordFileSyncOp(syncsvc.EntityFolder, relativePath, syncsvc.OpCreate, map[string]string{
"path": relativePath,
}); err != nil {
return err.Error()
}
return ""
}
@@ -681,9 +695,19 @@ func (a *App) MoveVaultPath(pluginID, fromRelativePath string, toRelativePath st
if a.files == nil {
return "files service not initialized"
}
meta, err := a.files.GetVaultFileMetadata(fromRelativePath)
if err != nil {
return err.Error()
}
if err := a.files.MoveVaultPath(fromRelativePath, toRelativePath, options); err != nil {
return err.Error()
}
if err := a.recordFileSyncOp(syncEntityTypeForFileType(meta.Type), fromRelativePath, syncsvc.OpMove, map[string]string{
"fromPath": fromRelativePath,
"toPath": toRelativePath,
}); err != nil {
return err.Error()
}
return ""
}
@@ -695,13 +719,36 @@ func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResu
if a.files == nil {
return corefiles.TrashResult{}, "files service not initialized"
}
meta, err := a.files.GetVaultFileMetadata(relativePath)
if err != nil {
return corefiles.TrashResult{}, err.Error()
}
result, err := a.files.TrashVaultPath(relativePath)
if err != nil {
return corefiles.TrashResult{}, err.Error()
}
if err := a.recordFileSyncOp(syncEntityTypeForFileType(meta.Type), relativePath, syncsvc.OpDelete, map[string]string{
"path": relativePath,
}); err != nil {
return corefiles.TrashResult{}, err.Error()
}
return result, ""
}
func (a *App) recordFileSyncOp(entityType, entityID, opType string, payload interface{}) error {
if a.syncSvc == nil {
return nil
}
return a.syncSvc.RecordOp(entityType, entityID, opType, payload)
}
func syncEntityTypeForFileType(fileType corefiles.FileType) string {
if fileType == corefiles.FileTypeFolder {
return syncsvc.EntityFolder
}
return syncsvc.EntityFile
}
func (a *App) activeOpenProviders() []contribution.ContributionOpenProvider {
if a.contribRegistry == nil {
return nil
@@ -1163,112 +1210,6 @@ func (a *App) SetCurrentWorkspaceNode(id string) string {
return ""
}
// ─── Notes API ───────────────────────────────────────────────
// EnsureOverview creates or returns the path to Notes/Overview.md under parent.
func (a *App) EnsureOverview(parent string) (map[string]interface{}, string) {
if a.notes == nil {
return nil, "notes service not initialized"
}
path, err := a.notes.EnsureOverview(parent)
if err != nil {
return nil, err.Error()
}
return map[string]interface{}{"path": path}, ""
}
// CreateNote creates a new note under the given parent's Notes/ folder.
// Returns the vault-relative path of the new note.
func (a *App) CreateNote(parent, title string) (map[string]interface{}, string) {
if a.notes == nil {
return nil, "notes service not initialized"
}
path, err := a.notes.CreateNote(parent, title, "")
if err != nil {
if _, ok := err.(*notes.ConflictError); ok {
return map[string]interface{}{"conflict": true, "path": "", "error": err.Error()}, ""
}
return nil, err.Error()
}
return map[string]interface{}{"path": path, "conflict": false}, ""
}
// RenameNote renames a note by changing its title. File is renamed accordingly.
// Returns the new vault-relative path.
func (a *App) RenameNote(notePath, newTitle string) (map[string]interface{}, string) {
if a.notes == nil {
return nil, "notes service not initialized"
}
newPath, err := a.notes.RenameNote(notePath, newTitle)
if err != nil {
if _, ok := err.(*notes.ConflictError); ok {
return map[string]interface{}{"conflict": true, "path": "", "error": err.Error()}, ""
}
return nil, err.Error()
}
return map[string]interface{}{"path": newPath, "conflict": false}, ""
}
// ReadNote reads the content of a note file.
func (a *App) ReadNote(notePath string) (string, string) {
if a.notes == nil {
return "", "notes service not initialized"
}
content, err := a.notes.ReadNote(notePath)
if err != nil {
return "", err.Error()
}
return content, ""
}
// SaveNote writes content to a note file.
func (a *App) SaveNote(notePath, content string) string {
if a.notes == nil {
return "notes service not initialized"
}
if err := a.notes.SaveNote(notePath, content); err != nil {
return err.Error()
}
return ""
}
// ListNotes returns all notes in the given parent's Notes/ folder.
func (a *App) ListNotes(parent string) ([]notes.NoteInfo, string) {
if a.notes == nil {
return nil, "notes service not initialized"
}
noteList, err := a.notes.ListNotes(parent)
if err != nil {
return nil, err.Error()
}
return noteList, ""
}
// SearchNotes performs a case-insensitive search across all notes in the vault.
func (a *App) SearchNotes(query string) ([]notes.NoteInfo, string) {
if a.notes == nil {
return nil, "notes service not initialized"
}
vaultPath := a.vaultPath()
if vaultPath == "" {
return nil, "vault not open"
}
results, err := a.notes.SearchNotes(vaultPath, query)
if err != nil {
return nil, err.Error()
}
return results, ""
}
// NormalizeNoteTitle converts a note title to a safe filename (including .md extension).
func (a *App) NormalizeNoteTitle(title string) (string, string) {
filename, err := notes.NormalizeTitleToFilename(title)
if err != nil {
return "", err.Error()
}
return filename, ""
}
// ─── Vault Plugin State API ────────────────────────────────
// GetVaultPluginState returns the current vault plugin state.
@@ -1427,6 +1368,18 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string)
// ─── Sync API ──────────────────────────────────────────────
func (a *App) requirePluginSyncAccess(pluginID string, remote bool) error {
if _, err := a.requirePluginAccess(pluginID, "sync.participate"); err != nil {
return err
}
if remote {
if _, err := a.requirePluginAccess(pluginID, "network.remote"); err != nil {
return err
}
}
return nil
}
func (a *App) requireVault() error {
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
return fmt.Errorf("vault not open")
@@ -1457,8 +1410,7 @@ type SyncStatusDTO struct {
StatusLabel string `json:"statusLabel"`
}
// SyncStatus returns the current sync status.
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
func (a *App) syncStatus() (*SyncStatusDTO, error) {
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
return &SyncStatusDTO{}, nil
}
@@ -1524,14 +1476,25 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
if cfg.Sync.LastSyncAt != lastSyncAt || cfg.Sync.LastStatus != dto.StatusLabel {
cfg.Sync.LastSyncAt = lastSyncAt
cfg.Sync.LastStatus = dto.StatusLabel
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
_ = a.appSettings.UpdateSync(cfg.Sync)
}
return dto, nil
}
// SyncConfigure pairs the device with a sync server.
func (a *App) SyncConfigure(serverURL, username, password string) error {
// PluginSyncStatus returns sync status for plugins with sync permission.
func (a *App) PluginSyncStatus(pluginID string) (*SyncStatusDTO, string) {
if err := a.requirePluginSyncAccess(pluginID, false); err != nil {
return nil, err.Error()
}
dto, err := a.syncStatus()
if err != nil {
return nil, err.Error()
}
return dto, ""
}
func (a *App) syncConfigure(serverURL, username, password string) error {
if err := a.requireVault(); err != nil {
return err
}
@@ -1558,13 +1521,23 @@ func (a *App) SyncConfigure(serverURL, username, password string) error {
cfg.Sync.DeviceID = deviceID
cfg.Sync.DeviceName = hostname
cfg.Sync.LastStatus = "connected"
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
_ = a.appSettings.UpdateSync(cfg.Sync)
return nil
}
// SyncDisconnect disconnects from the sync server and revokes the device token.
func (a *App) SyncDisconnect() error {
// PluginSyncConfigure pairs the current vault with a sync server for a plugin.
func (a *App) PluginSyncConfigure(pluginID, serverURL, username, password string) string {
if err := a.requirePluginSyncAccess(pluginID, true); err != nil {
return err.Error()
}
if err := a.syncConfigure(serverURL, username, password); err != nil {
return err.Error()
}
return ""
}
func (a *App) syncDisconnect() error {
if err := a.requireVault(); err != nil {
return err
}
@@ -1585,14 +1558,24 @@ func (a *App) SyncDisconnect() error {
cfg.Sync.DeviceName = ""
cfg.Sync.LastStatus = "disabled"
cfg.Sync.LastError = ""
if err := a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync}); err != nil {
if err := a.appSettings.UpdateSync(cfg.Sync); err != nil {
return err
}
return a.syncSvc.SetState("", "")
}
// SyncTestConnection tests the connection to a sync server with the given credentials.
func (a *App) SyncTestConnection(serverURL, username, password string) error {
// PluginSyncDisconnect disconnects sync for a plugin with sync permission.
func (a *App) PluginSyncDisconnect(pluginID string) string {
if err := a.requirePluginSyncAccess(pluginID, false); err != nil {
return err.Error()
}
if err := a.syncDisconnect(); err != nil {
return err.Error()
}
return ""
}
func (a *App) syncTestConnection(serverURL, username, password string) error {
vaultPath := a.vaultPath()
if vaultPath == "" {
vaultPath = "/tmp"
@@ -1601,8 +1584,18 @@ func (a *App) SyncTestConnection(serverURL, username, password string) error {
return client.TestAuth(serverURL, username, password)
}
// SyncSetInterval sets the auto-sync interval in minutes.
func (a *App) SyncSetInterval(minutes int) error {
// PluginSyncTestConnection tests sync server credentials for a plugin.
func (a *App) PluginSyncTestConnection(pluginID, serverURL, username, password string) string {
if err := a.requirePluginSyncAccess(pluginID, true); err != nil {
return err.Error()
}
if err := a.syncTestConnection(serverURL, username, password); err != nil {
return err.Error()
}
return ""
}
func (a *App) syncSetInterval(minutes int) error {
if err := a.requireVault(); err != nil {
return err
}
@@ -1611,11 +1604,21 @@ func (a *App) SyncSetInterval(minutes int) error {
if cfg.Sync.DeviceID == "" && a.syncSvc != nil {
cfg.Sync.DeviceID = a.syncSvc.GetDeviceID()
}
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
return a.appSettings.UpdateSync(cfg.Sync)
}
// SyncNow triggers an immediate sync cycle (push local ops, pull remote ops).
func (a *App) SyncNow() (map[string]interface{}, error) {
// PluginSyncSetInterval sets the sync interval for a plugin with sync permission.
func (a *App) PluginSyncSetInterval(pluginID string, minutes int) string {
if err := a.requirePluginSyncAccess(pluginID, false); err != nil {
return err.Error()
}
if err := a.syncSetInterval(minutes); err != nil {
return err.Error()
}
return ""
}
func (a *App) syncNow() (map[string]interface{}, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
@@ -1706,23 +1709,23 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
return result, nil
}
// ResetSyncKey clears the device token and resets sync state.
func (a *App) ResetSyncKey() error {
if err := a.requireVault(); err != nil {
return err
// PluginSyncNow triggers sync for a plugin with sync permission.
func (a *App) PluginSyncNow(pluginID string) (map[string]interface{}, string) {
if err := a.requirePluginSyncAccess(pluginID, true); err != nil {
return nil, err.Error()
}
_ = syncsvc.RemoveDeviceToken(a.vaultPath())
cfg := a.appSettings.Get()
cfg.Sync.LastStatus = "disabled"
cfg.Sync.LastError = ""
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
result, err := a.syncNow()
if err != nil {
return nil, err.Error()
}
return result, ""
}
func (a *App) updateSyncError(errMsg string) error {
cfg := a.appSettings.Get()
cfg.Sync.LastError = errMsg
cfg.Sync.LastStatus = "error"
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
return a.appSettings.UpdateSync(cfg.Sync)
}
func (a *App) updateSyncSuccess(lastSyncAt string) error {
@@ -1730,12 +1733,157 @@ func (a *App) updateSyncSuccess(lastSyncAt string) error {
cfg.Sync.LastError = ""
cfg.Sync.LastStatus = "connected"
cfg.Sync.LastSyncAt = lastSyncAt
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
return a.appSettings.UpdateSync(cfg.Sync)
}
func (a *App) applyRemoteOp(op syncsvc.Op) error {
if a.debug {
log.Printf("[sync] applyRemoteOp: type=%s entity=%s/%s", op.OpType, op.EntityType, op.EntityID)
}
return nil
if op.DeviceID != "" && op.DeviceID == a.localSyncDeviceID() {
return nil
}
if a.files == nil {
return fmt.Errorf("files service not initialized")
}
payload, err := parseSyncFilePayload(op.PayloadJSON)
if err != nil {
return err
}
switch op.EntityType {
case syncsvc.EntityFile:
return a.applyRemoteFileOp(op, payload)
case syncsvc.EntityFolder:
return a.applyRemoteFolderOp(op, payload)
default:
return fmt.Errorf("unsupported sync entity type: %s", op.EntityType)
}
}
type syncFilePayload struct {
Path string `json:"path"`
Content string `json:"content"`
FromPath string `json:"fromPath"`
ToPath string `json:"toPath"`
}
func parseSyncFilePayload(payloadJSON string) (syncFilePayload, error) {
if payloadJSON == "" {
return syncFilePayload{}, nil
}
var payload syncFilePayload
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
return syncFilePayload{}, fmt.Errorf("invalid sync payload: %w", err)
}
return payload, nil
}
func (a *App) applyRemoteFileOp(op syncsvc.Op, payload syncFilePayload) error {
switch op.OpType {
case syncsvc.OpCreate:
path := syncPayloadPath(op, payload)
if path == "" {
return fmt.Errorf("missing file path")
}
return a.files.WriteVaultTextFile(path, payload.Content, corefiles.WriteOptions{CreateIfMissing: true})
case syncsvc.OpUpdate:
path := syncPayloadPath(op, payload)
if path == "" {
return fmt.Errorf("missing file path")
}
return a.files.WriteVaultTextFile(path, payload.Content, corefiles.WriteOptions{CreateIfMissing: true, Overwrite: true})
case syncsvc.OpDelete:
path := syncPayloadPath(op, payload)
if path == "" {
return fmt.Errorf("missing file path")
}
_, err := a.files.TrashVaultPath(path)
if isSyncNotFound(err) {
return nil
}
return err
case syncsvc.OpMove:
fromPath := payload.FromPath
if fromPath == "" {
fromPath = op.EntityID
}
if fromPath == "" || payload.ToPath == "" {
return fmt.Errorf("missing file move path")
}
err := a.files.MoveVaultPath(fromPath, payload.ToPath, corefiles.MoveOptions{})
if isSyncNotFound(err) {
return nil
}
return err
default:
return fmt.Errorf("unsupported file sync op type: %s", op.OpType)
}
}
func (a *App) applyRemoteFolderOp(op syncsvc.Op, payload syncFilePayload) error {
switch op.OpType {
case syncsvc.OpCreate:
path := syncPayloadPath(op, payload)
if path == "" {
return fmt.Errorf("missing folder path")
}
err := a.files.CreateVaultFolder(path)
if isSyncConflict(err) {
return nil
}
return err
case syncsvc.OpDelete:
path := syncPayloadPath(op, payload)
if path == "" {
return fmt.Errorf("missing folder path")
}
_, err := a.files.TrashVaultPath(path)
if isSyncNotFound(err) {
return nil
}
return err
case syncsvc.OpMove:
fromPath := payload.FromPath
if fromPath == "" {
fromPath = op.EntityID
}
if fromPath == "" || payload.ToPath == "" {
return fmt.Errorf("missing folder move path")
}
err := a.files.MoveVaultPath(fromPath, payload.ToPath, corefiles.MoveOptions{})
if isSyncNotFound(err) {
return nil
}
return err
default:
return fmt.Errorf("unsupported folder sync op type: %s", op.OpType)
}
}
func syncPayloadPath(op syncsvc.Op, payload syncFilePayload) string {
if payload.Path != "" {
return payload.Path
}
return op.EntityID
}
func (a *App) localSyncDeviceID() string {
if a.appSettings != nil {
if deviceID := a.appSettings.Get().Sync.DeviceID; deviceID != "" {
return deviceID
}
}
if a.syncSvc != nil {
return a.syncSvc.GetDeviceID()
}
return ""
}
func isSyncNotFound(err error) bool {
return err != nil && strings.Contains(err.Error(), "not-found")
}
func isSyncConflict(err error) bool {
return err != nil && strings.Contains(err.Error(), "conflict")
}
+372
View File
@@ -1,6 +1,9 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -13,6 +16,7 @@ import (
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/storage"
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
"github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
)
@@ -78,6 +82,22 @@ func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
}, v.GetVaultPath()
}
func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, string) {
t.Helper()
app, root := newFilesTestApp(t, perms)
app.syncSvc = syncsvc.NewService(root, deviceID)
app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := app.appSettings.Load(); err != nil {
t.Fatalf("settings Load: %v", err)
}
cfg := app.appSettings.Get()
cfg.Sync.DeviceID = deviceID
if err := app.appSettings.UpdateSync(cfg.Sync); err != nil {
t.Fatalf("settings UpdateSync: %v", err)
}
return app, root
}
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
@@ -438,6 +458,326 @@ func TestFilesBridgeRequiresLoadedPluginAndOpenVault(t *testing.T) {
}
}
func TestApplyRemoteFileOps(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
ops := []syncsvc.Op{
{
OpID: "folder-create",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFolder,
EntityID: "Docs",
OpType: syncsvc.OpCreate,
PayloadJSON: `{"path":"Docs"}`,
},
{
OpID: "file-create",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFile,
EntityID: "Docs/one.txt",
OpType: syncsvc.OpCreate,
PayloadJSON: `{"path":"Docs/one.txt","content":"hello"}`,
},
{
OpID: "file-update",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFile,
EntityID: "Docs/one.txt",
OpType: syncsvc.OpUpdate,
PayloadJSON: `{"path":"Docs/one.txt","content":"updated"}`,
},
{
OpID: "file-move",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFile,
EntityID: "Docs/one.txt",
OpType: syncsvc.OpMove,
PayloadJSON: `{"fromPath":"Docs/one.txt","toPath":"Docs/two.txt"}`,
},
}
for _, op := range ops {
if err := app.applyRemoteOp(op); err != nil {
t.Fatalf("applyRemoteOp(%s): %v", op.OpID, err)
}
}
text, errStr := app.ReadVaultTextFile("files.plugin", "Docs/two.txt")
if errStr != "" {
t.Fatalf("ReadVaultTextFile: %s", errStr)
}
if text != "updated" {
t.Fatalf("content = %q, want updated", text)
}
if _, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/one.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("old path metadata err = %q, want not-found", errStr)
}
if err := app.applyRemoteOp(syncsvc.Op{
OpID: "file-delete",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFile,
EntityID: "Docs/two.txt",
OpType: syncsvc.OpDelete,
PayloadJSON: `{"path":"Docs/two.txt"}`,
}); err != nil {
t.Fatalf("applyRemoteOp(file-delete): %v", err)
}
if _, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/two.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("deleted path metadata err = %q, want not-found", errStr)
}
}
func TestApplyRemoteFolderOps(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
ops := []syncsvc.Op{
{
OpID: "folder-create",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFolder,
EntityID: "Projects",
OpType: syncsvc.OpCreate,
PayloadJSON: `{"path":"Projects"}`,
},
{
OpID: "folder-move",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFolder,
EntityID: "Projects",
OpType: syncsvc.OpMove,
PayloadJSON: `{"fromPath":"Projects","toPath":"Archive"}`,
},
{
OpID: "folder-delete",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFolder,
EntityID: "Archive",
OpType: syncsvc.OpDelete,
PayloadJSON: `{"path":"Archive"}`,
},
}
for _, op := range ops {
if err := app.applyRemoteOp(op); err != nil {
t.Fatalf("applyRemoteOp(%s): %v", op.OpID, err)
}
}
if _, errStr := app.GetVaultFileMetadata("files.plugin", "Archive"); !strings.Contains(errStr, "not-found") {
t.Fatalf("deleted folder metadata err = %q, want not-found", errStr)
}
}
func TestApplyRemoteOpSkipsLocalDevice(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
if errStr := app.WriteVaultTextFile("files.plugin", "local.txt", "keep", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
err := app.applyRemoteOp(syncsvc.Op{
OpID: "own-delete",
DeviceID: "local-device",
EntityType: syncsvc.EntityFile,
EntityID: "local.txt",
OpType: syncsvc.OpDelete,
PayloadJSON: `{"path":"local.txt"}`,
})
if err != nil {
t.Fatalf("applyRemoteOp: %v", err)
}
text, errStr := app.ReadVaultTextFile("files.plugin", "local.txt")
if errStr != "" {
t.Fatalf("ReadVaultTextFile: %s", errStr)
}
if text != "keep" {
t.Fatalf("content = %q, want keep", text)
}
}
func TestFileBridgeRecordsSyncOps(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
if errStr := app.CreateVaultFolder("files.plugin", "Docs"); errStr != "" {
t.Fatalf("CreateVaultFolder: %s", errStr)
}
if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile create: %s", errStr)
}
if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "updated", corefiles.WriteOptions{Overwrite: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile update: %s", errStr)
}
if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("MoveVaultPath: %s", errStr)
}
if _, errStr := app.TrashVaultPath("files.plugin", "Docs/two.txt"); errStr != "" {
t.Fatalf("TrashVaultPath: %s", errStr)
}
ops, err := app.syncSvc.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
if len(ops) != 5 {
t.Fatalf("ops len = %d, want 5: %#v", len(ops), ops)
}
want := []struct {
entityType string
entityID string
opType string
payload string
}{
{syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpUpdate, `"content":"updated"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpMove, `"toPath":"Docs/two.txt"`},
{syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpDelete, `"path":"Docs/two.txt"`},
}
for i, w := range want {
if ops[i].DeviceID != "local-device" || ops[i].EntityType != w.entityType || ops[i].EntityID != w.entityID || ops[i].OpType != w.opType {
t.Fatalf("op[%d] = %+v, want device/local %s %s %s", i, ops[i], w.entityType, w.entityID, w.opType)
}
if !strings.Contains(ops[i].PayloadJSON, w.payload) {
t.Fatalf("op[%d] payload = %s, want contains %s", i, ops[i].PayloadJSON, w.payload)
}
}
}
func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
var pushedOps []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
}
var pushedDeviceID string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer device-token" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sync/push":
var req struct {
DeviceID string `json:"device_id"`
Ops []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
} `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pushedDeviceID = req.DeviceID
pushedOps = req.Ops
accepted := make([]string, 0, len(req.Ops))
for _, op := range req.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": 2,
"ops": []map[string]interface{}{
{
"op_id": "remote-folder",
"server_sequence": 1,
"device_id": "remote-device",
"entity_type": syncsvc.EntityFolder,
"entity_id": "Remote",
"op_type": syncsvc.OpCreate,
"payload_json": `{"path":"Remote"}`,
"created_at": "2026-06-27T00:00:00Z",
},
{
"op_id": "remote-file",
"server_sequence": 2,
"device_id": "remote-device",
"entity_type": syncsvc.EntityFile,
"entity_id": "Remote/hello.txt",
"op_type": syncsvc.OpCreate,
"payload_json": `{"path":"Remote/hello.txt","content":"from remote"}`,
"created_at": "2026-06-27T00:00:01Z",
},
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
t.Fatalf("SetState: %v", err)
}
if err := syncsvc.SaveDeviceToken(root, "device-token"); err != nil {
t.Fatalf("SaveDeviceToken: %v", err)
}
if errStr := app.CreateVaultFolder("files.plugin", "Local"); errStr != "" {
t.Fatalf("CreateVaultFolder: %s", errStr)
}
if errStr := app.WriteVaultTextFile("files.plugin", "Local/one.txt", "local", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
result, err := app.syncNow()
if err != nil {
t.Fatalf("syncNow: %v", err)
}
if result["pushed"] != 2 || result["pulled"] != 2 || result["serverSequence"] != 2 {
t.Fatalf("sync result = %#v", result)
}
if pushedDeviceID != "local-device" {
t.Fatalf("pushed device = %q, want local-device", pushedDeviceID)
}
if len(pushedOps) != 2 {
t.Fatalf("pushed ops len = %d, want 2", len(pushedOps))
}
for i, op := range pushedOps {
if op.LastSeenServerSeq != 0 {
t.Fatalf("pushed op[%d] last seen = %d, want 0", i, op.LastSeenServerSeq)
}
}
text, errStr := app.ReadVaultTextFile("files.plugin", "Remote/hello.txt")
if errStr != "" {
t.Fatalf("ReadVaultTextFile remote: %s", errStr)
}
if text != "from remote" {
t.Fatalf("remote content = %q, want from remote", text)
}
unpushed, err := app.syncSvc.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
if len(unpushed) != 0 {
t.Fatalf("unpushed len = %d, want 0: %#v", len(unpushed), unpushed)
}
_, _, lastPullSeq, _, err := app.syncSvc.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if lastPullSeq != 2 {
t.Fatalf("last pull seq = %d, want 2", lastPullSeq)
}
}
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent")
@@ -782,6 +1122,38 @@ func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.
}
}
func TestPluginSyncBridgeRequiresDeclaredPermissions(t *testing.T) {
app := newBridgeTestApp(t)
app.plugins = append(app.plugins,
plugin.Plugin{
Manifest: plugin.Manifest{
ID: "sync.local",
Name: "Sync Local",
Version: "1.0.0",
Provides: []string{"sync/local/v1"},
Permissions: []string{"sync.participate"},
},
Status: plugin.StatusLoaded,
Enabled: true,
},
)
status, errStr := app.PluginSyncStatus("sync.local")
if errStr != "" {
t.Fatalf("PluginSyncStatus: %s", errStr)
}
if status == nil {
t.Fatal("PluginSyncStatus returned nil status")
}
if _, errStr := app.PluginSyncStatus("no.storage"); !strings.Contains(errStr, "sync.participate") {
t.Fatalf("PluginSyncStatus err = %q, want sync.participate permission error", errStr)
}
if _, errStr := app.PluginSyncNow("sync.local"); !strings.Contains(errStr, "network.remote") {
t.Fatalf("PluginSyncNow err = %q, want network.remote permission error", errStr)
}
}
func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) {
app := newBridgeTestApp(t)
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"os"
"strings"
"testing"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
)
func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
serverURL := os.Getenv("VERSTAK_SYNC_SMOKE_SERVER_URL")
deviceA := os.Getenv("VERSTAK_SYNC_SMOKE_DEVICE_A")
deviceB := os.Getenv("VERSTAK_SYNC_SMOKE_DEVICE_B")
apiKeyA := os.Getenv("VERSTAK_SYNC_SMOKE_KEY_A")
apiKeyB := os.Getenv("VERSTAK_SYNC_SMOKE_KEY_B")
if serverURL == "" || deviceA == "" || deviceB == "" || apiKeyA == "" || apiKeyB == "" {
t.Skip("set VERSTAK_SYNC_SMOKE_* env vars to run the real sync-server smoke test")
}
appA, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA)
appB, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB)
if err := appA.syncSvc.SetState(serverURL, apiKeyA); err != nil {
t.Fatalf("appA SetState: %v", err)
}
if err := appB.syncSvc.SetState(serverURL, apiKeyB); err != nil {
t.Fatalf("appB SetState: %v", err)
}
if errStr := appA.CreateVaultFolder("files.plugin", "Shared"); errStr != "" {
t.Fatalf("appA CreateVaultFolder: %s", errStr)
}
if errStr := appA.WriteVaultTextFile("files.plugin", "Shared/one.txt", "from A", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("appA WriteVaultTextFile: %s", errStr)
}
expectSyncCounts(t, appA, 2, 2)
expectSyncCounts(t, appB, 0, 2)
expectText(t, appB, "Shared/one.txt", "from A")
if errStr := appB.WriteVaultTextFile("files.plugin", "Shared/one.txt", "from B", corefiles.WriteOptions{Overwrite: true}); errStr != "" {
t.Fatalf("appB update: %s", errStr)
}
if errStr := appB.MoveVaultPath("files.plugin", "Shared/one.txt", "Shared/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appB move: %s", errStr)
}
expectSyncCounts(t, appB, 2, 2)
expectSyncCounts(t, appA, 0, 2)
expectText(t, appA, "Shared/two.txt", "from B")
if _, errStr := appA.TrashVaultPath("files.plugin", "Shared/two.txt"); errStr != "" {
t.Fatalf("appA trash: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/two.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appB deleted file metadata err = %q, want not-found", errStr)
}
}
func expectSyncCounts(t *testing.T, app *App, pushed, pulled int) {
t.Helper()
result, err := app.syncNow()
if err != nil {
t.Fatalf("syncNow: %v", err)
}
if result["pushed"] != pushed || result["pulled"] != pulled {
t.Fatalf("sync result = %#v, want pushed=%d pulled=%d", result, pushed, pulled)
}
}
func expectText(t *testing.T, app *App, path, want string) {
t.Helper()
text, errStr := app.ReadVaultTextFile("files.plugin", path)
if errStr != "" {
t.Fatalf("ReadVaultTextFile(%s): %s", path, errStr)
}
if text != want {
t.Fatalf("ReadVaultTextFile(%s) = %q, want %q", path, text, want)
}
}