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{