diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7ededf2..3a73a34 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -54,6 +54,7 @@ const name = workspaceName(workspace); return { id: name, + workspaceId: workspace?.id || workspace?.workspaceId || '', type: workspace?.type || 'space', title: workspace?.title || name, name, diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index d730752..3576d0f 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -429,6 +429,12 @@ export function createPluginAPI(pluginId) { return App.OpenVaultPathExternal(pluginId, relativePath); }); }, + openURL: function(url) { + assertActive('files.openURL'); + return callBackendErrorString(pluginId, 'files.openURL', function() { + return App.OpenExternalURL(pluginId, String(url == null ? '' : url)); + }); + }, showInFolder: function(relativePath) { assertActive('files.showInFolder(' + relativePath + ')'); return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() { diff --git a/frontend/src/lib/shell/WorkspaceHost.svelte b/frontend/src/lib/shell/WorkspaceHost.svelte index d9542da..97c8934 100644 --- a/frontend/src/lib/shell/WorkspaceHost.svelte +++ b/frontend/src/lib/shell/WorkspaceHost.svelte @@ -43,6 +43,7 @@ $: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; + $: workspaceId = selectedWorkspace?.workspaceId || ''; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceType = selectedWorkspace?.type || 'workspace'; $: if (workspaceRootPath !== metadataWorkspaceRoot) { @@ -235,7 +236,7 @@ {/if} {/if} diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index df19016..36c523c 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -3687,6 +3687,13 @@ window.__wailsMockExternalOpens = externalOpens.slice(); return Promise.resolve(''); }, + OpenExternalURL: function (pluginId, rawURL) { + var err = requirePluginPermission(pluginId, 'files.openExternal'); + if (err) return Promise.resolve(err); + externalOpens.push({ action: 'url', path: String(rawURL || '') }); + window.__wailsMockExternalOpens = externalOpens.slice(); + return Promise.resolve(''); + }, ShowVaultPathInFolder: function (pluginId, relativePath) { var err = requirePluginPermission(pluginId, 'files.openExternal'); if (err) return Promise.resolve(err); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 2284e77..a7a790f 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -86,6 +86,8 @@ export function OpenVault(arg1:string):Promise; export function OpenVaultPathExternal(arg1:string,arg2:string):Promise; +export function OpenExternalURL(arg1:string,arg2:string):Promise; + export function OpenWorkbenchResource(arg1:string,arg2:Record):Promise; export function PluginBrowserReceiverPairing(arg1:string):Promise|string>; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 1976fb1..eaa6752 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -158,6 +158,10 @@ export function OpenVaultPathExternal(arg1, arg2) { return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2); } +export function OpenExternalURL(arg1, arg2) { + return window['go']['api']['App']['OpenExternalURL'](arg1, arg2); +} + export function OpenWorkbenchResource(arg1, arg2) { return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2); } diff --git a/internal/api/app.go b/internal/api/app.go index e5338a9..6292ad6 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -577,7 +577,7 @@ func (a *App) recordActivityProviderEvent(event events.Event) { if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil { continue } - if err := a.appendActivityEvent(provider.PluginID, activityFromEvent(event)); err != nil { + if err := a.appendActivityEvent(provider.PluginID, a.activityFromEvent(event)); err != nil { log.Printf("[api] activity provider %s failed to record %s: %v", provider.PluginID, event.Name, err) } } @@ -622,6 +622,24 @@ func activityFromEvent(event events.Event) map[string]interface{} { } } +func (a *App) activityFromEvent(event events.Event) map[string]interface{} { + activity := activityFromEvent(event) + workspaceRoot := firstPayloadText(activity, "workspaceRootPath") + if workspaceRoot == "" || a == nil || a.workspace == nil { + activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"} + return activity + } + identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot) + if err != nil { + activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"} + return activity + } + activity["workspaceId"] = identity.WorkspaceID + activity["workspaceRootPath"] = identity.RootPath + activity["sessionScope"] = map[string]interface{}{"kind": "workspace", "workspaceId": identity.WorkspaceID} + return activity +} + func eventPayloadMap(payload interface{}) map[string]interface{} { switch value := payload.(type) { case map[string]interface{}: @@ -1498,6 +1516,22 @@ func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string { return "" } +// OpenExternalURL opens an HTTP(S) URL through the platform browser opener. +// This deliberately bypasses OS file associations for InternetShortcut files. +func (a *App) OpenExternalURL(pluginID, rawURL string) string { + if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil { + return err.Error() + } + parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "invalid HTTP(S) URL" + } + if err := a.externalOpenService().OpenPath(parsed.String()); 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 { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 3a55859..275aaa4 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -823,6 +823,24 @@ func TestBrowserInboxWorkspaceReferenceSurvivesRenameAndTrash(t *testing.T) { } } +func TestOpenExternalURLUsesBrowserOpenService(t *testing.T) { + app, _ := newFilesTestApp(t, []string{"files.openExternal"}) + opened := "" + app.externalOpen = newTestExternalOpenService(func(path string) error { + opened = path + return nil + }) + if errStr := app.OpenExternalURL("files.plugin", "https://example.com/path"); errStr != "" { + t.Fatalf("OpenExternalURL: %s", errStr) + } + if opened != "https://example.com/path" { + t.Fatalf("opened = %q, want URL", opened) + } + if errStr := app.OpenExternalURL("files.plugin", "ftp://example.com"); errStr == "" { + t.Fatal("OpenExternalURL accepted unsupported URL scheme") + } +} + func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) { v := vault.NewVault(nil) app := &App{