diff --git a/cmd/verstak-gui/bindings_bridge.go b/cmd/verstak-gui/bindings_bridge.go index f1fb641..0a6729c 100644 --- a/cmd/verstak-gui/bindings_bridge.go +++ b/cmd/verstak-gui/bindings_bridge.go @@ -44,13 +44,10 @@ func (a *App) startBridge(appCfg *config.AppConfig) { return } - // Save the actual port and secret back to config if auto-generated. + // Save the actual port back to config if auto-generated. if bc.AutoGenPort { bc.Port = port } - if bc.Secret == "" { - bc.Secret = srv.Secret() - } a.saveBridgeConfig(appCfg, bc) a.mu.Lock() @@ -58,20 +55,14 @@ func (a *App) startBridge(appCfg *config.AppConfig) { a.mu.Unlock() } -// bridgeConfig extracts bridge config from app config, generating defaults if needed. +// bridgeConfig extracts bridge config from app config. func (a *App) bridgeConfig(appCfg *config.AppConfig) *config.BridgeConfig { if appCfg != nil && appCfg.Vault.Bridge.Port != 0 { - bc := &appCfg.Vault.Bridge - // If secret is empty, generate one on first run - if bc.Secret == "" { - bc.Secret = bridge.GenerateSecret() - } - return bc + return &appCfg.Vault.Bridge } return &config.BridgeConfig{ Port: 9786, - AutoGenPort: true, - Secret: bridge.GenerateSecret(), + AutoGenPort: false, } } @@ -100,20 +91,15 @@ func (a *App) BridgeInfo() map[string]interface{} { if a.bridge != nil { info["running"] = a.bridge.Running() info["port"] = a.bridge.Port() - info["secret"] = a.bridge.Secret() } return info } // bridgeToBrowser converts a bridge.Event to a browser.Event. func bridgeToBrowser(ev bridge.Event) browser.Event { - deviceID := ev.ID - if idx := indexOf(ev.ID, "_"); idx > 0 { - deviceID = ev.ID[:idx] - } return browser.Event{ ID: ev.ID, - DeviceID: deviceID, + DeviceID: ev.DeviceID, Type: ev.Type, URL: ev.URL, Title: ev.Title, @@ -126,12 +112,3 @@ func bridgeToBrowser(ev bridge.Event) browser.Event { Note: ev.Note, } } - -func indexOf(s string, sub string) int { - for i := 0; i < len(s); i++ { - if string(s[i]) == sub { - return i - } - } - return -1 -} diff --git a/cmd/verstak-gui/bindings_config.go b/cmd/verstak-gui/bindings_config.go index 58192dc..1dd4a79 100644 --- a/cmd/verstak-gui/bindings_config.go +++ b/cmd/verstak-gui/bindings_config.go @@ -237,8 +237,8 @@ func (a *App) initVault(vaultPath string) error { // Determine if real-time watching is enabled. // Priority: CLI --no-watcher > env VERSTAK_NO_WATCHER > config file > default (true) fileWatcherEnabled := true - if appCfg != nil { - fileWatcherEnabled = appCfg.Vault.FileWatcher + if appCfg != nil && appCfg.Vault.FileWatcher != nil { + fileWatcherEnabled = *appCfg.Vault.FileWatcher } // Env override if os.Getenv("VERSTAK_NO_WATCHER") == "1" { diff --git a/cmd/verstak-gui/bindings_watcher.go b/cmd/verstak-gui/bindings_watcher.go index a184e33..74100af 100644 --- a/cmd/verstak-gui/bindings_watcher.go +++ b/cmd/verstak-gui/bindings_watcher.go @@ -45,7 +45,7 @@ func (a *App) ToggleFileWatcher(enable bool) error { if err != nil || cfg == nil { return fmt.Errorf("config: %w", err) } - cfg.Vault.FileWatcher = enable + cfg.Vault.FileWatcher = config.BoolPtr(enable) if err := config.SaveAppConfig(cfg); err != nil { return fmt.Errorf("save config: %w", err) } diff --git a/extension-firefox/background.js b/extension-firefox/background.js index 087ba8b..3ae0202 100644 --- a/extension-firefox/background.js +++ b/extension-firefox/background.js @@ -1,6 +1,9 @@ // Verstak Bridge for Firefox — Background Service Worker // Uses browser.* WebExtensions API with chrome.* polyfill layer. -// Functionally identical to the Chrome version. +// +// IMPORTANT: All event listeners are registered at global scope, +// NOT inside onInstalled/onStartup. MV3 service workers can be restarted at +// any time; listeners inside lifecycle hooks are lost on restart. // Firefox uses browser.* namespace; provide chrome.* aliases for shared code. if (typeof browser !== 'undefined' && typeof chrome === 'undefined') { @@ -10,7 +13,7 @@ if (typeof browser !== 'undefined' && typeof chrome === 'undefined') { const STORAGE_KEY = 'verstak_queue'; const BRIDGE_KEY = 'verstak_bridge_config'; const RECENT_KEY = 'verstak_recent'; -const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: true }; +const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: false }; const DEFAULT_DEVICE_ID = generateDeviceId(); // Session state @@ -22,11 +25,10 @@ let session = { startedAt: null, }; -// --- Lifecycle --- +// --- Global event listeners (registered once at script load) --- chrome.runtime.onInstalled.addListener(() => { setupAlarms(); - setupListeners(); // Generate a stable device ID on first install chrome.storage.local.get(BRIDGE_KEY, (data) => { const cfg = data[BRIDGE_KEY] || {}; @@ -38,38 +40,40 @@ chrome.runtime.onInstalled.addListener(() => { chrome.runtime.onStartup.addListener(() => { setupAlarms(); - setupListeners(); }); +// Tab tracking — global scope +chrome.tabs.onActivated.addListener(onTabActivated); +chrome.tabs.onUpdated.addListener(onTabUpdated); +chrome.windows.onFocusChanged.addListener(onWindowFocusChanged); + +// Alarm handler — global scope +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === 'flushEvents') flushSession(); + if (alarm.name === 'pingBridge') pingBridge(); +}); + +// Message handler — global scope +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type === 'FORCE_FLUSH') { + flushSession(); + flushQueue(); + sendResponse({ ok: true }); + } + if (msg.type === 'SET_TRACKING') { + chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled }); + sendResponse({ ok: true }); + } + return true; +}); + +// --- Alarm setup --- + function setupAlarms() { chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 }); chrome.alarms.create('pingBridge', { periodInMinutes: 1 }); } -function setupListeners() { - chrome.tabs.onActivated.addListener(onTabActivated); - chrome.tabs.onUpdated.addListener(onTabUpdated); - chrome.windows.onFocusChanged.addListener(onWindowFocusChanged); - - chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'flushEvents') flushSession(); - if (alarm.name === 'pingBridge') pingBridge(); - }); - - chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.type === 'FORCE_FLUSH') { - flushSession(); - flushQueue(); - sendResponse({ ok: true }); - } - if (msg.type === 'SET_TRACKING') { - chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled }); - sendResponse({ ok: true }); - } - return true; // keep channel open for async response - }); -} - // --- Tab tracking --- function onTabActivated(activeInfo) { diff --git a/extension/background.js b/extension/background.js index 66211b9..bd12e1c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,12 +1,17 @@ -// Verstak Bridge — Background Service Worker +// Verstak Bridge — Background Service Worker (Chrome) // Tracks active tab changes and queues browser events. // Pushes events to the Verstak HTTP bridge when available. +// +// IMPORTANT: All chrome.* event listeners are registered at global scope, +// NOT inside onInstalled/onStartup. MV3 service workers can be restarted at +// any time; listeners inside lifecycle hooks are lost on restart. +// See: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/events const STORAGE_KEY = 'verstak_queue'; const BRIDGE_KEY = 'verstak_bridge_config'; -const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: true }; -const FLUSH_INTERVAL_MS = 30_000; // flush every 30s -const IDLE_RESET_MS = 60_000; // reset session after 60s idle +const RECENT_KEY = 'verstak_recent'; +const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: false }; +const IDLE_RESET_MS = 60_000; // Session state let session = { @@ -17,43 +22,55 @@ let session = { startedAt: null, }; -// Start tracking on install +// --- Global event listeners (registered once at script load) --- + chrome.runtime.onInstalled.addListener(() => { setupAlarms(); - setupListeners(); }); -// Wake up on browser start chrome.runtime.onStartup.addListener(() => { setupAlarms(); - setupListeners(); }); +// Tab tracking — global scope +chrome.tabs.onActivated.addListener(onTabActivated); +chrome.tabs.onUpdated.addListener(onTabUpdated); +chrome.windows.onFocusChanged.addListener(onWindowFocusChanged); + +// Alarm handler — global scope +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === 'flushEvents') flushSession(); + if (alarm.name === 'pingBridge') pingBridge(); +}); + +// Message handler — global scope +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type === 'FORCE_FLUSH') { + flushSession(); + flushQueue(); + sendResponse({ ok: true }); + } + if (msg.type === 'SET_TRACKING') { + chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled }); + sendResponse({ ok: true }); + } + return true; +}); + +// --- Alarm setup --- + function setupAlarms() { chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 }); chrome.alarms.create('pingBridge', { periodInMinutes: 1 }); } -function setupListeners() { - // Track active tab changes - chrome.tabs.onActivated.addListener(onTabActivated); - chrome.tabs.onUpdated.addListener(onTabUpdated); - chrome.windows.onFocusChanged.addListener(onWindowFocusChanged); +// --- Tab tracking --- - // Listen for flush alarm - chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'flushEvents') flushSession(); - if (alarm.name === 'pingBridge') pingBridge(); - }); -} - -// Called when user switches tabs function onTabActivated(activeInfo) { flushSession(); startTracking(activeInfo.tabId); } -// Called when a tab's URL or title changes function onTabUpdated(tabId, changeInfo, tab) { if (changeInfo.status === 'complete' && tab.active) { flushSession(); @@ -61,14 +78,11 @@ function onTabUpdated(tabId, changeInfo, tab) { } } -// Called when window focus changes function onWindowFocusChanged(windowId) { if (windowId === chrome.windows.WINDOW_ID_NONE) { - // Window lost focus — flush and stop session flushSession(); session = { url: '', title: '', domain: '', tabId: -1, startedAt: null }; } else { - // Window gained focus — get active tab and start tracking chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (tabs && tabs.length > 0) { startTracking(tabs[0].id); @@ -86,19 +100,20 @@ function startTracking(tabId) { url: tab.url, title: tab.title || '', domain: extractDomain(tab.url), - tabId: tabId, + tabId, startedAt: Date.now(), }; }); } +// --- Session flush --- + function flushSession() { if (!session.url || !session.startedAt) return; const duration = Math.round((Date.now() - session.startedAt) / 1000); if (duration < 2) { - // Skip very short visits - session.startedAt = Date.now(); // reset timer + session.startedAt = Date.now(); return; } @@ -114,7 +129,7 @@ function flushSession() { }; queueEvent(event); - session.startedAt = Date.now(); // reset for next segment + session.startedAt = Date.now(); } function queueEvent(event) { @@ -122,13 +137,11 @@ function queueEvent(event) { const queue = data[STORAGE_KEY] || []; queue.push(event); - // Keep max 500 events in queue if (queue.length > 500) { queue.splice(0, queue.length - 500); } chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => { - // Immediately try to flush if we have enough events if (queue.length >= 5) { flushQueue(); } @@ -136,6 +149,8 @@ function queueEvent(event) { }); } +// --- Bridge communication --- + function flushQueue() { chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY], (data) => { const queue = data[STORAGE_KEY] || []; @@ -161,23 +176,17 @@ function flushQueue() { }) .then((res) => { if (res.ok) { - // Update recent events for (const ev of queue) { updateRecent(ev); } - // Clear queue on success chrome.storage.local.set({ [STORAGE_KEY]: [] }); - // Notify popup if open chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {}); } else if (res.status === 401) { console.warn('[verstak] bridge auth failed, check secret'); } }) - .catch((err) => { + .catch(() => { // Bridge not available — keep events in queue - if (queue.length % 10 === 0) { - console.debug('[verstak] bridge unavailable:', err.message); - } }); }); } @@ -202,23 +211,8 @@ function pingBridge() { }); } -// Listen for messages from popup -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.type === 'FORCE_FLUSH') { - flushSession(); - flushQueue(); - sendResponse({ ok: true }); - } - if (msg.type === 'SET_TRACKING') { - // Tracking is always on at the background level; - // the popup just shows the setting. - chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled }); - sendResponse({ ok: true }); - } -}); +// --- Recent events --- -// Update recent events from flushed queue entries. -// This is called by flushQueue on successful delivery. function updateRecent(event) { chrome.storage.local.get(RECENT_KEY, (data) => { const recent = data[RECENT_KEY] || []; @@ -229,7 +223,6 @@ function updateRecent(event) { active_seconds: event.active_seconds, ts: new Date().toISOString(), }); - // Keep last 50 if (recent.length > 50) { recent.splice(0, recent.length - 50); } @@ -237,9 +230,7 @@ function updateRecent(event) { }); } -const RECENT_KEY = 'verstak_recent'; - -// ... existing code continues below ... +// --- Helpers --- function extractDomain(url) { try { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6eccf53..deaa673 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1541,8 +1541,9 @@ } async function acceptBrowserEvent(ev) { - await wailsCall('DismissBrowserEvent', ev.id) - await loadBrowserEvents() + // Accept: mark as accepted (worklog linking comes later via suggestions) + await wailsCall('AcceptBrowserEvent', ev.id, '') + browserEvents = browserEvents.filter(e => e.id !== ev.id) } async function dismissBrowserEvent(ev) { @@ -1551,8 +1552,15 @@ } async function attachBrowserEvent(ev) { - await wailsCall('DismissBrowserEvent', ev.id) - await loadBrowserEvents() + // Attach to currently selected node, if any + if (selectedNode && selectedNode.id) { + await wailsCall('AttachBrowserEventToNode', ev.id, selectedNode.id) + browserEvents = browserEvents.filter(e => e.id !== ev.id) + } else { + // No node selected — just dismiss for now + await wailsCall('DismissBrowserEvent', ev.id) + browserEvents = browserEvents.filter(e => e.id !== ev.id) + } } function writeDebugLog(msg) { diff --git a/internal/core/bridge/bridge.go b/internal/core/bridge/bridge.go index d5c95a5..ae34fb8 100644 --- a/internal/core/bridge/bridge.go +++ b/internal/core/bridge/bridge.go @@ -31,7 +31,8 @@ type EventHandler func(events []Event) // Event represents a single browser event (page visit, note capture, etc.). type Event struct { ID string `json:"id"` - Type string `json:"type"` // page_visit, note_capture, screenshot + DeviceID string `json:"device_id,omitempty"` + Type string `json:"type"` // page_visit, note_capture, screenshot URL string `json:"url"` Title string `json:"title"` Domain string `json:"domain"` @@ -62,7 +63,7 @@ type Config struct { func DefaultConfig() Config { return Config{ Port: 9786, - AutoGenPort: true, + AutoGenPort: false, } } @@ -76,14 +77,11 @@ func GenerateSecret() string { return hex.EncodeToString(b) } -// NewServer creates a bridge server. If cfg.Secret is empty, one is generated. +// NewServer creates a bridge server. +// If cfg.Secret is empty, no authentication is required. func NewServer(cfg Config, handler EventHandler) *Server { - secret := cfg.Secret - if secret == "" { - secret = GenerateSecret() - } return &Server{ - secret: secret, + secret: cfg.Secret, handler: handler, } } @@ -219,6 +217,12 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } if s.handler != nil { + // Enrich events with device_id from the batch. + for i := range batch.Events { + if batch.Events[i].DeviceID == "" { + batch.Events[i].DeviceID = batch.DeviceID + } + } s.handler(batch.Events) } diff --git a/internal/core/config/appconfig.go b/internal/core/config/appconfig.go index 82751f5..e6deb61 100644 --- a/internal/core/config/appconfig.go +++ b/internal/core/config/appconfig.go @@ -47,7 +47,7 @@ type VaultAppConfig struct { VaultID string `json:"vault_id,omitempty"` CreatedAt string `json:"created_at,omitempty"` Sync SyncSettings `json:"sync,omitempty"` - FileWatcher bool `json:"file_watcher"` + FileWatcher *bool `json:"file_watcher,omitempty"` Bridge BridgeConfig `json:"bridge,omitempty"` } @@ -78,7 +78,7 @@ func DefaultAppConfig() *AppConfig { EnabledTemplates: []string{"folder.default", "project.default", "client.default", "document.default", "recipe.default"}, EnabledPlugins: []string{}, Vault: VaultAppConfig{ - FileWatcher: true, + FileWatcher: BoolPtr(true), }, } } @@ -160,3 +160,8 @@ func DefaultVaultPath() (string, error) { } return filepath.Join(dir, "vault"), nil } + +// BoolPtr returns a pointer to the given bool value. +func BoolPtr(b bool) *bool { + return &b +} diff --git a/internal/core/watcher/watcher.go b/internal/core/watcher/watcher.go index 98c25df..33be71f 100644 --- a/internal/core/watcher/watcher.go +++ b/internal/core/watcher/watcher.go @@ -236,11 +236,20 @@ func (w *Watcher) handleEvent(rel, absPath string, ev fsnotify.Event) { } } - // New file — create record. - _, err = w.files.CopyIntoVault(node.ID, absPath, parentDir) - if err != nil { - log.Printf("[watcher] auto-add file %s: %v", rel, err) - return + // New file — check if it's already inside the vault. + // If absPath is under vaultRoot, don't copy — just create a record. + if isUnderVault(absPath, w.vaultRoot) { + _, err = w.files.AddExternal(node.ID, absPath) + if err != nil { + log.Printf("[watcher] auto-add in-vault file %s: %v", rel, err) + return + } + } else { + _, err = w.files.CopyIntoVault(node.ID, absPath, parentDir) + if err != nil { + log.Printf("[watcher] auto-add file %s: %v", rel, err) + return + } } w.logActivity(node.ID, activity.TypeFileAdded, fi.Name(), rel) @@ -342,3 +351,10 @@ func hashFileFast(absPath string) (string, int64) { } return hex.EncodeToString(h.Sum(nil)), n } + +// isUnderVault reports whether absPath is inside vaultRoot. +func isUnderVault(absPath, vaultRoot string) bool { + absPath, _ = filepath.Abs(absPath) + vaultRoot, _ = filepath.Abs(vaultRoot) + return strings.HasPrefix(absPath, vaultRoot+string(filepath.Separator)) || absPath == vaultRoot +}