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:
+16
-12
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user