feat: ШАГ 1 — Bridge HTTP-сервер для браузерного расширения

- internal/core/bridge/ — лёгкий HTTP-сервер на 127.0.0.1
  - POST /api/events — приём батча событий от расширения
  - GET /api/ping — healthcheck для расширения
  - X-Verstak-Secret — аутентификация по shared-secret
  - AutoGenPort — случайный порт если 9786 занят
- config.BridgeConfig — порт, секрет, auto_gen_port
- App: интеграция startBridge/stopBridge при open/close vault
- bindings_bridge.go — BridgeInfo(), startBridge(), saveBridgeConfig()
- Тесты: ping, auth, success, empty batch, secret gen, auto-port
This commit is contained in:
2026-06-06 18:23:47 +08:00
parent f88376264d
commit 358c649b42
20 changed files with 1676 additions and 21 deletions
+16 -12
View File
@@ -13,6 +13,7 @@ import (
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/bridge"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
@@ -22,6 +23,7 @@ import (
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog"
)
@@ -31,18 +33,20 @@ type App struct {
mu sync.RWMutex
vaultOpen bool
db *storage.DB
nodes *nodes.Repository
templates *templates.Registry
files *files.Service
notes *notes.Service
activity *activity.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
sync *syncsvc.Service
vault string
db *storage.DB
nodes *nodes.Repository
templates *templates.Registry
files *files.Service
notes *notes.Service
activity *activity.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
sync *syncsvc.Service
fileWatcher *watcher.Service
bridge *bridge.Server
vault string
}
// requireVault returns an error if no vault is open and services are not initialized.
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"log"
"verstak/internal/core/bridge"
"verstak/internal/core/config"
)
// startBridge creates and starts the local HTTP bridge for browser extension.
func (a *App) startBridge(appCfg *config.AppConfig) {
// Determine bridge config
bc := a.bridgeConfig(appCfg)
handler := func(events []bridge.Event) {
// For now, log received events. Storage comes in Step 2.
for _, ev := range events {
log.Printf("[bridge] event: type=%s url=%s domain=%s", ev.Type, ev.URL, ev.Domain)
}
}
srv := bridge.NewServer(bridge.Config{
Port: bc.Port,
Secret: bc.Secret,
}, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
AutoGenPort: bc.AutoGenPort,
Secret: bc.Secret,
})
if err != nil {
log.Printf("[bridge] failed to start: %v", err)
return
}
// Save the actual port and secret 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()
a.bridge = srv
a.mu.Unlock()
}
// bridgeConfig extracts bridge config from app config, generating defaults if needed.
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 &config.BridgeConfig{
Port: 9786,
AutoGenPort: true,
Secret: bridge.GenerateSecret(),
}
}
// saveBridgeConfig persists the bridge config to disk.
func (a *App) saveBridgeConfig(appCfg *config.AppConfig, bc *config.BridgeConfig) {
if appCfg == nil {
// Load or create fresh
loaded, err := config.LoadAppConfig()
if err != nil || loaded == nil {
loaded = config.DefaultAppConfig()
}
appCfg = loaded
}
appCfg.Vault.Bridge = *bc
if err := config.SaveAppConfig(appCfg); err != nil {
log.Printf("[bridge] save config: %v", err)
}
}
// BridgeInfo returns the current bridge server status.
func (a *App) BridgeInfo() map[string]interface{} {
info := map[string]interface{}{
"running": false,
"port": 0,
}
if a.bridge != nil {
info["running"] = a.bridge.Running()
info["port"] = a.bridge.Port()
info["secret"] = a.bridge.Secret()
}
return info
}
+47
View File
@@ -18,6 +18,7 @@ import (
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/vault"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog"
)
@@ -229,6 +230,29 @@ func (a *App) initVault(vaultPath string) error {
}
syncSvc := syncsvc.NewService(db, deviceID)
// File watcher service
watcherSvc := watcher.NewService(abs, nodeRepo, fileSvc, activitySvc)
// 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
}
// Env override
if os.Getenv("VERSTAK_NO_WATCHER") == "1" {
fileWatcherEnabled = false
log.Println("[watcher] disabled by VERSTAK_NO_WATCHER=1")
}
// CLI override
for _, arg := range os.Args[1:] {
if arg == "--no-watcher" {
fileWatcherEnabled = false
log.Println("[watcher] disabled by --no-watcher")
break
}
}
a.mu.Lock()
a.db = db
a.nodes = nodeRepo
@@ -241,13 +265,26 @@ func (a *App) initVault(vaultPath string) error {
a.plugins = pm
a.templates = templatesReg
a.sync = syncSvc
a.fileWatcher = watcherSvc
a.vault = abs
a.vaultOpen = true
a.mu.Unlock()
// Snapshot scan (always runs). Real-time watcher depends on config.
scanResult, err := watcherSvc.Start(fileWatcherEnabled)
if err != nil {
log.Printf("[watcher] start error: %v", err)
} else {
log.Printf("[watcher] snapshot: %d missing, %d restored, %d modified, %d new",
scanResult.MissingFiles, scanResult.RestoredFiles, scanResult.ModifiedFiles, scanResult.NewFiles)
}
// Start auto-sync loop
go a.autoSyncLoop()
// Start bridge server for browser extension integration.
a.startBridge(appCfg)
return nil
}
@@ -258,6 +295,14 @@ func (a *App) closeVault() {
if !a.vaultOpen {
return
}
// Stop file watcher first.
if a.fileWatcher != nil {
a.fileWatcher.Stop()
}
// Stop bridge server.
if a.bridge != nil {
a.bridge.Stop()
}
if a.db != nil {
a.db.Close()
}
@@ -272,6 +317,8 @@ func (a *App) closeVault() {
a.plugins = nil
a.templates = nil
a.sync = nil
a.fileWatcher = nil
a.bridge = nil
a.vault = ""
a.vaultOpen = false
}
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"fmt"
"verstak/internal/core/config"
"verstak/internal/core/watcher"
)
// WatcherStatus returns whether the real-time file watcher is active.
func (a *App) WatcherStatus() (bool, error) {
if err := a.requireVault(); err != nil {
return false, err
}
return a.fileWatcher.IsWatching(), nil
}
// RunSnapshotScan performs a one-shot scan and returns results.
func (a *App) RunSnapshotScan() (*watcher.SnapshotResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.fileWatcher.RunScanner()
}
// ToggleFileWatcher enables or disables the real-time file watcher.
// Changing this persists to app config (~/.config/verstak/config.json → vault.file_watcher).
//
// При включении: запускает snapshot scan (сверка диска с БД), затем включает real-time watcher.
// При отключении: останавливает fsnotify, watcher_state в БД сохраняется.
//
// Отключить watcher НАВСЕГДА (независимо от галки):
// export VERSTAK_NO_WATCHER=1
//
// Отключить на один запуск:
// verstak-gui --no-watcher
//
// Snapshot scan запускается ВСЕГДА при открытии vault, даже при FileWatcher=false.
func (a *App) ToggleFileWatcher(enable bool) error {
if err := a.requireVault(); err != nil {
return err
}
cfg, err := config.LoadAppConfig()
if err != nil || cfg == nil {
return fmt.Errorf("config: %w", err)
}
cfg.Vault.FileWatcher = enable
if err := config.SaveAppConfig(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
if enable {
_, err := a.fileWatcher.RunScanner()
if err != nil {
return fmt.Errorf("scan: %w", err)
}
if _, err := a.fileWatcher.Start(true); err != nil {
return fmt.Errorf("start watcher: %w", err)
}
} else {
a.fileWatcher.Stop()
}
return nil
}