fix: исправление 6 пунктов из ревью

Critical:
- bridge: AutoGenPort=false по умолчанию, не генерируем secret если пустой
  → extension и bridge совпадают на port 9786 и empty secret
- bridgeConfig: убрана авто-генерация secret, убран secret из BridgeInfo

High:
- extension/background.js + extension-firefox/background.js:
  все chrome.* listeners вынесены в global scope (не внутри onInstalled/onStartup)
  → MV3 service worker корректно перезапускается
- UI: acceptBrowserEvent вызывает AcceptBrowserEvent, attachBrowserEvent вызывает
  AttachBrowserEventToNode (к текущему selectedNode), а не DismissBrowserEvent
- watcher: при Create проверяется isUnderVault(absPath, vaultRoot) —
  если файл уже в vault, используется AddExternal вместо CopyIntoVault
  → нет дублирования файлов с timestamp-суффиксом

Medium:
- bridge.Event: добавлено поле DeviceID, handleEvents обогащает events из batch.DeviceID
  → device_id сохраняется в DB как chrome-*/firefox-*, а не evt_*
- config: FileWatcher изменён на *bool — nil означает default true,
  false = явно выключено → старые config.json без поля file_watcher получают true
This commit is contained in:
mirivlad 2026-06-07 00:15:34 +08:00
parent b676ac675a
commit 1cc0c407b1
9 changed files with 142 additions and 137 deletions

View File

@ -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
}

View File

@ -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" {

View File

@ -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)
}

View File

@ -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,25 +40,21 @@ chrome.runtime.onInstalled.addListener(() => {
chrome.runtime.onStartup.addListener(() => {
setupAlarms();
setupListeners();
});
function setupAlarms() {
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
}
// Tab tracking — global scope
chrome.tabs.onActivated.addListener(onTabActivated);
chrome.tabs.onUpdated.addListener(onTabUpdated);
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
function setupListeners() {
chrome.tabs.onActivated.addListener(onTabActivated);
chrome.tabs.onUpdated.addListener(onTabUpdated);
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
chrome.alarms.onAlarm.addListener((alarm) => {
// Alarm handler — global scope
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'flushEvents') flushSession();
if (alarm.name === 'pingBridge') pingBridge();
});
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Message handler — global scope
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'FORCE_FLUSH') {
flushSession();
flushQueue();
@ -66,8 +64,14 @@ function setupListeners() {
chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled });
sendResponse({ ok: true });
}
return true; // keep channel open for async response
});
return true;
});
// --- Alarm setup ---
function setupAlarms() {
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
}
// --- Tab tracking ---

View File

@ -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 {

View File

@ -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) {
// 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)
await loadBrowserEvents()
browserEvents = browserEvents.filter(e => e.id !== ev.id)
}
}
function writeDebugLog(msg) {

View File

@ -31,6 +31,7 @@ type EventHandler func(events []Event)
// Event represents a single browser event (page visit, note capture, etc.).
type Event struct {
ID string `json:"id"`
DeviceID string `json:"device_id,omitempty"`
Type string `json:"type"` // page_visit, note_capture, screenshot
URL string `json:"url"`
Title string `json:"title"`
@ -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)
}

View File

@ -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
}

View File

@ -236,12 +236,21 @@ func (w *Watcher) handleEvent(rel, absPath string, ev fsnotify.Event) {
}
}
// New file — create record.
// 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)
case ev.Has(fsnotify.Remove) || ev.Has(fsnotify.Rename):
@ -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
}