diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index d7ce2aa..ec934bf 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -86,7 +86,7 @@ } function mouseHistoryDirection(event) { - if (currentView === 'workspace' || currentView === 'workbench') return ''; + if (currentView === 'workspace') return ''; if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back'; if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward'; return ''; diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index fc68dd6..be94dd0 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -225,20 +225,42 @@ export function createPluginAPI(pluginId) { } }, - backend: { - call: async function(method, ...args) { - assertActive('backend.call(' + method + ')'); - try { - const App = window['go']?.['api']?.['App']; - if (!App || typeof App[method] !== 'function') { - throw new Error('Backend method not found: ' + method); - } - const result = await App[method](...args); - return result; - } catch (e) { - const message = e && e.message ? e.message : String(e); - throw new Error('[plugin:' + pluginId + '] backend.call(' + method + ') failed: ' + message); - } + sync: { + status: function() { + assertActive('sync.status'); + return callBackend(pluginId, 'sync.status', function() { + return App.PluginSyncStatus(pluginId); + }); + }, + configure: function(serverURL, username, password) { + assertActive('sync.configure'); + return callBackendErrorString(pluginId, 'sync.configure', function() { + return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || ''); + }); + }, + disconnect: function() { + assertActive('sync.disconnect'); + return callBackendErrorString(pluginId, 'sync.disconnect', function() { + return App.PluginSyncDisconnect(pluginId); + }); + }, + testConnection: function(serverURL, username, password) { + assertActive('sync.testConnection'); + return callBackendErrorString(pluginId, 'sync.testConnection', function() { + return App.PluginSyncTestConnection(pluginId, serverURL || '', username || '', password || ''); + }); + }, + setInterval: function(minutes) { + assertActive('sync.setInterval'); + return callBackendErrorString(pluginId, 'sync.setInterval', function() { + return App.PluginSyncSetInterval(pluginId, Number(minutes) || 0); + }); + }, + now: function() { + assertActive('sync.now'); + return callBackend(pluginId, 'sync.now', function() { + return App.PluginSyncNow(pluginId); + }); } }, diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 91caaef..4f5191f 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -7,14 +7,11 @@ import {api} from '../models'; import {permissions} from '../models'; import {plugin} from '../models'; import {files} from '../models'; -import {notes} from '../models'; export function ArchiveWorkspaceNode(arg1:string):Promise; export function CloseVault():Promise; -export function CreateNote(arg1:string,arg2:string):Promise|string>; - export function CreateVault(arg1:string):Promise; export function CreateVaultFolder(arg1:string,arg2:string):Promise; @@ -29,8 +26,6 @@ export function EditWorkbenchResource(arg1:string,arg2:Record):Prom export function EnablePlugin(arg1:string):Promise; -export function EnsureOverview(arg1:string):Promise|string>; - export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record):Promise|string>; export function GetAppSettings():Promise>; @@ -67,8 +62,6 @@ export function GetWorkspaceMetadata(arg1:string):Promise>; -export function ListNotes(arg1:string):Promise|string>; - export function ListPluginCapabilities(arg1:string):Promise|string>; export function ListVaultFiles(arg1:string,arg2:string):Promise|string>; @@ -79,15 +72,23 @@ export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.Mov export function MoveWorkspaceNode(arg1:string,arg2:string):Promise; -export function NormalizeNoteTitle(arg1:string):Promise|string>; - export function OpenVault(arg1:string):Promise; export function OpenWorkbenchResource(arg1:string,arg2:Record):Promise; -export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record):Promise; +export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise; -export function ReadNote(arg1:string):Promise|string>; +export function PluginSyncDisconnect(arg1:string):Promise; + +export function PluginSyncNow(arg1:string):Promise|string>; + +export function PluginSyncSetInterval(arg1:string,arg2:number):Promise; + +export function PluginSyncStatus(arg1:string):Promise; + +export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise; + +export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record):Promise; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise>; @@ -101,18 +102,10 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise export function ReloadPlugins():Promise; -export function RenameNote(arg1:string,arg2:string):Promise|string>; - export function RenameWorkspace(arg1:string,arg2:string):Promise; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; -export function ResetSyncKey():Promise; - -export function SaveNote(arg1:string,arg2:string):Promise; - -export function SearchNotes(arg1:string):Promise|string>; - export function SelectDirectory():Promise; export function SelectVaultForOpen():Promise; @@ -125,18 +118,6 @@ export function SetCurrentWorkspaceNode(arg1:string):Promise; export function SubscribePluginEvent(arg1:string,arg2:string):Promise; -export function SyncConfigure(arg1:string,arg2:string,arg3:string):Promise; - -export function SyncDisconnect():Promise; - -export function SyncNow():Promise>; - -export function SyncSetInterval(arg1:number):Promise; - -export function SyncStatus():Promise; - -export function SyncTestConnection(arg1:string,arg2:string,arg3:string):Promise; - export function TrashVaultPath(arg1:string,arg2:string):Promise; export function TrashWorkspace(arg1:string):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 88c1302..a2c0dd5 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -10,10 +10,6 @@ export function CloseVault() { return window['go']['api']['App']['CloseVault'](); } -export function CreateNote(arg1, arg2) { - return window['go']['api']['App']['CreateNote'](arg1, arg2); -} - export function CreateVault(arg1) { return window['go']['api']['App']['CreateVault'](arg1); } @@ -42,10 +38,6 @@ export function EnablePlugin(arg1) { return window['go']['api']['App']['EnablePlugin'](arg1); } -export function EnsureOverview(arg1) { - return window['go']['api']['App']['EnsureOverview'](arg1); -} - export function ExecutePluginCommand(arg1, arg2, arg3) { return window['go']['api']['App']['ExecutePluginCommand'](arg1, arg2, arg3); } @@ -118,10 +110,6 @@ export function GetWorkspaceTree() { return window['go']['api']['App']['GetWorkspaceTree'](); } -export function ListNotes(arg1) { - return window['go']['api']['App']['ListNotes'](arg1); -} - export function ListPluginCapabilities(arg1) { return window['go']['api']['App']['ListPluginCapabilities'](arg1); } @@ -142,10 +130,6 @@ export function MoveWorkspaceNode(arg1, arg2) { return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2); } -export function NormalizeNoteTitle(arg1) { - return window['go']['api']['App']['NormalizeNoteTitle'](arg1); -} - export function OpenVault(arg1) { return window['go']['api']['App']['OpenVault'](arg1); } @@ -154,12 +138,32 @@ export function OpenWorkbenchResource(arg1, arg2) { return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2); } -export function PublishPluginEvent(arg1, arg2, arg3) { - return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3); +export function PluginSyncConfigure(arg1, arg2, arg3, arg4) { + return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4); } -export function ReadNote(arg1) { - return window['go']['api']['App']['ReadNote'](arg1); +export function PluginSyncDisconnect(arg1) { + return window['go']['api']['App']['PluginSyncDisconnect'](arg1); +} + +export function PluginSyncNow(arg1) { + return window['go']['api']['App']['PluginSyncNow'](arg1); +} + +export function PluginSyncSetInterval(arg1, arg2) { + return window['go']['api']['App']['PluginSyncSetInterval'](arg1, arg2); +} + +export function PluginSyncStatus(arg1) { + return window['go']['api']['App']['PluginSyncStatus'](arg1); +} + +export function PluginSyncTestConnection(arg1, arg2, arg3, arg4) { + return window['go']['api']['App']['PluginSyncTestConnection'](arg1, arg2, arg3, arg4); +} + +export function PublishPluginEvent(arg1, arg2, arg3) { + return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3); } export function ReadPluginDataJSON(arg1, arg2) { @@ -186,10 +190,6 @@ export function ReloadPlugins() { return window['go']['api']['App']['ReloadPlugins'](); } -export function RenameNote(arg1, arg2) { - return window['go']['api']['App']['RenameNote'](arg1, arg2); -} - export function RenameWorkspace(arg1, arg2) { return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); } @@ -198,18 +198,6 @@ export function RenameWorkspaceNode(arg1, arg2) { return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); } -export function ResetSyncKey() { - return window['go']['api']['App']['ResetSyncKey'](); -} - -export function SaveNote(arg1, arg2) { - return window['go']['api']['App']['SaveNote'](arg1, arg2); -} - -export function SearchNotes(arg1) { - return window['go']['api']['App']['SearchNotes'](arg1); -} - export function SelectDirectory() { return window['go']['api']['App']['SelectDirectory'](); } @@ -234,30 +222,6 @@ export function SubscribePluginEvent(arg1, arg2) { return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2); } -export function SyncConfigure(arg1, arg2, arg3) { - return window['go']['api']['App']['SyncConfigure'](arg1, arg2, arg3); -} - -export function SyncDisconnect() { - return window['go']['api']['App']['SyncDisconnect'](); -} - -export function SyncNow() { - return window['go']['api']['App']['SyncNow'](); -} - -export function SyncSetInterval(arg1) { - return window['go']['api']['App']['SyncSetInterval'](arg1); -} - -export function SyncStatus() { - return window['go']['api']['App']['SyncStatus'](); -} - -export function SyncTestConnection(arg1, arg2, arg3) { - return window['go']['api']['App']['SyncTestConnection'](arg1, arg2, arg3); -} - export function TrashVaultPath(arg1, arg2) { return window['go']['api']['App']['TrashVaultPath'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index fad07c5..b8a18ac 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -380,31 +380,6 @@ export namespace files { } -export namespace notes { - - export class NoteInfo { - title: string; - filename: string; - path: string; - parentPath: string; - isOverview: boolean; - - static createFrom(source: any = {}) { - return new NoteInfo(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.title = source["title"]; - this.filename = source["filename"]; - this.path = source["path"]; - this.parentPath = source["parentPath"]; - this.isOverview = source["isOverview"]; - } - } - -} - export namespace permissions { export class Entry { diff --git a/internal/api/app.go b/internal/api/app.go index 54b1e93..6ef2312 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -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") } diff --git a/internal/api/app_test.go b/internal/api/app_test.go index c7e24fa..2576dc7 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -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) diff --git a/internal/api/sync_real_server_test.go b/internal/api/sync_real_server_test.go new file mode 100644 index 0000000..8dad1f7 --- /dev/null +++ b/internal/api/sync_real_server_test.go @@ -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) + } +} diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go index b275200..68ce713 100644 --- a/internal/core/appsettings/manager.go +++ b/internal/core/appsettings/manager.go @@ -191,6 +191,20 @@ func (m *Manager) Update(patch *Config) error { return m.saveLocked() } +// UpdateSync replaces sync settings without changing unrelated app settings. +func (m *Manager) UpdateSync(syncSettings SyncSettings) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config == nil { + m.config = defaultConfig() + } + + m.config.Sync = syncSettings + m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) + return m.saveLocked() +} + // SetCurrentVault updates the current vault path and adds to recents. func (m *Manager) SetCurrentVault(path string) error { m.mu.Lock() diff --git a/internal/core/appsettings/manager_test.go b/internal/core/appsettings/manager_test.go index 79abcef..d36e5c2 100644 --- a/internal/core/appsettings/manager_test.go +++ b/internal/core/appsettings/manager_test.go @@ -155,6 +155,51 @@ func TestUpdate_WorkbenchPreferences(t *testing.T) { } } +func TestUpdateSync(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + m := NewManager(path) + if err := m.Load(); err != nil { + t.Fatal(err) + } + if err := m.Update(&Config{DevMode: true}); err != nil { + t.Fatalf("Update dev mode: %v", err) + } + + if err := m.UpdateSync(SyncSettings{ + Enabled: true, + ServerURL: "https://sync.example", + DeviceID: "device-1", + DeviceName: "Desktop", + SyncInterval: 15, + LastStatus: "connected", + LastSyncAt: "2026-06-27T00:00:00Z", + LastError: "previous", + }); err != nil { + t.Fatalf("UpdateSync: %v", err) + } + + reloaded := NewManager(path) + if err := reloaded.Load(); err != nil { + t.Fatal(err) + } + cfg := reloaded.Get() + if !cfg.Sync.Enabled || + cfg.Sync.ServerURL != "https://sync.example" || + cfg.Sync.DeviceID != "device-1" || + cfg.Sync.DeviceName != "Desktop" || + cfg.Sync.SyncInterval != 15 || + cfg.Sync.LastStatus != "connected" || + cfg.Sync.LastSyncAt != "2026-06-27T00:00:00Z" || + cfg.Sync.LastError != "previous" { + t.Fatalf("sync settings = %+v", cfg.Sync) + } + if !cfg.DevMode { + t.Fatal("UpdateSync changed DevMode") + } +} + func TestAppSettings_NotInsideVault(t *testing.T) { // App settings path should be under ~/.config/verstak/, not inside vault path := DefaultConfigPath() diff --git a/internal/core/storage/api.go b/internal/core/storage/api.go index fca557e..3cce7c7 100644 --- a/internal/core/storage/api.go +++ b/internal/core/storage/api.go @@ -44,6 +44,23 @@ func validatePluginID(pluginID string) error { return nil } +func validateStorageName(kind, name string) error { + if name == "" { + return fmt.Errorf("%s name is empty", kind) + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("%s name %q contains path separators", kind, name) + } + if name == "." || name == ".." { + return fmt.Errorf("%s name %q is a path traversal reference", kind, name) + } + cleaned := filepath.Clean(name) + if cleaned != name { + return fmt.Errorf("%s name %q contains path traversal", kind, name) + } + return nil +} + // ─── Atomic write helper ────────────────────────────────── func atomicWrite(path string, data []byte) error { @@ -135,8 +152,8 @@ func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interfac if err := validatePluginID(pluginID); err != nil { return nil, err } - if name == "" { - return nil, fmt.Errorf("data name is empty") + if err := validateStorageName("data", name); err != nil { + return nil, err } dir := s.vault.GetPluginDataPath(pluginID) @@ -162,8 +179,8 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int if err := validatePluginID(pluginID); err != nil { return err } - if name == "" { - return fmt.Errorf("data name is empty") + if err := validateStorageName("data", name); err != nil { + return err } dir := s.vault.GetPluginDataPath(pluginID) @@ -183,8 +200,8 @@ func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interfa if err := validatePluginID(pluginID); err != nil { return nil, err } - if name == "" { - return nil, fmt.Errorf("cache name is empty") + if err := validateStorageName("cache", name); err != nil { + return nil, err } dir := s.vault.GetPluginCachePath(pluginID) @@ -210,8 +227,8 @@ func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]in if err := validatePluginID(pluginID); err != nil { return err } - if name == "" { - return fmt.Errorf("cache name is empty") + if err := validateStorageName("cache", name); err != nil { + return err } dir := s.vault.GetPluginCachePath(pluginID) diff --git a/internal/core/storage/api_test.go b/internal/core/storage/api_test.go index 1f9ecf0..ea731f3 100644 --- a/internal/core/storage/api_test.go +++ b/internal/core/storage/api_test.go @@ -229,6 +229,60 @@ func TestPathTraversal_Blocked(t *testing.T) { } } +func TestPluginDataJSONNameTraversal_Blocked(t *testing.T) { + s, _ := newTestStorage(t) + + traversalNames := []string{ + "..", + "../evil", + "foo/../../bar", + "/absolute", + `backslash\traverse`, + "nested/name", + } + + for _, name := range traversalNames { + t.Run(name, func(t *testing.T) { + err := s.WritePluginDataJSON("data-plugin", name, map[string]interface{}{"x": 1}) + if err == nil { + t.Errorf("WritePluginDataJSON(%q): expected error, got nil", name) + } + + _, err = s.ReadPluginDataJSON("data-plugin", name) + if err == nil { + t.Errorf("ReadPluginDataJSON(%q): expected error, got nil", name) + } + }) + } +} + +func TestPluginCacheJSONNameTraversal_Blocked(t *testing.T) { + s, _ := newTestStorage(t) + + traversalNames := []string{ + "..", + "../evil", + "foo/../../bar", + "/absolute", + `backslash\traverse`, + "nested/name", + } + + for _, name := range traversalNames { + t.Run(name, func(t *testing.T) { + err := s.WritePluginCacheJSON("cache-plugin", name, map[string]interface{}{"x": 1}) + if err == nil { + t.Errorf("WritePluginCacheJSON(%q): expected error, got nil", name) + } + + _, err = s.ReadPluginCacheJSON("cache-plugin", name) + if err == nil { + t.Errorf("ReadPluginCacheJSON(%q): expected error, got nil", name) + } + }) + } +} + // ─── Atomic write tests ────────────────────────────────────── func TestAtomicWrite(t *testing.T) { diff --git a/main.go b/main.go index 3bcf6f2..caf2336 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,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" @@ -93,7 +92,6 @@ func main() { "verstak/core/events/v1", "verstak/core/files/v1", "verstak/core/workbench/v1", - "verstak/core/notes/v1", } if err := capRegistry.Register(corePluginID, coreCaps); err != nil { log.Fatalf("[main] failed to register core capabilities: %v", err) @@ -248,12 +246,11 @@ func main() { // Create the App struct storageService := storage.New(vaultService) filesService := corefiles.NewService(vaultService) - notesService := notes.NewService(filesService) var syncService *syncsvc.Service if vaultService.GetVaultStatus() == vault.StatusOpen { syncService = syncsvc.NewService(vaultService.GetVaultPath(), "") } - app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, notesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled) + app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled) // ─── Wails App ─────────────────────────────────────────── err := wails.Run(&options.App{ diff --git a/scripts/smoke-real-sync.sh b/scripts/smoke-real-sync.sh new file mode 100755 index 0000000..3d66aca --- /dev/null +++ b/scripts/smoke-real-sync.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SYNC_SERVER_DIR="$(cd "$DESKTOP_DIR/../verstak-sync-server" && pwd)" + +PORT="${VERSTAK_SYNC_SMOKE_PORT:-47733}" +DATA_DIR="$(mktemp -d)" +LOG_FILE="$DATA_DIR/server.log" +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$DATA_DIR" +} +trap cleanup EXIT + +if ss -ltn | grep -q ":$PORT "; then + echo "port $PORT is already in use" >&2 + exit 1 +fi + +( + cd "$SYNC_SERVER_DIR" + go run ./cmd/server --port "$PORT" --data "$DATA_DIR" +) >"$LOG_FILE" 2>&1 & +SERVER_PID="$!" + +for _ in $(seq 1 80); do + if curl -fsS "http://127.0.0.1:$PORT/api/v1/health" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + cat "$LOG_FILE" >&2 + exit 1 + fi + sleep 0.25 +done + +curl -fsS "http://127.0.0.1:$PORT/api/v1/health" >/dev/null + +NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +sqlite3 "$DATA_DIR/server.db" " +INSERT INTO server_devices (id, name, api_key, last_seen, created_at) +VALUES ('smoke-device-a', 'Smoke Device A', 'smoke-key-a', '$NOW', '$NOW'); +INSERT INTO server_devices (id, name, api_key, last_seen, created_at) +VALUES ('smoke-device-b', 'Smoke Device B', 'smoke-key-b', '$NOW', '$NOW'); +" + +( + cd "$DESKTOP_DIR" + VERSTAK_SYNC_SMOKE_SERVER_URL="http://127.0.0.1:$PORT" \ + VERSTAK_SYNC_SMOKE_DEVICE_A="smoke-device-a" \ + VERSTAK_SYNC_SMOKE_DEVICE_B="smoke-device-b" \ + VERSTAK_SYNC_SMOKE_KEY_A="smoke-key-a" \ + VERSTAK_SYNC_SMOKE_KEY_B="smoke-key-b" \ + go test ./internal/api -run TestSyncNowAgainstRealServerTwoVaults -count=1 -v +)