Add public external file open API

This commit is contained in:
mirivlad 2026-06-27 13:30:31 +08:00
parent 6cc37972d1
commit 173cc93258
13 changed files with 367 additions and 10 deletions

View File

@ -85,11 +85,12 @@ Frontend bundles are mounted with a plugin-scoped API created by
- `capabilities.list/get/has` - `capabilities.list/get/has`
- `commands.register/execute` for handlers declared in `contributes.commands` - `commands.register/execute` for handlers declared in `contributes.commands`
- `events.publish/subscribe` using the bundled frontend event bus - `events.publish/subscribe` using the bundled frontend event bus
- `files.list/metadata/readText/writeText/createFolder/move/trash` for - `files.list/metadata/readText/writeText/createFolder/move/trash/openExternal/showInFolder`
canonical vault-relative slash paths guarded by `files.read`, `files.write`, for canonical vault-relative slash paths guarded by `files.read`,
and `files.delete`. Backslashes, Windows absolute paths, UNC paths, `files.write`, `files.delete`, and `files.openExternal`. Backslashes,
traversal, `.verstak` variants, and symlink read/write/move/trash operations Windows absolute paths, UNC paths, traversal, `.verstak` variants, and
are rejected. Text read/write is UTF-8 only and limited to 2 MB for reads. 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 - `workbench.openResource/editResource` for routing vault resources to
contributed `openProviders`. Plugins must declare `workbench.open`; this is a contributed `openProviders`. Plugins must declare `workbench.open`; this is a
policy/contract check. Files and Notes plugins call this API and do not import policy/contract check. Files and Notes plugins call this API and do not import

View File

@ -426,8 +426,13 @@ contributions summary.
folder into itself and conflicts unless `options.overwrite` is true. folder into itself and conflicts unless `options.overwrite` is true.
- `files.trash(relativePath)` — moves a file/folder into internal - `files.trash(relativePath)` — moves a file/folder into internal
`.verstak/trash/files/<trashId>/...` and returns trash metadata. `.verstak/trash/files/<trashId>/...` 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 - 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 - All paths are canonical vault-relative slash paths. Backslashes, POSIX
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes, absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
symlink traversal, and public access to `.verstak/` are rejected. 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.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder |
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks | | `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет | | `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.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` | | `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance | | `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |

View File

@ -245,6 +245,18 @@ export function createPluginAPI(pluginId) {
return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() { return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() {
return App.TrashVaultPath(pluginId, relativePath); 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);
});
} }
}, },

View File

@ -24,7 +24,7 @@
provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'], provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'],
requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/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'], 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' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [ views: [
@ -124,7 +124,7 @@
icon: 'folder', icon: 'folder',
provides: ['verstak/files/v1'], provides: ['verstak/files/v1'],
requires: ['verstak/core/files/v1', 'verstak/core/workbench/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' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }],
@ -145,6 +145,8 @@
'verstak.platform-test': { savedText: 'initial value' } 'verstak.platform-test': { savedText: 'initial value' }
}; };
var vaultFiles = makeDefaultVaultFiles(); var vaultFiles = makeDefaultVaultFiles();
var externalOpens = [];
window.__wailsMockExternalOpens = [];
var workspaceTree = makeDefaultWorkspaceTree(); var workspaceTree = makeDefaultWorkspaceTree();
var reloadResponseMode = 'tuple'; var reloadResponseMode = 'tuple';
@ -293,6 +295,7 @@
{ name: 'files.read', description: 'Read vault files', dangerous: false }, { name: 'files.read', description: 'Read vault files', dangerous: false },
{ name: 'files.write', description: 'Write vault files', dangerous: true }, { name: 'files.write', description: 'Write vault files', dangerous: true },
{ name: 'files.delete', description: 'Trash 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 } { name: 'workbench.open', description: 'Request Workbench open/edit routing', dangerous: false }
]; ];
} }
@ -1125,6 +1128,26 @@
moving.forEach(function (path) { delete vaultFiles[path]; }); moving.forEach(function (path) { delete vaultFiles[path]; });
return Promise.resolve([{ originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString() }, '']); 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 () { ListWorkspaces: function () {
return Promise.resolve(listWorkspacesFromTree()); return Promise.resolve(listWorkspacesFromTree());
}, },
@ -1281,7 +1304,7 @@
provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'], provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'],
requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/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'], 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' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [ views: [
@ -1381,7 +1404,7 @@
icon: 'folder', icon: 'folder',
provides: ['verstak/files/v1'], provides: ['verstak/files/v1'],
requires: ['verstak/core/files/v1', 'verstak/core/workbench/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' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }],
@ -1399,6 +1422,8 @@
openedResources = []; openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } }; pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
vaultFiles = makeDefaultVaultFiles(); vaultFiles = makeDefaultVaultFiles();
externalOpens = [];
window.__wailsMockExternalOpens = [];
workspaceTree = makeDefaultWorkspaceTree(); workspaceTree = makeDefaultWorkspaceTree();
reloadResponseMode = 'tuple'; reloadResponseMode = 'tuple';
}, },

View File

@ -74,6 +74,8 @@ export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>; export function OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>; export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>; export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
@ -116,6 +118,8 @@ export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>; export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>; export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>; export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;

View File

@ -134,6 +134,10 @@ export function OpenVault(arg1) {
return window['go']['api']['App']['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) { export function OpenWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['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); 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) { export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2); return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
} }

View File

@ -17,6 +17,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events" "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" corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
@ -40,6 +41,7 @@ type App struct {
vault *vault.Vault vault *vault.Vault
storage *storage.Storage storage *storage.Storage
files *corefiles.Service files *corefiles.Service
externalOpen externalOpenService
appSettings *appsettings.Manager appSettings *appsettings.Manager
pluginState *pluginstate.Manager pluginState *pluginstate.Manager
workbench *coreworkbench.Router workbench *coreworkbench.Router
@ -48,6 +50,11 @@ type App struct {
debug bool debug bool
} }
type externalOpenService interface {
OpenPath(path string) error
ShowInFolder(path string, isDir bool) error
}
// NewApp creates a new App instance. // NewApp creates a new App instance.
func NewApp( func NewApp(
capReg *capability.Registry, capReg *capability.Registry,
@ -73,6 +80,7 @@ func NewApp(
vault: vaultService, vault: vaultService,
storage: storageService, storage: storageService,
files: filesService, files: filesService,
externalOpen: externalopen.NewService(),
appSettings: appSettingsMgr, appSettings: appSettingsMgr,
pluginState: pluginStateMgr, pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
@ -749,6 +757,50 @@ func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResu
return result, "" 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 { func (a *App) recordFileSyncOp(entityType, entityID, opType string, payload interface{}) error {
if a.syncSvc == nil { if a.syncSvc == nil {
return nil return nil

View File

@ -82,6 +82,22 @@ func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
}, v.GetVaultPath() }, 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) { func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, string) {
t.Helper() t.Helper()
app, root := newFilesTestApp(t, perms) 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) { func TestFilesBridgePermissions(t *testing.T) {
cases := []struct { cases := []struct {
name string 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 }, call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr },
wantPhrase: "files.delete", 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 { for _, tc := range cases {

View File

@ -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)}
}
}

View File

@ -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
}

View File

@ -88,6 +88,31 @@ func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error
return makeMetadata(rel, info), nil 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) { func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
root, rel, full, err := s.resolveFile(relativePath) root, rel, full, err := s.resolveFile(relativePath)
if err != nil { if err != nil {

View File

@ -39,6 +39,12 @@ type FileMetadata struct {
CanWrite bool `json:"canWrite"` CanWrite bool `json:"canWrite"`
} }
type ExternalOpenTarget struct {
RelativePath string `json:"relativePath"`
AbsolutePath string `json:"absolutePath"`
Metadata FileMetadata `json:"metadata"`
}
type WriteOptions struct { type WriteOptions struct {
CreateIfMissing bool `json:"createIfMissing"` CreateIfMissing bool `json:"createIfMissing"`
Overwrite bool `json:"overwrite"` Overwrite bool `json:"overwrite"`

View File

@ -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.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.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.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.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin 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}, {Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},