Refine v2 plugin API and sync flow
This commit is contained in:
parent
03175aa46d
commit
24444a8588
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
|
||||
export function CloseVault():Promise<void>;
|
||||
|
||||
export function CreateNote(arg1:string,arg2:string):Promise<Record<string, any>|string>;
|
||||
|
||||
export function CreateVault(arg1:string):Promise<void>;
|
||||
|
||||
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
|
||||
|
|
@ -29,8 +26,6 @@ export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Prom
|
|||
|
||||
export function EnablePlugin(arg1:string):Promise<string>;
|
||||
|
||||
export function EnsureOverview(arg1:string):Promise<Record<string, any>|string>;
|
||||
|
||||
export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record<string, any>):Promise<Record<string, any>|string>;
|
||||
|
||||
export function GetAppSettings():Promise<Record<string, any>>;
|
||||
|
|
@ -67,8 +62,6 @@ export function GetWorkspaceMetadata(arg1:string):Promise<workspace.Metadata|str
|
|||
|
||||
export function GetWorkspaceTree():Promise<Record<string, any>>;
|
||||
|
||||
export function ListNotes(arg1:string):Promise<Array<notes.NoteInfo>|string>;
|
||||
|
||||
export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
|
||||
|
||||
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|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<string>;
|
||||
|
||||
export function NormalizeNoteTitle(arg1:string):Promise<Record<string, any>|string>;
|
||||
|
||||
export function OpenVault(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
|
||||
|
||||
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
|
||||
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||
|
||||
export function ReadNote(arg1:string):Promise<Record<string, any>|string>;
|
||||
export function PluginSyncDisconnect(arg1:string):Promise<string>;
|
||||
|
||||
export function PluginSyncNow(arg1:string):Promise<Record<string, any>|string>;
|
||||
|
||||
export function PluginSyncSetInterval(arg1:string,arg2:number):Promise<string>;
|
||||
|
||||
export function PluginSyncStatus(arg1:string):Promise<api.SyncStatusDTO|string>;
|
||||
|
||||
export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||
|
||||
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
|
||||
|
||||
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||
|
||||
|
|
@ -101,18 +102,10 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise
|
|||
|
||||
export function ReloadPlugins():Promise<number|string>;
|
||||
|
||||
export function RenameNote(arg1:string,arg2:string):Promise<Record<string, any>|string>;
|
||||
|
||||
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function ResetSyncKey():Promise<void>;
|
||||
|
||||
export function SaveNote(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function SearchNotes(arg1:string):Promise<Array<notes.NoteInfo>|string>;
|
||||
|
||||
export function SelectDirectory():Promise<string>;
|
||||
|
||||
export function SelectVaultForOpen():Promise<string>;
|
||||
|
|
@ -125,18 +118,6 @@ export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
|
|||
|
||||
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function SyncConfigure(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function SyncDisconnect():Promise<void>;
|
||||
|
||||
export function SyncNow():Promise<Record<string, any>>;
|
||||
|
||||
export function SyncSetInterval(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncStatus():Promise<api.SyncStatusDTO>;
|
||||
|
||||
export function SyncTestConnection(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
|
||||
|
||||
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
5
main.go
5
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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
Loading…
Reference in New Issue