Add public external file open API

This commit is contained in:
2026-06-27 13:30:31 +08:00
parent 6cc37972d1
commit 173cc93258
13 changed files with 367 additions and 10 deletions
+52
View File
@@ -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
+83
View File
@@ -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 {
+62
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)}
}
}
@@ -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
}
+25
View File
@@ -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 {
+6
View File
@@ -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"`
+1
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.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},