fix: persist browser captures atomically

This commit is contained in:
2026-07-11 23:30:38 +08:00
parent c129c1a2ac
commit 477b372110
7 changed files with 641 additions and 55 deletions
+229 -38
View File
@@ -9,7 +9,9 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
@@ -41,6 +43,12 @@ const pluginEventRuntimeName = "verstak:plugin-event"
const activityGlobalKey = "events:global"
const activityWorkspacePrefix = "events:workspace:"
const maxActivityEvents = 250
const browserInboxPluginID = "verstak.browser-inbox"
const browserInboxGlobalKey = "captures:global"
const browserInboxLegacyKey = "captures"
const browserInboxWorkspacePrefix = "captures:workspace:"
const browserInboxMutationEvent = "browser-inbox.storage.mutate"
const maxBrowserInboxCaptures = 100
const workspaceCreatedEventName = "workspace.created"
const workspaceRenamedEventName = "workspace.renamed"
const workspaceTrashedEventName = "workspace.trashed"
@@ -48,26 +56,28 @@ const workspaceSelectedEventName = "workspace.selected"
// App is the main application struct exposed to the Wails frontend.
type App struct {
ctx context.Context
capRegistry *capability.Registry
contribRegistry *contribution.Registry
permRegistry *permissions.Registry
eventBus *events.Bus
plugins []plugin.Plugin
vault *vault.Vault
storage *storage.Storage
files *corefiles.Service
externalOpen externalOpenService
appSettings *appsettings.Manager
pluginState *pluginstate.Manager
workbench *coreworkbench.Router
workspace *workspace.Manager
syncSvc *syncsvc.Service
browserReceiver *browserreceiver.Receiver
secretsSession *coresecrets.VaultSession
fileWatcher *filewatcher.Service
debug bool
activityEvents map[string]bool
ctx context.Context
capRegistry *capability.Registry
contribRegistry *contribution.Registry
permRegistry *permissions.Registry
eventBus *events.Bus
plugins []plugin.Plugin
vault *vault.Vault
storage *storage.Storage
files *corefiles.Service
externalOpen externalOpenService
appSettings *appsettings.Manager
pluginState *pluginstate.Manager
workbench *coreworkbench.Router
workspace *workspace.Manager
syncSvc *syncsvc.Service
browserReceiver *browserreceiver.Receiver
secretsSession *coresecrets.VaultSession
fileWatcher *filewatcher.Service
debug bool
activityEvents map[string]bool
browserInboxEvents map[string]bool
browserInboxEnabled atomic.Bool
}
type externalOpenService interface {
@@ -93,29 +103,34 @@ func NewApp(
debugEnabled bool,
) *App {
app := &App{
capRegistry: capReg,
contribRegistry: contribReg,
permRegistry: permReg,
eventBus: bus,
plugins: plugins,
vault: vaultService,
storage: storageService,
files: filesService,
externalOpen: externalopen.NewService(),
appSettings: appSettingsMgr,
pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
workspace: workspaceMgr,
syncSvc: syncService,
browserReceiver: browserReceiverService,
fileWatcher: filewatcher.NewService(bus, 0),
debug: debugEnabled,
activityEvents: make(map[string]bool),
capRegistry: capReg,
contribRegistry: contribReg,
permRegistry: permReg,
eventBus: bus,
plugins: plugins,
vault: vaultService,
storage: storageService,
files: filesService,
externalOpen: externalopen.NewService(),
appSettings: appSettingsMgr,
pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
workspace: workspaceMgr,
syncSvc: syncService,
browserReceiver: browserReceiverService,
fileWatcher: filewatcher.NewService(bus, 0),
debug: debugEnabled,
activityEvents: make(map[string]bool),
browserInboxEvents: make(map[string]bool),
}
if app.syncSvc == nil {
app.rebindSyncService()
}
app.ensureActivityProviderSubscriptions()
app.ensureBrowserInboxSubscriptions()
if app.browserReceiver != nil {
app.browserReceiver.SetPersistence(app.browserInboxAvailable, app.recordBrowserCapture)
}
app.startFileWatcherForOpenVault()
return app
}
@@ -151,9 +166,184 @@ func (a *App) ensureWorkbench() *coreworkbench.Router {
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.ensureActivityProviderSubscriptions()
a.ensureBrowserInboxSubscriptions()
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
}
func (a *App) ensureBrowserInboxSubscriptions() {
if a.eventBus == nil || a.storage == nil {
a.browserInboxEnabled.Store(false)
return
}
if _, err := a.requirePluginAccess(browserInboxPluginID, "storage.namespace"); err != nil {
a.browserInboxEnabled.Store(false)
return
}
a.browserInboxEnabled.Store(true)
if a.browserInboxEvents == nil {
a.browserInboxEvents = make(map[string]bool)
}
for _, eventName := range []string{browserInboxMutationEvent} {
if a.browserInboxEvents[eventName] {
continue
}
a.browserInboxEvents[eventName] = true
a.eventBus.Subscribe(eventName, func(event events.Event) {
if err := a.mutateBrowserInboxCapture(event); err != nil {
log.Printf("[api] browser inbox mutation failed: %v", err)
}
})
}
}
func (a *App) browserInboxAvailable() bool {
if a == nil || a.storage == nil || a.vault == nil || !a.browserInboxEnabled.Load() || a.vault.GetVaultStatus() != vault.StatusOpen {
return false
}
return true
}
func (a *App) recordBrowserCapture(event events.Event) error {
if !a.browserInboxAvailable() {
return fmt.Errorf("browser inbox unavailable")
}
capture := eventPayloadMap(event.Payload)
captureID := firstPayloadText(capture, "captureId")
if captureID == "" {
return fmt.Errorf("captureId is empty")
}
if firstPayloadText(capture, "kind") == "" {
capture["kind"] = strings.TrimPrefix(event.Name, "browser.capture.")
}
if firstPayloadText(capture, "capturedAt") == "" {
capture["capturedAt"] = event.Timestamp
}
capture["receivedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
for _, stored := range captures {
if firstPayloadText(stored, "captureId") == captureID {
return captures
}
}
result := []map[string]interface{}{capture}
result = append(result, captures...)
return result
})
}
func (a *App) mutateBrowserInboxCapture(event events.Event) error {
payload := eventPayloadMap(event.Payload)
if firstPayloadText(payload, "pluginId") != browserInboxPluginID {
return fmt.Errorf("browser inbox mutation source is not authorized")
}
action := firstPayloadText(payload, "action")
switch action {
case "migrate", "assign", "delete", "processed":
default:
return fmt.Errorf("unsupported browser inbox mutation %q", action)
}
captureID := firstPayloadText(payload, "captureId")
captureIDs := make(map[string]bool)
if captureID != "" {
captureIDs[captureID] = true
}
if items, ok := payload["captureIds"].([]interface{}); ok {
for _, item := range items {
if id, ok := item.(string); ok && strings.TrimSpace(id) != "" {
captureIDs[strings.TrimSpace(id)] = true
}
}
}
if action != "migrate" && len(captureIDs) == 0 {
return fmt.Errorf("captureId is empty")
}
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(captures))
for _, capture := range captures {
storedID := firstPayloadText(capture, "captureId")
if !captureIDs[storedID] {
result = append(result, capture)
continue
}
switch action {
case "delete":
continue
case "assign":
workspaceRoot := firstPayloadText(payload, "workspaceRootPath")
capture["workspaceRootPath"] = workspaceRoot
capture["workspaceName"] = workspaceRoot
case "processed":
capture["processed"], _ = payload["processed"].(bool)
}
result = append(result, capture)
}
return result
})
}
func (a *App) updateBrowserInboxCaptures(update func([]map[string]interface{}) []map[string]interface{}) error {
if !a.browserInboxAvailable() {
return fmt.Errorf("browser inbox unavailable")
}
return a.storage.UpdatePluginSettings(browserInboxPluginID, func(settings map[string]interface{}) error {
captures, legacyKeys := browserInboxCaptures(settings)
captures = update(captures)
if len(captures) > maxBrowserInboxCaptures {
captures = captures[:maxBrowserInboxCaptures]
}
stored := make([]interface{}, 0, len(captures))
for _, capture := range captures {
stored = append(stored, capture)
}
settings[browserInboxGlobalKey] = stored
for _, key := range legacyKeys {
settings[key] = []interface{}{}
}
return nil
})
}
func browserInboxCaptures(settings map[string]interface{}) ([]map[string]interface{}, []string) {
keys := []string{browserInboxGlobalKey, browserInboxLegacyKey}
for key := range settings {
if strings.HasPrefix(key, browserInboxWorkspacePrefix) {
keys = append(keys, key)
}
}
sort.Strings(keys[2:])
seen := make(map[string]bool)
legacyKeys := make([]string, 0, len(keys)-1)
var captures []map[string]interface{}
for _, key := range keys {
if key != browserInboxGlobalKey {
legacyKeys = append(legacyKeys, key)
}
workspaceRoot := ""
if strings.HasPrefix(key, browserInboxWorkspacePrefix) {
workspaceRoot, _ = url.PathUnescape(strings.TrimPrefix(key, browserInboxWorkspacePrefix))
}
items, _ := settings[key].([]interface{})
for _, item := range items {
original, ok := item.(map[string]interface{})
if !ok {
continue
}
capture := eventPayloadMap(original)
captureID := firstPayloadText(capture, "captureId")
if captureID == "" || seen[captureID] {
continue
}
seen[captureID] = true
if firstPayloadText(capture, "workspaceRootPath") == "" && workspaceRoot != "" {
capture["workspaceRootPath"] = workspaceRoot
capture["workspaceName"] = workspaceRoot
}
captures = append(captures, capture)
}
}
return captures, legacyKeys
}
func (a *App) findPlugin(pluginID string) (*plugin.Plugin, error) {
for i := range a.plugins {
if a.plugins[i].Manifest.ID == pluginID {
@@ -646,6 +836,7 @@ func (a *App) ReloadPlugins() (int, string) {
a.plugins = plugins
a.ensureActivityProviderSubscriptions()
a.ensureBrowserInboxSubscriptions()
var buf strings.Builder
buf.WriteString("discovery complete")
+246
View File
@@ -3,12 +3,14 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -617,6 +619,250 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
}
}
func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: "verstak.browser-inbox",
Name: "Browser Inbox",
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Timestamp: "2026-07-11T12:00:00Z",
Payload: map[string]interface{}{
"captureId": "capture-background",
"capturedAt": "2026-07-11T11:59:00Z",
"kind": "page",
"url": "https://example.com/article",
"title": "Background capture",
"domain": "example.com",
},
}); err != nil {
t.Fatalf("RecordBrowserCapture: %v", err)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "assign", "captureId": "capture-background", "workspaceRootPath": "Project",
}})
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "processed", "captureId": "capture-background", "processed": true,
}})
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Payload: map[string]interface{}{
"captureId": "capture-background",
"title": "Retried payload",
},
}); err != nil {
t.Fatalf("RecordBrowserCapture retry: %v", err)
}
settings, err := app.storage.ReadPluginSettings("verstak.browser-inbox")
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings["captures:global"].([]interface{})
if !ok || len(stored) != 1 {
t.Fatalf("captures:global = %#v, want one capture", settings["captures:global"])
}
capture, ok := stored[0].(map[string]interface{})
if !ok {
t.Fatalf("capture = %#v, want map[string]interface{}", stored[0])
}
if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true {
t.Fatalf("stored capture = %#v", capture)
}
}
func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) {
v := vault.NewVault(nil)
app := &App{
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: browserInboxPluginID,
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Payload: map[string]interface{}{"captureId": "capture-no-vault"},
})
if err == nil || !strings.Contains(err.Error(), "unavailable") {
t.Fatalf("RecordBrowserCapture error = %v, want unavailable", err)
}
}
func TestBrowserInboxSerializesConcurrentCapturesAndMutations(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: browserInboxPluginID,
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := app.storage.WritePluginSetting(browserInboxPluginID, "domainBindings", map[string]interface{}{
"example.com": "Project",
}); err != nil {
t.Errorf("WritePluginSetting(domainBindings): %v", err)
}
}()
for i := 0; i < 50; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Timestamp: "2026-07-11T12:00:00Z",
Payload: map[string]interface{}{
"captureId": fmt.Sprintf("capture-%02d", index),
"kind": "page",
"url": fmt.Sprintf("https://example.com/%d", index),
},
}); err != nil {
t.Errorf("RecordBrowserCapture(%d): %v", index, err)
}
}(i)
}
wg.Wait()
bus.Publish(events.Event{
Name: browserInboxMutationEvent,
Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "assign",
"captureId": "capture-00",
"workspaceRootPath": "Project",
},
})
bus.Publish(events.Event{
Name: browserInboxMutationEvent,
Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "delete",
"captureId": "capture-01",
},
})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings[browserInboxGlobalKey].([]interface{})
if !ok || len(stored) != 49 {
t.Fatalf("captures:global contains %d captures, want 49", len(stored))
}
var assigned map[string]interface{}
for _, item := range stored {
capture := item.(map[string]interface{})
if capture["captureId"] == "capture-00" {
assigned = capture
}
if capture["captureId"] == "capture-01" {
t.Fatal("deleted capture remained in storage")
}
}
if assigned == nil || assigned["workspaceRootPath"] != "Project" {
t.Fatalf("assigned capture = %#v", assigned)
}
if settings["domainBindings"] == nil {
t.Fatal("concurrent domainBindings update was lost")
}
}
func TestBrowserInboxMutationMigratesAndClearsLegacyCaptures(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
if err := app.storage.WritePluginSettings(browserInboxPluginID, map[string]interface{}{
browserInboxLegacyKey: []interface{}{map[string]interface{}{
"captureId": "legacy-duplicate",
"title": "Stale legacy copy",
}},
browserInboxGlobalKey: []interface{}{map[string]interface{}{
"captureId": "legacy-duplicate",
"title": "Canonical copy",
"workspaceRootPath": "Project",
"processed": true,
}},
}); err != nil {
t.Fatalf("WritePluginSettings: %v", err)
}
app.ensureBrowserInboxSubscriptions()
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "migrate",
}})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
legacy := settings[browserInboxLegacyKey].([]interface{})
if len(legacy) != 0 {
t.Fatalf("legacy captures = %#v, want empty", legacy)
}
stored := settings[browserInboxGlobalKey].([]interface{})
if len(stored) != 1 {
t.Fatalf("canonical captures = %#v, want one", stored)
}
capture := stored[0].(map[string]interface{})
if capture["title"] != "Canonical copy" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true {
t.Fatalf("canonical capture = %#v", capture)
}
}
func TestActivityFromEventRedactsBinaryPayload(t *testing.T) {
activity := activityFromEvent(events.Event{
Name: "browser.capture.file",