diff --git a/docs/DEV_PLUGINS.md b/docs/DEV_PLUGINS.md index 216bddc..909558f 100644 --- a/docs/DEV_PLUGINS.md +++ b/docs/DEV_PLUGINS.md @@ -85,11 +85,12 @@ Frontend bundles are mounted with a plugin-scoped API created by - `capabilities.list/get/has` - `commands.register/execute` for handlers declared in `contributes.commands` - `events.publish/subscribe` using the bundled frontend event bus -- `files.list/metadata/readText/writeText/createFolder/move/trash` for - canonical vault-relative slash paths guarded by `files.read`, `files.write`, - and `files.delete`. Backslashes, Windows absolute paths, UNC paths, - traversal, `.verstak` variants, and symlink read/write/move/trash operations - are rejected. Text read/write is UTF-8 only and limited to 2 MB for reads. +- `files.list/metadata/readText/writeText/createFolder/move/trash/openExternal/showInFolder` + for canonical vault-relative slash paths guarded by `files.read`, + `files.write`, `files.delete`, and `files.openExternal`. Backslashes, + Windows absolute paths, UNC paths, traversal, `.verstak` variants, and + symlink read/write/move/trash/external-open operations are rejected. Text + read/write is UTF-8 only and limited to 2 MB for reads. - `workbench.openResource/editResource` for routing vault resources to contributed `openProviders`. Plugins must declare `workbench.open`; this is a policy/contract check. Files and Notes plugins call this API and do not import diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index 1ed58cb..38c54d7 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -426,8 +426,13 @@ contributions summary. folder into itself and conflicts unless `options.overwrite` is true. - `files.trash(relativePath)` — moves a file/folder into internal `.verstak/trash/files//...` and returns trash metadata. +- `files.openExternal(relativePath)` — opens a vault-relative file/folder in + the OS default application. +- `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in + the OS file manager where the platform supports it. - Backend requires plugin exists, enabled, status `loaded`/`degraded`, open - vault, and `files.read`, `files.write`, or `files.delete`. + vault, and `files.read`, `files.write`, `files.delete`, or + `files.openExternal`. - All paths are canonical vault-relative slash paths. Backslashes, POSIX absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes, symlink traversal, and public access to `.verstak/` are rejected. @@ -511,6 +516,8 @@ bundled runtime. Это реальный runtime contract для cooperative bun | `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder | | `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks | | `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет | +| `api.files.openExternal(relativePath)` | ✅ Работает | Открывает vault file/folder во внешнем приложении, требует `files.openExternal` | +| `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` | | `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` | | `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` | | `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance | diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index fd00e16..22ffe30 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -245,6 +245,18 @@ export function createPluginAPI(pluginId) { return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() { return App.TrashVaultPath(pluginId, relativePath); }); + }, + openExternal: function(relativePath) { + assertActive('files.openExternal(' + relativePath + ')'); + return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() { + return App.OpenVaultPathExternal(pluginId, relativePath); + }); + }, + showInFolder: function(relativePath) { + assertActive('files.showInFolder(' + relativePath + ')'); + return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() { + return App.ShowVaultPathInFolder(pluginId, relativePath); + }); } }, diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 4e2f18a..c3a599f 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -24,7 +24,7 @@ provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'], requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/v1'], optionalRequires: ['verstak/core/vault/v1', 'verstak/core/sync/v1', 'verstak/core/files/v1', 'verstak/core/workbench/v1'], - permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'workbench.open'], + permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'files.openExternal', 'workbench.open'], frontend: { entry: 'frontend/dist/index.js' }, contributes: { views: [ @@ -124,7 +124,7 @@ icon: 'folder', provides: ['verstak/files/v1'], requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'], - permissions: ['files.read', 'files.write', 'files.delete', 'workbench.open', 'ui.register'], + permissions: ['files.read', 'files.write', 'files.delete', 'files.openExternal', 'workbench.open', 'ui.register'], frontend: { entry: 'frontend/dist/index.js' }, contributes: { views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], @@ -145,6 +145,8 @@ 'verstak.platform-test': { savedText: 'initial value' } }; var vaultFiles = makeDefaultVaultFiles(); + var externalOpens = []; + window.__wailsMockExternalOpens = []; var workspaceTree = makeDefaultWorkspaceTree(); var reloadResponseMode = 'tuple'; @@ -293,6 +295,7 @@ { name: 'files.read', description: 'Read vault files', dangerous: false }, { name: 'files.write', description: 'Write vault files', dangerous: true }, { name: 'files.delete', description: 'Trash vault files', dangerous: true }, + { name: 'files.openExternal', description: 'Open vault files and folders externally', dangerous: true }, { name: 'workbench.open', description: 'Request Workbench open/edit routing', dangerous: false } ]; } @@ -1125,6 +1128,26 @@ moving.forEach(function (path) { delete vaultFiles[path]; }); return Promise.resolve([{ originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString() }, '']); }, + OpenVaultPathExternal: function (pluginId, relativePath) { + var err = requirePluginPermission(pluginId, 'files.openExternal'); + if (err) return Promise.resolve(err); + var norm = normalizeVaultPath(relativePath, false); + if (norm.error) return Promise.resolve(norm.error); + if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path); + externalOpens.push({ action: 'open', path: norm.path }); + window.__wailsMockExternalOpens = externalOpens.slice(); + return Promise.resolve(''); + }, + ShowVaultPathInFolder: function (pluginId, relativePath) { + var err = requirePluginPermission(pluginId, 'files.openExternal'); + if (err) return Promise.resolve(err); + var norm = normalizeVaultPath(relativePath, false); + if (norm.error) return Promise.resolve(norm.error); + if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path); + externalOpens.push({ action: 'show', path: norm.path }); + window.__wailsMockExternalOpens = externalOpens.slice(); + return Promise.resolve(''); + }, ListWorkspaces: function () { return Promise.resolve(listWorkspacesFromTree()); }, @@ -1281,7 +1304,7 @@ provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'], requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/v1'], optionalRequires: ['verstak/core/vault/v1', 'verstak/core/sync/v1', 'verstak/core/files/v1', 'verstak/core/workbench/v1'], - permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'workbench.open'], + permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'files.openExternal', 'workbench.open'], frontend: { entry: 'frontend/dist/index.js' }, contributes: { views: [ @@ -1381,7 +1404,7 @@ icon: 'folder', provides: ['verstak/files/v1'], requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'], - permissions: ['files.read', 'files.write', 'files.delete', 'workbench.open', 'ui.register'], + permissions: ['files.read', 'files.write', 'files.delete', 'files.openExternal', 'workbench.open', 'ui.register'], frontend: { entry: 'frontend/dist/index.js' }, contributes: { views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], @@ -1399,6 +1422,8 @@ openedResources = []; pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } }; vaultFiles = makeDefaultVaultFiles(); + externalOpens = []; + window.__wailsMockExternalOpens = []; workspaceTree = makeDefaultWorkspaceTree(); reloadResponseMode = 'tuple'; }, diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 4f5191f..9e8c0fc 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -74,6 +74,8 @@ export function MoveWorkspaceNode(arg1:string,arg2:string):Promise; export function OpenVault(arg1:string):Promise; +export function OpenVaultPathExternal(arg1:string,arg2:string):Promise; + export function OpenWorkbenchResource(arg1:string,arg2:Record):Promise; export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise; @@ -116,6 +118,8 @@ export function SetCurrentWorkspace(arg1:string):Promise; export function SetCurrentWorkspaceNode(arg1:string):Promise; +export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise; + export function SubscribePluginEvent(arg1:string,arg2:string):Promise; export function TrashVaultPath(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index a2c0dd5..07d9ada 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -134,6 +134,10 @@ export function OpenVault(arg1) { return window['go']['api']['App']['OpenVault'](arg1); } +export function OpenVaultPathExternal(arg1, arg2) { + return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2); +} + export function OpenWorkbenchResource(arg1, arg2) { return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2); } @@ -218,6 +222,10 @@ export function SetCurrentWorkspaceNode(arg1) { return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1); } +export function ShowVaultPathInFolder(arg1, arg2) { + return window['go']['api']['App']['ShowVaultPathInFolder'](arg1, arg2); +} + export function SubscribePluginEvent(arg1, arg2) { return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2); } diff --git a/internal/api/app.go b/internal/api/app.go index 37755bd..3228b02 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -17,6 +17,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" + "github.com/verstak/verstak-desktop/internal/core/externalopen" corefiles "github.com/verstak/verstak-desktop/internal/core/files" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" @@ -40,6 +41,7 @@ type App struct { vault *vault.Vault storage *storage.Storage files *corefiles.Service + externalOpen externalOpenService appSettings *appsettings.Manager pluginState *pluginstate.Manager workbench *coreworkbench.Router @@ -48,6 +50,11 @@ type App struct { debug bool } +type externalOpenService interface { + OpenPath(path string) error + ShowInFolder(path string, isDir bool) error +} + // NewApp creates a new App instance. func NewApp( capReg *capability.Registry, @@ -73,6 +80,7 @@ func NewApp( vault: vaultService, storage: storageService, files: filesService, + externalOpen: externalopen.NewService(), appSettings: appSettingsMgr, pluginState: pluginStateMgr, workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), @@ -749,6 +757,50 @@ func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResu return result, "" } +// OpenVaultPathExternal opens a vault-relative file or folder in the OS default app. +func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string { + if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil { + return err.Error() + } + if a.files == nil { + return "files service not initialized" + } + target, err := a.files.ResolveExternalOpenTarget(relativePath) + if err != nil { + return err.Error() + } + if err := a.externalOpenService().OpenPath(target.AbsolutePath); err != nil { + return err.Error() + } + return "" +} + +// ShowVaultPathInFolder reveals a vault-relative file or folder in the OS file manager. +func (a *App) ShowVaultPathInFolder(pluginID, relativePath string) string { + if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil { + return err.Error() + } + if a.files == nil { + return "files service not initialized" + } + target, err := a.files.ResolveExternalOpenTarget(relativePath) + if err != nil { + return err.Error() + } + isDir := target.Metadata.Type == corefiles.FileTypeFolder + if err := a.externalOpenService().ShowInFolder(target.AbsolutePath, isDir); err != nil { + return err.Error() + } + return "" +} + +func (a *App) externalOpenService() externalOpenService { + if a.externalOpen != nil { + return a.externalOpen + } + return externalopen.NewService() +} + func (a *App) recordFileSyncOp(entityType, entityID, opType string, payload interface{}) error { if a.syncSvc == nil { return nil diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 5cd30ea..1795590 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -82,6 +82,22 @@ func newFilesTestApp(t *testing.T, perms []string) (*App, string) { }, v.GetVaultPath() } +type testExternalOpenService struct { + open func(path string) error +} + +func newTestExternalOpenService(open func(path string) error) *testExternalOpenService { + return &testExternalOpenService{open: open} +} + +func (s *testExternalOpenService) OpenPath(path string) error { + return s.open(path) +} + +func (s *testExternalOpenService) ShowInFolder(path string, _ bool) error { + return s.open(path) +} + func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, string) { t.Helper() app, root := newFilesTestApp(t, perms) @@ -358,6 +374,61 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) { } } +func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) { + app, root := newFilesTestApp(t, []string{"files.openExternal"}) + filePath := filepath.Join(root, "Docs", "one.txt") + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filePath, []byte("hello"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var opened []string + app.externalOpen = newTestExternalOpenService(func(path string) error { + opened = append(opened, path) + return nil + }) + + if errStr := app.OpenVaultPathExternal("files.plugin", "Docs/one.txt"); errStr != "" { + t.Fatalf("OpenVaultPathExternal: %s", errStr) + } + if len(opened) != 1 || opened[0] != filePath { + t.Fatalf("opened = %#v, want %q", opened, filePath) + } + + if errStr := app.OpenVaultPathExternal("files.plugin", ".verstak/vault.json"); errStr == "" || !strings.Contains(errStr, "reserved-path") { + t.Fatalf("reserved path error = %q, want reserved-path", errStr) + } + if len(opened) != 1 { + t.Fatalf("reserved path should not open, opened = %#v", opened) + } +} + +func TestFilesBridgeShowInFolderUsesVaultPathPolicyAndPermission(t *testing.T) { + app, root := newFilesTestApp(t, []string{"files.openExternal"}) + filePath := filepath.Join(root, "Docs", "one.txt") + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filePath, []byte("hello"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var shown []string + app.externalOpen = newTestExternalOpenService(func(path string) error { + shown = append(shown, path) + return nil + }) + + if errStr := app.ShowVaultPathInFolder("files.plugin", "Docs/one.txt"); errStr != "" { + t.Fatalf("ShowVaultPathInFolder: %s", errStr) + } + if len(shown) != 1 || shown[0] != filePath { + t.Fatalf("shown = %#v, want %q", shown, filePath) + } +} + func TestFilesBridgePermissions(t *testing.T) { cases := []struct { name string @@ -411,6 +482,18 @@ func TestFilesBridgePermissions(t *testing.T) { call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr }, wantPhrase: "files.delete", }, + { + name: "open external requires openExternal", + perms: []string{"files.read", "files.write", "files.delete"}, + call: func(app *App) string { return app.OpenVaultPathExternal("files.plugin", "one.txt") }, + wantPhrase: "files.openExternal", + }, + { + name: "show in folder requires openExternal", + perms: []string{"files.read", "files.write", "files.delete"}, + call: func(app *App) string { return app.ShowVaultPathInFolder("files.plugin", "one.txt") }, + wantPhrase: "files.openExternal", + }, } for _, tc := range cases { diff --git a/internal/core/externalopen/service.go b/internal/core/externalopen/service.go new file mode 100644 index 0000000..a92372b --- /dev/null +++ b/internal/core/externalopen/service.go @@ -0,0 +1,62 @@ +package externalopen + +import ( + "os/exec" + "path/filepath" + "runtime" +) + +type Runner func(name string, args ...string) error + +type Service struct { + goos string + runner Runner +} + +func NewService() *Service { + return NewServiceFor(runtime.GOOS, func(name string, args ...string) error { + return exec.Command(name, args...).Start() + }) +} + +func NewServiceFor(goos string, runner Runner) *Service { + return &Service{goos: goos, runner: runner} +} + +func (s *Service) OpenPath(path string) error { + name, args := s.openCommand(path) + return s.runner(name, args...) +} + +func (s *Service) ShowInFolder(path string, isDir bool) error { + name, args := s.showCommand(path, isDir) + return s.runner(name, args...) +} + +func (s *Service) openCommand(path string) (string, []string) { + switch s.goos { + case "darwin": + return "open", []string{path} + case "windows": + return "rundll32", []string{"url.dll,FileProtocolHandler", path} + default: + return "xdg-open", []string{path} + } +} + +func (s *Service) showCommand(path string, isDir bool) (string, []string) { + switch s.goos { + case "darwin": + return "open", []string{"-R", path} + case "windows": + if isDir { + return "explorer", []string{path} + } + return "explorer", []string{"/select," + path} + default: + if isDir { + return "xdg-open", []string{path} + } + return "xdg-open", []string{filepath.Dir(path)} + } +} diff --git a/internal/core/externalopen/service_test.go b/internal/core/externalopen/service_test.go new file mode 100644 index 0000000..c295888 --- /dev/null +++ b/internal/core/externalopen/service_test.go @@ -0,0 +1,71 @@ +package externalopen + +import ( + "reflect" + "testing" +) + +func TestOpenPathCommands(t *testing.T) { + cases := []struct { + goos string + path string + want commandCall + }{ + {goos: "linux", path: "/vault/file.txt", want: commandCall{name: "xdg-open", args: []string{"/vault/file.txt"}}}, + {goos: "darwin", path: "/vault/file.txt", want: commandCall{name: "open", args: []string{"/vault/file.txt"}}}, + {goos: "windows", path: `C:\Vault\file.txt`, want: commandCall{name: "rundll32", args: []string{"url.dll,FileProtocolHandler", `C:\Vault\file.txt`}}}, + } + + for _, tc := range cases { + t.Run(tc.goos, func(t *testing.T) { + var got commandCall + svc := NewServiceFor(tc.goos, func(name string, args ...string) error { + got = commandCall{name: name, args: args} + return nil + }) + if err := svc.OpenPath(tc.path); err != nil { + t.Fatalf("OpenPath: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("command = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestShowInFolderCommands(t *testing.T) { + cases := []struct { + name string + goos string + path string + isDir bool + want commandCall + }{ + {name: "linux file opens parent", goos: "linux", path: "/vault/Docs/file.txt", want: commandCall{name: "xdg-open", args: []string{"/vault/Docs"}}}, + {name: "linux dir opens dir", goos: "linux", path: "/vault/Docs", isDir: true, want: commandCall{name: "xdg-open", args: []string{"/vault/Docs"}}}, + {name: "darwin reveal", goos: "darwin", path: "/vault/file.txt", want: commandCall{name: "open", args: []string{"-R", "/vault/file.txt"}}}, + {name: "windows select file", goos: "windows", path: `C:\Vault\file.txt`, want: commandCall{name: "explorer", args: []string{`/select,C:\Vault\file.txt`}}}, + {name: "windows open dir", goos: "windows", path: `C:\Vault\Docs`, isDir: true, want: commandCall{name: "explorer", args: []string{`C:\Vault\Docs`}}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got commandCall + svc := NewServiceFor(tc.goos, func(name string, args ...string) error { + got = commandCall{name: name, args: args} + return nil + }) + if err := svc.ShowInFolder(tc.path, tc.isDir); err != nil { + t.Fatalf("ShowInFolder: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("command = %#v, want %#v", got, tc.want) + } + }) + } +} + +type commandCall struct { + name string + args []string +} diff --git a/internal/core/files/service.go b/internal/core/files/service.go index c1e6b7c..980202d 100644 --- a/internal/core/files/service.go +++ b/internal/core/files/service.go @@ -88,6 +88,31 @@ func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error return makeMetadata(rel, info), nil } +func (s *Service) ResolveExternalOpenTarget(relativePath string) (ExternalOpenTarget, error) { + root, rel, full, err := s.resolveFile(relativePath) + if err != nil { + return ExternalOpenTarget{}, err + } + if err := rejectSymlinkPath(root, rel, true); err != nil { + return ExternalOpenTarget{}, err + } + info, err := os.Lstat(full) + if err != nil { + if os.IsNotExist(err) { + return ExternalOpenTarget{}, fmt.Errorf("not-found: %s", rel) + } + return ExternalOpenTarget{}, err + } + if info.Mode()&os.ModeSymlink != 0 { + return ExternalOpenTarget{}, fmt.Errorf("symlink-not-allowed: %s", rel) + } + return ExternalOpenTarget{ + RelativePath: rel, + AbsolutePath: full, + Metadata: makeMetadata(rel, info), + }, nil +} + func (s *Service) ReadVaultTextFile(relativePath string) (string, error) { root, rel, full, err := s.resolveFile(relativePath) if err != nil { diff --git a/internal/core/files/types.go b/internal/core/files/types.go index 4f7af71..622b7aa 100644 --- a/internal/core/files/types.go +++ b/internal/core/files/types.go @@ -39,6 +39,12 @@ type FileMetadata struct { CanWrite bool `json:"canWrite"` } +type ExternalOpenTarget struct { + RelativePath string `json:"relativePath"` + AbsolutePath string `json:"absolutePath"` + Metadata FileMetadata `json:"metadata"` +} + type WriteOptions struct { CreateIfMissing bool `json:"createIfMissing"` Overwrite bool `json:"overwrite"` diff --git a/internal/core/permissions/registry.go b/internal/core/permissions/registry.go index 3868fcf..d3deb8d 100644 --- a/internal/core/permissions/registry.go +++ b/internal/core/permissions/registry.go @@ -36,6 +36,7 @@ func (r *Registry) registerDefaults() { {Name: "files.read", Description: "List files and read text files through the vault Files API", Dangerous: false}, {Name: "files.write", Description: "Create folders, write text files, and move paths through the vault Files API", Dangerous: true}, {Name: "files.delete", Description: "Trash vault files and folders through the vault Files API", Dangerous: true}, + {Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true}, {Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false}, {Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false}, {Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},