Add public external file open API
This commit is contained in:
parent
6cc37972d1
commit
173cc93258
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<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
|
||||
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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
},
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
|||
|
||||
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 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 ShowVaultPathInFolder(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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
Loading…
Reference in New Issue