diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 9236e08c..b3400e7b 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -118,7 +118,8 @@ jobs: -X github.com/omniviewdev/omniview/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ -X github.com/omniviewdev/omniview/internal/version.Development=true \ -X github.com/omniviewdev/omniview/internal/telemetry.buildOTLPEndpoint=${{ secrets.TELEMETRY_OTLP_ENDPOINT }} \ - -X github.com/omniviewdev/omniview/internal/telemetry.buildPyroscopeEndpoint=${{ secrets.TELEMETRY_PYROSCOPE_ENDPOINT }}" \ + -X github.com/omniviewdev/omniview/internal/telemetry.buildPyroscopeEndpoint=${{ secrets.TELEMETRY_PYROSCOPE_ENDPOINT }} \ + -X github.com/omniviewdev/omniview/internal/appstate.buildStateDir=~/.omniview-nightly" \ -o bin/Omniview . - name: Create macOS .app bundle @@ -253,7 +254,8 @@ jobs: -X github.com/omniviewdev/omniview/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ -X github.com/omniviewdev/omniview/internal/version.Development=true \ -X github.com/omniviewdev/omniview/internal/telemetry.buildOTLPEndpoint=${{ secrets.TELEMETRY_OTLP_ENDPOINT }} \ - -X github.com/omniviewdev/omniview/internal/telemetry.buildPyroscopeEndpoint=${{ secrets.TELEMETRY_PYROSCOPE_ENDPOINT }}" \ + -X github.com/omniviewdev/omniview/internal/telemetry.buildPyroscopeEndpoint=${{ secrets.TELEMETRY_PYROSCOPE_ENDPOINT }} \ + -X github.com/omniviewdev/omniview/internal/appstate.buildStateDir=~/.omniview-nightly" \ -o bin/Omniview . - name: Rename executable @@ -325,7 +327,8 @@ jobs: -X github.com/omniviewdev/omniview/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ -X github.com/omniviewdev/omniview/internal/version.Development=true \ -X github.com/omniviewdev/omniview/internal/telemetry.buildOTLPEndpoint=${{ secrets.TELEMETRY_OTLP_ENDPOINT }} \ - -X github.com/omniviewdev/omniview/internal/telemetry.buildPyroscopeEndpoint=${{ secrets.TELEMETRY_PYROSCOPE_ENDPOINT }}" \ + -X github.com/omniviewdev/omniview/internal/telemetry.buildPyroscopeEndpoint=${{ secrets.TELEMETRY_PYROSCOPE_ENDPOINT }} \ + -X github.com/omniviewdev/omniview/internal/appstate.buildStateDir=~/.omniview-nightly" \ -o bin/Omniview.exe . - name: Rename executable diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b2f2aa74..e9499b7c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -189,7 +189,8 @@ jobs: -X github.com/omniviewdev/omniview/internal/version.Version=0.0.0-pr.${{ github.event.pull_request.number }} \ -X github.com/omniviewdev/omniview/internal/version.GitCommit=${{ github.sha }} \ -X github.com/omniviewdev/omniview/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ - -X github.com/omniviewdev/omniview/internal/version.Development=true" \ + -X github.com/omniviewdev/omniview/internal/version.Development=true \ + -X github.com/omniviewdev/omniview/internal/appstate.buildStateDir=~/.omniview-pr" \ -o bin/Omniview . - name: Create macOS .app bundle diff --git a/.gitignore b/.gitignore index ab7e0a41..4dd33f96 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ e2e/playwright-report/ # Task runner .task -omniview +/omniview +docs/superpowers/ +cmd/omniview-plugin-dev/omniview-plugin-dev diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b8e62ac7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# Claude Code Guidelines + +## Wails Bindings + +**Always use the Taskfile task to generate bindings.** Never run `wails3 generate bindings` manually. + +```bash +task bindings +``` + +This ensures bindings are generated with the correct flags, output directory (`packages/omniviewdev-runtime/src/bindings`), and cleanup. The raw `wails3` command generates to the wrong location. + +## Build & Test + +```bash +GOWORK=off go build . # Build the Go backend +GOWORK=off go test ./... -count=1 # Run all Go tests +task dev # Run the full app in dev mode +``` + +Use `GOWORK=off` for all Go commands — the module is not in the parent `go.work` file. + +## Commits + +- Do not add `Co-Authored-By` or any Claude attribution to commits or PRs. diff --git a/backend/clients/logger.go b/backend/clients/logger.go index 71185480..3b60df74 100644 --- a/backend/clients/logger.go +++ b/backend/clients/logger.go @@ -4,7 +4,7 @@ import ( "fmt" "net/url" "os" - "path" + "path/filepath" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -19,7 +19,8 @@ func (lumberjackSink) Sync() error { return nil } -func CreateLogger(dev bool) *zap.SugaredLogger { +// CreateLogger creates a zap SugaredLogger that writes to a log file in logDir. +func CreateLogger(dev bool, logDir string) *zap.SugaredLogger { var level zapcore.Level if dev { level = zap.DebugLevel @@ -27,19 +28,12 @@ func CreateLogger(dev bool) *zap.SugaredLogger { level = zap.ErrorLevel } - // store the logs in the dot directory - baseDir, err := os.UserHomeDir() - if err != nil { - baseDir = os.TempDir() - } else { - baseDir = path.Join(baseDir, ".omniview", "logs") - } - - if err := os.MkdirAll(baseDir, 0755); err != nil { + if err := os.MkdirAll(logDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "failed to create log directory %s: %v\n", logDir, err) return zap.L().Sugar() } - logFile := path.Join(baseDir, "app.log") + logFile := filepath.Join(logDir, "app.log") encoderConfig := zapcore.EncoderConfig{ TimeKey: "ts", diff --git a/backend/diagnostics/diagnostics.go b/backend/diagnostics/diagnostics.go index 44c1ce7a..9249dd14 100644 --- a/backend/diagnostics/diagnostics.go +++ b/backend/diagnostics/diagnostics.go @@ -14,8 +14,8 @@ type DiagnosticsClient struct { UI *BackendLogger } -func NewDiagnosticsClient(dev bool) *DiagnosticsClient { - uiLogger, err := NewBackendLogger("ui", dev) +func NewDiagnosticsClient(dev bool, logDir string) *DiagnosticsClient { + uiLogger, err := NewBackendLogger("ui", dev, logDir) if err != nil { log.Fatalf("failed to init UI logger: %v", err) } diff --git a/backend/diagnostics/logger.go b/backend/diagnostics/logger.go index 2f713a1e..ac25c232 100644 --- a/backend/diagnostics/logger.go +++ b/backend/diagnostics/logger.go @@ -6,7 +6,7 @@ import ( "fmt" "log" "os" - "path" + "path/filepath" "regexp" "strings" "sync" @@ -40,25 +40,21 @@ func (b *BackendLogger) ServiceShutdown() error { } // NewBackendLogger creates (and binds) a Zap SugaredLogger writing to `.log`. -func NewBackendLogger(name string, dev bool) (*BackendLogger, error) { +// logDir is the directory where log files are stored (e.g. from appstate.Service.Logs().ResolvePath("")). +func NewBackendLogger(name string, dev bool, logDir string) (*BackendLogger, error) { // determine level lvl := zapcore.ErrorLevel if dev { lvl = zapcore.DebugLevel } - // prepare log directory - home, err := os.UserHomeDir() - if err != nil { - home = os.TempDir() - } - baseDir := path.Join(home, ".omniview", "logs") + baseDir := logDir if err := os.MkdirAll(baseDir, 0755); err != nil { return nil, err } // file rotate - logFile := path.Join(baseDir, name+".log") + logFile := filepath.Join(baseDir, name+".log") log.Println(("set logger to app.log")) lj := &lumberjack.Logger{ @@ -180,7 +176,7 @@ func (b *BackendLogger) ListLogFiles(ctx context.Context) ([]string, error) { } func (b *BackendLogger) ReadLog(ctx context.Context, name string) (string, error) { - data, err := os.ReadFile(path.Join(b.logDir, name+".log")) + data, err := os.ReadFile(filepath.Join(b.logDir, name+".log")) if err != nil { return "", err } @@ -188,7 +184,7 @@ func (b *BackendLogger) ReadLog(ctx context.Context, name string) (string, error } func (b *BackendLogger) SearchLog(ctx context.Context, name, pattern string) ([]string, error) { - f, err := os.Open(path.Join(b.logDir, name+".log")) + f, err := os.Open(filepath.Join(b.logDir, name+".log")) if err != nil { return nil, err } @@ -214,7 +210,7 @@ func (b *BackendLogger) StartTail(ctx context.Context, name string) error { if _, ok := b.watchers[name]; ok { return nil // already tailing } - t, err := tail.TailFile(path.Join(b.logDir, name+".log"), tail.Config{ + t, err := tail.TailFile(filepath.Join(b.logDir, name+".log"), tail.Config{ Follow: true, ReOpen: true, MustExist: true, }) if err != nil { diff --git a/backend/pkg/plugin/adapters.go b/backend/pkg/plugin/adapters.go new file mode 100644 index 00000000..5f762d9c --- /dev/null +++ b/backend/pkg/plugin/adapters.go @@ -0,0 +1,20 @@ +package plugin + +// PluginRefAdapter adapts plugin.Manager to devserver.PluginRef. +type PluginRefAdapter struct{ Mgr Manager } + +func (a *PluginRefAdapter) GetDevPluginInfo(pluginID string) (bool, string, error) { + info, err := a.Mgr.GetPlugin(pluginID) + if err != nil { + return false, "", err + } + return info.DevMode, info.DevPath, nil +} + +// PluginReloaderAdapter adapts plugin.Manager to devserver.PluginReloader. +type PluginReloaderAdapter struct{ Mgr Manager } + +func (a *PluginReloaderAdapter) ReloadPlugin(id string) error { + _, err := a.Mgr.ReloadPlugin(id) + return err +} diff --git a/backend/pkg/plugin/data/controller.go b/backend/pkg/plugin/data/controller.go index 839bd746..d6dd4531 100644 --- a/backend/pkg/plugin/data/controller.go +++ b/backend/pkg/plugin/data/controller.go @@ -4,17 +4,17 @@ import ( "context" "encoding/json" "errors" - "fmt" "os" - "path/filepath" "strings" "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/omniviewdev/omniview/internal/appstate" logging "github.com/omniviewdev/plugin-sdk/log" ) // Controller provides a JSON key-value store for plugins to persist arbitrary data. -// Each key is stored as a separate JSON file under ~/.omniview/plugins/{pluginID}/data/. +// Each key is stored as a separate JSON file under /plugins/{pluginID}/data/. type Controller interface { ServiceStartup(ctx context.Context, options application.ServiceOptions) error ServiceShutdown() error @@ -27,13 +27,16 @@ type Controller interface { var _ Controller = (*controller)(nil) type controller struct { - logger logging.Logger + logger logging.Logger + pluginDataFn func(pluginID string) (*appstate.ScopedRoot, error) } // NewController creates a new data store controller. -func NewController(logger logging.Logger) Controller { +// pluginDataFn returns a ScopedRoot for the given plugin's data directory. +func NewController(logger logging.Logger, pluginDataFn func(string) (*appstate.ScopedRoot, error)) Controller { return &controller{ - logger: logger.Named("DataController"), + logger: logger.Named("DataController"), + pluginDataFn: pluginDataFn, } } @@ -45,48 +48,15 @@ func (c *controller) ServiceShutdown() error { return nil } -// dataDir returns the data directory for a plugin, creating it if necessary. -func (c *controller) dataDir(pluginID string) (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - dir := filepath.Join(homeDir, ".omniview", "plugins", filepath.Clean(pluginID), "data") - // Containment check: ensure the resolved path stays under .omniview/plugins. - pluginsRoot := filepath.Join(homeDir, ".omniview", "plugins") - if !strings.HasPrefix(dir, pluginsRoot+string(filepath.Separator)) { - return "", fmt.Errorf("invalid plugin ID %q: path escapes plugins directory", pluginID) - } - if err := os.MkdirAll(dir, 0700); err != nil { - return "", err - } - return dir, nil -} - -// keyPath returns the full file path for a given plugin/key combination. -func (c *controller) keyPath(pluginID, key string) (string, error) { - dir, err := c.dataDir(pluginID) - if err != nil { - return "", err - } - full := filepath.Join(dir, filepath.Clean(key)+".json") - // Containment check: ensure the key doesn't escape the data directory. - if !strings.HasPrefix(full, dir+string(filepath.Separator)) { - return "", fmt.Errorf("invalid key %q: path escapes data directory", key) - } - return full, nil -} func (c *controller) Get(pluginID, key string) (any, error) { logger := c.logger.With(logging.Any("pluginID", pluginID), logging.Any("key", key)) - path, err := c.keyPath(pluginID, key) + root, err := c.pluginDataFn(pluginID) if err != nil { - logger.Errorw(context.Background(), "failed to resolve key path", "error", err) return nil, err } - - data, err := os.ReadFile(path) + data, err := root.ReadFile(key + ".json") if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -107,19 +77,17 @@ func (c *controller) Get(pluginID, key string) (any, error) { func (c *controller) Set(pluginID, key string, value any) error { logger := c.logger.With(logging.Any("pluginID", pluginID), logging.Any("key", key)) - path, err := c.keyPath(pluginID, key) + root, err := c.pluginDataFn(pluginID) if err != nil { - logger.Errorw(context.Background(), "failed to resolve key path", "error", err) return err } - data, err := json.MarshalIndent(value, "", " ") if err != nil { logger.Errorw(context.Background(), "failed to marshal data", "error", err) return err } - if err := os.WriteFile(path, data, 0600); err != nil { + if err := root.WriteFile(key+".json", data, 0600); err != nil { logger.Errorw(context.Background(), "failed to write data file", "error", err) return err } @@ -130,13 +98,11 @@ func (c *controller) Set(pluginID, key string, value any) error { func (c *controller) Delete(pluginID, key string) error { logger := c.logger.With(logging.Any("pluginID", pluginID), logging.Any("key", key)) - path, err := c.keyPath(pluginID, key) + root, err := c.pluginDataFn(pluginID) if err != nil { - logger.Errorw(context.Background(), "failed to resolve key path", "error", err) return err } - - if err := os.Remove(path); err != nil { + if err := root.Remove(key + ".json"); err != nil { if errors.Is(err, os.ErrNotExist) { return nil } @@ -150,13 +116,11 @@ func (c *controller) Delete(pluginID, key string) error { func (c *controller) Keys(pluginID string) ([]string, error) { logger := c.logger.With(logging.Any("pluginID", pluginID)) - dir, err := c.dataDir(pluginID) + root, err := c.pluginDataFn(pluginID) if err != nil { - logger.Errorw(context.Background(), "failed to resolve data dir", "error", err) return nil, err } - - entries, err := os.ReadDir(dir) + entries, err := root.ReadDir(".") if err != nil { if errors.Is(err, os.ErrNotExist) { return []string{}, nil diff --git a/backend/pkg/plugin/data/service_wrapper.go b/backend/pkg/plugin/data/service_wrapper.go new file mode 100644 index 00000000..2a7bf52f --- /dev/null +++ b/backend/pkg/plugin/data/service_wrapper.go @@ -0,0 +1,39 @@ +package data + +import ( + "context" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper is an explicit delegation wrapper around data.Controller. +type ServiceWrapper struct { + Ctrl Controller +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if ss, ok := s.Ctrl.(interface { + ServiceStartup(context.Context, application.ServiceOptions) error + }); ok { + return ss.ServiceStartup(ctx, options) + } + return nil +} +func (s *ServiceWrapper) ServiceShutdown() error { + if ss, ok := s.Ctrl.(interface{ ServiceShutdown() error }); ok { + return ss.ServiceShutdown() + } + return nil +} +func (s *ServiceWrapper) Get(pluginID, key string) (any, error) { + return s.Ctrl.Get(pluginID, key) +} +func (s *ServiceWrapper) Set(pluginID, key string, value any) error { + return s.Ctrl.Set(pluginID, key, value) +} +func (s *ServiceWrapper) Delete(pluginID, key string) error { + return s.Ctrl.Delete(pluginID, key) +} +func (s *ServiceWrapper) Keys(pluginID string) ([]string, error) { + return s.Ctrl.Keys(pluginID) +} diff --git a/backend/pkg/plugin/devserver/external.go b/backend/pkg/plugin/devserver/external.go index 01efc913..f0fd6615 100644 --- a/backend/pkg/plugin/devserver/external.go +++ b/backend/pkg/plugin/devserver/external.go @@ -13,6 +13,7 @@ import ( logging "github.com/omniviewdev/plugin-sdk/log" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" ) // ExternalConnection tracks a connection to an externally-managed plugin. @@ -24,7 +25,7 @@ type ExternalConnection struct { cancelHealth context.CancelFunc } -// ExternalWatcher watches for .devinfo files in ~/.omniview/plugins/ and +// ExternalWatcher watches for .devinfo files in the plugins directory and // manages connections to externally-run plugin processes. type ExternalWatcher struct { ctx context.Context @@ -32,7 +33,8 @@ type ExternalWatcher struct { watcher *fsnotify.Watcher connections map[string]*ExternalConnection // pluginID -> connection mu sync.RWMutex - pluginDir string + pluginsRoot *appstate.ScopedRoot + pluginDir string // resolved absolute path (for fsnotify) done chan struct{} // closed when the run() goroutine exits // onConnect is called when a new external plugin is detected. @@ -45,15 +47,11 @@ type ExternalWatcher struct { // NewExternalWatcher creates a new watcher for .devinfo files. func NewExternalWatcher( logger logging.Logger, + pluginsRoot *appstate.ScopedRoot, onConnect func(pluginID string, info *DevInfoFile), onDisconnect func(pluginID string), ) (*ExternalWatcher, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, apperror.Internal(err, "Failed to get home directory") - } - - pluginDir := filepath.Join(homeDir, ".omniview", "plugins") + pluginDir := pluginsRoot.ResolvePath("") watcher, err := fsnotify.NewWatcher() if err != nil { @@ -64,6 +62,7 @@ func NewExternalWatcher( logger: logger.Named("ExternalWatcher"), watcher: watcher, connections: make(map[string]*ExternalConnection), + pluginsRoot: pluginsRoot, pluginDir: pluginDir, done: make(chan struct{}), onConnect: onConnect, @@ -77,12 +76,12 @@ func (ew *ExternalWatcher) Start(ctx context.Context) error { ew.ctx = ctx // Ensure the plugin directory exists. - if err := os.MkdirAll(ew.pluginDir, 0755); err != nil { + if err := ew.pluginsRoot.MkdirAll(".", 0755); err != nil { return apperror.Internal(err, "Failed to create plugin directory") } // Watch each plugin subdirectory for .devinfo files. - entries, err := os.ReadDir(ew.pluginDir) + entries, err := ew.pluginsRoot.ReadDir(".") if err != nil { return apperror.Internal(err, "Failed to read plugin directory") } @@ -159,7 +158,7 @@ func (ew *ExternalWatcher) GetExternalInfo(pluginID string) *DevInfoFile { // and attempts to connect to them. This handles the case where the IDE restarts // while external plugins are still running. func (ew *ExternalWatcher) scanExistingDevInfoFiles() { - entries, err := os.ReadDir(ew.pluginDir) + entries, err := ew.pluginsRoot.ReadDir(".") if err != nil { ew.logger.Warnw(context.Background(), "failed to scan for existing devinfo files", "error", err) return diff --git a/backend/pkg/plugin/devserver/external_test.go b/backend/pkg/plugin/devserver/external_test.go index 26bd72d0..37f17135 100644 --- a/backend/pkg/plugin/devserver/external_test.go +++ b/backend/pkg/plugin/devserver/external_test.go @@ -16,6 +16,7 @@ import ( logging "github.com/omniviewdev/plugin-sdk/log" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" ) func TestReadDevInfoFile_Valid(t *testing.T) { @@ -127,11 +128,13 @@ func newTestExternalWatcher(t *testing.T) (*ExternalWatcher, *connectRecorder, * cr := &connectRecorder{} dr := &disconnectRecorder{} + svc := appstate.NewTestService(t) ew := &ExternalWatcher{ ctx: context.Background(), logger: logging.NewNop(), connections: make(map[string]*ExternalConnection), - pluginDir: t.TempDir(), + pluginsRoot: svc.Plugins(), + pluginDir: svc.Plugins().ResolvePath(""), onConnect: cr.record, onDisconnect: dr.record, } @@ -393,7 +396,8 @@ func TestNewExternalWatcher(t *testing.T) { onConnect := func(string, *DevInfoFile) {} onDisconnect := func(string) {} - ew, err := NewExternalWatcher(logger, onConnect, onDisconnect) + svc := appstate.NewTestService(t) + ew, err := NewExternalWatcher(logger, svc.Plugins(), onConnect, onDisconnect) require.NoError(t, err) require.NotNil(t, ew) diff --git a/backend/pkg/plugin/devserver/gowatch.go b/backend/pkg/plugin/devserver/gowatch.go index 36c7c293..d7ebaef8 100644 --- a/backend/pkg/plugin/devserver/gowatch.go +++ b/backend/pkg/plugin/devserver/gowatch.go @@ -19,6 +19,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" logging "github.com/omniviewdev/plugin-sdk/log" ) @@ -33,10 +34,11 @@ type goWatcherProcess struct { cancel context.CancelFunc logger logging.Logger - pluginID string - devPath string - buildOpts BuildOpts - reloader PluginReloader + pluginID string + devPath string + buildOpts BuildOpts + reloader PluginReloader + pluginsRoot *appstate.ScopedRoot appendLog func(LogEntry) setStatus func(DevProcessStatus) @@ -55,6 +57,7 @@ func newGoWatcherProcess( devPath string, buildOpts BuildOpts, reloader PluginReloader, + pluginsRoot *appstate.ScopedRoot, appendLog func(LogEntry), setStatus func(DevProcessStatus), setBuild func(duration time.Duration, buildErr string), @@ -62,18 +65,19 @@ func newGoWatcherProcess( ) *goWatcherProcess { ctx, cancel := context.WithCancel(parentCtx) return &goWatcherProcess{ - ctx: ctx, - cancel: cancel, - logger: logger.Named("gowatch"), - pluginID: pluginID, - devPath: devPath, - buildOpts: buildOpts, - reloader: reloader, - appendLog: appendLog, - setStatus: setStatus, - setBuild: setBuild, - emitErrors: emitErrors, - done: make(chan struct{}), + ctx: ctx, + cancel: cancel, + logger: logger.Named("gowatch"), + pluginID: pluginID, + devPath: devPath, + buildOpts: buildOpts, + reloader: reloader, + pluginsRoot: pluginsRoot, + appendLog: appendLog, + setStatus: setStatus, + setBuild: setBuild, + emitErrors: emitErrors, + done: make(chan struct{}), } } @@ -370,7 +374,7 @@ func (gw *goWatcherProcess) handleRebuild(changedFile string) { l.Warnw(context.Background(), "failed to sync plugin.yaml", "error", err) } - // Transfer the binary to ~/.omniview/plugins//bin/plugin. + // Transfer the binary to //bin/plugin. if err := gw.transferBinary(); err != nil { l.Errorw(context.Background(), "failed to transfer binary", "error", err) gw.appendLog(LogEntry{ @@ -459,17 +463,12 @@ func (gw *goWatcherProcess) runGoBuild() error { } // transferBinary copies the built binary from /build/bin/plugin to -// ~/.omniview/plugins//bin/plugin. +// //bin/plugin. func (gw *goWatcherProcess) transferBinary() error { srcPath := filepath.Join(gw.devPath, "build", "bin", "plugin") - homeDir, err := os.UserHomeDir() - if err != nil { - return apperror.Internal(err, "Failed to get home directory") - } - dstDir := filepath.Join(homeDir, ".omniview", "plugins", gw.pluginID, "bin") - dstPath := filepath.Join(dstDir, "plugin") + binDir := filepath.Join(gw.pluginID, "bin") - if err := os.MkdirAll(dstDir, 0755); err != nil { + if err := gw.pluginsRoot.MkdirAll(binDir, 0755); err != nil { return apperror.Internal(err, "Failed to create plugin bin directory") } @@ -485,10 +484,12 @@ func (gw *goWatcherProcess) transferBinary() error { return apperror.Internal(err, "Failed to stat built binary") } + dstRelPath := filepath.Join(binDir, "plugin") + // Remove existing binary first (in case it's being held open). - _ = os.Remove(dstPath) + _ = gw.pluginsRoot.Remove(dstRelPath) - dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) + dstFile, err := gw.pluginsRoot.OpenFile(dstRelPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) if err != nil { return apperror.Internal(err, "Failed to create destination binary") } @@ -506,11 +507,6 @@ func (gw *goWatcherProcess) transferBinary() error { // the next reload without requiring a full reinstall. func (gw *goWatcherProcess) syncPluginYaml() error { srcPath := filepath.Join(gw.devPath, "plugin.yaml") - homeDir, err := os.UserHomeDir() - if err != nil { - return apperror.Internal(err, "Failed to get home directory") - } - dstPath := filepath.Join(homeDir, ".omniview", "plugins", gw.pluginID, "plugin.yaml") src, err := os.Open(srcPath) if err != nil { @@ -518,7 +514,13 @@ func (gw *goWatcherProcess) syncPluginYaml() error { } defer src.Close() - dst, err := os.Create(dstPath) + pluginDir := gw.pluginID + if err := gw.pluginsRoot.MkdirAll(pluginDir, 0755); err != nil { + return apperror.Internal(err, "Failed to create plugin directory") + } + + dstRelPath := filepath.Join(gw.pluginID, "plugin.yaml") + dst, err := gw.pluginsRoot.OpenFile(dstRelPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return apperror.Internal(err, "Failed to create destination plugin.yaml") } diff --git a/backend/pkg/plugin/devserver/gowatch_test.go b/backend/pkg/plugin/devserver/gowatch_test.go index 4415ed9c..b9baf95e 100644 --- a/backend/pkg/plugin/devserver/gowatch_test.go +++ b/backend/pkg/plugin/devserver/gowatch_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" logging "github.com/omniviewdev/plugin-sdk/log" + "github.com/omniviewdev/omniview/internal/appstate" + "github.com/omniviewdev/omniview/backend/pkg/apperror" ) @@ -127,21 +129,20 @@ func TestTransferBinary_Success(t *testing.T) { srcBinary := filepath.Join(buildDir, "plugin") require.NoError(t, os.WriteFile(srcBinary, []byte("fake-binary-data"), 0755)) - // Override HOME so transferBinary writes to a temp dir. - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) + svc := appstate.NewTestService(t) gw := &goWatcherProcess{ - logger: logging.NewNop(), - pluginID: "transfer-test", - devPath: devPath, + logger: logging.NewNop(), + pluginID: "transfer-test", + devPath: devPath, + pluginsRoot: svc.Plugins(), } err := gw.transferBinary() require.NoError(t, err) // Verify the binary was copied. - dstPath := filepath.Join(fakeHome, ".omniview", "plugins", "transfer-test", "bin", "plugin") + dstPath := filepath.Join(svc.Plugins().ResolvePath(""), "transfer-test", "bin", "plugin") data, err := os.ReadFile(dstPath) require.NoError(t, err) assert.Equal(t, "fake-binary-data", string(data)) @@ -156,13 +157,13 @@ func TestTransferBinary_SourceMissing(t *testing.T) { devPath := t.TempDir() // Do NOT create the binary — it should be missing. - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) + svc := appstate.NewTestService(t) gw := &goWatcherProcess{ - logger: logging.NewNop(), - pluginID: "missing-src", - devPath: devPath, + logger: logging.NewNop(), + pluginID: "missing-src", + devPath: devPath, + pluginsRoot: svc.Plugins(), } err := gw.transferBinary() @@ -182,17 +183,17 @@ func TestTransferBinary_DestDirCreated(t *testing.T) { require.NoError(t, os.MkdirAll(buildDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(buildDir, "plugin"), []byte("bin"), 0755)) - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) + svc := appstate.NewTestService(t) gw := &goWatcherProcess{ - logger: logging.NewNop(), - pluginID: "dest-create", - devPath: devPath, + logger: logging.NewNop(), + pluginID: "dest-create", + devPath: devPath, + pluginsRoot: svc.Plugins(), } // The dest dir doesn't exist yet. - dstDir := filepath.Join(fakeHome, ".omniview", "plugins", "dest-create", "bin") + dstDir := filepath.Join(svc.Plugins().ResolvePath(""), "dest-create", "bin") _, err := os.Stat(dstDir) assert.True(t, os.IsNotExist(err)) @@ -211,17 +212,17 @@ func TestTransferBinary_OverwritesExisting(t *testing.T) { require.NoError(t, os.MkdirAll(buildDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(buildDir, "plugin"), []byte("new-binary"), 0755)) - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) + svc := appstate.NewTestService(t) gw := &goWatcherProcess{ - logger: logging.NewNop(), - pluginID: "overwrite-test", - devPath: devPath, + logger: logging.NewNop(), + pluginID: "overwrite-test", + devPath: devPath, + pluginsRoot: svc.Plugins(), } // Pre-create an old binary at the destination. - dstDir := filepath.Join(fakeHome, ".omniview", "plugins", "overwrite-test", "bin") + dstDir := filepath.Join(svc.Plugins().ResolvePath(""), "overwrite-test", "bin") require.NoError(t, os.MkdirAll(dstDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(dstDir, "plugin"), []byte("old-binary"), 0755)) @@ -264,7 +265,7 @@ func TestNewGoWatcherProcess(t *testing.T) { gw := newGoWatcherProcess(ctx, logger, "gw-test", "/dev/path", BuildOpts{ GoPath: "/usr/local/go/bin/go", - }, reloader, appendLog, setStatus, setBuild, emitErrors) + }, reloader, appstate.NewTestService(t).Plugins(), appendLog, setStatus, setBuild, emitErrors) assert.Equal(t, "gw-test", gw.pluginID) assert.Equal(t, "/dev/path", gw.devPath) @@ -349,6 +350,7 @@ func TestGoWatcherStart_WatchesNestedSubdirs(t *testing.T) { devPath, BuildOpts{}, // Empty GoPath → initial build will fail (expected) &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logs = append(logs, e) }, func(DevProcessStatus) {}, func(time.Duration, string) {}, @@ -379,6 +381,7 @@ func TestGoWatcherStart_SkipsHiddenVendorNodeModules(t *testing.T) { devPath, BuildOpts{}, // Empty GoPath → initial build will fail (expected) &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logs = append(logs, e) }, func(DevProcessStatus) {}, func(time.Duration, string) {}, @@ -412,6 +415,7 @@ func TestGoWatcherStart_InitialBuild_FailsGracefully(t *testing.T) { devPath, BuildOpts{GoPath: ""}, // Empty → build will fail &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logs = append(logs, e) }, func(s DevProcessStatus) { statuses = append(statuses, s) }, func(time.Duration, string) {}, @@ -454,6 +458,7 @@ func TestGoWatcherStart_InitialBuild_StatusProgression(t *testing.T) { devPath, BuildOpts{GoPath: "nonexistent-go-binary"}, &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) {}, func(s DevProcessStatus) { statuses = append(statuses, s) }, func(time.Duration, string) {}, @@ -476,19 +481,19 @@ func TestTransferBinary_ReadOnlyDestDir(t *testing.T) { require.NoError(t, os.MkdirAll(buildDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(buildDir, "plugin"), []byte("bin"), 0755)) - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) + svc := appstate.NewTestService(t) // Create the dest dir as read-only so file creation fails. - dstDir := filepath.Join(fakeHome, ".omniview", "plugins", "readonly-test", "bin") + dstDir := filepath.Join(svc.Plugins().ResolvePath(""), "readonly-test", "bin") require.NoError(t, os.MkdirAll(dstDir, 0755)) require.NoError(t, os.Chmod(dstDir, 0444)) t.Cleanup(func() { os.Chmod(dstDir, 0755) }) gw := &goWatcherProcess{ - logger: logging.NewNop(), - pluginID: "readonly-test", - devPath: devPath, + logger: logging.NewNop(), + pluginID: "readonly-test", + devPath: devPath, + pluginsRoot: svc.Plugins(), } err := gw.transferBinary() @@ -621,6 +626,7 @@ func TestGoWatcherStart_WatchesGoWorkModules(t *testing.T) { devPath, BuildOpts{}, // Empty GoPath → initial build will fail (expected) &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logs = append(logs, e) }, func(DevProcessStatus) {}, func(time.Duration, string) {}, @@ -662,6 +668,7 @@ func TestGoWatcherStart_NoGoWork_StillWorks(t *testing.T) { devPath, BuildOpts{}, &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logs = append(logs, e) }, func(DevProcessStatus) {}, func(time.Duration, string) {}, @@ -692,6 +699,7 @@ func TestGoWatcherStart_GoWork_SkipsDotModule(t *testing.T) { devPath, BuildOpts{}, &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logs = append(logs, e) }, func(DevProcessStatus) {}, func(time.Duration, string) {}, @@ -722,6 +730,7 @@ func TestGoWatcherStart_GoWork_NonExistentModule(t *testing.T) { devPath, BuildOpts{}, &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logs = append(logs, e) }, func(DevProcessStatus) {}, func(time.Duration, string) {}, diff --git a/backend/pkg/plugin/devserver/helpers_test.go b/backend/pkg/plugin/devserver/helpers_test.go index 295cde55..d6d15e02 100644 --- a/backend/pkg/plugin/devserver/helpers_test.go +++ b/backend/pkg/plugin/devserver/helpers_test.go @@ -6,6 +6,8 @@ import ( "testing" logging "github.com/omniviewdev/plugin-sdk/log" + + "github.com/omniviewdev/omniview/internal/appstate" ) // ============================================================================ @@ -170,6 +172,7 @@ func newTestInstance(t *testing.T, pluginID string) (*DevServerInstance, *status 15173, BuildOpts{}, nil, + appstate.NewTestService(t).Plugins(), sr.record, lr.record, er.record, diff --git a/backend/pkg/plugin/devserver/instance.go b/backend/pkg/plugin/devserver/instance.go index 9acc081b..2586823d 100644 --- a/backend/pkg/plugin/devserver/instance.go +++ b/backend/pkg/plugin/devserver/instance.go @@ -7,6 +7,8 @@ import ( "time" logging "github.com/omniviewdev/plugin-sdk/log" + + "github.com/omniviewdev/omniview/internal/appstate" ) // emitStatusFunc is the callback type for emitting status updates. @@ -22,14 +24,15 @@ type emitErrorsFunc func(pluginID string, errors []BuildError) // It coordinates the Vite process and Go file watcher. type DevServerInstance struct { // Immutable fields (set at construction, never change) - ctx context.Context - cancel context.CancelFunc - logger logging.Logger - pluginID string - devPath string // absolute path to the plugin source directory - vitePort int - buildOpts BuildOpts - reloader PluginReloader + ctx context.Context + cancel context.CancelFunc + logger logging.Logger + pluginID string + devPath string // absolute path to the plugin source directory + vitePort int + buildOpts BuildOpts + reloader PluginReloader + pluginsRoot *appstate.ScopedRoot // Event emission callbacks (bound to DevServerManager methods) onStatus emitStatusFunc @@ -61,6 +64,7 @@ func NewDevServerInstance( vitePort int, buildOpts BuildOpts, reloader PluginReloader, + pluginsRoot *appstate.ScopedRoot, onStatus emitStatusFunc, onLogs emitLogsFunc, onErrors emitErrorsFunc, @@ -68,21 +72,22 @@ func NewDevServerInstance( ctx, cancel := context.WithCancel(parentCtx) return &DevServerInstance{ - ctx: ctx, - cancel: cancel, - logger: logger.Named("instance").With(logging.Any("pluginID", pluginID)), - pluginID: pluginID, - devPath: devPath, - vitePort: vitePort, - buildOpts: buildOpts, - reloader: reloader, - onStatus: onStatus, - onLogs: onLogs, - onErrors: onErrors, - mode: DevServerModeManaged, - viteStatus: DevProcessStatusIdle, - goStatus: DevProcessStatusIdle, - logBuffer: NewLogRingBuffer(DefaultLogBufferSize), + ctx: ctx, + cancel: cancel, + logger: logger.Named("instance").With(logging.Any("pluginID", pluginID)), + pluginID: pluginID, + devPath: devPath, + vitePort: vitePort, + buildOpts: buildOpts, + reloader: reloader, + pluginsRoot: pluginsRoot, + onStatus: onStatus, + onLogs: onLogs, + onErrors: onErrors, + mode: DevServerModeManaged, + viteStatus: DevProcessStatusIdle, + goStatus: DevProcessStatusIdle, + logBuffer: NewLogRingBuffer(DefaultLogBufferSize), } } @@ -115,6 +120,7 @@ func (inst *DevServerInstance) Start() error { inst.devPath, inst.buildOpts, inst.reloader, + inst.pluginsRoot, inst.appendLog, inst.setGoStatus, inst.setBuildResult, diff --git a/backend/pkg/plugin/devserver/integration_test.go b/backend/pkg/plugin/devserver/integration_test.go index 29a9eeb5..8b23afca 100644 --- a/backend/pkg/plugin/devserver/integration_test.go +++ b/backend/pkg/plugin/devserver/integration_test.go @@ -20,6 +20,7 @@ import ( logging "github.com/omniviewdev/plugin-sdk/log" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" ) // ============================================================================ @@ -162,6 +163,13 @@ func (r *goWatcherErrorRecorder) waitForErrors(timeout time.Duration) ([]BuildEr } } +// newTestPluginsRoot creates a temporary appstate.ScopedRoot for the plugins +// directory, cleaned up when the test finishes. +func newTestPluginsRoot(t *testing.T) *appstate.ScopedRoot { + t.Helper() + return appstate.NewTestService(t).Plugins() +} + // ============================================================================ // Go Watcher Integration Tests // ============================================================================ @@ -195,6 +203,8 @@ func TestIntegration_GoWatcher_BuildOnFileChange(t *testing.T) { logEntries = append(logEntries, entry) } + testSvc := appstate.NewTestService(t) + // Create and start the watcher. gw := newGoWatcherProcess( context.Background(), @@ -203,6 +213,7 @@ func TestIntegration_GoWatcher_BuildOnFileChange(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + testSvc.Plugins(), appendLog, statusRec.record, buildRec.record, @@ -247,10 +258,10 @@ func TestIntegration_GoWatcher_BuildOnFileChange(t *testing.T) { _, err = os.Stat(filepath.Join(devPath, "build", "bin", "plugin")) assert.NoError(t, err, "built binary should exist in devPath/build/bin/plugin") - // Verify binary was transferred to fake home. - transferredPath := filepath.Join(fakeHome, ".omniview", "plugins", "test-integration", "bin", "plugin") + // Verify binary was transferred to the plugins directory. + transferredPath := filepath.Join(testSvc.Plugins().ResolvePath("test-integration"), "bin", "plugin") _, err = os.Stat(transferredPath) - assert.NoError(t, err, "binary should be transferred to ~/.omniview/plugins//bin/plugin") + assert.NoError(t, err, "binary should be transferred to plugins//bin/plugin") } func TestIntegration_GoWatcher_BuildError(t *testing.T) { @@ -276,6 +287,7 @@ func TestIntegration_GoWatcher_BuildError(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(LogEntry) {}, statusRec.record, buildRec.record, @@ -336,6 +348,7 @@ func TestIntegration_GoWatcher_Start_MissingPkgDir(t *testing.T) { devPath, BuildOpts{GoPath: "/usr/bin/go"}, &mockPluginReloader{}, + appstate.NewTestService(t).Plugins(), func(LogEntry) {}, func(DevProcessStatus) {}, func(time.Duration, string) {}, @@ -566,6 +579,7 @@ func TestIntegration_GoWatcher_InitialBuild_TriggersReload(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logEntries = append(logEntries, e) }, statusRec.record, func(time.Duration, string) {}, @@ -620,6 +634,7 @@ func TestIntegration_GoWatcher_InitialBuild_ReloadFails(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(e LogEntry) { logEntries = append(logEntries, e) }, func(DevProcessStatus) {}, func(time.Duration, string) {}, @@ -747,6 +762,7 @@ func TestIntegration_GoWatcher_NoBuildAfterStop(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(LogEntry) {}, statusRec.record, buildRec.record, @@ -871,6 +887,7 @@ func TestIntegration_GoWatcher_DebounceRapidChanges(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(LogEntry) {}, statusRec.record, buildRec.record, @@ -923,6 +940,7 @@ func TestIntegration_GoWatcher_IgnoresNonGoFiles(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(LogEntry) {}, statusRec.record, func(time.Duration, string) {}, @@ -981,6 +999,7 @@ func TestIntegration_GoWatcher_NestedSubdirChange(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(LogEntry) {}, statusRec.record, func(time.Duration, string) {}, @@ -1024,6 +1043,7 @@ func TestIntegration_GoWatcher_ReloaderError(t *testing.T) { devPath, BuildOpts{GoPath: goPath}, reloader, + appstate.NewTestService(t).Plugins(), func(LogEntry) {}, statusRec.record, buildRec.record, diff --git a/backend/pkg/plugin/devserver/manager.go b/backend/pkg/plugin/devserver/manager.go index 88791717..9aab20d8 100644 --- a/backend/pkg/plugin/devserver/manager.go +++ b/backend/pkg/plugin/devserver/manager.go @@ -9,6 +9,7 @@ import ( logging "github.com/omniviewdev/plugin-sdk/log" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" pkgsettings "github.com/omniviewdev/plugin-sdk/settings" ) @@ -36,12 +37,15 @@ type DevServerManager struct { pluginReloader PluginReloader settingsProvider pkgsettings.Provider externalWatcher *ExternalWatcher + pluginsRoot *appstate.ScopedRoot // scoped to ~/.omniview/plugins } // NewDevServerManager creates a new DevServerManager. Call Initialize() with the Wails // context before using any other methods. func NewDevServerManager( logger logging.Logger, + stateRoot *appstate.ScopedRoot, + pluginsRoot *appstate.ScopedRoot, pluginRef PluginRef, pluginReloader PluginReloader, settingsProvider pkgsettings.Provider, @@ -49,10 +53,11 @@ func NewDevServerManager( return &DevServerManager{ logger: logger.Named("DevServerManager"), instances: make(map[string]*DevServerInstance), - ports: NewPortAllocator(), + ports: NewPortAllocator(stateRoot), pluginRef: pluginRef, pluginReloader: pluginReloader, settingsProvider: settingsProvider, + pluginsRoot: pluginsRoot, } } @@ -71,6 +76,7 @@ func (m *DevServerManager) ServiceStartup(ctx context.Context, options applicati // Start the external watcher for .devinfo files. watcher, err := NewExternalWatcher( m.logger, + m.pluginsRoot, m.handleExternalConnect, m.handleExternalDisconnect, ) @@ -205,6 +211,7 @@ func (m *DevServerManager) startDevServer(pluginID, devPath string) (DevServerSt port, buildOpts, m.pluginReloader, + m.pluginsRoot, m.emitStatus, m.emitLogs, m.emitErrors, diff --git a/backend/pkg/plugin/devserver/manager_test.go b/backend/pkg/plugin/devserver/manager_test.go index 7877497d..50fad751 100644 --- a/backend/pkg/plugin/devserver/manager_test.go +++ b/backend/pkg/plugin/devserver/manager_test.go @@ -13,6 +13,7 @@ import ( logging "github.com/omniviewdev/plugin-sdk/log" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" pkgsettings "github.com/omniviewdev/plugin-sdk/settings" ) @@ -59,7 +60,8 @@ func (m *mockSettingsProvider) GetBool(string) (bool, error) { return func newTestManager(t *testing.T) *DevServerManager { t.Helper() - return NewDevServerManager(logging.NewNop(), nil, nil, &mockSettingsProvider{}) + svc := appstate.NewTestService(t) + return NewDevServerManager(logging.NewNop(), svc.RootDir(), svc.Plugins(), nil, nil, &mockSettingsProvider{}) } func TestNewDevServerManager(t *testing.T) { @@ -167,7 +169,7 @@ func TestManager_ListDevServerStates_WithInstances(t *testing.T) { noopLogs := func(string, []LogEntry) {} noopErrors := func(string, []BuildError) {} - inst := NewDevServerInstance(ctx, logging.NewNop(), "plugin-a", "/dev/path", 15173, BuildOpts{}, nil, noop, noopLogs, noopErrors) + inst := NewDevServerInstance(ctx, logging.NewNop(), "plugin-a", "/dev/path", 15173, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors) mgr.mu.Lock() mgr.instances["plugin-a"] = inst @@ -238,7 +240,7 @@ func TestManager_GetDevServerLogs_WithInstance(t *testing.T) { noopLogs := func(string, []LogEntry) {} noopErrors := func(string, []BuildError) {} - inst := NewDevServerInstance(ctx, logging.NewNop(), "plugin-a", "/dev/path", 15173, BuildOpts{}, nil, noop, noopLogs, noopErrors) + inst := NewDevServerInstance(ctx, logging.NewNop(), "plugin-a", "/dev/path", 15173, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors) inst.appendLog(LogEntry{Message: "build started", Source: "go-build", Level: "info", Timestamp: time.Now().Format(time.RFC3339)}) inst.appendLog(LogEntry{Message: "build complete", Source: "go-build", Level: "info", Timestamp: time.Now().Format(time.RFC3339)}) @@ -304,7 +306,7 @@ func TestManager_StartDevServer_AlreadyRunning(t *testing.T) { noopErrors := func(string, []BuildError) {} inst := NewDevServerInstance( context.Background(), logging.NewNop(), - "dup-plugin", "/dev/path", 15173, BuildOpts{}, nil, + "dup-plugin", "/dev/path", 15173, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors, ) @@ -369,12 +371,12 @@ func TestManager_ServiceShutdown_WithInstances(t *testing.T) { inst1 := NewDevServerInstance( context.Background(), logging.NewNop(), - "plugin-1", "/p1", 15173, BuildOpts{}, nil, + "plugin-1", "/p1", 15173, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors, ) inst2 := NewDevServerInstance( context.Background(), logging.NewNop(), - "plugin-2", "/p2", 15174, BuildOpts{}, nil, + "plugin-2", "/p2", 15174, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors, ) @@ -424,7 +426,7 @@ func TestManager_GetDevServerState_WithInstance(t *testing.T) { inst := NewDevServerInstance( context.Background(), logging.NewNop(), - "state-plug", "/dev/path", 15180, BuildOpts{}, nil, + "state-plug", "/dev/path", 15180, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors, ) // Simulate the instance being in a running state. @@ -547,7 +549,7 @@ func TestManager_RebuildPlugin_WithInstance(t *testing.T) { noopErrors := func(string, []BuildError) {} inst := NewDevServerInstance( context.Background(), logging.NewNop(), - "rebuild-plugin", "/dev/path", 15173, BuildOpts{}, nil, + "rebuild-plugin", "/dev/path", 15173, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors, ) @@ -585,7 +587,7 @@ func TestManager_StartDevServerForPath_AlreadyRunning(t *testing.T) { noopErrors := func(string, []BuildError) {} inst := NewDevServerInstance( context.Background(), logging.NewNop(), - "dup-plugin", "/dev/path", 15173, BuildOpts{}, nil, + "dup-plugin", "/dev/path", 15173, BuildOpts{}, nil, nil, noop, noopLogs, noopErrors, ) diff --git a/backend/pkg/plugin/devserver/ports.go b/backend/pkg/plugin/devserver/ports.go index ca846d52..27a5f0f7 100644 --- a/backend/pkg/plugin/devserver/ports.go +++ b/backend/pkg/plugin/devserver/ports.go @@ -4,34 +4,39 @@ import ( "context" "encoding/json" "fmt" + "log" "net" "os" - "path/filepath" "sync" logging "github.com/omniviewdev/plugin-sdk/log" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" ) const ( PortRangeStart = 15173 PortRangeEnd = 15273 + + devserverPIDFileName = "devserver_pids.json" ) // PortAllocator manages port allocation for Vite dev servers. // It tracks which ports are currently in use and finds free ones. type PortAllocator struct { - mu sync.Mutex - assigned map[int]string // port -> pluginID - pids map[int]int // port -> process group ID (for cleanup) + mu sync.Mutex + assigned map[int]string // port -> pluginID + pids map[int]int // port -> process group ID (for cleanup) + stateRoot *appstate.ScopedRoot } // NewPortAllocator creates a new PortAllocator. -func NewPortAllocator() *PortAllocator { +func NewPortAllocator(stateRoot *appstate.ScopedRoot) *PortAllocator { return &PortAllocator{ - assigned: make(map[int]string), - pids: make(map[int]int), + assigned: make(map[int]string), + pids: make(map[int]int), + stateRoot: stateRoot, } } @@ -101,13 +106,7 @@ func (pa *PortAllocator) GetPort(pluginID string) int { return 0 } -// pidFilePath returns the path to the PID tracking file. -func pidFilePath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".omniview", "devserver_pids.json") -} - -// SavePIDs persists the current port→PGID map to disk so stale processes +// SavePIDs persists the current port->PGID map to disk so stale processes // can be cleaned up after an unclean shutdown. func (pa *PortAllocator) SavePIDs() { pa.mu.Lock() @@ -121,20 +120,25 @@ func (pa *PortAllocator) SavePIDs() { if err != nil { return } - _ = os.WriteFile(pidFilePath(), b, 0644) + if err := pa.stateRoot.WriteFile(devserverPIDFileName, b, 0644); err != nil { + log.Printf("devserver: failed to save PID file: %v", err) + } } // CleanupStaleProcesses kills zombie Vite dev server process groups left over // from a previous unclean shutdown. It reads the PID file written by SavePIDs, // kills each recorded process group, and removes the file. func (pa *PortAllocator) CleanupStaleProcesses(ctx context.Context, logger logging.Logger) { - pidFile := pidFilePath() - b, err := os.ReadFile(pidFile) + b, err := pa.stateRoot.ReadFile(devserverPIDFileName) if err != nil { - // No PID file — nothing to clean up. + if os.IsNotExist(err) { + // No PID file -- nothing to clean up. + return + } + logger.Warnw(ctx, "failed to read devserver PID file", "error", err) return } - _ = os.Remove(pidFile) + _ = pa.stateRoot.Remove(devserverPIDFileName) var data map[string]int if err := json.Unmarshal(b, &data); err != nil { @@ -150,7 +154,7 @@ func (pa *PortAllocator) CleanupStaleProcesses(ctx context.Context, logger loggi // Kill the entire process group. if err := killProcessGroup(pgid); err != nil { - // Process already dead — that's fine. + // Process already dead -- that's fine. if !isProcessNotFound(err) { logger.Warnw(ctx, "failed to kill stale dev server process group", "port", portStr, diff --git a/backend/pkg/plugin/devserver/ports_test.go b/backend/pkg/plugin/devserver/ports_test.go index a2ed7907..33b7a343 100644 --- a/backend/pkg/plugin/devserver/ports_test.go +++ b/backend/pkg/plugin/devserver/ports_test.go @@ -6,10 +6,18 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/omniviewdev/omniview/internal/appstate" ) +func newTestPortAllocator(t *testing.T) *PortAllocator { + t.Helper() + svc := appstate.NewTestService(t) + return NewPortAllocator(svc.RootDir()) +} + func TestPortAllocator_ReturnsInRange(t *testing.T) { - pa := NewPortAllocator() + pa := newTestPortAllocator(t) port, err := pa.Allocate("test-plugin") require.NoError(t, err) assert.GreaterOrEqual(t, port, PortRangeStart) @@ -24,14 +32,14 @@ func TestPortAllocator_SkipsUsedPorts(t *testing.T) { } defer listener.Close() - pa := NewPortAllocator() + pa := newTestPortAllocator(t) port, err := pa.Allocate("test-plugin") require.NoError(t, err) assert.NotEqual(t, 15173, port) } func TestPortAllocator_UniquePerPlugin(t *testing.T) { - pa := NewPortAllocator() + pa := newTestPortAllocator(t) port1, err := pa.Allocate("plugin-a") require.NoError(t, err) @@ -43,7 +51,7 @@ func TestPortAllocator_UniquePerPlugin(t *testing.T) { } func TestPortAllocator_Release(t *testing.T) { - pa := NewPortAllocator() + pa := newTestPortAllocator(t) port1, err := pa.Allocate("test-plugin") require.NoError(t, err) @@ -57,7 +65,7 @@ func TestPortAllocator_Release(t *testing.T) { } func TestPortAllocator_ReleaseByPlugin(t *testing.T) { - pa := NewPortAllocator() + pa := newTestPortAllocator(t) port, err := pa.Allocate("test-plugin") require.NoError(t, err) @@ -70,7 +78,7 @@ func TestPortAllocator_ReleaseByPlugin(t *testing.T) { } func TestPortAllocator_GetPort(t *testing.T) { - pa := NewPortAllocator() + pa := newTestPortAllocator(t) port, err := pa.Allocate("test-plugin") require.NoError(t, err) diff --git a/backend/pkg/plugin/devserver/service_wrapper.go b/backend/pkg/plugin/devserver/service_wrapper.go new file mode 100644 index 00000000..c0277a85 --- /dev/null +++ b/backend/pkg/plugin/devserver/service_wrapper.go @@ -0,0 +1,52 @@ +package devserver + +import ( + "context" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper exposes only frontend-safe methods of devserver.DevServerManager. +// The DevServerManager implements ServiceStartup/ServiceShutdown directly, +// but registering it raw causes service/model shadowing. This wrapper separates +// the service identity from the model type. +type ServiceWrapper struct { + Mgr *DevServerManager +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + return s.Mgr.ServiceStartup(ctx, options) +} +func (s *ServiceWrapper) ServiceShutdown() error { + return s.Mgr.ServiceShutdown() +} +func (s *ServiceWrapper) StartDevServer(pluginID string) (DevServerState, error) { + return s.Mgr.StartDevServer(pluginID) +} +func (s *ServiceWrapper) StartDevServerForPath(pluginID, devPath string) (DevServerState, error) { + return s.Mgr.StartDevServerForPath(pluginID, devPath) +} +func (s *ServiceWrapper) StopDevServer(pluginID string) error { + return s.Mgr.StopDevServer(pluginID) +} +func (s *ServiceWrapper) RestartDevServer(pluginID string) (DevServerState, error) { + return s.Mgr.RestartDevServer(pluginID) +} +func (s *ServiceWrapper) RebuildPlugin(pluginID string) error { + return s.Mgr.RebuildPlugin(pluginID) +} +func (s *ServiceWrapper) GetDevServerState(pluginID string) DevServerState { + return s.Mgr.GetDevServerState(pluginID) +} +func (s *ServiceWrapper) ListDevServerStates() []DevServerState { + return s.Mgr.ListDevServerStates() +} +func (s *ServiceWrapper) GetDevServerLogs(pluginID string, count int) []LogEntry { + return s.Mgr.GetDevServerLogs(pluginID, count) +} +func (s *ServiceWrapper) IsManaged(pluginID string) bool { + return s.Mgr.IsManaged(pluginID) +} +func (s *ServiceWrapper) GetExternalPluginInfo(pluginID string) *DevInfoFile { + return s.Mgr.GetExternalPluginInfo(pluginID) +} diff --git a/backend/pkg/plugin/exec/service_wrapper.go b/backend/pkg/plugin/exec/service_wrapper.go new file mode 100644 index 00000000..80fbcfa3 --- /dev/null +++ b/backend/pkg/plugin/exec/service_wrapper.go @@ -0,0 +1,73 @@ +package exec + +import ( + "context" + + execsdk "github.com/omniviewdev/plugin-sdk/pkg/v1/exec" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper is an explicit delegation wrapper around exec.Controller. +// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, +// +// OnPluginDestroy +type ServiceWrapper struct { + Ctrl Controller +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if ss, ok := s.Ctrl.(interface { + ServiceStartup(context.Context, application.ServiceOptions) error + }); ok { + return ss.ServiceStartup(ctx, options) + } + return nil +} +func (s *ServiceWrapper) ServiceShutdown() error { + if ss, ok := s.Ctrl.(interface{ ServiceShutdown() error }); ok { + return ss.ServiceShutdown() + } + return nil +} +func (s *ServiceWrapper) CreateSession(plugin, connectionID string, opts execsdk.SessionOptions) (*execsdk.Session, error) { + return s.Ctrl.CreateSession(plugin, connectionID, opts) +} +func (s *ServiceWrapper) CreateTerminal(opts execsdk.SessionOptions) (*execsdk.Session, error) { + return s.Ctrl.CreateTerminal(opts) +} +func (s *ServiceWrapper) ListSessions() ([]*execsdk.Session, error) { + return s.Ctrl.ListSessions() +} +func (s *ServiceWrapper) GetSession(sessionID string) (*execsdk.Session, error) { + return s.Ctrl.GetSession(sessionID) +} +func (s *ServiceWrapper) AttachSession(sessionID string) (*execsdk.Session, []byte, error) { + return s.Ctrl.AttachSession(sessionID) +} +func (s *ServiceWrapper) DetachSession(sessionID string) (*execsdk.Session, error) { + return s.Ctrl.DetachSession(sessionID) +} +func (s *ServiceWrapper) WriteSession(sessionID string, data []byte) error { + return s.Ctrl.WriteSession(sessionID, data) +} +func (s *ServiceWrapper) CloseSession(sessionID string) error { + return s.Ctrl.CloseSession(sessionID) +} +func (s *ServiceWrapper) ResizeSession(sessionID string, rows, cols uint16) error { + return s.Ctrl.ResizeSession(sessionID, rows, cols) +} +func (s *ServiceWrapper) GetHandler(plugin, resource string) *execsdk.Handler { + return s.Ctrl.GetHandler(plugin, resource) +} +func (s *ServiceWrapper) GetHandlers() map[string]map[string]execsdk.Handler { + return s.Ctrl.GetHandlers() +} +func (s *ServiceWrapper) GetPluginHandlers(plugin string) map[string]execsdk.Handler { + return s.Ctrl.GetPluginHandlers(plugin) +} +func (s *ServiceWrapper) ListPlugins() ([]string, error) { + return s.Ctrl.ListPlugins() +} +func (s *ServiceWrapper) HasPlugin(pluginID string) bool { + return s.Ctrl.HasPlugin(pluginID) +} diff --git a/backend/pkg/plugin/installer.go b/backend/pkg/plugin/installer.go index fb0baca7..ee8e73fb 100644 --- a/backend/pkg/plugin/installer.go +++ b/backend/pkg/plugin/installer.go @@ -76,7 +76,7 @@ func (pm *pluginManager) InstallInDevMode() (metadata *config.PluginMeta, err er } // Copy plugin.yaml to ~/.omniview/plugins//. - if err = transferPluginBuild(path, metadata, metadata.ID, plugintypes.BuildOpts{ + if err = pm.transferPluginBuild(path, metadata, metadata.ID, plugintypes.BuildOpts{ ExcludeBackend: true, ExcludeUI: true, }); err != nil { @@ -85,7 +85,7 @@ func (pm *pluginManager) InstallInDevMode() (metadata *config.PluginMeta, err er } // Ensure the bin directory exists for the GoWatcher to transfer into. - installLocation := getPluginLocation(metadata.ID) + installLocation := pm.pluginsRoot.ResolvePath(metadata.ID) if err = os.MkdirAll(filepath.Join(installLocation, "bin"), 0755); err != nil { pm.emitter.Emit(EventDevInstallError, metadata) return nil, apperror.Wrap(err, apperror.TypePluginInstallFailed, 500, "Failed to create plugin bin directory") @@ -207,10 +207,6 @@ func (pm *pluginManager) InstallPluginVersion( func (pm *pluginManager) InstallPluginFromPath(path string) (*config.PluginMeta, error) { defer os.Remove(path) - if err := auditPluginDir(); err != nil { - return nil, err - } - if !isGzippedTarball(path) { return nil, apperror.New(apperror.TypeValidation, 422, "Invalid plugin package", fmt.Sprintf("The file at '%s' is not a valid tar.gz archive.", path)). @@ -229,11 +225,11 @@ func (pm *pluginManager) InstallPluginFromPath(path string) (*config.PluginMeta, pm.emitter.Emit(EventInstallStarted, metadata) - location := getPluginLocation(metadata.ID) + location := pm.pluginsRoot.ResolvePath(metadata.ID) // Unpack to a temporary directory first so we don't destroy a working // installation if the archive is corrupt or extraction fails. - tmpDir, mkErr := os.MkdirTemp(getPluginDir(), metadata.ID+"-install-") + tmpDir, mkErr := os.MkdirTemp(pm.pluginsRoot.ResolvePath(""), metadata.ID+"-install-") if mkErr != nil { pm.emitter.Emit(EventInstallError, metadata) return nil, apperror.Wrap(mkErr, apperror.TypePluginInstallFailed, 500, "Failed to create temp directory") @@ -311,8 +307,7 @@ func (pm *pluginManager) UninstallPlugin(id string) (sdktypes.PluginInfo, error) } l.Debugw(pm.ctx, "unloaded plugin", "pluginID", id) - location := getPluginLocation(id) - if err := os.RemoveAll(location); err != nil { + if err := pm.pluginsRoot.RemoveAll(id); err != nil { appErr := apperror.Internal(err, "Failed to remove plugin from filesystem").WithInstance(id) l.Errorw(pm.ctx, appErr.Error()) return sdktypes.PluginInfo{}, appErr @@ -324,8 +319,8 @@ func (pm *pluginManager) UninstallPlugin(id string) (sdktypes.PluginInfo, error) // Build-related helper functions (extracted from dev.go). -func transferPluginBuild(path string, meta *config.PluginMeta, pluginID string, opts plugintypes.BuildOpts) error { - installLocation := getPluginLocation(pluginID) +func (pm *pluginManager) transferPluginBuild(path string, meta *config.PluginMeta, pluginID string, opts plugintypes.BuildOpts) error { + installLocation := pm.pluginsRoot.ResolvePath(pluginID) if err := os.MkdirAll(installLocation, 0755); err != nil { return apperror.Internal(err, "Failed to create plugin install location") @@ -476,7 +471,7 @@ func buildPluginUi(path string, opts plugintypes.BuildOpts) error { return nil } -func buildAndTransferPlugin(path string, meta *config.PluginMeta, pluginID string, opts plugintypes.BuildOpts) error { +func (pm *pluginManager) buildAndTransferPlugin(path string, meta *config.PluginMeta, pluginID string, opts plugintypes.BuildOpts) error { if meta == nil { return apperror.New(apperror.TypeValidation, 422, "Invalid plugin", "Plugin metadata is missing.") } @@ -516,5 +511,5 @@ func buildAndTransferPlugin(path string, meta *config.PluginMeta, pluginID strin return feError } - return transferPluginBuild(path, meta, pluginID, opts) + return pm.transferPluginBuild(path, meta, pluginID, opts) } diff --git a/backend/pkg/plugin/loader.go b/backend/pkg/plugin/loader.go index f32d6d71..c2775bc2 100644 --- a/backend/pkg/plugin/loader.go +++ b/backend/pkg/plugin/loader.go @@ -72,7 +72,7 @@ func (pm *pluginManager) loadPluginLocked(id string, opts *LoadPluginOptions) (s delete(pm.records, id) pm.recordsMu.Unlock() - location := getPluginLocation(id) + location := pm.pluginsRoot.ResolvePath(id) log.Debugw(pm.ctx, "loading plugin from location", "location", location, "pluginID", id) if _, err := os.Stat(location); os.IsNotExist(err) { diff --git a/backend/pkg/plugin/loader_test.go b/backend/pkg/plugin/loader_test.go index 9c0bfe21..c8fbb8ba 100644 --- a/backend/pkg/plugin/loader_test.go +++ b/backend/pkg/plugin/loader_test.go @@ -12,6 +12,7 @@ import ( "github.com/omniviewdev/omniview/backend/pkg/plugin/lifecycle" plugintypes "github.com/omniviewdev/omniview/backend/pkg/plugin/types" + "github.com/omniviewdev/omniview/internal/appstate" "github.com/omniviewdev/plugin-sdk/pkg/config" sdktypes "github.com/omniviewdev/plugin-sdk/pkg/types" ) @@ -20,43 +21,27 @@ import ( // a backendFactory seam and a temp plugin directory. func newTestManager(t *testing.T) *pluginManager { t.Helper() - dir := t.TempDir() - old := pluginDirOverride - pluginDirOverride = dir - t.Cleanup(func() { pluginDirOverride = old }) + svc := appstate.NewTestService(t) return &pluginManager{ logger: testLogger(t), + stateRoot: svc.RootDir(), + pluginsRoot: svc.Plugins(), records: make(map[string]*plugintypes.PluginRecord), connlessControllers: make(map[sdktypes.Capability]plugintypes.Controller), connfullControllers: make(map[sdktypes.Capability]plugintypes.ConnectedController), managers: make(map[string]plugintypes.PluginManager), - pidTracker: NewPluginPIDTracker(), + pidTracker: NewPluginPIDTracker(svc.RootDir()), pluginOpsLocks: make(map[string]*sync.Mutex), emitter: testNoopEmitter{}, } } // installPluginFixture creates a plugin directory with plugin.yaml and optional binary. -func installPluginFixture(t *testing.T, id string, caps []string, withBinary bool) { +func installPluginFixture(t *testing.T, pm *pluginManager, id string, caps []string, withBinary bool) { t.Helper() - dir := filepath.Join(getPluginDir(), id) - require.NoError(t, os.MkdirAll(dir, 0755)) - - content := "id: " + id + "\nname: " + id + "\nversion: 1.0.0\n" - if len(caps) > 0 { - content += "capabilities:\n" - for _, c := range caps { - content += " - " + c + "\n" - } - } - require.NoError(t, os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(content), 0644)) - - if withBinary { - binDir := filepath.Join(dir, "bin") - require.NoError(t, os.MkdirAll(binDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(binDir, "plugin"), []byte("#!/bin/sh\n"), 0755)) - } + pluginsDir := pm.pluginsRoot.ResolvePath("") + installPluginFixtureAt(t, pluginsDir, id, caps, withBinary) } func TestLoadPlugin_NotFound(t *testing.T) { @@ -69,8 +54,7 @@ func TestLoadPlugin_NotFound(t *testing.T) { func TestLoadPlugin_MetadataMissing(t *testing.T) { pm := newTestManager(t) - dir := filepath.Join(getPluginDir(), "no-meta") - require.NoError(t, os.MkdirAll(dir, 0755)) + require.NoError(t, pm.pluginsRoot.MkdirAll("no-meta", 0755)) _, err := pm.LoadPlugin("no-meta", nil) assert.Error(t, err) @@ -79,7 +63,7 @@ func TestLoadPlugin_MetadataMissing(t *testing.T) { func TestLoadPlugin_AlreadyRunning_Idempotent(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "running-plugin", []string{"ui"}, false) + installPluginFixture(t, pm, "running-plugin", []string{"ui"}, false) // Pre-populate a running record. pm.records["running-plugin"] = &plugintypes.PluginRecord{ @@ -100,7 +84,7 @@ func TestLoadPlugin_AlreadyRunning_Idempotent(t *testing.T) { func TestLoadPlugin_AlreadyStarting_Returns409(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "starting-plugin", []string{"ui"}, false) + installPluginFixture(t, pm, "starting-plugin", []string{"ui"}, false) pm.records["starting-plugin"] = &plugintypes.PluginRecord{ ID: "starting-plugin", @@ -114,10 +98,10 @@ func TestLoadPlugin_AlreadyStarting_Returns409(t *testing.T) { func TestLoadPlugin_UIOnly_Succeeds(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "ui-plugin", []string{"ui"}, false) + installPluginFixture(t, pm, "ui-plugin", []string{"ui"}, false) // UI plugins need assets directory. - require.NoError(t, os.MkdirAll(filepath.Join(getPluginDir(), "ui-plugin", "assets"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(pm.pluginsRoot.ResolvePath("ui-plugin"), "assets"), 0755)) info, err := pm.LoadPlugin("ui-plugin", nil) require.NoError(t, err) @@ -128,7 +112,7 @@ func TestLoadPlugin_UIOnly_Succeeds(t *testing.T) { func TestLoadPlugin_BackendPlugin_ViaFactory(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "backend-plugin", []string{"resource"}, true) + installPluginFixture(t, pm, "backend-plugin", []string{"resource"}, true) factoryCalled := false pm.backendFactory = func(meta config.PluginMeta, location string) (plugintypes.PluginBackend, error) { @@ -147,7 +131,7 @@ func TestLoadPlugin_BackendPlugin_ViaFactory(t *testing.T) { func TestLoadPlugin_NoBinary_FailsValidation(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "no-binary", []string{"resource"}, false) + installPluginFixture(t, pm, "no-binary", []string{"resource"}, false) _, err := pm.LoadPlugin("no-binary", nil) assert.Error(t, err) @@ -156,7 +140,7 @@ func TestLoadPlugin_NoBinary_FailsValidation(t *testing.T) { func TestLoadPlugin_DevMode_SkipsUIValidation(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "dev-plugin", []string{"resource", "ui"}, true) + installPluginFixture(t, pm, "dev-plugin", []string{"resource", "ui"}, true) pm.backendFactory = func(meta config.PluginMeta, location string) (plugintypes.PluginBackend, error) { return plugintypes.NewInProcessBackend(nil), nil @@ -206,7 +190,7 @@ func TestUnloadPlugin_Running_Stops(t *testing.T) { func TestLoadPlugin_ExistingState_Applied(t *testing.T) { pm := newTestManager(t) // Dev mode with backend caps requires a binary. - installPluginFixture(t, "state-plugin", []string{"resource"}, true) + installPluginFixture(t, pm, "state-plugin", []string{"resource"}, true) pm.backendFactory = func(meta config.PluginMeta, location string) (plugintypes.PluginBackend, error) { return plugintypes.NewInProcessBackend(nil), nil @@ -227,8 +211,8 @@ func TestLoadPlugin_ExistingState_Applied(t *testing.T) { func TestLoadPlugin_PreviousFailedRecord_Removed(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "failed-plugin", []string{"ui"}, false) - require.NoError(t, os.MkdirAll(filepath.Join(getPluginDir(), "failed-plugin", "assets"), 0755)) + installPluginFixture(t, pm, "failed-plugin", []string{"ui"}, false) + require.NoError(t, os.MkdirAll(filepath.Join(pm.pluginsRoot.ResolvePath("failed-plugin"), "assets"), 0755)) // Pre-populate a failed record. pm.records["failed-plugin"] = &plugintypes.PluginRecord{ @@ -243,8 +227,8 @@ func TestLoadPlugin_PreviousFailedRecord_Removed(t *testing.T) { func TestReloadPlugin_ReloadsSuccessfully(t *testing.T) { pm := newTestManager(t) - installPluginFixture(t, "reload-test", []string{"ui"}, false) - require.NoError(t, os.MkdirAll(filepath.Join(getPluginDir(), "reload-test", "assets"), 0755)) + installPluginFixture(t, pm, "reload-test", []string{"ui"}, false) + require.NoError(t, os.MkdirAll(filepath.Join(pm.pluginsRoot.ResolvePath("reload-test"), "assets"), 0755)) // First load. _, err := pm.LoadPlugin("reload-test", nil) @@ -312,10 +296,7 @@ func TestShutdownPlugin_NilRecord(t *testing.T) { func TestDevInstall_StatePersistsDevModeFields(t *testing.T) { pm := newTestManager(t) - cleanup := withTempStateFile(t) - defer cleanup() - - installPluginFixture(t, "dev-persist", []string{"resource", "ui"}, true) + installPluginFixture(t, pm, "dev-persist", []string{"resource", "ui"}, true) pm.backendFactory = func(meta config.PluginMeta, location string) (plugintypes.PluginBackend, error) { return plugintypes.NewInProcessBackend(nil), nil @@ -332,7 +313,7 @@ func TestDevInstall_StatePersistsDevModeFields(t *testing.T) { require.NoError(t, pm.writePluginStateJSON()) // Read it back and verify dev fields survived serialization. - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) require.Len(t, records, 1) @@ -344,12 +325,22 @@ func TestDevInstall_StatePersistsDevModeFields(t *testing.T) { } func TestDevPlugin_SurvivesRestart(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() + svc := appstate.NewTestService(t) // --- Session 1: install a dev plugin and persist state --- - pm1 := newTestManager(t) - installPluginFixture(t, "dev-restart", []string{"resource", "ui"}, true) + pm1 := &pluginManager{ + logger: testLogger(t), + stateRoot: svc.RootDir(), + pluginsRoot: svc.Plugins(), + records: make(map[string]*plugintypes.PluginRecord), + connlessControllers: make(map[sdktypes.Capability]plugintypes.Controller), + connfullControllers: make(map[sdktypes.Capability]plugintypes.ConnectedController), + managers: make(map[string]plugintypes.PluginManager), + pidTracker: NewPluginPIDTracker(svc.RootDir()), + pluginOpsLocks: make(map[string]*sync.Mutex), + } + + installPluginFixtureAt(t, svc.Plugins().ResolvePath(""), "dev-restart", []string{"resource", "ui"}, true) pm1.backendFactory = func(meta config.PluginMeta, location string) (plugintypes.PluginBackend, error) { return plugintypes.NewInProcessBackend(nil), nil @@ -365,21 +356,22 @@ func TestDevPlugin_SurvivesRestart(t *testing.T) { // --- Session 2: simulate restart with a new manager --- pm2 := &pluginManager{ logger: testLogger(t), + stateRoot: svc.RootDir(), + pluginsRoot: svc.Plugins(), records: make(map[string]*plugintypes.PluginRecord), connlessControllers: make(map[sdktypes.Capability]plugintypes.Controller), connfullControllers: make(map[sdktypes.Capability]plugintypes.ConnectedController), managers: make(map[string]plugintypes.PluginManager), - pidTracker: NewPluginPIDTracker(), + pidTracker: NewPluginPIDTracker(svc.RootDir()), pluginOpsLocks: make(map[string]*sync.Mutex), emitter: testNoopEmitter{}, } - // Use the same plugin dir as pm1. pm2.backendFactory = func(meta config.PluginMeta, location string) (plugintypes.PluginBackend, error) { return plugintypes.NewInProcessBackend(nil), nil } // Replay what Initialize does: read state, build lookup, load with ExistingState. - states, err := readPluginStateJSON() + states, err := pm2.readPluginStateJSON() require.NoError(t, err) require.Len(t, states, 1) @@ -403,9 +395,6 @@ func TestDevPlugin_SurvivesRestart(t *testing.T) { } func TestInitialize_DoesNotLoseFailedPluginState(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() - pm := newTestManager(t) pm.ctx = context.Background() @@ -422,7 +411,7 @@ func TestInitialize_DoesNotLoseFailedPluginState(t *testing.T) { // Create the plugin directory with metadata but NO binary. // This causes LoadPlugin to fail validation for dev mode. - installPluginFixture(t, "dev-lost", []string{"resource", "ui"}, false) + installPluginFixture(t, pm, "dev-lost", []string{"resource", "ui"}, false) // Replay what Initialize does: read state, build lookup, attempt load. stateByID := make(map[string]plugintypes.PluginStateRecord) @@ -434,12 +423,12 @@ func TestInitialize_DoesNotLoseFailedPluginState(t *testing.T) { _, err := pm.LoadPlugin("dev-lost", &LoadPluginOptions{ExistingState: &state}) assert.Error(t, err, "LoadPlugin should fail (missing binary)") - // Now call mergeAndWritePluginState — this is what Initialize does. + // Now call mergeAndWritePluginState -- this is what Initialize does. // It should preserve the failed plugin's state. require.NoError(t, pm.mergeAndWritePluginState(persistedStates)) // Read state file after merge. - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) // The dev-lost plugin failed to load, but its state should still be preserved. @@ -588,8 +577,8 @@ func TestShutdownPlugin_SettingsNotCalledTwice(t *testing.T) { func TestConcurrentReloads_Serialized(t *testing.T) { pm := newTestManager(t) pm.ctx = context.Background() - installPluginFixture(t, "concurrent-test", []string{"ui"}, false) - require.NoError(t, os.MkdirAll(filepath.Join(getPluginDir(), "concurrent-test", "assets"), 0755)) + installPluginFixture(t, pm, "concurrent-test", []string{"ui"}, false) + require.NoError(t, os.MkdirAll(filepath.Join(pm.pluginsRoot.ResolvePath("concurrent-test"), "assets"), 0755)) // Initial load. _, err := pm.LoadPlugin("concurrent-test", nil) diff --git a/backend/pkg/plugin/logs/service_wrapper.go b/backend/pkg/plugin/logs/service_wrapper.go new file mode 100644 index 00000000..54638de3 --- /dev/null +++ b/backend/pkg/plugin/logs/service_wrapper.go @@ -0,0 +1,62 @@ +package logs + +import ( + "context" + "fmt" + + logssdk "github.com/omniviewdev/plugin-sdk/pkg/v1/logs" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper is an explicit delegation wrapper around logs.Controller. +// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, +// +// OnPluginDestroy +type ServiceWrapper struct { + Ctrl Controller +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if s.Ctrl == nil { + return fmt.Errorf("logs: ServiceWrapper.Ctrl is nil") + } + if ss, ok := s.Ctrl.(interface { + ServiceStartup(context.Context, application.ServiceOptions) error + }); ok { + return ss.ServiceStartup(ctx, options) + } + return nil +} +func (s *ServiceWrapper) ServiceShutdown() error { + if ss, ok := s.Ctrl.(interface{ ServiceShutdown() error }); ok { + return ss.ServiceShutdown() + } + return nil +} +func (s *ServiceWrapper) GetSupportedResources(pluginID string) []logssdk.Handler { + return s.Ctrl.GetSupportedResources(pluginID) +} +func (s *ServiceWrapper) CreateSession(plugin, connectionID string, opts logssdk.CreateSessionOptions) (*logssdk.LogSession, error) { + return s.Ctrl.CreateSession(plugin, connectionID, opts) +} +func (s *ServiceWrapper) GetSession(sessionID string) (*logssdk.LogSession, error) { + return s.Ctrl.GetSession(sessionID) +} +func (s *ServiceWrapper) ListSessions() ([]*logssdk.LogSession, error) { + return s.Ctrl.ListSessions() +} +func (s *ServiceWrapper) CloseSession(sessionID string) error { + return s.Ctrl.CloseSession(sessionID) +} +func (s *ServiceWrapper) SendCommand(sessionID string, cmd logssdk.LogStreamCommand) error { + return s.Ctrl.SendCommand(sessionID, cmd) +} +func (s *ServiceWrapper) UpdateSessionOptions(sessionID string, opts logssdk.LogSessionOptions) (*logssdk.LogSession, error) { + return s.Ctrl.UpdateSessionOptions(sessionID, opts) +} +func (s *ServiceWrapper) ListPlugins() ([]string, error) { + return s.Ctrl.ListPlugins() +} +func (s *ServiceWrapper) HasPlugin(pluginID string) bool { + return s.Ctrl.HasPlugin(pluginID) +} diff --git a/backend/pkg/plugin/manager.go b/backend/pkg/plugin/manager.go index 059032ad..63a7f3a1 100644 --- a/backend/pkg/plugin/manager.go +++ b/backend/pkg/plugin/manager.go @@ -16,6 +16,7 @@ import ( pluginmetric "github.com/omniviewdev/omniview/backend/pkg/plugin/metric" "github.com/omniviewdev/omniview/backend/pkg/plugin/networker" "github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog" + "github.com/omniviewdev/omniview/internal/appstate" regclient "github.com/omniviewdev/registry" "github.com/omniviewdev/omniview/backend/pkg/plugin/registry" @@ -95,6 +96,8 @@ type Manager interface { // NewManager returns a new plugin manager. func NewManager( logger logging.Logger, + stateRoot *appstate.ScopedRoot, + pluginsRoot *appstate.ScopedRoot, resourceController resource.Controller, settingsController settings.Controller, execController pluginexec.Controller, @@ -107,8 +110,10 @@ func NewManager( telemetryConfigFn func() TelemetryEnvConfig, ) Manager { return &pluginManager{ - logger: logger, - records: make(map[string]*plugintypes.PluginRecord), + logger: logger, + stateRoot: stateRoot, + pluginsRoot: pluginsRoot, + records: make(map[string]*plugintypes.PluginRecord), connlessControllers: map[sdktypes.Capability]plugintypes.Controller{ sdktypes.CapabilitySettings: settingsController, sdktypes.CapabilityExec: execController, @@ -124,7 +129,7 @@ func NewManager( registryClient: registryClient, telemetryConfigFn: telemetryConfigFn, emitter: resource.NoopEmitter{}, - pidTracker: NewPluginPIDTracker(), + pidTracker: NewPluginPIDTracker(stateRoot), pluginOpsLocks: make(map[string]*sync.Mutex), } } @@ -139,6 +144,8 @@ type DevServerChecker interface { type pluginManager struct { ctx context.Context logger logging.Logger + stateRoot *appstate.ScopedRoot // scoped to ~/.omniview (for state files) + pluginsRoot *appstate.ScopedRoot // scoped to ~/.omniview/plugins recordsMu sync.RWMutex records map[string]*plugintypes.PluginRecord connlessControllers map[sdktypes.Capability]plugintypes.Controller @@ -332,15 +339,13 @@ func (pm *pluginManager) Initialize(ctx context.Context) error { // Kill any orphaned plugin processes from a previous unclean shutdown. pm.pidTracker.CleanupStale(pm.logger) - if err := auditPluginDir(); err != nil { - return err - } + pluginDir := pm.pluginsRoot.ResolvePath("") - states, err := readPluginStateJSON() + states, err := pm.readPluginStateJSON() if err != nil { pm.logger.Warnw(pm.ctx, "failed to read plugin state file, reconciling from filesystem", "error", err) reconciler := NewReconciler(pm.logger) - result, reconErr := reconciler.ReconcileFromFilesystem(pm.ctx, getPluginDir()) + result, reconErr := reconciler.ReconcileFromFilesystem(pm.ctx, pluginDir) if reconErr != nil { pm.logger.Errorw(pm.ctx, "filesystem reconciliation failed", "error", reconErr) } else { @@ -354,36 +359,40 @@ func (pm *pluginManager) Initialize(ctx context.Context) error { pm.logger.Debugw(pm.ctx, "Loading plugins states from disk", "states", states) - files, err := os.ReadDir(getPluginDir()) + files, err := os.ReadDir(pluginDir) if err != nil { return fmt.Errorf("error reading plugin directory: %w", err) } // Reverse migration: rename -dev → if needed. - pluginDir := getPluginDir() for _, state := range states { - devDir := filepath.Join(pluginDir, state.ID+"-dev") - canonDir := filepath.Join(pluginDir, state.ID) + devName := state.ID + "-dev" devExists := false - if info, statErr := os.Stat(devDir); statErr == nil && info.IsDir() { + if info, statErr := pm.pluginsRoot.Stat(devName); statErr == nil && info.IsDir() { devExists = true } canonExists := false - if info, statErr := os.Stat(canonDir); statErr == nil && info.IsDir() { + if info, statErr := pm.pluginsRoot.Stat(state.ID); statErr == nil && info.IsDir() { canonExists = true } if devExists && !canonExists { pm.logger.Infow(pm.ctx, "migrating dev plugin directory to canonical ID", - "from", devDir, "to", canonDir) - if renameErr := os.Rename(devDir, canonDir); renameErr != nil { + "from", devName, "to", state.ID) + if renameErr := pm.pluginsRoot.Rename(devName, state.ID); renameErr != nil { pm.logger.Errorw(pm.ctx, "failed to rename dev plugin directory", - "from", devDir, "to", canonDir, "error", renameErr) + "from", devName, "to", state.ID, "error", renameErr) } } } + // Re-read directory after potential renames so subsequent code sees migrated names. + files, err = os.ReadDir(pluginDir) + if err != nil { + return fmt.Errorf("error re-reading plugin directory after migration: %w", err) + } + // Build a lookup for persisted state. stateByID := make(map[string]plugintypes.PluginStateRecord) for _, s := range states { @@ -424,9 +433,10 @@ func (pm *pluginManager) Initialize(ctx context.Context) error { // If the dev binary doesn't exist, create a stub record // and skip LoadPlugin. Phase 2 will start the dev server, // build the binary, and call ReloadPlugin. - binPath := filepath.Join(getPluginLocation(file.Name()), "bin", "plugin") + pluginLocation := pm.pluginsRoot.ResolvePath(file.Name()) + binPath := filepath.Join(pluginLocation, "bin", "plugin") if _, statErr := os.Stat(binPath); os.IsNotExist(statErr) { - meta, metaErr := sdktypes.LoadPluginMetadata(getPluginLocation(file.Name())) + meta, metaErr := sdktypes.LoadPluginMetadata(pluginLocation) if metaErr == nil { record := plugintypes.NewPluginRecord(file.Name(), meta, lifecycle.PhaseBuildFailed) record.Enabled = state.Enabled diff --git a/backend/pkg/plugin/manager_test.go b/backend/pkg/plugin/manager_test.go index 16f9cf5b..05ba0cba 100644 --- a/backend/pkg/plugin/manager_test.go +++ b/backend/pkg/plugin/manager_test.go @@ -10,6 +10,7 @@ import ( "github.com/omniviewdev/omniview/backend/pkg/plugin/lifecycle" plugintypes "github.com/omniviewdev/omniview/backend/pkg/plugin/types" + "github.com/omniviewdev/omniview/internal/appstate" "github.com/omniviewdev/plugin-sdk/pkg/config" sdktypes "github.com/omniviewdev/plugin-sdk/pkg/types" ) @@ -234,10 +235,11 @@ func TestRegisterStateObserver(t *testing.T) { } func TestShutdown_EmptyRecords(t *testing.T) { + svc := appstate.NewTestService(t) pm := &pluginManager{ logger: testLogger(t), records: make(map[string]*plugintypes.PluginRecord), - pidTracker: NewPluginPIDTracker(), + pidTracker: NewPluginPIDTracker(svc.RootDir()), } // Should not panic with empty records. @@ -245,6 +247,7 @@ func TestShutdown_EmptyRecords(t *testing.T) { } func TestShutdown_StopsAllBackends(t *testing.T) { + svc := appstate.NewTestService(t) backendA := plugintypes.NewInProcessBackend(nil) backendB := plugintypes.NewInProcessBackend(nil) @@ -273,7 +276,7 @@ func TestShutdown_StopsAllBackends(t *testing.T) { connlessControllers: make(map[sdktypes.Capability]plugintypes.Controller), connfullControllers: make(map[sdktypes.Capability]plugintypes.ConnectedController), managers: make(map[string]plugintypes.PluginManager), - pidTracker: NewPluginPIDTracker(), + pidTracker: NewPluginPIDTracker(svc.RootDir()), } pm.Shutdown() diff --git a/backend/pkg/plugin/metric/service_wrapper.go b/backend/pkg/plugin/metric/service_wrapper.go new file mode 100644 index 00000000..25e0af15 --- /dev/null +++ b/backend/pkg/plugin/metric/service_wrapper.go @@ -0,0 +1,63 @@ +package metric + +import ( + "context" + "fmt" + "time" + + metricsdk "github.com/omniviewdev/plugin-sdk/pkg/v1/metric" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper is an explicit delegation wrapper around metric.Controller. +// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, +// +// OnPluginDestroy +type ServiceWrapper struct { + Ctrl Controller +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if s.Ctrl == nil { + return fmt.Errorf("metric: ServiceWrapper.Ctrl is nil") + } + if ss, ok := s.Ctrl.(interface { + ServiceStartup(context.Context, application.ServiceOptions) error + }); ok { + return ss.ServiceStartup(ctx, options) + } + return nil +} +func (s *ServiceWrapper) ServiceShutdown() error { + if ss, ok := s.Ctrl.(interface{ ServiceShutdown() error }); ok { + return ss.ServiceShutdown() + } + return nil +} +func (s *ServiceWrapper) GetProviders() []MetricProviderSummary { + return s.Ctrl.GetProviders() +} +func (s *ServiceWrapper) GetProvidersForResource(resourceKey string) []MetricProviderSummary { + return s.Ctrl.GetProvidersForResource(resourceKey) +} +func (s *ServiceWrapper) Query(pluginID, connectionID string, req metricsdk.QueryRequest) (*metricsdk.QueryResponse, error) { + return s.Ctrl.Query(pluginID, connectionID, req) +} +func (s *ServiceWrapper) QueryAll(connectionID, resourceKey, resourceID, namespace string, + resourceData map[string]interface{}, metricIDs []string, + shape metricsdk.MetricShape, startTime, endTime time.Time, step time.Duration, +) (map[string]*metricsdk.QueryResponse, error) { + return s.Ctrl.QueryAll(connectionID, resourceKey, resourceID, namespace, resourceData, metricIDs, shape, startTime, endTime, step) +} +func (s *ServiceWrapper) Subscribe(pluginID, connectionID string, req SubscribeRequest) (string, error) { + return s.Ctrl.Subscribe(pluginID, connectionID, req) +} +func (s *ServiceWrapper) Unsubscribe(subscriptionID string) error { + return s.Ctrl.Unsubscribe(subscriptionID) +} +func (s *ServiceWrapper) ListPlugins() ([]string, error) { + return s.Ctrl.ListPlugins() +} +func (s *ServiceWrapper) HasPlugin(pluginID string) bool { + return s.Ctrl.HasPlugin(pluginID) +} diff --git a/backend/pkg/plugin/networker/service_wrapper.go b/backend/pkg/plugin/networker/service_wrapper.go new file mode 100644 index 00000000..70fb733d --- /dev/null +++ b/backend/pkg/plugin/networker/service_wrapper.go @@ -0,0 +1,58 @@ +package networker + +import ( + "context" + + networkersdk "github.com/omniviewdev/plugin-sdk/pkg/v1/networker" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper is an explicit delegation wrapper around networker.Controller. +// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, +// +// OnPluginDestroy +type ServiceWrapper struct { + Ctrl Controller +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if ss, ok := s.Ctrl.(interface { + ServiceStartup(context.Context, application.ServiceOptions) error + }); ok { + return ss.ServiceStartup(ctx, options) + } + return nil +} +func (s *ServiceWrapper) ServiceShutdown() error { + if ss, ok := s.Ctrl.(interface{ ServiceShutdown() error }); ok { + return ss.ServiceShutdown() + } + return nil +} +func (s *ServiceWrapper) GetSupportedPortForwardTargets(pluginID string) ([]string, error) { + return s.Ctrl.GetSupportedPortForwardTargets(pluginID) +} +func (s *ServiceWrapper) GetPortForwardSession(sessionID string) (*networkersdk.PortForwardSession, error) { + return s.Ctrl.GetPortForwardSession(sessionID) +} +func (s *ServiceWrapper) ListPortForwardSessions(pluginID, connectionID string) ([]*networkersdk.PortForwardSession, error) { + return s.Ctrl.ListPortForwardSessions(pluginID, connectionID) +} +func (s *ServiceWrapper) ListAllPortForwardSessions() ([]*networkersdk.PortForwardSession, error) { + return s.Ctrl.ListAllPortForwardSessions() +} +func (s *ServiceWrapper) FindPortForwardSessions(pluginID, connectionID string, request networkersdk.FindPortForwardSessionRequest) ([]*networkersdk.PortForwardSession, error) { + return s.Ctrl.FindPortForwardSessions(pluginID, connectionID, request) +} +func (s *ServiceWrapper) StartResourcePortForwardingSession(pluginID, connectionID string, opts networkersdk.PortForwardSessionOptions) (*networkersdk.PortForwardSession, error) { + return s.Ctrl.StartResourcePortForwardingSession(pluginID, connectionID, opts) +} +func (s *ServiceWrapper) ClosePortForwardSession(sessionID string) (*networkersdk.PortForwardSession, error) { + return s.Ctrl.ClosePortForwardSession(sessionID) +} +func (s *ServiceWrapper) ListPlugins() ([]string, error) { + return s.Ctrl.ListPlugins() +} +func (s *ServiceWrapper) HasPlugin(pluginID string) bool { + return s.Ctrl.HasPlugin(pluginID) +} diff --git a/backend/pkg/plugin/pids.go b/backend/pkg/plugin/pids.go index 17a6e0db..6d4633ee 100644 --- a/backend/pkg/plugin/pids.go +++ b/backend/pkg/plugin/pids.go @@ -3,13 +3,17 @@ package plugin import ( "context" "encoding/json" - "os" - "path/filepath" + "errors" + "io/fs" "sync" logging "github.com/omniviewdev/plugin-sdk/log" + + "github.com/omniviewdev/omniview/internal/appstate" ) +const pluginPIDFileName = "plugin_pids.json" + // PluginPIDTracker tracks PIDs of running plugin binary processes so that // orphaned processes from a previous unclean shutdown (force-quit, crash, // SIGKILL) can be cleaned up on the next startup. @@ -18,14 +22,16 @@ import ( // saved PID file will typically be empty. The file only matters when shutdown // was interrupted. type PluginPIDTracker struct { - mu sync.Mutex - pids map[string]int // pluginID -> PID + mu sync.Mutex + pids map[string]int // pluginID -> PID + stateRoot *appstate.ScopedRoot } // NewPluginPIDTracker creates a new tracker with an empty PID map. -func NewPluginPIDTracker() *PluginPIDTracker { +func NewPluginPIDTracker(stateRoot *appstate.ScopedRoot) *PluginPIDTracker { return &PluginPIDTracker{ - pids: make(map[string]int), + pids: make(map[string]int), + stateRoot: stateRoot, } } @@ -45,14 +51,8 @@ func (t *PluginPIDTracker) Remove(pluginID string) { delete(t.pids, pluginID) } -// pidFilePath returns the path to the plugin PID tracking file. -func pluginPIDFilePath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".omniview", "plugin_pids.json") -} - -// Save persists the current pluginID→PID map to disk. This is called during -// Shutdown as a safety net — if all plugins were stopped cleanly the map is +// Save persists the current pluginID->PID map to disk. This is called during +// Shutdown as a safety net -- if all plugins were stopped cleanly the map is // empty, but if shutdownPlugin failed for any plugin its PID will be saved // for cleanup on the next startup. func (t *PluginPIDTracker) Save() error { @@ -67,20 +67,23 @@ func (t *PluginPIDTracker) Save() error { if err != nil { return err } - return os.WriteFile(pluginPIDFilePath(), b, 0644) + return t.stateRoot.WriteFile(pluginPIDFileName, b, 0644) } // CleanupStale reads the PID file from a previous session, kills any processes // that are still alive, and removes the file. This should be called early in // Initialize(), before any new plugins are started. func (t *PluginPIDTracker) CleanupStale(logger logging.Logger) { - pidFile := pluginPIDFilePath() - b, err := os.ReadFile(pidFile) + b, err := t.stateRoot.ReadFile(pluginPIDFileName) if err != nil { - // No PID file — nothing to clean up (normal case after clean shutdown). + if errors.Is(err, fs.ErrNotExist) { + // No PID file -- nothing to clean up (normal case after clean shutdown). + return + } + logger.Warnw(context.Background(), "failed to read plugin PID file", "error", err) return } - _ = os.Remove(pidFile) + _ = t.stateRoot.Remove(pluginPIDFileName) var data map[string]int if err := json.Unmarshal(b, &data); err != nil { @@ -95,7 +98,7 @@ func (t *PluginPIDTracker) CleanupStale(logger logging.Logger) { } if err := killProcess(pid); err != nil { - // Process already dead — that's fine. + // Process already dead -- that's fine. if !isProcessNotFound(err) { logger.Warnw(context.Background(), "failed to kill stale plugin process", "pluginID", pluginID, diff --git a/backend/pkg/plugin/pids_test.go b/backend/pkg/plugin/pids_test.go index cdc84040..1dc173f5 100644 --- a/backend/pkg/plugin/pids_test.go +++ b/backend/pkg/plugin/pids_test.go @@ -6,23 +6,18 @@ import ( "encoding/json" "os" "os/exec" - "path/filepath" "syscall" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" -) -// ensurePluginPIDDir creates the ~/.omniview directory if it doesn't exist. -// CI runners may not have this directory pre-created. -func ensurePluginPIDDir(t *testing.T) { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(pluginPIDFilePath()), 0755)) -} + "github.com/omniviewdev/omniview/internal/appstate" +) func TestPluginPIDTracker_RecordAndRemove(t *testing.T) { - tracker := NewPluginPIDTracker() + svc := appstate.NewTestService(t) + tracker := NewPluginPIDTracker(svc.RootDir()) tracker.Record("aws", 1234) tracker.Record("kubernetes", 5678) @@ -51,18 +46,16 @@ func TestPluginPIDTracker_RecordAndRemove(t *testing.T) { } func TestPluginPIDTracker_SaveAndLoad(t *testing.T) { - ensurePluginPIDDir(t) - - tracker := NewPluginPIDTracker() + svc := appstate.NewTestService(t) + tracker := NewPluginPIDTracker(svc.RootDir()) tracker.Record("aws", 12345) tracker.Record("kubernetes", 67890) // Save via the real method require.NoError(t, tracker.Save()) - defer os.Remove(pluginPIDFilePath()) // Read the file back and verify contents - raw, err := os.ReadFile(pluginPIDFilePath()) + raw, err := svc.RootDir().ReadFile(pluginPIDFileName) require.NoError(t, err) var loaded map[string]int @@ -73,7 +66,7 @@ func TestPluginPIDTracker_SaveAndLoad(t *testing.T) { } func TestPluginPIDTracker_CleanupStale_KillsProcesses(t *testing.T) { - ensurePluginPIDDir(t) + svc := appstate.NewTestService(t) // Spawn a real sleep process to kill cmd := exec.Command("sleep", "300") @@ -83,18 +76,17 @@ func TestPluginPIDTracker_CleanupStale_KillsProcesses(t *testing.T) { // Verify it's alive require.NoError(t, syscall.Kill(pid, 0)) - // Write a PID file pointing at this process to the real path. - realPidFile := pluginPIDFilePath() + // Write a PID file pointing at this process. data := map[string]int{"test-plugin": pid} b, err := json.Marshal(data) require.NoError(t, err) - require.NoError(t, os.WriteFile(realPidFile, b, 0644)) + require.NoError(t, svc.RootDir().WriteFile(pluginPIDFileName, b, 0644)) logger := testLogger(t) - tracker := NewPluginPIDTracker() + tracker := NewPluginPIDTracker(svc.RootDir()) tracker.CleanupStale(logger) - // Reap the zombie — our test process is the parent, so we must Wait() + // Reap the zombie -- our test process is the parent, so we must Wait() // before the kernel removes the process table entry. _ = cmd.Wait() @@ -103,23 +95,22 @@ func TestPluginPIDTracker_CleanupStale_KillsProcesses(t *testing.T) { assert.ErrorIs(t, err, syscall.ESRCH, "process should be dead after cleanup") // Verify the PID file was removed - _, err = os.Stat(realPidFile) + _, err = svc.RootDir().Stat(pluginPIDFileName) assert.True(t, os.IsNotExist(err), "PID file should be removed after cleanup") } func TestPluginPIDTracker_CleanupStale_NoFile(t *testing.T) { - // Ensure no PID file exists - _ = os.Remove(pluginPIDFilePath()) + svc := appstate.NewTestService(t) logger := testLogger(t) - tracker := NewPluginPIDTracker() + tracker := NewPluginPIDTracker(svc.RootDir()) // Should not panic or error tracker.CleanupStale(logger) } func TestPluginPIDTracker_CleanupStale_DeadProcess(t *testing.T) { - ensurePluginPIDDir(t) + svc := appstate.NewTestService(t) // Spawn a process and kill it immediately so the PID is dead cmd := exec.Command("sleep", "300") @@ -136,17 +127,15 @@ func TestPluginPIDTracker_CleanupStale_DeadProcess(t *testing.T) { data := map[string]int{"dead-plugin": pid} b, marshalErr := json.Marshal(data) require.NoError(t, marshalErr) - - realPidFile := pluginPIDFilePath() - require.NoError(t, os.WriteFile(realPidFile, b, 0644)) + require.NoError(t, svc.RootDir().WriteFile(pluginPIDFileName, b, 0644)) logger := testLogger(t) - tracker := NewPluginPIDTracker() + tracker := NewPluginPIDTracker(svc.RootDir()) // Should handle ESRCH gracefully tracker.CleanupStale(logger) // Verify the PID file was removed - _, err = os.Stat(realPidFile) + _, err = svc.RootDir().Stat(pluginPIDFileName) assert.True(t, os.IsNotExist(err), "PID file should be removed after cleanup") } diff --git a/backend/pkg/plugin/pluginlog/service_wrapper.go b/backend/pkg/plugin/pluginlog/service_wrapper.go new file mode 100644 index 00000000..ce5fb8b0 --- /dev/null +++ b/backend/pkg/plugin/pluginlog/service_wrapper.go @@ -0,0 +1,23 @@ +package pluginlog + +// ServiceWrapper exposes only frontend-safe methods of pluginlog.Manager. +// Excludes OnEmit (EmitFunc type), Stream (io.Writer), Close, LogDir. +type ServiceWrapper struct { + Mgr *Manager +} + +func (s *ServiceWrapper) GetLogs(pluginID string, count int) []LogEntry { + return s.Mgr.GetLogs(pluginID, count) +} +func (s *ServiceWrapper) ListStreams() []string { + return s.Mgr.ListStreams() +} +func (s *ServiceWrapper) SearchLogs(pluginID, pattern string) ([]LogEntry, error) { + return s.Mgr.SearchLogs(pluginID, pattern) +} +func (s *ServiceWrapper) Subscribe(pluginID string) int { + return s.Mgr.Subscribe(pluginID) +} +func (s *ServiceWrapper) Unsubscribe(pluginID string) int { + return s.Mgr.Unsubscribe(pluginID) +} diff --git a/backend/pkg/plugin/resource/connections.go b/backend/pkg/plugin/resource/connections.go index 30ee407b..ee1b3835 100644 --- a/backend/pkg/plugin/resource/connections.go +++ b/backend/pkg/plugin/resource/connections.go @@ -5,6 +5,7 @@ import ( "time" "github.com/omniviewdev/omniview/backend/pkg/plugin/utils" + "github.com/omniviewdev/omniview/internal/appstate" "github.com/omniviewdev/plugin-sdk/pkg/types" ) @@ -27,9 +28,9 @@ func mergeConnections(existing, incoming []types.Connection) []types.Connection } // saveToLocalStore persists the controller's connection map to a GOB file. -func saveToLocalStore(pluginID string, connections map[string][]types.Connection) error { +func saveToLocalStore(storeRoot *appstate.ScopedRoot, connections map[string][]types.Connection) error { gob.Register(map[string]interface{}{}) - store, err := utils.GetStore(storeName, pluginID) + store, err := utils.GetStore(storeName, storeRoot) if err != nil { return err } @@ -40,9 +41,9 @@ func saveToLocalStore(pluginID string, connections map[string][]types.Connection // loadFromLocalStore reads the connection map from a GOB file. // Resets LastRefresh on loaded connections (we're not actually connected yet). -func loadFromLocalStore(pluginID string) (map[string][]types.Connection, error) { +func loadFromLocalStore(storeRoot *appstate.ScopedRoot) (map[string][]types.Connection, error) { gob.Register(map[string]interface{}{}) - store, err := utils.GetStore(storeName, pluginID) + store, err := utils.GetStore(storeName, storeRoot) if err != nil { return nil, err } @@ -74,6 +75,6 @@ func loadFromLocalStore(pluginID string) (map[string][]types.Connection, error) } // removeLocalStore deletes the GOB file for a plugin. -func removeLocalStore(pluginID string) error { - return utils.RemoveStore(storeName, pluginID) +func removeLocalStore(storeRoot *appstate.ScopedRoot) error { + return utils.RemoveStore(storeName, storeRoot) } diff --git a/backend/pkg/plugin/resource/controller.go b/backend/pkg/plugin/resource/controller.go index 93c1a4e2..603ba98f 100644 --- a/backend/pkg/plugin/resource/controller.go +++ b/backend/pkg/plugin/resource/controller.go @@ -27,6 +27,7 @@ import ( "github.com/omniviewdev/omniview/backend/pkg/plugin/resource/registry" "github.com/omniviewdev/omniview/backend/pkg/plugin/telemetryutil" plugintypes "github.com/omniviewdev/omniview/backend/pkg/plugin/types" + "github.com/omniviewdev/omniview/internal/appstate" "github.com/omniviewdev/plugin-sdk/pkg/config" "github.com/omniviewdev/plugin-sdk/pkg/types" @@ -98,6 +99,7 @@ type controller struct { graph *graph.RelationshipGraph onCrashCallback func(pluginID string) + pluginStoreFn func(pluginID string) (*appstate.ScopedRoot, error) } // compile-time assertions @@ -107,7 +109,8 @@ var ( ) // NewController creates a new resource Controller. -func NewController(logger logging.Logger, sp pkgsettings.Provider) Controller { +// pluginStoreFn returns a ScopedRoot for the given plugin's store directory. +func NewController(logger logging.Logger, sp pkgsettings.Provider, pluginStoreFn func(string) (*appstate.ScopedRoot, error)) Controller { store := registry.NewMemoryStore() g := graph.NewRelationshipGraph() graphIndexer := graph.NewGraphIndexer(g, store) @@ -123,6 +126,7 @@ func NewController(logger logging.Logger, sp pkgsettings.Provider) Controller { registryStore: store, dispatcher: dispatcher, graph: g, + pluginStoreFn: pluginStoreFn, } } @@ -192,8 +196,11 @@ func (c *controller) OnPluginInit(pluginID string, meta config.PluginMeta) { logger.Debugw(context.Background(), "OnPluginInit") // Load persisted connections from disk. - state, err := loadFromLocalStore(pluginID) - if err != nil { + var state map[string][]types.Connection + if storeRoot, err := c.pluginStoreFn(pluginID); err != nil { + logger.Errorw(context.Background(), "failed to resolve plugin store root", "error", err) + state = make(map[string][]types.Connection) + } else if state, err = loadFromLocalStore(storeRoot); err != nil { logger.Errorw(context.Background(), "failed to load connections from local store", "error", err) state = make(map[string][]types.Connection) } @@ -293,11 +300,16 @@ func (c *controller) OnPluginStop(pluginID string, meta config.PluginMeta) error logger := c.logger.With(logging.Any("pluginID", pluginID)) logger.Debugw(context.Background(), "OnPluginStop") - // Persist connections. + // Persist only this plugin's connections. c.connsMu.RLock() - conns := c.connections + pluginConnsForPersist := make(map[string][]types.Connection, 1) + if pcs, ok := c.connections[pluginID]; ok { + pluginConnsForPersist[pluginID] = pcs + } c.connsMu.RUnlock() - if err := saveToLocalStore(pluginID, conns); err != nil { + if storeRoot, err := c.pluginStoreFn(pluginID); err != nil { + logger.Errorw(context.Background(), "failed to resolve plugin store root", "error", err) + } else if err := saveToLocalStore(storeRoot, pluginConnsForPersist); err != nil { logger.Errorw(context.Background(), "failed to save connections to local store", "error", err) } @@ -351,7 +363,9 @@ func (c *controller) OnPluginShutdown(pluginID string, meta config.PluginMeta) e func (c *controller) OnPluginDestroy(pluginID string, meta config.PluginMeta) error { logger := c.logger.With(logging.Any("pluginID", pluginID)) logger.Debugw(context.Background(), "OnPluginDestroy") - if err := removeLocalStore(pluginID); err != nil { + if storeRoot, err := c.pluginStoreFn(pluginID); err != nil { + logger.Errorw(context.Background(), "failed to resolve plugin store root", "error", err) + } else if err := removeLocalStore(storeRoot); err != nil { logger.Errorw(context.Background(), "failed to remove local store", "error", err) } return nil @@ -666,10 +680,16 @@ func (c *controller) LoadConnections(pluginID string) ([]types.Connection, error c.connections[pluginID] = mergeConnections(c.connections[pluginID], conns) c.connsMu.Unlock() - // Best-effort persist. - c.connsMu.RLock() - _ = saveToLocalStore(pluginID, c.connections) - c.connsMu.RUnlock() + // Best-effort persist — only this plugin's connections. + if storeRoot, err := c.pluginStoreFn(pluginID); err == nil { + c.connsMu.RLock() + pluginConns := make(map[string][]types.Connection, 1) + if pcs, ok := c.connections[pluginID]; ok { + pluginConns[pluginID] = pcs + } + c.connsMu.RUnlock() + _ = saveToLocalStore(storeRoot, pluginConns) + } return conns, nil } diff --git a/backend/pkg/plugin/resource/controller_apperror_test.go b/backend/pkg/plugin/resource/controller_apperror_test.go index 64887005..827fdd25 100644 --- a/backend/pkg/plugin/resource/controller_apperror_test.go +++ b/backend/pkg/plugin/resource/controller_apperror_test.go @@ -10,6 +10,7 @@ import ( logging "github.com/omniviewdev/plugin-sdk/log" "github.com/omniviewdev/omniview/backend/pkg/apperror" + "github.com/omniviewdev/omniview/internal/appstate" resource "github.com/omniviewdev/plugin-sdk/pkg/v1/resource" "github.com/omniviewdev/plugin-sdk/pkg/types" ) @@ -17,7 +18,12 @@ import ( // newTestController creates a controller with a no-op logger and no settings // provider, then calls Run so the context is initialised. func newTestController() *controller { - ctrl := NewController(logging.NewNop(), nil).(*controller) + // Provide a pluginStoreFn that panics if called — these tests never exercise + // the local store path, so the function should never be invoked. + storeFn := func(pluginID string) (*appstate.ScopedRoot, error) { + panic("unexpected call to pluginStoreFn in test for plugin " + pluginID) + } + ctrl := NewController(logging.NewNop(), nil, storeFn).(*controller) ctrl.Run(context.Background()) return ctrl } diff --git a/backend/pkg/plugin/resource/service_wrapper.go b/backend/pkg/plugin/resource/service_wrapper.go new file mode 100644 index 00000000..bb00e4c2 --- /dev/null +++ b/backend/pkg/plugin/resource/service_wrapper.go @@ -0,0 +1,194 @@ +package resource + +import ( + "context" + "encoding/json" + + sdkresource "github.com/omniviewdev/plugin-sdk/pkg/v1/resource" + sdktypes "github.com/omniviewdev/plugin-sdk/pkg/types" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper is an explicit delegation wrapper around resource.Controller. +// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, +// +// OnPluginDestroy, Run, SetCrashCallback, Graph +type ServiceWrapper struct { + Ctrl Controller +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if ss, ok := s.Ctrl.(interface { + ServiceStartup(context.Context, application.ServiceOptions) error + }); ok { + return ss.ServiceStartup(ctx, options) + } + return nil +} + +func (s *ServiceWrapper) ServiceShutdown() error { + if ss, ok := s.Ctrl.(interface{ ServiceShutdown() error }); ok { + return ss.ServiceShutdown() + } + return nil +} + +// CRUD +func (s *ServiceWrapper) Get(pluginID, connectionID, key string, input sdkresource.GetInput) (*sdkresource.GetResult, error) { + return s.Ctrl.Get(pluginID, connectionID, key, input) +} +func (s *ServiceWrapper) List(pluginID, connectionID, key string, input sdkresource.ListInput) (*sdkresource.ListResult, error) { + return s.Ctrl.List(pluginID, connectionID, key, input) +} +func (s *ServiceWrapper) Find(pluginID, connectionID, key string, input sdkresource.FindInput) (*sdkresource.FindResult, error) { + return s.Ctrl.Find(pluginID, connectionID, key, input) +} +func (s *ServiceWrapper) Create(pluginID, connectionID, key string, input sdkresource.CreateInput) (*sdkresource.CreateResult, error) { + return s.Ctrl.Create(pluginID, connectionID, key, input) +} +func (s *ServiceWrapper) Update(pluginID, connectionID, key string, input sdkresource.UpdateInput) (*sdkresource.UpdateResult, error) { + return s.Ctrl.Update(pluginID, connectionID, key, input) +} +func (s *ServiceWrapper) Delete(pluginID, connectionID, key string, input sdkresource.DeleteInput) (*sdkresource.DeleteResult, error) { + return s.Ctrl.Delete(pluginID, connectionID, key, input) +} + +// Connection lifecycle +func (s *ServiceWrapper) StartConnection(pluginID, connectionID string) (sdktypes.ConnectionStatus, error) { + return s.Ctrl.StartConnection(pluginID, connectionID) +} +func (s *ServiceWrapper) StopConnection(pluginID, connectionID string) (sdktypes.Connection, error) { + return s.Ctrl.StopConnection(pluginID, connectionID) +} +func (s *ServiceWrapper) CheckConnection(pluginID, connectionID string) (sdktypes.ConnectionStatus, error) { + return s.Ctrl.CheckConnection(pluginID, connectionID) +} +func (s *ServiceWrapper) LoadConnections(pluginID string) ([]sdktypes.Connection, error) { + return s.Ctrl.LoadConnections(pluginID) +} +func (s *ServiceWrapper) ListConnections(pluginID string) ([]sdktypes.Connection, error) { + return s.Ctrl.ListConnections(pluginID) +} +func (s *ServiceWrapper) ListAllConnections() (map[string][]sdktypes.Connection, error) { + return s.Ctrl.ListAllConnections() +} +func (s *ServiceWrapper) GetAllConnectionStates() (map[string][]ConnectionState, error) { + return s.Ctrl.GetAllConnectionStates() +} +func (s *ServiceWrapper) GetConnection(pluginID, connectionID string) (sdktypes.Connection, error) { + return s.Ctrl.GetConnection(pluginID, connectionID) +} +func (s *ServiceWrapper) GetConnectionNamespaces(pluginID, connectionID string) ([]string, error) { + return s.Ctrl.GetConnectionNamespaces(pluginID, connectionID) +} +func (s *ServiceWrapper) AddConnection(pluginID string, connection sdktypes.Connection) error { + return s.Ctrl.AddConnection(pluginID, connection) +} +func (s *ServiceWrapper) UpdateConnection(pluginID string, connection sdktypes.Connection) (sdktypes.Connection, error) { + return s.Ctrl.UpdateConnection(pluginID, connection) +} +func (s *ServiceWrapper) RemoveConnection(pluginID, connectionID string) error { + return s.Ctrl.RemoveConnection(pluginID, connectionID) +} + +// Watch lifecycle +func (s *ServiceWrapper) StartConnectionWatch(pluginID, connectionID string) error { + return s.Ctrl.StartConnectionWatch(pluginID, connectionID) +} +func (s *ServiceWrapper) StopConnectionWatch(pluginID, connectionID string) error { + return s.Ctrl.StopConnectionWatch(pluginID, connectionID) +} +func (s *ServiceWrapper) GetWatchState(pluginID, connectionID string) (*sdkresource.WatchConnectionSummary, error) { + return s.Ctrl.GetWatchState(pluginID, connectionID) +} +func (s *ServiceWrapper) EnsureResourceWatch(pluginID, connectionID, resourceKey string) error { + return s.Ctrl.EnsureResourceWatch(pluginID, connectionID, resourceKey) +} +func (s *ServiceWrapper) StopResourceWatch(pluginID, connectionID, resourceKey string) error { + return s.Ctrl.StopResourceWatch(pluginID, connectionID, resourceKey) +} +func (s *ServiceWrapper) RestartResourceWatch(pluginID, connectionID, resourceKey string) error { + return s.Ctrl.RestartResourceWatch(pluginID, connectionID, resourceKey) +} +func (s *ServiceWrapper) IsResourceWatchRunning(pluginID, connectionID, resourceKey string) (bool, error) { + return s.Ctrl.IsResourceWatchRunning(pluginID, connectionID, resourceKey) +} + +// Subscriptions +func (s *ServiceWrapper) SubscribeResource(pluginID, connectionID, resourceKey string) error { + return s.Ctrl.SubscribeResource(pluginID, connectionID, resourceKey) +} +func (s *ServiceWrapper) UnsubscribeResource(pluginID, connectionID, resourceKey string) error { + return s.Ctrl.UnsubscribeResource(pluginID, connectionID, resourceKey) +} + +// Type metadata +func (s *ServiceWrapper) GetResourceGroups(pluginID, connectionID string) map[string]sdkresource.ResourceGroup { + return s.Ctrl.GetResourceGroups(pluginID, connectionID) +} +func (s *ServiceWrapper) GetResourceGroup(pluginID, groupID string) (sdkresource.ResourceGroup, error) { + return s.Ctrl.GetResourceGroup(pluginID, groupID) +} +func (s *ServiceWrapper) GetResourceTypes(pluginID, connectionID string) map[string]sdkresource.ResourceMeta { + return s.Ctrl.GetResourceTypes(pluginID, connectionID) +} +func (s *ServiceWrapper) GetResourceType(pluginID, typeID string) (*sdkresource.ResourceMeta, error) { + return s.Ctrl.GetResourceType(pluginID, typeID) +} +func (s *ServiceWrapper) HasResourceType(pluginID, typeID string) bool { + return s.Ctrl.HasResourceType(pluginID, typeID) +} +func (s *ServiceWrapper) GetResourceDefinition(pluginID, typeID string) (sdkresource.ResourceDefinition, error) { + return s.Ctrl.GetResourceDefinition(pluginID, typeID) +} +func (s *ServiceWrapper) GetResourceCapabilities(pluginID, key string) (*sdkresource.ResourceCapabilities, error) { + return s.Ctrl.GetResourceCapabilities(pluginID, key) +} +func (s *ServiceWrapper) GetFilterFields(pluginID, connectionID, key string) ([]sdkresource.FilterField, error) { + return s.Ctrl.GetFilterFields(pluginID, connectionID, key) +} +func (s *ServiceWrapper) GetResourceSchema(pluginID, connectionID, key string) (json.RawMessage, error) { + return s.Ctrl.GetResourceSchema(pluginID, connectionID, key) +} + +// Actions +func (s *ServiceWrapper) GetActions(pluginID, connectionID, key string) ([]sdkresource.ActionDescriptor, error) { + return s.Ctrl.GetActions(pluginID, connectionID, key) +} +func (s *ServiceWrapper) ExecuteAction(pluginID, connectionID, key, actionID string, input sdkresource.ActionInput) (*sdkresource.ActionResult, error) { + return s.Ctrl.ExecuteAction(pluginID, connectionID, key, actionID, input) +} +func (s *ServiceWrapper) StreamAction(pluginID, connectionID, key, actionID string, input sdkresource.ActionInput) (string, error) { + return s.Ctrl.StreamAction(pluginID, connectionID, key, actionID, input) +} + +// Editor schemas +func (s *ServiceWrapper) GetEditorSchemas(pluginID, connectionID string) ([]sdkresource.EditorSchema, error) { + return s.Ctrl.GetEditorSchemas(pluginID, connectionID) +} + +// Relationships +func (s *ServiceWrapper) GetRelationships(pluginID, key string) ([]sdkresource.RelationshipDescriptor, error) { + return s.Ctrl.GetRelationships(pluginID, key) +} +func (s *ServiceWrapper) ResolveRelationships(pluginID, connectionID, key, id, namespace string) ([]sdkresource.ResolvedRelationship, error) { + return s.Ctrl.ResolveRelationships(pluginID, connectionID, key, id, namespace) +} + +// Health +func (s *ServiceWrapper) GetHealth(pluginID, connectionID, key string, data json.RawMessage) (*sdkresource.ResourceHealth, error) { + return s.Ctrl.GetHealth(pluginID, connectionID, key, data) +} +func (s *ServiceWrapper) GetResourceEvents(pluginID, connectionID, key, id, namespace string, limit int32) ([]sdkresource.ResourceEvent, error) { + return s.Ctrl.GetResourceEvents(pluginID, connectionID, key, id, namespace, limit) +} + +// ListPlugins +func (s *ServiceWrapper) ListPlugins() ([]string, error) { + return s.Ctrl.ListPlugins() +} + +// HasPlugin +func (s *ServiceWrapper) HasPlugin(pluginID string) bool { + return s.Ctrl.HasPlugin(pluginID) +} diff --git a/backend/pkg/plugin/resource/testutil_test.go b/backend/pkg/plugin/resource/testutil_test.go index c2c31494..7175368f 100644 --- a/backend/pkg/plugin/resource/testutil_test.go +++ b/backend/pkg/plugin/resource/testutil_test.go @@ -18,6 +18,7 @@ import ( "github.com/omniviewdev/omniview/backend/pkg/plugin/resource/graph" "github.com/omniviewdev/omniview/backend/pkg/plugin/resource/indexer" "github.com/omniviewdev/omniview/backend/pkg/plugin/resource/registry" + "github.com/omniviewdev/omniview/internal/appstate" ) // ============================================================================ @@ -588,6 +589,7 @@ func newTestControllerWithEmitter(t *testing.T) (*controller, *recordingEmitter) disp.Start() t.Cleanup(disp.Stop) + svc := appstate.NewTestService(t) ctrl := &controller{ logger: logging.NewNop(), plugins: make(map[string]*pluginState), @@ -598,6 +600,7 @@ func newTestControllerWithEmitter(t *testing.T) (*controller, *recordingEmitter) registryStore: store, dispatcher: disp, graph: g, + pluginStoreFn: svc.PluginStore, } return ctrl, emitter } diff --git a/backend/pkg/plugin/service_wrapper.go b/backend/pkg/plugin/service_wrapper.go new file mode 100644 index 00000000..5b62bc45 --- /dev/null +++ b/backend/pkg/plugin/service_wrapper.go @@ -0,0 +1,74 @@ +package plugin + +import ( + "github.com/omniviewdev/plugin-sdk/pkg/config" + sdktypes "github.com/omniviewdev/plugin-sdk/pkg/types" + + "github.com/omniviewdev/omniview/backend/pkg/plugin/registry" +) + +// ServiceWrapper exposes only the frontend-safe methods of plugin.Manager. +// Internal methods (SetDevServerChecker, SetPluginLogManager, HandlePluginCrash, +// Initialize, Run, Shutdown) are excluded to avoid binding warnings from +// interface/function-type parameters. +type ServiceWrapper struct { + Mgr Manager +} + +func (s *ServiceWrapper) InstallInDevMode() (*config.PluginMeta, error) { + return s.Mgr.InstallInDevMode() +} +func (s *ServiceWrapper) InstallFromPathPrompt() (*config.PluginMeta, error) { + return s.Mgr.InstallFromPathPrompt() +} +func (s *ServiceWrapper) InstallPluginFromPath(path string) (*config.PluginMeta, error) { + return s.Mgr.InstallPluginFromPath(path) +} +func (s *ServiceWrapper) InstallPluginVersion(pluginID, version string) (*config.PluginMeta, error) { + return s.Mgr.InstallPluginVersion(pluginID, version) +} +func (s *ServiceWrapper) LoadPlugin(id string, opts *LoadPluginOptions) (sdktypes.PluginInfo, error) { + return s.Mgr.LoadPlugin(id, opts) +} +func (s *ServiceWrapper) ReloadPlugin(id string) (sdktypes.PluginInfo, error) { + return s.Mgr.ReloadPlugin(id) +} +func (s *ServiceWrapper) RetryFailedPlugin(id string) (sdktypes.PluginInfo, error) { + return s.Mgr.RetryFailedPlugin(id) +} +func (s *ServiceWrapper) UninstallPlugin(id string) (sdktypes.PluginInfo, error) { + return s.Mgr.UninstallPlugin(id) +} +func (s *ServiceWrapper) GetPlugin(id string) (sdktypes.PluginInfo, error) { + return s.Mgr.GetPlugin(id) +} +func (s *ServiceWrapper) ListPlugins() []sdktypes.PluginInfo { + return s.Mgr.ListPlugins() +} +func (s *ServiceWrapper) GetPluginMeta(id string) (config.PluginMeta, error) { + return s.Mgr.GetPluginMeta(id) +} +func (s *ServiceWrapper) ListPluginMetas() []config.PluginMeta { + return s.Mgr.ListPluginMetas() +} +func (s *ServiceWrapper) ListAvailablePlugins() ([]registry.AvailablePlugin, error) { + return s.Mgr.ListAvailablePlugins() +} +func (s *ServiceWrapper) SearchPlugins(query, category, sort string) ([]registry.AvailablePlugin, error) { + return s.Mgr.SearchPlugins(query, category, sort) +} +func (s *ServiceWrapper) GetPluginReadme(pluginID string) (string, error) { + return s.Mgr.GetPluginReadme(pluginID) +} +func (s *ServiceWrapper) GetPluginVersions(pluginID string) ([]registry.VersionInfo, error) { + return s.Mgr.GetPluginVersions(pluginID) +} +func (s *ServiceWrapper) GetPluginReviews(pluginID string, page int) ([]registry.Review, error) { + return s.Mgr.GetPluginReviews(pluginID, page) +} +func (s *ServiceWrapper) GetPluginDownloadStats(pluginID string) (*registry.DownloadStats, error) { + return s.Mgr.GetPluginDownloadStats(pluginID) +} +func (s *ServiceWrapper) GetPluginReleaseHistory(pluginID string) ([]registry.VersionInfo, error) { + return s.Mgr.GetPluginReleaseHistory(pluginID) +} diff --git a/backend/pkg/plugin/settings/controller.go b/backend/pkg/plugin/settings/controller.go index 16ff8487..a20f5a98 100644 --- a/backend/pkg/plugin/settings/controller.go +++ b/backend/pkg/plugin/settings/controller.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/wailsapp/wails/v3/pkg/application" pkgsettings "github.com/omniviewdev/plugin-sdk/settings" @@ -15,6 +16,7 @@ import ( "github.com/omniviewdev/omniview/backend/pkg/apperror" "github.com/omniviewdev/omniview/backend/pkg/plugin/telemetryutil" internaltypes "github.com/omniviewdev/omniview/backend/pkg/plugin/types" + settingsstore "github.com/omniviewdev/omniview/internal/settings/store" "github.com/omniviewdev/plugin-sdk/pkg/config" sdksettings "github.com/omniviewdev/plugin-sdk/pkg/v1/settings" @@ -64,19 +66,37 @@ type Controller interface { // runtime assertion to make sure we satisfy both internal and external interfaces. var _ Controller = (*controller)(nil) +// pendingChange represents a queued settings change for a plugin that is not yet hydrated. +type pendingChange struct { + settings map[string]any + errCh chan error +} + type controller struct { logger logging.Logger settingsProvider pkgsettings.Provider + store *settingsstore.Store mu sync.RWMutex clients map[string]SettingsProvider + hydrated map[string]bool + schemaCache map[string]map[string]pkgsettings.Setting + pendingMu sync.Mutex + pendingChanges map[string][]pendingChange + hydratingMu sync.Mutex + hydrating map[string]bool } // NewController returns a new Controller instance. -func NewController(logger logging.Logger, sp pkgsettings.Provider) Controller { +func NewController(logger logging.Logger, sp pkgsettings.Provider, store *settingsstore.Store) Controller { return &controller{ logger: logger.Named("SettingsController"), settingsProvider: sp, + store: store, clients: make(map[string]SettingsProvider), + hydrated: make(map[string]bool), + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + hydrating: make(map[string]bool), } } @@ -133,29 +153,207 @@ func (c *controller) OnPluginStart( meta config.PluginMeta, backend internaltypes.PluginBackend, ) error { + ctx := context.Background() logger := c.logger.With(logging.Any("pluginID", pluginID)) - logger.Debugw(context.Background(), "OnPluginStart") + logger.Debugw(ctx, "OnPluginStart") provider, err := dispenseProvider(pluginID, backend) if err != nil { - logger.Errorw(context.Background(), "error", "error", err) + logger.Errorw(ctx, "error", "error", err) return err } + // Mark as hydrating before publishing the client so that concurrent + // readers calling tryHydrate will see the guard and skip. + c.hydratingMu.Lock() + c.hydrating[pluginID] = true + c.hydratingMu.Unlock() + c.mu.Lock() c.clients[pluginID] = provider c.mu.Unlock() + + // Hydrate the plugin from bbolt (hydratePlugin clears the hydrating flag via its callers). + c.hydratePlugin(ctx, pluginID, provider, logger) + + c.hydratingMu.Lock() + delete(c.hydrating, pluginID) + c.hydratingMu.Unlock() + return nil } +// hydratePlugin reads the plugin's declared schema, merges with persisted values from bbolt, +// pushes the merged values to the plugin, and persists the result. +func (c *controller) hydratePlugin(ctx context.Context, pluginID string, client SettingsProvider, logger logging.Logger) { + // 1. Get the plugin's declared schema + schema := client.ListSettings() + if schema == nil { + logger.Warnw(ctx, "plugin returned nil schema, skipping hydration", "plugin", pluginID) + c.mu.Lock() + c.hydrated[pluginID] = false + c.mu.Unlock() + c.failPendingChanges(pluginID, errors.New("plugin returned nil schema")) + return + } + + // 2. Load persisted values from bbolt + var persisted map[string]any + if c.store != nil { + var err error + persisted, err = c.store.LoadPluginSettings(pluginID) + if err != nil { + logger.Warnw(ctx, "failed to load persisted settings", "plugin", pluginID, "error", err) + persisted = make(map[string]any) + } + } else { + persisted = make(map[string]any) + } + + // 3. Merge: for each setting in schema, prefer persisted value, otherwise use default + merged := make(map[string]any, len(schema)) + for key, setting := range schema { + if val, ok := persisted[key]; ok { + merged[key] = val + } else if setting.Value != nil { + merged[key] = setting.Value + } else { + merged[key] = setting.Default + } + } + + // 4. Push merged values to the plugin + if err := client.SetSettings(merged); err != nil { + logger.Errorw(ctx, "failed to push hydrated settings to plugin", "plugin", pluginID, "error", err) + c.mu.Lock() + c.hydrated[pluginID] = false + c.mu.Unlock() + c.failPendingChanges(pluginID, fmt.Errorf("hydration failed: %w", err)) + return + } + + // 5. Persist merged state + if c.store != nil { + if err := c.store.SavePluginSettings(pluginID, merged); err != nil { + logger.Warnw(ctx, "failed to persist merged settings", "plugin", pluginID, "error", err) + } + } + + // 6. Mark as hydrated and cache the schema for fallback use + c.mu.Lock() + c.hydrated[pluginID] = true + c.schemaCache[pluginID] = schema + c.mu.Unlock() + + // 7. Drain pending changes + c.drainPendingChanges(ctx, pluginID, client, logger) +} + +// failPendingChanges removes and fails all pending changes for a plugin, sending err on each errCh. +func (c *controller) failPendingChanges(pluginID string, err error) { + c.pendingMu.Lock() + pending := c.pendingChanges[pluginID] + delete(c.pendingChanges, pluginID) + c.pendingMu.Unlock() + + for _, p := range pending { + if p.errCh != nil { + p.errCh <- err + } + } +} + +// drainPendingChanges applies any queued settings changes for a plugin. +func (c *controller) drainPendingChanges(ctx context.Context, pluginID string, client SettingsProvider, logger logging.Logger) { + c.pendingMu.Lock() + pending := c.pendingChanges[pluginID] + delete(c.pendingChanges, pluginID) + c.pendingMu.Unlock() + + var anySuccess bool + for _, p := range pending { + err := client.SetSettings(p.settings) + if err == nil { + anySuccess = true + } + if p.errCh != nil { + p.errCh <- err + } + } + + // Persist once after all pending changes are applied, rather than per-change. + if anySuccess { + c.persistCurrentSettings(ctx, pluginID, client, logger) + } +} + +// persistCurrentSettings reads the current settings from the plugin and persists them. +func (c *controller) persistCurrentSettings(ctx context.Context, pluginID string, client SettingsProvider, logger logging.Logger) { + if c.store == nil { + return + } + allSettings := client.ListSettings() + if allSettings == nil { + return + } + vals := make(map[string]any, len(allSettings)) + for k, s := range allSettings { + vals[k] = s.Value + } + if err := c.store.SavePluginSettings(pluginID, vals); err != nil { + logger.Warnw(ctx, "failed to persist plugin settings", "plugin", pluginID, "error", err) + } +} + +// tryHydrate attempts hydration if the plugin is not yet hydrated. +// Returns true if the plugin is hydrated (or became so). +// It guards against concurrent hydration of the same plugin. +func (c *controller) tryHydrate(ctx context.Context, pluginID string) bool { + c.mu.RLock() + isHydrated := c.hydrated[pluginID] + client, hasClient := c.clients[pluginID] + c.mu.RUnlock() + + if isHydrated || !hasClient { + return isHydrated + } + + // Prevent redundant concurrent hydrations for the same plugin. + c.hydratingMu.Lock() + if c.hydrating[pluginID] { + c.hydratingMu.Unlock() + return false + } + c.hydrating[pluginID] = true + c.hydratingMu.Unlock() + + defer func() { + c.hydratingMu.Lock() + delete(c.hydrating, pluginID) + c.hydratingMu.Unlock() + }() + + logger := c.logger.With(logging.Any("pluginID", pluginID)) + c.hydratePlugin(ctx, pluginID, client, logger) + + c.mu.RLock() + result := c.hydrated[pluginID] + c.mu.RUnlock() + return result +} + func (c *controller) OnPluginStop(pluginID string, meta config.PluginMeta) error { logger := c.logger.With(logging.Any("pluginID", pluginID)) logger.Debugw(context.Background(), "OnPluginStop") c.mu.Lock() delete(c.clients, pluginID) + c.hydrated[pluginID] = false c.mu.Unlock() + // Discard any pending changes (plugin is no longer running). + c.failPendingChanges(pluginID, errors.New("plugin stopped")) + return nil } @@ -167,10 +365,26 @@ func (c *controller) OnPluginShutdown(pluginID string, meta config.PluginMeta) e } func (c *controller) OnPluginDestroy(pluginID string, meta config.PluginMeta) error { + ctx := context.Background() logger := c.logger.With(logging.Any("pluginID", pluginID)) - logger.Debugw(context.Background(), "OnPluginDestroy") + logger.Debugw(ctx, "OnPluginDestroy") + + // Delete persisted settings from bbolt + if c.store != nil { + if err := c.store.DeletePluginSettings(pluginID); err != nil { + logger.Warnw(ctx, "failed to delete plugin settings from store", "plugin", pluginID, "error", err) + } + } + + // Discard any pending changes + c.failPendingChanges(pluginID, errors.New("plugin destroyed")) + + c.mu.Lock() + delete(c.hydrated, pluginID) + delete(c.clients, pluginID) + delete(c.schemaCache, pluginID) + c.mu.Unlock() - // nothing to do here return nil } @@ -198,6 +412,40 @@ func (c *controller) HasPlugin(pluginID string) bool { // ================================== CLIENT METHODS ================================== // +// bboltFallbackSettings loads settings from bbolt when gRPC is unavailable. +// It enriches each setting with metadata from the cached schema (populated +// during hydration). If no schema is cached, Type defaults to Text. +func (c *controller) bboltFallbackSettings(pluginID string) map[string]pkgsettings.Setting { + if c.store == nil { + return nil + } + vals, err := c.store.LoadPluginSettings(pluginID) + if err != nil || len(vals) == 0 { + return nil + } + + c.mu.RLock() + schema := c.schemaCache[pluginID] + c.mu.RUnlock() + + result := make(map[string]pkgsettings.Setting, len(vals)) + for k, v := range vals { + if cached, ok := schema[k]; ok { + copy := cached + copy.Value = v + result[k] = copy + } else { + result[k] = pkgsettings.Setting{ + ID: k, + Label: k, + Type: pkgsettings.Text, + Value: v, + } + } + } + return result +} + // Values returns all of the values for all of the plugins func (c *controller) Values() map[string]any { ctx, span := tracer.Start(context.Background(), "settings.Values") @@ -214,7 +462,16 @@ func (c *controller) Values() map[string]any { values := make(map[string]any) for pluginID, client := range snapshot { + // Attempt hydration if not yet done + c.tryHydrate(ctx, pluginID) + clientValues := client.ListSettings() + if clientValues == nil { + // gRPC failed, fall back to bbolt + if fallback := c.bboltFallbackSettings(pluginID); fallback != nil { + clientValues = fallback + } + } for settingID, setting := range clientValues { key := fmt.Sprintf("%s.%s", pluginID, settingID) values[key] = setting.Value @@ -234,18 +491,38 @@ func (c *controller) PluginValues(plugin string) map[string]any { if plugin == "" { return nil } + + // Attempt hydration if not yet done + c.tryHydrate(ctx, plugin) + c.mu.RLock() client, ok := c.clients[plugin] c.mu.RUnlock() if !ok { + // No client — try bbolt fallback + if fallback := c.bboltFallbackSettings(plugin); fallback != nil { + values := make(map[string]any, len(fallback)) + for settingID, setting := range fallback { + key := fmt.Sprintf("%s.%s", plugin, settingID) + values[key] = setting.Value + } + return values + } err := errors.New("plugin not found") telemetryutil.RecordError(span, err) logger.Errorw(ctx, "plugin not found", "error", err) return nil } - values := make(map[string]any) clientValues := client.ListSettings() + if clientValues == nil { + // gRPC failed, fall back to bbolt + if fallback := c.bboltFallbackSettings(plugin); fallback != nil { + clientValues = fallback + } + } + + values := make(map[string]any) for settingID, setting := range clientValues { key := fmt.Sprintf("%s.%s", plugin, settingID) values[key] = setting.Value @@ -261,16 +538,25 @@ func (c *controller) ListSettings(plugin string) map[string]pkgsettings.Setting span.SetAttributes(attribute.String("plugin", plugin)) logger := c.logger.With(logging.Any("plugin", plugin), logging.Any("method", "ListSettings")) + // Attempt hydration if not yet done + c.tryHydrate(ctx, plugin) + c.mu.RLock() client, ok := c.clients[plugin] c.mu.RUnlock() if !ok { telemetryutil.RecordError(span, errors.New("plugin not found")) logger.Errorw(ctx, "plugin not found for ListSettings") - return nil + // Fall back to bbolt + return c.bboltFallbackSettings(plugin) } - return client.ListSettings() + result := client.ListSettings() + if result == nil { + // gRPC failed, fall back to bbolt + return c.bboltFallbackSettings(plugin) + } + return result } // GetSetting returns the setting by ID. This ID should be in the form of a dot separated string @@ -286,16 +572,35 @@ func (c *controller) GetSetting(plugin, id string) (result pkgsettings.Setting, span.SetAttributes(attribute.String("plugin", plugin), attribute.String("setting_id", id)) logger := c.logger.With(logging.Any("plugin", plugin), logging.Any("method", "GetSetting"), logging.Any("id", id)) + // Attempt hydration if not yet done + c.tryHydrate(ctx, plugin) + c.mu.RLock() client, ok := c.clients[plugin] c.mu.RUnlock() if !ok { + // Try bbolt fallback + if fallback := c.bboltFallbackSettings(plugin); fallback != nil { + if s, found := fallback[id]; found { + return s, nil + } + } retErr = apperror.PluginNotFound(plugin) logger.Errorw(ctx, "plugin not found for GetSetting", "error", retErr) return pkgsettings.Setting{}, retErr } - return client.GetSetting(id) + setting, err := client.GetSetting(id) + if err != nil { + // gRPC failed, try bbolt fallback + if fallback := c.bboltFallbackSettings(plugin); fallback != nil { + if s, found := fallback[id]; found { + return s, nil + } + } + return pkgsettings.Setting{}, err + } + return setting, nil } // SetSetting sets the value of the setting by ID. @@ -312,14 +617,49 @@ func (c *controller) SetSetting(plugin, id string, value any) (retErr error) { c.mu.RLock() client, ok := c.clients[plugin] + isHydrated := c.hydrated[plugin] c.mu.RUnlock() - if !ok { - retErr = apperror.PluginNotFound(plugin) - logger.Errorw(ctx, "plugin not found for SetSetting", "error", retErr) - return retErr + + // If no client or not hydrated, queue the change + if !ok || !isHydrated { + if !ok { + retErr = apperror.PluginNotFound(plugin) + logger.Errorw(ctx, "plugin not found for SetSetting", "error", retErr) + return retErr + } + // Not hydrated — queue + errCh := make(chan error, 1) + c.pendingMu.Lock() + c.pendingChanges[plugin] = append(c.pendingChanges[plugin], pendingChange{ + settings: map[string]any{id: value}, + errCh: errCh, + }) + c.pendingMu.Unlock() + select { + case err := <-errCh: + return err + case <-time.After(30 * time.Second): + c.pendingMu.Lock() + pending := c.pendingChanges[plugin] + for i, p := range pending { + if p.errCh == errCh { + c.pendingChanges[plugin] = append(pending[:i], pending[i+1:]...) + break + } + } + c.pendingMu.Unlock() + return fmt.Errorf("timeout waiting for plugin %q to hydrate", plugin) + } + } + + if err := client.SetSetting(id, value); err != nil { + return err } - return client.SetSetting(id, value) + // Persist after successful gRPC + c.persistCurrentSettings(ctx, plugin, client, logger) + + return nil } // SetSettings sets multiple settings at once. @@ -340,12 +680,51 @@ func (c *controller) SetSettings(plugin string, settings map[string]any) (retErr c.mu.RLock() client, ok := c.clients[plugin] + isHydrated := c.hydrated[plugin] c.mu.RUnlock() - if !ok { - retErr = apperror.PluginNotFound(plugin) - logger.Errorw(ctx, "plugin not found for SetSettings", "error", retErr) - return retErr + + // If no client or not hydrated, queue the change + if !ok || !isHydrated { + if !ok { + retErr = apperror.PluginNotFound(plugin) + logger.Errorw(ctx, "plugin not found for SetSettings", "error", retErr) + return retErr + } + // Not hydrated — queue (clone the map to avoid caller mutation) + cloned := make(map[string]any, len(settings)) + for k, v := range settings { + cloned[k] = v + } + errCh := make(chan error, 1) + c.pendingMu.Lock() + c.pendingChanges[plugin] = append(c.pendingChanges[plugin], pendingChange{ + settings: cloned, + errCh: errCh, + }) + c.pendingMu.Unlock() + select { + case err := <-errCh: + return err + case <-time.After(30 * time.Second): + c.pendingMu.Lock() + pending := c.pendingChanges[plugin] + for i, p := range pending { + if p.errCh == errCh { + c.pendingChanges[plugin] = append(pending[:i], pending[i+1:]...) + break + } + } + c.pendingMu.Unlock() + return fmt.Errorf("timeout waiting for plugin %q to hydrate", plugin) + } } - return client.SetSettings(settings) + if err := client.SetSettings(settings); err != nil { + return err + } + + // Persist after successful gRPC + c.persistCurrentSettings(ctx, plugin, client, logger) + + return nil } diff --git a/backend/pkg/plugin/settings/controller_apperror_test.go b/backend/pkg/plugin/settings/controller_apperror_test.go index 1afec90e..839b8e5a 100644 --- a/backend/pkg/plugin/settings/controller_apperror_test.go +++ b/backend/pkg/plugin/settings/controller_apperror_test.go @@ -12,7 +12,7 @@ import ( ) func newTestController() Controller { - return NewController(logging.NewNop(), nil) + return NewController(logging.NewNop(), nil, nil) } func TestGetSetting_PluginNotFound(t *testing.T) { diff --git a/backend/pkg/plugin/settings/controller_store_test.go b/backend/pkg/plugin/settings/controller_store_test.go new file mode 100644 index 00000000..e66763ec --- /dev/null +++ b/backend/pkg/plugin/settings/controller_store_test.go @@ -0,0 +1,303 @@ +package settings + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + logging "github.com/omniviewdev/plugin-sdk/log" + "github.com/omniviewdev/plugin-sdk/pkg/config" + pkgsettings "github.com/omniviewdev/plugin-sdk/settings" + + settingsstore "github.com/omniviewdev/omniview/internal/settings/store" +) + +// pluginMeta returns a minimal config.PluginMeta for testing. +func pluginMeta(id string) config.PluginMeta { + return config.PluginMeta{ID: id} +} + +// mockSettingsProvider implements SettingsProvider for testing. +type mockSettingsProvider struct { + settings map[string]pkgsettings.Setting + setErr error + getErr error + listReturn map[string]pkgsettings.Setting // if non-nil, overrides settings for ListSettings +} + +func newMockProvider(settings map[string]pkgsettings.Setting) *mockSettingsProvider { + return &mockSettingsProvider{ + settings: settings, + } +} + +func (m *mockSettingsProvider) ListSettings() map[string]pkgsettings.Setting { + if m.listReturn != nil { + return m.listReturn + } + return m.settings +} + +func (m *mockSettingsProvider) GetSetting(id string) (pkgsettings.Setting, error) { + if m.getErr != nil { + return pkgsettings.Setting{}, m.getErr + } + s, ok := m.settings[id] + if !ok { + return pkgsettings.Setting{}, assert.AnError + } + return s, nil +} + +func (m *mockSettingsProvider) GetSettingValue(id string) (any, error) { + s, err := m.GetSetting(id) + if err != nil { + return nil, err + } + return s.Value, nil +} + +func (m *mockSettingsProvider) SetSetting(id string, value any) error { + if m.setErr != nil { + return m.setErr + } + if s, ok := m.settings[id]; ok { + s.Value = value + m.settings[id] = s + } + return nil +} + +func (m *mockSettingsProvider) SetSettings(vals map[string]any) error { + if m.setErr != nil { + return m.setErr + } + for k, v := range vals { + if s, ok := m.settings[k]; ok { + s.Value = v + m.settings[k] = s + } + } + return nil +} + +func openTestStore(t *testing.T) *settingsstore.Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test-settings.db") + s, err := settingsstore.Open(dbPath) + require.NoError(t, err) + t.Cleanup(func() { s.Close() }) + return s +} + +func TestOnPluginStart_HydratesFromBbolt(t *testing.T) { + store := openTestStore(t) + + // Pre-persist values in bbolt + require.NoError(t, store.SavePluginSettings("testplugin", map[string]any{ + "theme": "dark", + "lang": "en", + })) + + // Create a mock provider with schema that includes the persisted keys + mock := newMockProvider(map[string]pkgsettings.Setting{ + "theme": {ID: "theme", Value: "light", Default: "light"}, + "lang": {ID: "lang", Value: nil, Default: "en-US"}, + "size": {ID: "size", Value: nil, Default: 12}, + }) + + ctrl := &controller{ + logger: logging.NewNop().Named("test"), + store: store, + clients: map[string]SettingsProvider{"testplugin": mock}, + hydrated: make(map[string]bool), + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + } + + ctrl.hydratePlugin(context.Background(), "testplugin", mock, ctrl.logger) + + // Verify hydration: persisted values should override defaults + assert.True(t, ctrl.hydrated["testplugin"]) + assert.Equal(t, "dark", mock.settings["theme"].Value) // from bbolt + assert.Equal(t, "en", mock.settings["lang"].Value) // from bbolt + assert.Equal(t, 12, mock.settings["size"].Value) // default (not in bbolt) + + // Verify bbolt was updated with merged values + vals, err := store.LoadPluginSettings("testplugin") + require.NoError(t, err) + assert.Equal(t, "dark", vals["theme"]) + assert.Equal(t, "en", vals["lang"]) + assert.InDelta(t, 12, vals["size"], 0) // JSON round-trips integers as float64 +} + +func TestOnPluginStart_NoPersisted(t *testing.T) { + store := openTestStore(t) + + // No pre-persisted values — first-time plugin + mock := newMockProvider(map[string]pkgsettings.Setting{ + "theme": {ID: "theme", Value: "light", Default: "light"}, + "lang": {ID: "lang", Value: nil, Default: "en-US"}, + }) + + ctrl := &controller{ + logger: logging.NewNop().Named("test"), + store: store, + clients: map[string]SettingsProvider{"testplugin": mock}, + hydrated: make(map[string]bool), + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + } + + ctrl.hydratePlugin(context.Background(), "testplugin", mock, ctrl.logger) + + assert.True(t, ctrl.hydrated["testplugin"]) + assert.Equal(t, "light", mock.settings["theme"].Value) // from Value field + assert.Equal(t, "en-US", mock.settings["lang"].Value) // from Default (Value was nil) + + // Verify bbolt now has defaults + vals, err := store.LoadPluginSettings("testplugin") + require.NoError(t, err) + assert.Equal(t, "light", vals["theme"]) + assert.Equal(t, "en-US", vals["lang"]) +} + +func TestSetSetting_PersistsToBbolt(t *testing.T) { + store := openTestStore(t) + + mock := newMockProvider(map[string]pkgsettings.Setting{ + "theme": {ID: "theme", Value: "light", Default: "light"}, + }) + + ctrl := &controller{ + logger: logging.NewNop().Named("test"), + store: store, + clients: map[string]SettingsProvider{"testplugin": mock}, + hydrated: map[string]bool{"testplugin": true}, + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + } + + err := ctrl.SetSetting("testplugin", "theme", "dark") + require.NoError(t, err) + + // Verify value was persisted to bbolt + vals, err := store.LoadPluginSettings("testplugin") + require.NoError(t, err) + assert.Equal(t, "dark", vals["theme"]) +} + +func TestSetSetting_GRPCFails_NoPersistence(t *testing.T) { + store := openTestStore(t) + + mock := newMockProvider(map[string]pkgsettings.Setting{ + "theme": {ID: "theme", Value: "light", Default: "light"}, + }) + mock.setErr = assert.AnError // gRPC will fail + + ctrl := &controller{ + logger: logging.NewNop().Named("test"), + store: store, + clients: map[string]SettingsProvider{"testplugin": mock}, + hydrated: map[string]bool{"testplugin": true}, + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + } + + err := ctrl.SetSetting("testplugin", "theme", "dark") + require.Error(t, err) + + // Verify bbolt was NOT updated + vals, err := store.LoadPluginSettings("testplugin") + require.NoError(t, err) + assert.Empty(t, vals) // nothing persisted +} + +func TestPluginCrash_SettingsPreserved(t *testing.T) { + store := openTestStore(t) + + // Simulate a plugin that was running and had persisted settings + require.NoError(t, store.SavePluginSettings("testplugin", map[string]any{ + "theme": "dark", + "lang": "en", + })) + + ctrl := &controller{ + logger: logging.NewNop().Named("test"), + store: store, + clients: make(map[string]SettingsProvider), + hydrated: make(map[string]bool), + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + } + + // After crash, client is removed (OnPluginStop) + // Settings should still be in bbolt + vals, err := store.LoadPluginSettings("testplugin") + require.NoError(t, err) + assert.Equal(t, "dark", vals["theme"]) + assert.Equal(t, "en", vals["lang"]) + + // bbolt fallback should work via ListSettings + settings := ctrl.ListSettings("testplugin") + require.NotNil(t, settings) + assert.Equal(t, "dark", settings["theme"].Value) + assert.Equal(t, "en", settings["lang"].Value) +} + +func TestUninstall_DeletesFromBbolt(t *testing.T) { + store := openTestStore(t) + + // Pre-persist + require.NoError(t, store.SavePluginSettings("testplugin", map[string]any{ + "theme": "dark", + })) + + ctrl := &controller{ + logger: logging.NewNop().Named("test"), + store: store, + clients: make(map[string]SettingsProvider), + hydrated: make(map[string]bool), + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + } + + err := ctrl.OnPluginDestroy("testplugin", pluginMeta("testplugin")) + require.NoError(t, err) + + // Verify bbolt no longer has the plugin + vals, err := store.LoadPluginSettings("testplugin") + require.NoError(t, err) + assert.Empty(t, vals) +} + +func TestStaleClient_ReturnsBboltFallback(t *testing.T) { + store := openTestStore(t) + + // Pre-persist values + require.NoError(t, store.SavePluginSettings("testplugin", map[string]any{ + "theme": "dark", + "lang": "en", + })) + + // Mock provider returns nil from ListSettings (simulating stale gRPC) + mock := newMockProvider(nil) + mock.listReturn = nil // explicitly nil + + ctrl := &controller{ + logger: logging.NewNop().Named("test"), + store: store, + clients: map[string]SettingsProvider{"testplugin": mock}, + hydrated: map[string]bool{"testplugin": true}, + schemaCache: make(map[string]map[string]pkgsettings.Setting), + pendingChanges: make(map[string][]pendingChange), + } + + settings := ctrl.ListSettings("testplugin") + require.NotNil(t, settings) + assert.Equal(t, "dark", settings["theme"].Value) + assert.Equal(t, "en", settings["lang"].Value) +} diff --git a/backend/pkg/plugin/settings/service_wrapper.go b/backend/pkg/plugin/settings/service_wrapper.go new file mode 100644 index 00000000..4ffa5274 --- /dev/null +++ b/backend/pkg/plugin/settings/service_wrapper.go @@ -0,0 +1,55 @@ +package settings + +import ( + "context" + + pkgsettings "github.com/omniviewdev/plugin-sdk/settings" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceWrapper is an explicit delegation wrapper around settings.Controller. +// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, +// +// OnPluginDestroy +type ServiceWrapper struct { + Ctrl Controller +} + +func (s *ServiceWrapper) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if ss, ok := s.Ctrl.(interface { + ServiceStartup(context.Context, application.ServiceOptions) error + }); ok { + return ss.ServiceStartup(ctx, options) + } + return nil +} +func (s *ServiceWrapper) ServiceShutdown() error { + if ss, ok := s.Ctrl.(interface{ ServiceShutdown() error }); ok { + return ss.ServiceShutdown() + } + return nil +} +func (s *ServiceWrapper) ListPlugins() ([]string, error) { + return s.Ctrl.ListPlugins() +} +func (s *ServiceWrapper) HasPlugin(pluginID string) bool { + return s.Ctrl.HasPlugin(pluginID) +} +func (s *ServiceWrapper) Values() map[string]any { + return s.Ctrl.Values() +} +func (s *ServiceWrapper) PluginValues(plugin string) map[string]any { + return s.Ctrl.PluginValues(plugin) +} +func (s *ServiceWrapper) ListSettings(plugin string) map[string]pkgsettings.Setting { + return s.Ctrl.ListSettings(plugin) +} +func (s *ServiceWrapper) GetSetting(plugin, id string) (pkgsettings.Setting, error) { + return s.Ctrl.GetSetting(plugin, id) +} +func (s *ServiceWrapper) SetSetting(plugin, id string, value any) error { + return s.Ctrl.SetSetting(plugin, id, value) +} +func (s *ServiceWrapper) SetSettings(plugin string, settingsMap map[string]any) error { + return s.Ctrl.SetSettings(plugin, settingsMap) +} diff --git a/backend/pkg/plugin/state.go b/backend/pkg/plugin/state.go index 3423e5f3..5e1d54ee 100644 --- a/backend/pkg/plugin/state.go +++ b/backend/pkg/plugin/state.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sync" "github.com/omniviewdev/omniview/backend/pkg/plugin/types" @@ -12,16 +11,7 @@ import ( var stateMu sync.RWMutex -// stateFilePathOverride allows tests to redirect state persistence. -var stateFilePathOverride string - -// stateFilePath returns the path to the JSON state file. -func stateFilePath() string { - if stateFilePathOverride != "" { - return stateFilePathOverride - } - return filepath.Join(resolveHomeDir(), ".omniview", "plugin_state.json") -} +const stateFileName = "plugin_state.json" // writePluginStateJSON atomically persists plugin records as JSON. // Write to .tmp file then rename for POSIX atomicity. @@ -44,19 +34,14 @@ func (pm *pluginManager) writePluginStateJSON() error { return fmt.Errorf("error marshaling plugin state: %w", err) } - path := stateFilePath() - tmpPath := path + ".tmp" + tmpName := stateFileName + ".tmp" - if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("error creating state directory: %w", err) - } - - if err = os.WriteFile(tmpPath, data, 0644); err != nil { + if err = pm.stateRoot.WriteFile(tmpName, data, 0644); err != nil { return fmt.Errorf("error writing temp state file: %w", err) } - if err = os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) + if err = pm.stateRoot.Rename(tmpName, stateFileName); err != nil { + _ = pm.stateRoot.Remove(tmpName) return fmt.Errorf("error renaming state file: %w", err) } @@ -84,11 +69,9 @@ func (pm *pluginManager) mergeAndWritePluginState(persisted []types.PluginStateR // Keep persisted entries that are NOT in the loaded records, // but only if their plugin directory still exists on disk. - pluginDir := getPluginDir() for _, s := range persisted { if _, loaded := merged[s.ID]; !loaded { - dir := filepath.Join(pluginDir, s.ID) - if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + if _, statErr := pm.pluginsRoot.Stat(s.ID); os.IsNotExist(statErr) { // Ghost entry — plugin directory was removed; drop it. continue } @@ -106,19 +89,14 @@ func (pm *pluginManager) mergeAndWritePluginState(persisted []types.PluginStateR return fmt.Errorf("error marshaling plugin state: %w", err) } - path := stateFilePath() - tmpPath := path + ".tmp" - - if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("error creating state directory: %w", err) - } + tmpName := stateFileName + ".tmp" - if err = os.WriteFile(tmpPath, data, 0644); err != nil { + if err = pm.stateRoot.WriteFile(tmpName, data, 0644); err != nil { return fmt.Errorf("error writing temp state file: %w", err) } - if err = os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) + if err = pm.stateRoot.Rename(tmpName, stateFileName); err != nil { + _ = pm.stateRoot.Remove(tmpName) return fmt.Errorf("error renaming state file: %w", err) } @@ -126,11 +104,11 @@ func (pm *pluginManager) mergeAndWritePluginState(persisted []types.PluginStateR } // readPluginStateJSON reads persisted plugin state from JSON. -func readPluginStateJSON() ([]types.PluginStateRecord, error) { +func (pm *pluginManager) readPluginStateJSON() ([]types.PluginStateRecord, error) { stateMu.RLock() defer stateMu.RUnlock() - data, err := os.ReadFile(stateFilePath()) + data, err := pm.stateRoot.ReadFile(stateFileName) if err != nil { if os.IsNotExist(err) { return nil, nil diff --git a/backend/pkg/plugin/state_test.go b/backend/pkg/plugin/state_test.go index f486c1ae..981e690a 100644 --- a/backend/pkg/plugin/state_test.go +++ b/backend/pkg/plugin/state_test.go @@ -12,41 +12,39 @@ import ( "github.com/omniviewdev/omniview/backend/pkg/plugin/lifecycle" "github.com/omniviewdev/omniview/backend/pkg/plugin/types" + "github.com/omniviewdev/omniview/internal/appstate" "github.com/omniviewdev/plugin-sdk/pkg/config" ) -func withTempStateFile(t *testing.T) func() { +func newTestManagerWithAppstate(t *testing.T) *pluginManager { t.Helper() - dir := t.TempDir() - old := stateFilePathOverride - stateFilePathOverride = filepath.Join(dir, "plugin_state.json") - return func() { - stateFilePathOverride = old + svc := appstate.NewTestService(t) + return &pluginManager{ + logger: testLogger(t), + stateRoot: svc.RootDir(), + pluginsRoot: svc.Plugins(), + records: make(map[string]*types.PluginRecord), + pidTracker: NewPluginPIDTracker(svc.RootDir()), } } func TestWriteAndReadJSON_RoundTrip(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() - - pm := &pluginManager{ - logger: testLogger(t), - records: map[string]*types.PluginRecord{ - "test-plugin": { - ID: "test-plugin", - Phase: lifecycle.PhaseRunning, - Metadata: config.PluginMeta{ID: "test-plugin", Name: "Test", Version: "1.0"}, - Enabled: true, - DevMode: true, - DevPath: "/dev/path", - InstalledAt: time.Now().Truncate(time.Second), - }, + pm := newTestManagerWithAppstate(t) + pm.records = map[string]*types.PluginRecord{ + "test-plugin": { + ID: "test-plugin", + Phase: lifecycle.PhaseRunning, + Metadata: config.PluginMeta{ID: "test-plugin", Name: "Test", Version: "1.0"}, + Enabled: true, + DevMode: true, + DevPath: "/dev/path", + InstalledAt: time.Now().Truncate(time.Second), }, } require.NoError(t, pm.writePluginStateJSON()) - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) require.Len(t, records, 1) @@ -59,52 +57,42 @@ func TestWriteAndReadJSON_RoundTrip(t *testing.T) { } func TestReadJSON_EmptyFile(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() + pm := newTestManagerWithAppstate(t) // Write empty JSON array. - require.NoError(t, os.MkdirAll(filepath.Dir(stateFilePathOverride), 0755)) - require.NoError(t, os.WriteFile(stateFilePathOverride, []byte("[]"), 0644)) + require.NoError(t, pm.stateRoot.WriteFile(stateFileName, []byte("[]"), 0644)) - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) assert.Empty(t, records) } func TestReadJSON_NonexistentFile(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() + pm := newTestManagerWithAppstate(t) - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) assert.Nil(t, records) } func TestReadJSON_CorruptJSON(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() + pm := newTestManagerWithAppstate(t) - require.NoError(t, os.MkdirAll(filepath.Dir(stateFilePathOverride), 0755)) - require.NoError(t, os.WriteFile(stateFilePathOverride, []byte("{invalid json"), 0644)) + require.NoError(t, pm.stateRoot.WriteFile(stateFileName, []byte("{invalid json"), 0644)) - _, err := readPluginStateJSON() + _, err := pm.readPluginStateJSON() assert.Error(t, err) assert.Contains(t, err.Error(), "parsing state file") } func TestWriteJSON_AtomicDoesNotCorrupt(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() - - pm := &pluginManager{ - logger: testLogger(t), - records: map[string]*types.PluginRecord{ - "plugin-a": { - ID: "plugin-a", - Phase: lifecycle.PhaseRunning, - Metadata: config.PluginMeta{ID: "plugin-a"}, - Enabled: true, - }, + pm := newTestManagerWithAppstate(t) + pm.records = map[string]*types.PluginRecord{ + "plugin-a": { + ID: "plugin-a", + Phase: lifecycle.PhaseRunning, + Metadata: config.PluginMeta{ID: "plugin-a"}, + Enabled: true, }, } @@ -112,31 +100,20 @@ func TestWriteJSON_AtomicDoesNotCorrupt(t *testing.T) { require.NoError(t, pm.writePluginStateJSON()) // Verify the temp file was cleaned up. - _, err := os.Stat(stateFilePathOverride + ".tmp") + _, err := pm.stateRoot.Stat(stateFileName + ".tmp") assert.True(t, os.IsNotExist(err)) // Read and verify the file is valid JSON. - data, err := os.ReadFile(stateFilePathOverride) + data, err := pm.stateRoot.ReadFile(stateFileName) require.NoError(t, err) assert.True(t, json.Valid(data)) } func TestMergeAndWrite_DropsGhostEntries(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() + pm := newTestManagerWithAppstate(t) - // Create a plugin directory for "alive-plugin" only — "ghost-plugin" has no directory. - pluginDir := t.TempDir() - old := pluginDirOverride - pluginDirOverride = pluginDir - defer func() { pluginDirOverride = old }() - - require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, "alive-plugin"), 0755)) - - pm := &pluginManager{ - logger: testLogger(t), - records: make(map[string]*types.PluginRecord), - } + // Create a plugin directory for "alive-plugin" only -- "ghost-plugin" has no directory. + require.NoError(t, pm.pluginsRoot.MkdirAll("alive-plugin", 0755)) persisted := []types.PluginStateRecord{ {ID: "ghost-plugin", Phase: lifecycle.PhaseRunning, Enabled: true}, @@ -145,34 +122,25 @@ func TestMergeAndWrite_DropsGhostEntries(t *testing.T) { require.NoError(t, pm.mergeAndWritePluginState(persisted)) - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) require.Len(t, records, 1, "ghost entry should have been dropped") assert.Equal(t, "alive-plugin", records[0].ID) } func TestMergeAndWrite_PreservesNotLoadedWithDirectory(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() - - pluginDir := t.TempDir() - old := pluginDirOverride - pluginDirOverride = pluginDir - defer func() { pluginDirOverride = old }() + pm := newTestManagerWithAppstate(t) // Both plugins have directories on disk. - require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, "loaded-plugin"), 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, "unloaded-plugin"), 0755)) - - pm := &pluginManager{ - logger: testLogger(t), - records: map[string]*types.PluginRecord{ - "loaded-plugin": { - ID: "loaded-plugin", - Phase: lifecycle.PhaseRunning, - Metadata: config.PluginMeta{ID: "loaded-plugin"}, - Enabled: true, - }, + require.NoError(t, pm.pluginsRoot.MkdirAll("loaded-plugin", 0755)) + require.NoError(t, pm.pluginsRoot.MkdirAll("unloaded-plugin", 0755)) + + pm.records = map[string]*types.PluginRecord{ + "loaded-plugin": { + ID: "loaded-plugin", + Phase: lifecycle.PhaseRunning, + Metadata: config.PluginMeta{ID: "loaded-plugin"}, + Enabled: true, }, } @@ -183,7 +151,7 @@ func TestMergeAndWrite_PreservesNotLoadedWithDirectory(t *testing.T) { require.NoError(t, pm.mergeAndWritePluginState(persisted)) - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) require.Len(t, records, 2, "not-loaded entry with directory should be preserved") @@ -196,20 +164,15 @@ func TestMergeAndWrite_PreservesNotLoadedWithDirectory(t *testing.T) { } func TestWriteJSON_MultipleRecords(t *testing.T) { - cleanup := withTempStateFile(t) - defer cleanup() - - pm := &pluginManager{ - logger: testLogger(t), - records: map[string]*types.PluginRecord{ - "a": {ID: "a", Phase: lifecycle.PhaseRunning, Metadata: config.PluginMeta{ID: "a"}, Enabled: true}, - "b": {ID: "b", Phase: lifecycle.PhaseStopped, Metadata: config.PluginMeta{ID: "b"}, Enabled: false}, - }, + pm := newTestManagerWithAppstate(t) + pm.records = map[string]*types.PluginRecord{ + "a": {ID: "a", Phase: lifecycle.PhaseRunning, Metadata: config.PluginMeta{ID: "a"}, Enabled: true}, + "b": {ID: "b", Phase: lifecycle.PhaseStopped, Metadata: config.PluginMeta{ID: "b"}, Enabled: false}, } require.NoError(t, pm.writePluginStateJSON()) - records, err := readPluginStateJSON() + records, err := pm.readPluginStateJSON() require.NoError(t, err) assert.Len(t, records, 2) @@ -221,3 +184,26 @@ func TestWriteJSON_MultipleRecords(t *testing.T) { assert.False(t, byID["b"].Enabled) assert.Equal(t, lifecycle.PhaseStopped, byID["b"].Phase) } + +// installPluginFixtureAt creates a plugin directory with plugin.yaml and optional binary +// at the given pluginsRoot directory. +func installPluginFixtureAt(t *testing.T, pluginsDir string, id string, caps []string, withBinary bool) { + t.Helper() + dir := filepath.Join(pluginsDir, id) + require.NoError(t, os.MkdirAll(dir, 0755)) + + content := "id: " + id + "\nname: " + id + "\nversion: 1.0.0\n" + if len(caps) > 0 { + content += "capabilities:\n" + for _, c := range caps { + content += " - " + c + "\n" + } + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(content), 0644)) + + if withBinary { + binDir := filepath.Join(dir, "bin") + require.NoError(t, os.MkdirAll(binDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(binDir, "plugin"), []byte("#!/bin/sh\n"), 0755)) + } +} diff --git a/backend/pkg/plugin/utils.go b/backend/pkg/plugin/utils.go index a51a3e53..84de009e 100644 --- a/backend/pkg/plugin/utils.go +++ b/backend/pkg/plugin/utils.go @@ -10,60 +10,12 @@ import ( "os" "path/filepath" "strings" - "sync" "gopkg.in/yaml.v2" "github.com/omniviewdev/plugin-sdk/pkg/config" ) -// pluginDirOverride allows tests to redirect the plugin directory. -var pluginDirOverride string - -// make sure our plugin dir is all set. -func auditPluginDir() error { - if err := os.MkdirAll(getPluginDir(), 0755); err != nil { - return fmt.Errorf("error creating plugin directory: %w", err) - } - - return nil -} - -// resolveHomeDir returns the user's home directory with safe fallbacks. -var ( - homeDirOnce sync.Once - homeDirPath string -) - -func resolveHomeDir() string { - homeDirOnce.Do(func() { - var err error - homeDirPath, err = os.UserHomeDir() - if err != nil { - homeDirPath = os.Getenv("HOME") - if homeDirPath == "" { - homeDirPath = os.TempDir() - } - } - }) - return homeDirPath -} - -func getOmniviewLogDir() string { - return filepath.Join(resolveHomeDir(), ".omniview", "logs") -} - -func getPluginDir() string { - if pluginDirOverride != "" { - return pluginDirOverride - } - return filepath.Join(resolveHomeDir(), ".omniview", "plugins") -} - -func getPluginLocation(id string) string { - return filepath.Join(getPluginDir(), id) -} - func checkTarball(filePath string) error { file, err := os.Open(filePath) if err != nil { diff --git a/backend/pkg/plugin/utils/store.go b/backend/pkg/plugin/utils/store.go index 8182e583..7056c6c0 100644 --- a/backend/pkg/plugin/utils/store.go +++ b/backend/pkg/plugin/utils/store.go @@ -1,59 +1,28 @@ package utils import ( - "errors" "os" - "path/filepath" -) - -// GetPluginStorePath returns the path to the plugin store for the given plugin. -func GetPluginStorePath(pluginID string) (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - - basePluginPath := filepath.Join(homeDir, ".omniview", "plugins", pluginID) - - // check to make sure the plugin store directory exists - if _, err = os.Stat(basePluginPath); os.IsNotExist(err) { - return "", errors.New("failed to create plugin store: plugin is not installed") - } - - return filepath.Join(homeDir, ".omniview", "plugins", pluginID, "store"), nil -} -// initializeStore creates the store directory for the given plugin and capability. -func InitializePluginStore(pluginID string) error { - storePath, err := GetPluginStorePath(pluginID) - if err != nil { - return err - } + "github.com/omniviewdev/omniview/internal/appstate" +) - return os.MkdirAll(storePath, 0700) +// InitializePluginStore creates the store directory for the given plugin. +func InitializePluginStore(storeRoot *appstate.ScopedRoot) error { + return storeRoot.MkdirAll(".", 0700) } -// getStore returns a file handle to the store for the given plugin and capability. +// GetStore returns a file handle to the store for the given plugin and capability. // If the store does not exist, it will be created. // Make sure to close the file handle after using it! -func GetStore(capability, pluginID string) (*os.File, error) { - storePath, err := GetPluginStorePath(pluginID) - if err != nil { +func GetStore(capability string, storeRoot *appstate.ScopedRoot) (*os.File, error) { + if err := InitializePluginStore(storeRoot); err != nil { return nil, err } - if err = InitializePluginStore(pluginID); err != nil { - return nil, err - } - - return os.OpenFile(filepath.Join(storePath, capability), os.O_CREATE|os.O_RDWR, 0600) + return storeRoot.OpenFile(capability, os.O_CREATE|os.O_RDWR, 0600) } -// removeStore removes the store for the given plugin and capability. -func RemoveStore(capability, pluginID string) error { - storePath, err := GetPluginStorePath(pluginID) - if err != nil { - return err - } - return os.Remove(filepath.Join(storePath, capability)) +// RemoveStore removes the store for the given plugin and capability. +func RemoveStore(capability string, storeRoot *appstate.ScopedRoot) error { + return storeRoot.Remove(capability) } diff --git a/backend/pkg/store/store.go b/backend/pkg/store/store.go index 7f74bd0f..3e3cac2e 100644 --- a/backend/pkg/store/store.go +++ b/backend/pkg/store/store.go @@ -4,19 +4,13 @@ import ( "encoding/gob" "errors" "os" - "path/filepath" -) -// InitStore initializes the local store. -func InitStore() error { - basePath := getBasePath() + "github.com/omniviewdev/omniview/internal/appstate" +) - // make sure the base directory exists - err := os.MkdirAll(filepath.Join(basePath, "plugins"), 0755) - if err != nil { - return err - } - return nil +// InitStore initializes the local store by ensuring the plugins subdirectory exists. +func InitStore(root *appstate.ScopedRoot) error { + return root.MkdirAll("plugins", 0755) } // RegisterTypes registers the types that are going to be stored in the local store. @@ -26,9 +20,9 @@ func RegisterTypes(impls ...interface{}) { } } -// WriteDataToGlobalStore writes the data to the global store. -func WriteToGlobalStore[T any](store string, data T) error { - storeFile, err := getStoreFile(store) +// WriteToGlobalStore writes the data to the global store. +func WriteToGlobalStore[T any](root *appstate.ScopedRoot, store string, data T) error { + storeFile, err := getStoreFile(root, store) if err != nil { return err } @@ -39,13 +33,15 @@ func WriteToGlobalStore[T any](store string, data T) error { return encoder.Encode(data) } -// ReadDataFromGlobalStore reads the data from the global store. -func ReadFromGlobalStore[T any](store string, data *T) error { +// ReadFromGlobalStore reads the data from the global store. +// Unlike WriteToGlobalStore, this opens the file read-only and does not create +// it if it does not exist. +func ReadFromGlobalStore[T any](root *appstate.ScopedRoot, store string, data *T) error { if data == nil { return errors.New("data cannot be nil") } - storeFile, err := getStoreFile(store) + storeFile, err := root.OpenFile(store, os.O_RDONLY, 0) if err != nil { return err } diff --git a/backend/pkg/store/utils.go b/backend/pkg/store/utils.go index 3fc9d4a8..05f18b65 100644 --- a/backend/pkg/store/utils.go +++ b/backend/pkg/store/utils.go @@ -5,32 +5,26 @@ import ( "fmt" "os" "path/filepath" -) + "strings" -// getBasePath returns the base path for the omniview store. -func getBasePath() string { - baseDir, err := os.UserHomeDir() - if err != nil { - // if we can't get a home directory, we can't continue - panic("failed to get or create a home directory") - } - return filepath.Join(baseDir, ".omniview") -} + "github.com/omniviewdev/omniview/internal/appstate" +) -func getStoreFile(store string) (*os.File, error) { - storePath := filepath.Join(getBasePath(), store) - if _, err := os.Stat(storePath); os.IsNotExist(err) { - // make sure the parent directory exists - if err = os.MkdirAll(filepath.Dir(storePath), 0755); err != nil { +// getStoreFile opens (or creates) a store file relative to the given ScopedRoot. +func getStoreFile(root *appstate.ScopedRoot, store string) (*os.File, error) { + // Ensure parent directories exist within the scope. + dir := filepath.Dir(store) + if dir != "." && dir != "" { + if err := root.MkdirAll(dir, 0755); err != nil { return nil, err } } - return os.OpenFile(storePath, os.O_RDWR|os.O_CREATE, 0755) + return root.OpenFile(store, os.O_RDWR|os.O_CREATE, 0644) } // ExpandTilde takes a path and if it starts with a ~, it will replace it with the home directory. func ExpandTilde(path string) (string, error) { - if path[:2] == "~/" { + if len(path) >= 2 && path[:2] == "~/" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get home directory: %w", err) @@ -47,7 +41,7 @@ func DeexpandTilde(path string) string { // fallback to the original path return path } - if home != "" { + if home != "" && strings.HasPrefix(path, home) { path = filepath.Join("~", path[len(home):]) } return path diff --git a/backend/storage/local.go b/backend/storage/local.go deleted file mode 100644 index 6e0ce992..00000000 --- a/backend/storage/local.go +++ /dev/null @@ -1,56 +0,0 @@ -package storage - -import ( - "os" - "path" - - "github.com/vrischmann/userdir" -) - -// localStorage provides reading and writing application data to the user's -// configuration directory. -type localStorage struct { - ConfPath string -} - -// NewLocalStore returns a localStore instance. -func NewLocalStore(filename string) *localStorage { - return &localStorage{ - ConfPath: path.Join(userdir.GetConfigHome(), "Infraview", filename), - } -} - -// Load reads the given file in the user's configuration directory and returns -// its contents. -func (l *localStorage) Load() ([]byte, error) { - d, err := os.ReadFile(l.ConfPath) - if err != nil { - return nil, err - } - return d, err -} - -// Store writes data to the user's configuration directory at the given -// filename. -func (l *localStorage) Store(data []byte) error { - dir := path.Dir(l.ConfPath) - if err := ensureDirExists(dir); err != nil { - return err - } - if err := os.WriteFile(l.ConfPath, data, 0777); err != nil { - return err - } - return nil -} - -// ensureDirExists checks for the existence of the directory at the given path, -// which is created if it does not exist. -func ensureDirExists(path string) error { - _, err := os.Stat(path) - if os.IsNotExist(err) { - if err = os.Mkdir(path, 0777); err != nil { - return err - } - } - return nil -} diff --git a/cmd/omniview-plugin-dev/builder.go b/cmd/omniview-plugin-dev/builder.go index 4a92d796..3712a451 100644 --- a/cmd/omniview-plugin-dev/builder.go +++ b/cmd/omniview-plugin-dev/builder.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" ) @@ -68,14 +69,20 @@ func (b *Builder) BinaryPath() string { return filepath.Join(b.pluginDir, "build", "bin", "plugin") } -// TransferToInstall copies the built artifacts to ~/.omniview/plugins//. +// TransferToInstall copies the built artifacts to the plugins install directory. func (b *Builder) TransferToInstall() error { - homeDir, err := os.UserHomeDir() + // Validate plugin ID to prevent path traversal. + if b.meta.ID == "" || b.meta.ID == "." || b.meta.ID == ".." || + strings.ContainsAny(b.meta.ID, "/\\") { + return fmt.Errorf("invalid plugin ID %q: must not be empty or contain path separators", b.meta.ID) + } + + stateDir, err := resolveStateDir() if err != nil { return err } - installDir := filepath.Join(homeDir, ".omniview", "plugins", b.meta.ID) + installDir := filepath.Join(stateDir, "plugins", b.meta.ID) binDir := filepath.Join(installDir, "bin") if err := os.MkdirAll(binDir, 0755); err != nil { diff --git a/cmd/omniview-plugin-dev/devinfo.go b/cmd/omniview-plugin-dev/devinfo.go index a3150ff9..650ee7ef 100644 --- a/cmd/omniview-plugin-dev/devinfo.go +++ b/cmd/omniview-plugin-dev/devinfo.go @@ -8,6 +8,11 @@ import ( "time" ) +// stateDirOverride can be injected at build time via: +// +// -X main.stateDirOverride= +var stateDirOverride string //nolint:gochecknoglobals // injected via ldflags + // DevInfoCLI represents the .devinfo file structure. type DevInfoCLI struct { PID int `json:"pid"` @@ -20,14 +25,67 @@ type DevInfoCLI struct { StartedAt time.Time `json:"startedAt"` } +// resolveStateDir determines the application state directory. +// Priority: OMNIVIEW_STATE_DIR env var > stateDirOverride (ldflags) > ~/.omniview. +// Both the env var and the ldflags override support tilde (~/) expansion. +// This mirrors appstate.ResolveRoot() from the main module. +func resolveStateDir() (string, error) { + if dir := os.Getenv("OMNIVIEW_STATE_DIR"); dir != "" { + expanded, err := expandTilde(dir) + if err != nil { + return "", fmt.Errorf("OMNIVIEW_STATE_DIR: %w", err) + } + if !filepath.IsAbs(expanded) { + return "", fmt.Errorf("OMNIVIEW_STATE_DIR must be absolute, got %q", dir) + } + return filepath.Clean(expanded), nil + } + if stateDirOverride != "" { + override, err := expandTilde(stateDirOverride) + if err != nil { + return "", fmt.Errorf("stateDirOverride: %w", err) + } + if !filepath.IsAbs(override) { + return "", fmt.Errorf("stateDirOverride must be absolute, got %q", stateDirOverride) + } + return filepath.Clean(override), nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".omniview"), nil +} + +// expandTilde replaces a leading ~/ (or bare ~) with the user's home directory. +// Returns the path unchanged if no tilde prefix is present. +// Returns an error if the home directory cannot be resolved. +func expandTilde(path string) (string, error) { + if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expand ~: %w", err) + } + return home, nil + } + if len(path) > 1 && path[:2] == "~/" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expand ~: %w", err) + } + return filepath.Join(home, path[2:]), nil + } + return path, nil +} + // WriteDevInfoCLI writes a .devinfo file for the running plugin. func WriteDevInfoCLI(pluginID, version string, proc *PluginProcess, vitePort int) error { - homeDir, err := os.UserHomeDir() + stateDir, err := resolveStateDir() if err != nil { return err } - dir := filepath.Join(homeDir, ".omniview", "plugins", pluginID) + dir := filepath.Join(stateDir, "plugins", pluginID) if err := os.MkdirAll(dir, 0755); err != nil { return err } @@ -66,12 +124,12 @@ func WriteDevInfoCLI(pluginID, version string, proc *PluginProcess, vitePort int // CleanupDevInfoCLI removes the .devinfo file. func CleanupDevInfoCLI(pluginID string, log *Logger) { - homeDir, err := os.UserHomeDir() + stateDir, err := resolveStateDir() if err != nil { return } - path := filepath.Join(homeDir, ".omniview", "plugins", pluginID, ".devinfo") + path := filepath.Join(stateDir, "plugins", pluginID, ".devinfo") if err := os.Remove(path); err != nil && !os.IsNotExist(err) { log.Error("Failed to clean up .devinfo: %v", err) } diff --git a/go.mod b/go.mod index 461bef84..e07a2b65 100644 --- a/go.mod +++ b/go.mod @@ -6,19 +6,20 @@ require ( github.com/creack/pty v1.1.21 github.com/fsnotify/fsnotify v1.9.0 github.com/go-enry/go-enry/v2 v2.8.7 + github.com/gofrs/flock v0.13.0 github.com/google/uuid v1.6.0 github.com/grafana/otel-profiling-go v0.5.1 github.com/grafana/pyroscope-go v1.2.7 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/nxadm/tail v1.4.11 - github.com/omniviewdev/plugin-sdk v0.4.1 + github.com/omniviewdev/plugin-sdk v0.5.0 github.com/omniviewdev/registry v0.2.1 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 - github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 github.com/wailsapp/mimetype v1.4.1 github.com/wailsapp/wails/v3 v3.0.0-alpha.74 + go.etcd.io/bbolt v1.4.3 go.opentelemetry.io/contrib/bridges/otelzap v0.17.0 go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 go.opentelemetry.io/otel v1.42.0 diff --git a/go.sum b/go.sum index b61b8364..c4f18a02 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -169,8 +171,8 @@ github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/omniviewdev/plugin-sdk v0.4.1 h1:DLZrKzm+szGrVslp3vvCN8Ao7yd4czCkjzxgZKU1+Rk= -github.com/omniviewdev/plugin-sdk v0.4.1/go.mod h1:7EL5BfctDEQdflClkZMhgvSAGkDff79UteN7rcuBUho= +github.com/omniviewdev/plugin-sdk v0.5.0 h1:2a9KQ8E3h8GdF06opO9YuriOfvbZ0xHs9Uc2EuepizQ= +github.com/omniviewdev/plugin-sdk v0.5.0/go.mod h1:7EL5BfctDEQdflClkZMhgvSAGkDff79UteN7rcuBUho= github.com/omniviewdev/registry v0.2.1 h1:4CsiBZmlftBZV/3LyQNiI2plRkMAqgFD8Q4zffFCVYk= github.com/omniviewdev/registry v0.2.1/go.mod h1:/IZABypY6iIaHo2Gw5g5Ll4SIbUhEm5/02spR/svl3Q= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= @@ -221,8 +223,6 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 h1:Ah2/69Z24rwD6OByyOdpJDmttftz0FTF8Q4QZ/SF1E4= -github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68/go.mod h1:EqKqAeKddSL9XSGnfXd/7iLncccKhR16HBKVva7ENw8= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= @@ -235,6 +235,8 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelzap v0.17.0 h1:oCltVHJcblcth2z9B9dRTeZIZTe2Sf9Ad9h8bcc+s8M= diff --git a/internal/appstate/appstate.go b/internal/appstate/appstate.go new file mode 100644 index 00000000..fe257121 --- /dev/null +++ b/internal/appstate/appstate.go @@ -0,0 +1,243 @@ +package appstate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/gofrs/flock" +) + +// validatePluginID rejects empty, traversal, or path-separator-containing IDs. +func validatePluginID(id string) error { + if id == "" { + return fmt.Errorf("appstate: empty plugin ID") + } + if strings.ContainsAny(id, "/\\") { + return fmt.Errorf("appstate: plugin ID contains path separator: %q", id) + } + if id == "." || id == ".." { + return fmt.Errorf("appstate: invalid plugin ID: %q", id) + } + return nil +} + +// Service is the central authority for all application state filesystem access. +// Create once at startup via New() and inject into subsystems. +type Service struct { + root string + lock *flock.Flock + osRoot *os.Root + + // Cached scoped roots for top-level directories. + plugins *ScopedRoot + logs *ScopedRoot + rootDir *ScopedRoot + + // Cached per-plugin scoped roots. + pluginRootsMu sync.Mutex + pluginDataRoots map[string]*ScopedRoot + pluginStoreRoots map[string]*ScopedRoot + + closeOnce sync.Once + closeErr error +} + +type config struct { + root string + flockOn bool +} + +// Option configures the Service constructor. +type Option func(*config) + +// WithRoot sets the root directory for the state service. +func WithRoot(path string) Option { + return func(c *config) { c.root = path } +} + +// WithFlock enables or disables file locking. +func WithFlock(enabled bool) Option { + return func(c *config) { c.flockOn = enabled } +} + +// New creates a new Service with the given options. +func New(opts ...Option) (*Service, error) { + cfg := config{flockOn: true} + for _, o := range opts { + o(&cfg) + } + + root := cfg.root + if root == "" { + var err error + root, err = ResolveRoot() + if err != nil { + return nil, err + } + } + + if err := os.MkdirAll(root, 0755); err != nil { + return nil, fmt.Errorf("appstate: create root %q: %w", root, err) + } + + svc := &Service{ + root: root, + pluginDataRoots: make(map[string]*ScopedRoot), + pluginStoreRoots: make(map[string]*ScopedRoot), + } + + if cfg.flockOn { + lockPath := filepath.Join(root, ".lock") + fl, err := acquireLock(lockPath) + if err != nil { + return nil, err + } + svc.lock = fl + } + + osRoot, err := os.OpenRoot(root) + if err != nil { + releaseLock(svc.lock) + return nil, fmt.Errorf("appstate: open root %q: %w", root, err) + } + svc.osRoot = osRoot + + svc.plugins, err = newScopedRoot(filepath.Join(root, "plugins")) + if err != nil { + svc.closePartial() + return nil, err + } + svc.logs, err = newScopedRoot(filepath.Join(root, "logs")) + if err != nil { + svc.closePartial() + return nil, err + } + svc.rootDir, err = newScopedRoot(root) + if err != nil { + svc.closePartial() + return nil, err + } + + return svc, nil +} + +func (s *Service) closePartial() { + if s.plugins != nil { + s.plugins.Close() + } + if s.logs != nil { + s.logs.Close() + } + if s.rootDir != nil { + s.rootDir.Close() + } + if s.osRoot != nil { + s.osRoot.Close() + } + releaseLock(s.lock) +} + +// Close releases all resources held by the Service. +// Errors from individual close operations are aggregated and returned together. +// Close is idempotent; subsequent calls return the result of the first call. +func (s *Service) Close() error { + s.closeOnce.Do(func() { + var allErrors []error + + s.pluginRootsMu.Lock() + for id, r := range s.pluginDataRoots { + if err := r.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("close plugin data root %q: %w", id, err)) + } + } + for id, r := range s.pluginStoreRoots { + if err := r.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("close plugin store root %q: %w", id, err)) + } + } + s.pluginDataRoots = nil + s.pluginStoreRoots = nil + s.pluginRootsMu.Unlock() + + if err := s.plugins.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("close plugins root: %w", err)) + } + if err := s.logs.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("close logs root: %w", err)) + } + if err := s.rootDir.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("close root dir: %w", err)) + } + if s.osRoot != nil { + if err := s.osRoot.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("close os root: %w", err)) + } + } + releaseLock(s.lock) + + s.closeErr = errors.Join(allErrors...) + }) + return s.closeErr +} + +// Root returns the absolute path to the state directory. +func (s *Service) Root() string { return s.root } + +// Plugins returns the scoped root for the plugins directory. +func (s *Service) Plugins() *ScopedRoot { return s.plugins } + +// Logs returns the scoped root for the logs directory. +func (s *Service) Logs() *ScopedRoot { return s.logs } + +// RootDir returns the scoped root for the top-level state directory. +func (s *Service) RootDir() *ScopedRoot { return s.rootDir } + +// PluginData returns a scoped root for a specific plugin's data directory. +// The result is cached; subsequent calls for the same id return the cached instance. +func (s *Service) PluginData(id string) (*ScopedRoot, error) { + if err := validatePluginID(id); err != nil { + return nil, fmt.Errorf("appstate: PluginData: %w", err) + } + + s.pluginRootsMu.Lock() + defer s.pluginRootsMu.Unlock() + + if r, ok := s.pluginDataRoots[id]; ok { + return r, nil + } + + dir := filepath.Join(s.root, "plugins", id, "data") + r, err := newScopedRoot(dir) + if err != nil { + return nil, fmt.Errorf("appstate: create plugin data root for %q: %w", id, err) + } + s.pluginDataRoots[id] = r + return r, nil +} + +// PluginStore returns a scoped root for a specific plugin's store directory. +// The result is cached; subsequent calls for the same id return the cached instance. +func (s *Service) PluginStore(id string) (*ScopedRoot, error) { + if err := validatePluginID(id); err != nil { + return nil, fmt.Errorf("appstate: PluginStore: %w", err) + } + + s.pluginRootsMu.Lock() + defer s.pluginRootsMu.Unlock() + + if r, ok := s.pluginStoreRoots[id]; ok { + return r, nil + } + + dir := filepath.Join(s.root, "plugins", id, "store") + r, err := newScopedRoot(dir) + if err != nil { + return nil, fmt.Errorf("appstate: create plugin store root for %q: %w", id, err) + } + s.pluginStoreRoots[id] = r + return r, nil +} diff --git a/internal/appstate/appstate_test.go b/internal/appstate/appstate_test.go new file mode 100644 index 00000000..88721e45 --- /dev/null +++ b/internal/appstate/appstate_test.go @@ -0,0 +1,70 @@ +package appstate + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew_CreatesRootDir(t *testing.T) { + t.Parallel() + dir := t.TempDir() + svc, err := New(WithRoot(filepath.Join(dir, "state")), WithFlock(false)) + require.NoError(t, err) + defer svc.Close() + assert.Equal(t, filepath.Join(dir, "state"), svc.Root()) +} + +func TestNew_Accessors(t *testing.T) { + t.Parallel() + dir := t.TempDir() + svc, err := New(WithRoot(dir), WithFlock(false)) + require.NoError(t, err) + defer svc.Close() + assert.NoError(t, svc.Plugins().WriteFile("test.txt", []byte("p"), 0644)) + assert.NoError(t, svc.Logs().WriteFile("test.txt", []byte("l"), 0644)) + assert.NoError(t, svc.RootDir().WriteFile("test.txt", []byte("r"), 0644)) +} + +func TestNew_PluginDataAccessor(t *testing.T) { + t.Parallel() + dir := t.TempDir() + svc, err := New(WithRoot(dir), WithFlock(false)) + require.NoError(t, err) + defer svc.Close() + pd, err := svc.PluginData("my-plugin") + require.NoError(t, err) + require.NoError(t, pd.WriteFile("key.json", []byte(`{"v":1}`), 0600)) + got, err := pd.ReadFile("key.json") + require.NoError(t, err) + assert.JSONEq(t, `{"v":1}`, string(got)) +} + +func TestNew_PluginStoreAccessor(t *testing.T) { + t.Parallel() + dir := t.TempDir() + svc, err := New(WithRoot(dir), WithFlock(false)) + require.NoError(t, err) + defer svc.Close() + ps, err := svc.PluginStore("my-plugin") + require.NoError(t, err) + require.NoError(t, ps.WriteFile("resource", []byte("binary-data"), 0600)) +} + +func TestNew_FlockContention(t *testing.T) { + t.Parallel() + dir := t.TempDir() + svc1, err := New(WithRoot(dir)) + require.NoError(t, err) + defer svc1.Close() + _, err = New(WithRoot(dir)) + assert.Error(t, err, "second New on same dir should fail") +} + +func TestNewTestService(t *testing.T) { + t.Parallel() + svc := NewTestService(t) + require.NoError(t, svc.RootDir().WriteFile("test.txt", []byte("ok"), 0644)) +} diff --git a/internal/appstate/lock.go b/internal/appstate/lock.go new file mode 100644 index 00000000..14b2aec8 --- /dev/null +++ b/internal/appstate/lock.go @@ -0,0 +1,32 @@ +// internal/appstate/lock.go +package appstate + +import ( + "fmt" + "log" + + "github.com/gofrs/flock" +) + +// acquireLock attempts a non-blocking lock on the given path. +// Returns an error if another process holds the lock. +func acquireLock(path string) (*flock.Flock, error) { + fl := flock.New(path) + ok, err := fl.TryLock() + if err != nil { + return nil, fmt.Errorf("appstate: failed to acquire lock %q: %w", path, err) + } + if !ok { + return nil, fmt.Errorf("appstate: another instance holds the lock at %q", path) + } + return fl, nil +} + +// releaseLock releases the flock. Safe to call with nil. +func releaseLock(fl *flock.Flock) { + if fl != nil { + if err := fl.Unlock(); err != nil { + log.Printf("appstate: failed to release lock: %v", err) + } + } +} diff --git a/internal/appstate/lock_test.go b/internal/appstate/lock_test.go new file mode 100644 index 00000000..204d0408 --- /dev/null +++ b/internal/appstate/lock_test.go @@ -0,0 +1,33 @@ +// internal/appstate/lock_test.go +package appstate + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAcquireLock(t *testing.T) { + t.Parallel() + dir := t.TempDir() + lockPath := filepath.Join(dir, ".lock") + + l, err := acquireLock(lockPath) + require.NoError(t, err) + defer releaseLock(l) +} + +func TestAcquireLock_Contention(t *testing.T) { + t.Parallel() + dir := t.TempDir() + lockPath := filepath.Join(dir, ".lock") + + l1, err := acquireLock(lockPath) + require.NoError(t, err) + defer releaseLock(l1) + + _, err = acquireLock(lockPath) + assert.Error(t, err, "second lock should fail with contention error") +} diff --git a/internal/appstate/resolve.go b/internal/appstate/resolve.go new file mode 100644 index 00000000..a7db5632 --- /dev/null +++ b/internal/appstate/resolve.go @@ -0,0 +1,54 @@ +// Package appstate centralises all filesystem state access for the Omniview +// application, providing a single source of truth for the state directory root. +package appstate + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// buildStateDir is injected at build time via: +// +// -X github.com/omniviewdev/omniview/internal/appstate.buildStateDir= +var buildStateDir string //nolint:gochecknoglobals // injected via ldflags + +// ResolveRoot determines the application state directory. +// Priority: OMNIVIEW_STATE_DIR env var > buildStateDir ldflags > ~/.omniview. +// Exported for use by secondary binaries (e.g., cmd/omniview-plugin-dev). +func ResolveRoot() (string, error) { + if dir := os.Getenv("OMNIVIEW_STATE_DIR"); dir != "" { + return expandAndValidate(dir) + } + if buildStateDir != "" { + return expandAndValidate(buildStateDir) + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("appstate: cannot determine home directory: %w", err) + } + return filepath.Join(home, ".omniview"), nil +} + +// expandAndValidate expands ~ to the user's home directory and ensures the +// resulting path is absolute. +func expandAndValidate(dir string) (string, error) { + if strings.HasPrefix(dir, "~/") || strings.HasPrefix(dir, `~\`) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("appstate: cannot expand ~: %w", err) + } + dir = filepath.Join(home, dir[2:]) + } else if dir == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("appstate: cannot expand ~: %w", err) + } + dir = home + } + if !filepath.IsAbs(dir) { + return "", fmt.Errorf("appstate: state directory must be absolute, got %q", dir) + } + return filepath.Clean(dir), nil +} diff --git a/internal/appstate/resolve_test.go b/internal/appstate/resolve_test.go new file mode 100644 index 00000000..16c7c295 --- /dev/null +++ b/internal/appstate/resolve_test.go @@ -0,0 +1,75 @@ +package appstate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveRoot_EnvVar(t *testing.T) { + t.Setenv("OMNIVIEW_STATE_DIR", "/tmp/test-omniview-env") + got, err := ResolveRoot() + require.NoError(t, err) + assert.Equal(t, "/tmp/test-omniview-env", got) +} + +func TestResolveRoot_TildeExpansion(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + t.Setenv("OMNIVIEW_STATE_DIR", "~/custom-omniview") + got, err := ResolveRoot() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, "custom-omniview"), got) +} + +func TestResolveRoot_Default(t *testing.T) { + t.Setenv("OMNIVIEW_STATE_DIR", "") + old := buildStateDir + buildStateDir = "" + t.Cleanup(func() { buildStateDir = old }) + + home, err := os.UserHomeDir() + require.NoError(t, err) + got, err := ResolveRoot() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".omniview"), got) +} + +func TestResolveRoot_BuildFlag(t *testing.T) { + t.Setenv("OMNIVIEW_STATE_DIR", "") + old := buildStateDir + buildStateDir = "/opt/omniview-nightly" + t.Cleanup(func() { buildStateDir = old }) + + got, err := ResolveRoot() + require.NoError(t, err) + assert.Equal(t, "/opt/omniview-nightly", got) +} + +func TestResolveRoot_Priority(t *testing.T) { + old := buildStateDir + buildStateDir = "/opt/build-default" + t.Cleanup(func() { buildStateDir = old }) + + t.Setenv("OMNIVIEW_STATE_DIR", "/tmp/env-override") + + got, err := ResolveRoot() + require.NoError(t, err) + assert.Equal(t, "/tmp/env-override", got, "env var should take priority over build flag") +} + +func TestExpandAndValidate_RejectsRelative(t *testing.T) { + _, err := expandAndValidate("relative/path") + assert.Error(t, err) +} + +func TestExpandAndValidate_TildeOnly(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + got, err := expandAndValidate("~") + require.NoError(t, err) + assert.Equal(t, home, got) +} diff --git a/internal/appstate/scoped.go b/internal/appstate/scoped.go new file mode 100644 index 00000000..2e07dddd --- /dev/null +++ b/internal/appstate/scoped.go @@ -0,0 +1,167 @@ +// internal/appstate/scoped.go +package appstate + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// ScopedRoot provides contained filesystem access to a subdirectory. +// All path arguments are relative to the scope root. The underlying os.Root +// enforces containment at the kernel level. +type ScopedRoot struct { + root *os.Root // kernel-enforced containment + path string // absolute path for ResolvePath / logging +} + +// newScopedRoot opens an os.Root at dir and returns a ScopedRoot. +// The directory is created if it does not exist. +func newScopedRoot(dir string) (*ScopedRoot, error) { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("appstate: create directory %q: %w", dir, err) + } + r, err := os.OpenRoot(dir) + if err != nil { + return nil, fmt.Errorf("appstate: open root %q: %w", dir, err) + } + return &ScopedRoot{root: r, path: dir}, nil +} + +// Close releases the underlying os.Root handle. +func (s *ScopedRoot) Close() error { + return s.root.Close() +} + +// validate checks that name is a safe relative path. +func validate(name string) error { + if name == "" { + return fmt.Errorf("appstate: empty path not allowed") + } + if filepath.IsAbs(name) { + return fmt.Errorf("appstate: absolute path not allowed: %q", name) + } + for part := range strings.SplitSeq(filepath.ToSlash(name), "/") { + if part == ".." { + return fmt.Errorf("appstate: path traversal not allowed: %q", name) + } + } + return nil +} + +func (s *ScopedRoot) ReadFile(name string) ([]byte, error) { + if err := validate(name); err != nil { + return nil, err + } + return s.root.ReadFile(name) +} + +func (s *ScopedRoot) WriteFile(name string, data []byte, perm fs.FileMode) error { + if err := validate(name); err != nil { + return err + } + return s.root.WriteFile(name, data, perm) +} + +func (s *ScopedRoot) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) { + if err := validate(name); err != nil { + return nil, err + } + return s.root.OpenFile(name, flag, perm) +} + +func (s *ScopedRoot) MkdirAll(path string, perm fs.FileMode) error { + if err := validate(path); err != nil { + return err + } + return s.root.MkdirAll(path, perm) +} + +func (s *ScopedRoot) Remove(name string) error { + if err := validate(name); err != nil { + return err + } + return s.root.Remove(name) +} + +func (s *ScopedRoot) RemoveAll(name string) error { + if err := validate(name); err != nil { + return err + } + if name == "." { + return fmt.Errorf("appstate: refusing to remove root directory via RemoveAll(%q)", name) + } + + target := filepath.Join(s.path, filepath.FromSlash(name)) + + // Resolve symlinks and verify the resolved path is still within the + // scoped root to prevent symlink escape attacks. + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + if os.IsNotExist(err) { + return nil // already gone + } + return fmt.Errorf("appstate: resolve symlinks for %q: %w", name, err) + } + + // Ensure the resolved path is contained within the scoped root. + resolvedRoot, err := filepath.EvalSymlinks(s.path) + if err != nil { + return fmt.Errorf("appstate: resolve root path: %w", err) + } + if !strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) && resolved != resolvedRoot { + return fmt.Errorf("appstate: RemoveAll %q resolves to %q which is outside scope %q", name, resolved, s.path) + } + + return os.RemoveAll(target) +} + +func (s *ScopedRoot) Stat(name string) (fs.FileInfo, error) { + if err := validate(name); err != nil { + return nil, err + } + return s.root.Stat(name) +} + +func (s *ScopedRoot) Rename(oldpath, newpath string) error { + if err := validate(oldpath); err != nil { + return err + } + if err := validate(newpath); err != nil { + return err + } + return s.root.Rename(oldpath, newpath) +} + +// ReadDir reads the named directory relative to the scoped root. +// Implemented as Open + (*File).ReadDir since os.Root does not expose ReadDir. +func (s *ScopedRoot) ReadDir(name string) ([]fs.DirEntry, error) { + if err := validate(name); err != nil { + return nil, err + } + f, err := s.root.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.ReadDir(-1) +} + +// ResolvePath returns the absolute path for a relative name within the scoped root. +// If name is empty, the root path itself is returned. If name fails validation +// (e.g. absolute path or traversal), the root path is returned as a safe fallback. +func (s *ScopedRoot) ResolvePath(name string) string { + if name == "" { + return s.path + } + if err := validate(name); err != nil { + return s.path + } + return filepath.Join(s.path, filepath.FromSlash(name)) +} + +func (s *ScopedRoot) FS() fs.FS { + return s.root.FS() +} diff --git a/internal/appstate/scoped_test.go b/internal/appstate/scoped_test.go new file mode 100644 index 00000000..04457e11 --- /dev/null +++ b/internal/appstate/scoped_test.go @@ -0,0 +1,176 @@ +package appstate + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestScopedRoot(t *testing.T) *ScopedRoot { + t.Helper() + dir := t.TempDir() + r, err := newScopedRoot(dir) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + return r +} + +func TestScopedRoot_WriteAndReadFile(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.WriteFile("test.txt", []byte("hello world"), 0644)) + got, err := r.ReadFile("test.txt") + require.NoError(t, err) + assert.Equal(t, "hello world", string(got)) +} + +func TestScopedRoot_MkdirAll(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.MkdirAll("a/b/c", 0755)) + require.NoError(t, r.WriteFile("a/b/c/file.txt", []byte("nested"), 0644)) +} + +func TestScopedRoot_Stat(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.WriteFile("exists.txt", []byte("data"), 0644)) + info, err := r.Stat("exists.txt") + require.NoError(t, err) + assert.Equal(t, int64(4), info.Size()) + _, err = r.Stat("nope.txt") + assert.Error(t, err) +} + +func TestScopedRoot_Remove(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.WriteFile("delete-me.txt", []byte("bye"), 0644)) + require.NoError(t, r.Remove("delete-me.txt")) + _, err := r.Stat("delete-me.txt") + assert.Error(t, err, "file should be gone after Remove") +} + +func TestScopedRoot_Rename(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.WriteFile("old.tmp", []byte("atomic"), 0644)) + require.NoError(t, r.Rename("old.tmp", "new.json")) + got, err := r.ReadFile("new.json") + require.NoError(t, err) + assert.Equal(t, "atomic", string(got)) + _, err = r.Stat("old.tmp") + assert.Error(t, err, "old name should not exist after Rename") +} + +func TestScopedRoot_ReadDir(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.WriteFile("a.txt", []byte("a"), 0644)) + require.NoError(t, r.WriteFile("b.txt", []byte("b"), 0644)) + entries, err := r.ReadDir(".") + require.NoError(t, err) + assert.GreaterOrEqual(t, len(entries), 2) +} + +func TestScopedRoot_OpenFile(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + f, err := r.OpenFile("rw.txt", os.O_CREATE|os.O_RDWR, 0600) + require.NoError(t, err) + f.Close() + info, err := r.Stat("rw.txt") + require.NoError(t, err) + assert.Equal(t, fs.FileMode(0600), info.Mode().Perm()) +} + +func TestScopedRoot_ResolvePath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + r, err := newScopedRoot(dir) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + assert.Equal(t, filepath.Join(dir, "logs", "app.log"), r.ResolvePath("logs/app.log")) +} + +func TestScopedRoot_FS(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.WriteFile("readme.txt", []byte("hello"), 0644)) + data, err := fs.ReadFile(r.FS(), "readme.txt") + require.NoError(t, err) + assert.Equal(t, "hello", string(data)) +} + +// Containment tests +func TestScopedRoot_RejectsAbsolutePath(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + _, err := r.ReadFile("/etc/passwd") + assert.Error(t, err, "ReadFile should reject absolute paths") +} + +func TestScopedRoot_RejectsTraversal(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + _, err := r.ReadFile("../../../etc/passwd") + assert.Error(t, err, "ReadFile should reject path traversal") +} + +func TestScopedRoot_RejectsTraversalInMiddle(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + _, err := r.ReadFile("subdir/../../etc/passwd") + assert.Error(t, err, "ReadFile should reject traversal in middle of path") +} + +func TestScopedRoot_RemoveAll(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.MkdirAll("a/b/c", 0755)) + require.NoError(t, r.WriteFile("a/b/c/file.txt", []byte("data"), 0644)) + require.NoError(t, r.RemoveAll("a")) + _, err := r.Stat("a") + assert.Error(t, err, "directory should be gone after RemoveAll") +} + +func TestScopedRoot_RemoveAll_RejectsTraversal(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + assert.Error(t, r.RemoveAll("../escape"), "RemoveAll should reject path traversal") +} + +func TestScopedRoot_Rename_RejectsTraversal(t *testing.T) { + t.Parallel() + r := newTestScopedRoot(t) + require.NoError(t, r.WriteFile("safe.txt", []byte("data"), 0644)) + assert.Error(t, r.Rename("safe.txt", "../escaped.txt"), "Rename should reject traversal in newpath") +} + +func TestScopedRoot_RemoveAll_SymlinkEscape(t *testing.T) { + t.Parallel() + + // Create a target directory outside the scoped root. + externalDir := t.TempDir() + externalFile := filepath.Join(externalDir, "precious.txt") + require.NoError(t, os.WriteFile(externalFile, []byte("do not delete"), 0644)) + + // Create a scoped root with a symlink pointing outside. + r := newTestScopedRoot(t) + linkPath := r.ResolvePath("escape-link") + require.NoError(t, os.Symlink(externalDir, linkPath)) + + // RemoveAll on the symlink path should fail containment check. + err := r.RemoveAll("escape-link") + assert.Error(t, err, "RemoveAll should reject symlink that escapes the scope") + + // The external directory and its contents must still exist. + _, err = os.Stat(externalDir) + assert.NoError(t, err, "external directory should still exist after blocked RemoveAll") + _, err = os.Stat(externalFile) + assert.NoError(t, err, "external file should still exist after blocked RemoveAll") +} diff --git a/internal/appstate/testing.go b/internal/appstate/testing.go new file mode 100644 index 00000000..8849cb3e --- /dev/null +++ b/internal/appstate/testing.go @@ -0,0 +1,20 @@ +package appstate + +import "testing" + +// NewTestService creates a Service backed by a temporary directory for use in tests. +// The service is automatically cleaned up when the test finishes. +func NewTestService(t testing.TB) *Service { + t.Helper() + dir := t.TempDir() + svc, err := New(WithRoot(dir), WithFlock(false)) + if err != nil { + t.Fatalf("appstate.NewTestService: %v", err) + } + t.Cleanup(func() { + if err := svc.Close(); err != nil { + t.Errorf("appstate.NewTestService cleanup: %v", err) + } + }) + return svc +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 00000000..f65b7c83 --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,158 @@ +package bootstrap + +import ( + "context" + "fmt" + "net/url" + "os" + + logging "github.com/omniviewdev/plugin-sdk/log" + pkgsettings "github.com/omniviewdev/plugin-sdk/settings" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/omniviewdev/omniview/backend/pkg/plugin" + "github.com/omniviewdev/omniview/backend/pkg/plugin/registry" + coresettings "github.com/omniviewdev/omniview/internal/settings" + settingsstore "github.com/omniviewdev/omniview/internal/settings/store" + "github.com/omniviewdev/omniview/internal/telemetry" +) + +// Service wraps the startup/shutdown logic that was previously in the +// Wails v2 OnStartup/OnShutdown closures. It implements ServiceStartup and +// ServiceShutdown so the Wails v3 runtime calls it automatically. +type Service struct { + Log logging.Logger + SettingsProvider pkgsettings.Provider + SettingsStore *settingsstore.Store + TelemetrySvc *telemetry.Service + PluginManager plugin.Manager + PluginRegistryClient *registry.Client +} + +func (b *Service) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error { + // Register core settings categories and hydrate from bbolt. + coreCategories := []pkgsettings.Category{ + coresettings.General, coresettings.Appearance, + coresettings.Terminal, coresettings.Editor, + coresettings.Developer, coresettings.Telemetry, + } + for _, category := range coreCategories { + for _, s := range category.Settings { + b.SettingsProvider.RegisterSetting(category.ID, s) + } + if b.SettingsStore != nil { + if vals, loadErr := b.SettingsStore.LoadCategory(category.ID); loadErr == nil && len(vals) > 0 { + prefixed := make(map[string]any, len(vals)) + for k, v := range vals { + prefixed[category.ID+"."+k] = v + } + if setErr := b.SettingsProvider.SetSettings(prefixed); setErr != nil { + fmt.Fprintf(os.Stderr, "failed to hydrate settings category %s: %v\n", category.ID, setErr) + } + } + } + } + + // Wire persistence: save to bbolt whenever a category changes. + for _, category := range coreCategories { + catID := category.ID + b.SettingsProvider.RegisterChangeHandler(catID, func(vals map[string]any) { + if b.SettingsStore != nil { + if saveErr := b.SettingsStore.SaveCategory(catID, vals); saveErr != nil { + fmt.Fprintf(os.Stderr, "failed to persist settings category %s: %v\n", catID, saveErr) + } + } + }) + } + + // Wire telemetry settings hot-toggle: when any setting in the + // "telemetry" category changes, rebuild TelemetryConfig and apply. + telemetryFromSettings := func(vals map[string]any) telemetry.TelemetryConfig { + cfg := b.TelemetrySvc.Config() + if v, ok := vals["enabled"].(bool); ok { + cfg.Enabled = v + } + if v, ok := vals["traces"].(bool); ok { + cfg.Traces = v + } + if v, ok := vals["metrics"].(bool); ok { + cfg.Metrics = v + } + if v, ok := vals["logs_ship"].(bool); ok { + cfg.LogsShip = v + } + if v, ok := vals["logs_ship_level"].(string); ok { + cfg.LogsShipLevel = v + } + if v, ok := vals["profiling"].(bool); ok { + cfg.Profiling = v + } + if v, ok := vals["endpoint_otlp"].(string); ok { + cfg.OTLPEndpoint = v + } + if v, ok := vals["endpoint_pyroscope"].(string); ok { + cfg.PyroscopeEndpoint = v + } + if v, ok := vals["auth_header"].(string); ok { + cfg.AuthHeader = v + } + if v, ok := vals["auth_value"].(string); ok { + cfg.AuthValue = v + } + return cfg + } + + b.SettingsProvider.RegisterChangeHandler("telemetry", func(vals map[string]any) { + cfg := telemetryFromSettings(vals) + if err := b.TelemetrySvc.ApplyConfig(ctx, cfg); err != nil { + b.Log.Errorw(ctx, "failed to apply telemetry config change", "error", err) + } else { + b.Log.Infow(ctx, "telemetry config updated from settings") + } + }) + + // Apply the persisted telemetry settings immediately so telemetry + // activates on startup (the change handler only fires on changes). + if b.SettingsStore != nil { + if vals, err := b.SettingsStore.LoadCategory("telemetry"); err == nil && len(vals) > 0 { + cfg := telemetryFromSettings(vals) + if err := b.TelemetrySvc.ApplyConfig(ctx, cfg); err != nil { + b.Log.Errorw(ctx, "failed to apply initial telemetry config", "error", err) + } else { + b.Log.Infow(ctx, "telemetry initialized from persisted settings", "enabled", cfg.Enabled) + } + } + } + + // Apply user-configured marketplace URL to the registry client. + if marketplaceURL, err := b.SettingsProvider.GetString("developer.marketplace_url"); err == nil && marketplaceURL != "" { + b.PluginRegistryClient.SetBaseURL(marketplaceURL) + safeHost := marketplaceURL + if u, parseErr := url.Parse(marketplaceURL); parseErr == nil { + safeHost = u.Host + } + b.Log.Infow(ctx, "using custom marketplace URL", "host", safeHost) + } + + // Controllers now implement ServiceStartup/ServiceShutdown and are + // registered as Wails v3 services, so Wails calls their lifecycle + // methods automatically. + + // Initialize the plugin system + if err := b.PluginManager.Initialize(ctx); err != nil { + b.Log.Errorw(ctx, "error while initializing plugin system", "error", err) + } + b.PluginManager.Run(ctx) + + return nil +} + +func (b *Service) ServiceShutdown() error { + // DevServerManager and controllers have their own ServiceShutdown + // called by Wails v3 automatically. + b.PluginManager.Shutdown() + if err := b.TelemetrySvc.Shutdown(context.Background()); err != nil { + b.Log.Errorw(context.Background(), "failed to shut down telemetry", "error", err) + } + return nil +} diff --git a/internal/settings/service_wrapper.go b/internal/settings/service_wrapper.go new file mode 100644 index 00000000..4626e9db --- /dev/null +++ b/internal/settings/service_wrapper.go @@ -0,0 +1,141 @@ +package categories + +import ( + "fmt" + + pkgsettings "github.com/omniviewdev/plugin-sdk/settings" +) + +// ServiceWrapper exposes only frontend-safe methods of pkgsettings.Provider. +// Excluded: RegisterChangeHandler, RegisterSetting, RegisterSettings +// +// (internal-only methods). +type ServiceWrapper struct { + Provider pkgsettings.Provider + CategoryMeta map[string]pkgsettings.Category // Label, Icon, Description for each category +} + +func (s *ServiceWrapper) ListSettings() pkgsettings.Store { + store := s.Provider.ListSettings() + // Merge registered category metadata (Label, Icon, Description) into the + // store since RegisterSetting only populates ID + Settings. + result := make(pkgsettings.Store, len(store)) + for id, cat := range store { + if meta, ok := s.CategoryMeta[id]; ok { + cat.Label = meta.Label + cat.Description = meta.Description + cat.Icon = meta.Icon + } + result[id] = cat + } + return result +} +func (s *ServiceWrapper) GetSetting(id string) (pkgsettings.Setting, error) { + return s.Provider.GetSetting(id) +} +func (s *ServiceWrapper) GetSettingValue(id string) (any, error) { + return s.Provider.GetSettingValue(id) +} +func (s *ServiceWrapper) SetSetting(id string, value any) error { + return s.Provider.SetSetting(id, value) +} +func (s *ServiceWrapper) SetSettings(settingsMap map[string]any) error { + return s.Provider.SetSettings(settingsMap) +} +func (s *ServiceWrapper) GetString(id string) (string, error) { + return s.Provider.GetString(id) +} +func (s *ServiceWrapper) GetStringSlice(id string) ([]string, error) { + return s.Provider.GetStringSlice(id) +} +func (s *ServiceWrapper) GetInt(id string) (int, error) { + return s.Provider.GetInt(id) +} +func (s *ServiceWrapper) GetIntSlice(id string) ([]int, error) { + return s.Provider.GetIntSlice(id) +} +func (s *ServiceWrapper) GetFloat(id string) (float64, error) { + return s.Provider.GetFloat(id) +} +func (s *ServiceWrapper) GetFloatSlice(id string) ([]float64, error) { + return s.Provider.GetFloatSlice(id) +} +func (s *ServiceWrapper) GetBool(id string) (bool, error) { + return s.Provider.GetBool(id) +} + +// Values returns a flat map of all setting values keyed by "category.settingID". +// This is a host-side convenience for the frontend — it was removed from the SDK +// Provider interface (plugins don't need it) but the UI settings context uses it +// to populate the full settings state. +func (s *ServiceWrapper) Values() map[string]any { + store := s.Provider.ListSettings() + m := make(map[string]any) + for catID, cat := range store { + for settingID, setting := range cat.Settings { + m[catID+"."+settingID] = setting.Value + } + } + return m +} + +// GetCategory returns a single settings category by ID, including full metadata +// (Label, Icon, Description) from the registered category definitions and live +// setting values from the in-memory provider. +func (s *ServiceWrapper) GetCategory(id string) (pkgsettings.Category, error) { + store := s.Provider.ListSettings() + cat, ok := store[id] + if !ok { + return pkgsettings.Category{}, fmt.Errorf("settings category %q not found", id) + } + // The provider store may only have ID + Settings (RegisterSetting doesn't + // preserve Label/Icon/Description). Merge in the registered metadata. + if meta, hasMeta := s.CategoryMeta[id]; hasMeta { + cat.Label = meta.Label + cat.Description = meta.Description + cat.Icon = meta.Icon + } + return cat, nil +} + +// GetCategoryValues returns a flat map of setting values for a single category. +func (s *ServiceWrapper) GetCategoryValues(id string) (map[string]any, error) { + cat, err := s.GetCategory(id) + if err != nil { + return nil, err + } + vals := make(map[string]any, len(cat.Settings)) + for settingID, setting := range cat.Settings { + vals[settingID] = setting.Value + } + return vals, nil +} + +// GetCategories returns all category metadata for the UI settings navigation. +func (s *ServiceWrapper) GetCategories() []pkgsettings.Category { + store := s.Provider.ListSettings() + cats := make([]pkgsettings.Category, 0, len(store)) + for id, cat := range store { + // Merge in registered metadata (Label, Icon, Description). + if meta, hasMeta := s.CategoryMeta[id]; hasMeta { + cat.Label = meta.Label + cat.Description = meta.Description + cat.Icon = meta.Icon + } + cats = append(cats, pkgsettings.Category{ + ID: id, + Label: cat.Label, + Description: cat.Description, + Icon: cat.Icon, + }) + } + return cats +} + +// LoadSettings is a no-op reload trigger for the frontend. With bbolt-backed +// persistence, settings are always in memory — this exists so the frontend's +// "reload" button still has a valid binding. It re-reads from the in-memory +// store (which is already current). +func (s *ServiceWrapper) LoadSettings() error { + return nil +} diff --git a/internal/settings/store/migrate.go b/internal/settings/store/migrate.go new file mode 100644 index 00000000..3504cb70 --- /dev/null +++ b/internal/settings/store/migrate.go @@ -0,0 +1,184 @@ +package store + +import ( + "bytes" + "encoding/gob" + "fmt" + "log" + "maps" + "os" + "path/filepath" + + "github.com/omniviewdev/plugin-sdk/settings" +) + +func init() { + // Register types that may appear inside GOB-encoded settings files. + gob.Register(settings.Store{}) + gob.Register(settings.Category{}) + gob.Register(settings.Setting{}) + gob.Register(settings.SettingOption{}) + gob.Register([]any{}) + gob.Register(map[string]any{}) +} + +// MigrateFromGOB checks for old GOB settings files and migrates them to bbolt. +// stateRoot is the absolute path to the state directory (e.g., ~/.omniview). +// Old files are renamed to .gob.bak after successful migration. +// Corrupt or unreadable files are skipped and reported in a single aggregated log. +func MigrateFromGOB(stateRoot string, s *Store) error { + var skipped []string + + // Migrate core settings. + corePath := filepath.Join(stateRoot, "settings") + if err := migrateCore(corePath, s, &skipped); err != nil { + log.Printf("[settings/migrate] warning: core migration failed: %v", err) + } + + // Migrate plugin settings. + pluginsDir := filepath.Join(stateRoot, "plugins") + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + if len(skipped) > 0 { + log.Printf("[settings/migrate] skipped corrupt files: %v", skipped) + } + return nil + } + log.Printf("[settings/migrate] warning: cannot read plugins dir: %v", err) + if len(skipped) > 0 { + log.Printf("[settings/migrate] skipped corrupt files: %v", skipped) + } + return nil + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pluginID := entry.Name() + pluginSettingsPath := filepath.Join(pluginsDir, pluginID, "settings") + if err := migratePlugin(pluginSettingsPath, pluginID, s, &skipped); err != nil { + log.Printf("[settings/migrate] warning: plugin %q migration failed: %v", pluginID, err) + } + } + + if len(skipped) > 0 { + log.Printf("[settings/migrate] skipped %d corrupt file(s): %v", len(skipped), skipped) + } + + return nil +} + +// migrateCore reads the core GOB settings file, extracts values, and saves them. +func migrateCore(path string, s *Store, skipped *[]string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read core settings: %w", err) + } + + store, err := decodeGOB(data) + if err != nil { + *skipped = append(*skipped, path) + return nil + } + + for catID, cat := range store { + values := extractValues(cat) + if len(values) == 0 { + continue + } + if err := s.SaveCategory(catID, values); err != nil { + return fmt.Errorf("save category %q: %w", catID, err) + } + } + + return renameToBackup(path) +} + +// migratePlugin reads a plugin GOB settings file, extracts values from the +// "plugin" category key, and saves them. +func migratePlugin(path, pluginID string, s *Store, skipped *[]string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read plugin settings %q: %w", pluginID, err) + } + + store, err := decodeGOB(data) + if err != nil { + *skipped = append(*skipped, path) + return nil + } + + cat, ok := store["plugin"] + if !ok { + // No "plugin" category key — try migrating all categories into a flat map. + values := make(map[string]any) + for _, c := range store { + maps.Copy(values, extractValues(c)) + } + if len(values) > 0 { + if err := s.SavePluginSettings(pluginID, values); err != nil { + return fmt.Errorf("save plugin settings %q: %w", pluginID, err) + } + } + } else { + values := extractValues(cat) + if len(values) > 0 { + if err := s.SavePluginSettings(pluginID, values); err != nil { + return fmt.Errorf("save plugin settings %q: %w", pluginID, err) + } + } + } + + return renameToBackup(path) +} + +// decodeGOB decodes a GOB-encoded settings.Store from raw bytes. +func decodeGOB(data []byte) (settings.Store, error) { + var store settings.Store + dec := gob.NewDecoder(bytes.NewReader(data)) + if err := dec.Decode(&store); err != nil { + return nil, fmt.Errorf("gob decode: %w", err) + } + return store, nil +} + +// extractValues pulls setting ID → Value pairs from a Category. +func extractValues(cat settings.Category) map[string]any { + if cat.Settings == nil { + return nil + } + values := make(map[string]any, len(cat.Settings)) + for id, setting := range cat.Settings { + values[id] = setting.Value + } + return values +} + +// renameToBackup renames a file to .gob.bak, avoiding overwrites. +func renameToBackup(path string) error { + backup := path + ".gob.bak" + if _, err := os.Stat(backup); err == nil { + // .gob.bak already exists, try numbered suffix. + found := false + for i := 1; i <= 100; i++ { + candidate := fmt.Sprintf("%s.gob.bak.%d", path, i) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + backup = candidate + found = true + break + } + } + if !found { + return fmt.Errorf("too many backup files for %q (100 limit reached)", path) + } + } + return os.Rename(path, backup) +} diff --git a/internal/settings/store/migrate_test.go b/internal/settings/store/migrate_test.go new file mode 100644 index 00000000..76755b86 --- /dev/null +++ b/internal/settings/store/migrate_test.go @@ -0,0 +1,216 @@ +package store + +import ( + "bytes" + "encoding/gob" + "os" + "path/filepath" + "testing" + + "github.com/omniviewdev/plugin-sdk/settings" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// encodeGOB is a test helper that GOB-encodes a settings.Store to bytes. +func encodeGOB(t *testing.T, store settings.Store) []byte { + t.Helper() + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + require.NoError(t, enc.Encode(store)) + return buf.Bytes() +} + +// writeGOBFile writes a GOB-encoded settings.Store to the given path. +func writeGOBFile(t *testing.T, path string, store settings.Store) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, encodeGOB(t, store), 0o600)) +} + +// openTestStore creates a bbolt store in a temp directory for testing. +func openTestStore(t *testing.T, dir string) *Store { + t.Helper() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + t.Cleanup(func() { s.Close() }) + return s +} + +func TestMigrate_CoreSettings(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + gobStore := settings.Store{ + "general": { + Settings: map[string]settings.Setting{ + "theme": {ID: "theme", Value: "dark"}, + "fontSize": {ID: "fontSize", Value: float64(14)}, + }, + }, + "editor": { + Settings: map[string]settings.Setting{ + "tabSize": {ID: "tabSize", Value: float64(4)}, + }, + }, + } + writeGOBFile(t, filepath.Join(dir, "settings"), gobStore) + + s := openTestStore(t, dir) + require.NoError(t, MigrateFromGOB(dir, s)) + + // Verify core settings migrated. + general, err := s.LoadCategory("general") + require.NoError(t, err) + assert.Equal(t, "dark", general["theme"]) + assert.Equal(t, float64(14), general["fontSize"]) + + editor, err := s.LoadCategory("editor") + require.NoError(t, err) + assert.Equal(t, float64(4), editor["tabSize"]) + + // Verify old file renamed. + _, err = os.Stat(filepath.Join(dir, "settings")) + assert.True(t, os.IsNotExist(err), "original file should be renamed") + _, err = os.Stat(filepath.Join(dir, "settings.gob.bak")) + assert.NoError(t, err, "backup file should exist") +} + +func TestMigrate_PluginSettings(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + gobStore := settings.Store{ + "plugin": { + Settings: map[string]settings.Setting{ + "apiKey": {ID: "apiKey", Value: "secret123"}, + "timeout": {ID: "timeout", Value: float64(30)}, + }, + }, + } + pluginDir := filepath.Join(dir, "plugins", "my-plugin") + writeGOBFile(t, filepath.Join(pluginDir, "settings"), gobStore) + + s := openTestStore(t, dir) + require.NoError(t, MigrateFromGOB(dir, s)) + + got, err := s.LoadPluginSettings("my-plugin") + require.NoError(t, err) + assert.Equal(t, "secret123", got["apiKey"]) + assert.Equal(t, float64(30), got["timeout"]) + + // Old file renamed. + _, err = os.Stat(filepath.Join(pluginDir, "settings")) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(filepath.Join(pluginDir, "settings.gob.bak")) + assert.NoError(t, err) +} + +func TestMigrate_MultiplePlugins(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + plugins := map[string]map[string]settings.Setting{ + "plugin-a": { + "keyA": {ID: "keyA", Value: "valA"}, + }, + "plugin-b": { + "keyB": {ID: "keyB", Value: float64(42)}, + }, + "plugin-c": { + "keyC": {ID: "keyC", Value: true}, + }, + } + + for id, settingsMap := range plugins { + gobStore := settings.Store{ + "plugin": {Settings: settingsMap}, + } + writeGOBFile(t, filepath.Join(dir, "plugins", id, "settings"), gobStore) + } + + s := openTestStore(t, dir) + require.NoError(t, MigrateFromGOB(dir, s)) + + all, err := s.LoadAllPluginSettings() + require.NoError(t, err) + assert.Len(t, all, 3) + + gotA, err := s.LoadPluginSettings("plugin-a") + require.NoError(t, err) + assert.Equal(t, "valA", gotA["keyA"]) + + gotB, err := s.LoadPluginSettings("plugin-b") + require.NoError(t, err) + assert.Equal(t, float64(42), gotB["keyB"]) + + gotC, err := s.LoadPluginSettings("plugin-c") + require.NoError(t, err) + assert.Equal(t, true, gotC["keyC"]) +} + +func TestMigrate_NoOldFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + s := openTestStore(t, dir) + err := MigrateFromGOB(dir, s) + assert.NoError(t, err) + + // Store should be empty. + general, err := s.LoadCategory("general") + require.NoError(t, err) + assert.Empty(t, general) +} + +func TestMigrate_CorruptGOB(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Write garbage as the core settings file. + require.NoError(t, os.WriteFile(filepath.Join(dir, "settings"), []byte("not valid gob data!!!"), 0o600)) + + // Also write a corrupt plugin settings file. + pluginDir := filepath.Join(dir, "plugins", "bad-plugin") + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "settings"), []byte("also garbage"), 0o600)) + + s := openTestStore(t, dir) + err := MigrateFromGOB(dir, s) + assert.NoError(t, err, "corrupt files should be skipped gracefully") + + // Store should be empty since nothing could be decoded. + general, err := s.LoadCategory("general") + require.NoError(t, err) + assert.Empty(t, general) +} + +func TestMigrate_Idempotent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + gobStore := settings.Store{ + "general": { + Settings: map[string]settings.Setting{ + "theme": {ID: "theme", Value: "dark"}, + }, + }, + } + writeGOBFile(t, filepath.Join(dir, "settings"), gobStore) + + s := openTestStore(t, dir) + require.NoError(t, MigrateFromGOB(dir, s)) + + // First migration should have renamed the file. + _, err := os.Stat(filepath.Join(dir, "settings")) + assert.True(t, os.IsNotExist(err)) + + // Running migration again should be a no-op (no source file). + err = MigrateFromGOB(dir, s) + assert.NoError(t, err) + + // Data should still be there. + general, err := s.LoadCategory("general") + require.NoError(t, err) + assert.Equal(t, "dark", general["theme"]) +} diff --git a/internal/settings/store/store.go b/internal/settings/store/store.go new file mode 100644 index 00000000..6f328038 --- /dev/null +++ b/internal/settings/store/store.go @@ -0,0 +1,203 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + bolt "go.etcd.io/bbolt" +) + +var ( + coreBucket = []byte("core") + pluginsBucket = []byte("plugins") +) + +// isCorruptionError returns true if err indicates database corruption (invalid +// magic, checksum mismatch, etc.) rather than a transient or permissions error. +func isCorruptionError(err error) bool { + if err == nil { + return false + } + // Permission errors should not trigger corruption recovery. + if errors.Is(err, os.ErrPermission) { + return false + } + // bbolt surfaces corruption as string-based errors; match common patterns. + // NOTE: This substring-based detection is fragile and tightly coupled to + // bbolt's internal error messages. If bbolt changes its error wording, + // these checks may silently stop matching. Revisit if bbolt exposes + // typed error sentinels in a future release. + msg := err.Error() + for _, substr := range []string{ + "invalid database", + "checksum error", + "unexpected magic", + "version mismatch", + "invalid freelist", + } { + if strings.Contains(msg, substr) { + return true + } + } + // Default: only return true for known corruption sentinels above. + // Unrecognized errors (timeouts, I/O errors, etc.) should not trigger + // corruption recovery to avoid data loss. + return false +} + +// config holds optional configuration for the Store. +// Reserved for future use (e.g., encryption support). +type config struct{} + +// Option configures the Store. Reserved for future encryption support. +type Option func(*config) + +// Store wraps bbolt for settings persistence. +type Store struct { + db *bolt.DB +} + +// Open opens (or creates) a bbolt database at path. +// Both top-level buckets ("core" and "plugins") are pre-created. +// If the file is corrupt, it is renamed with a .corrupt. suffix +// and a fresh database is created in its place. +func Open(path string, opts ...Option) (*Store, error) { + // Apply options (currently unused, reserved for future use). + _ = opts + + db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + if !isCorruptionError(err) { + return nil, fmt.Errorf("failed to open database: %w", err) + } + // Attempt corruption recovery: rename the corrupt file and retry. + backupPath := fmt.Sprintf("%s.corrupt.%d", path, time.Now().Unix()) + if renameErr := os.Rename(path, backupPath); renameErr != nil { + return nil, fmt.Errorf("failed to open database and could not rename corrupt file: %w (rename error: %v)", err, renameErr) + } + db, err = bolt.Open(path, 0o600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return nil, fmt.Errorf("failed to create fresh database after corruption recovery: %w", err) + } + } + + // Pre-create buckets. + if err := db.Update(func(tx *bolt.Tx) error { + if _, err := tx.CreateBucketIfNotExists(coreBucket); err != nil { + return fmt.Errorf("create core bucket: %w", err) + } + if _, err := tx.CreateBucketIfNotExists(pluginsBucket); err != nil { + return fmt.Errorf("create plugins bucket: %w", err) + } + return nil + }); err != nil { + db.Close() + return nil, fmt.Errorf("initialize buckets: %w", err) + } + + return &Store{db: db}, nil +} + +// Close closes the underlying bbolt database. +// It is safe to call on a nil Store or a Store with a nil db. +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +// SaveCategory persists a category's settings under the "core" bucket. +// Values are JSON-encoded and stored under the given categoryID key. +func (s *Store) SaveCategory(categoryID string, values map[string]any) error { + data, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("marshal category %q: %w", categoryID, err) + } + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(coreBucket) + return b.Put([]byte(categoryID), data) + }) +} + +// LoadCategory retrieves a category's settings from the "core" bucket. +// Returns an empty (non-nil) map if the category does not exist. +func (s *Store) LoadCategory(categoryID string) (map[string]any, error) { + result := make(map[string]any) + err := s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(coreBucket) + data := b.Get([]byte(categoryID)) + if data == nil { + return nil + } + return json.Unmarshal(data, &result) + }) + if err != nil { + return nil, fmt.Errorf("load category %q: %w", categoryID, err) + } + return result, nil +} + +// SavePluginSettings persists a plugin's settings under the "plugins" bucket. +func (s *Store) SavePluginSettings(pluginID string, values map[string]any) error { + data, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("marshal plugin settings %q: %w", pluginID, err) + } + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(pluginsBucket) + return b.Put([]byte(pluginID), data) + }) +} + +// LoadPluginSettings retrieves a plugin's settings from the "plugins" bucket. +// Returns an empty (non-nil) map if the plugin does not exist. +func (s *Store) LoadPluginSettings(pluginID string) (map[string]any, error) { + result := make(map[string]any) + err := s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(pluginsBucket) + data := b.Get([]byte(pluginID)) + if data == nil { + return nil + } + return json.Unmarshal(data, &result) + }) + if err != nil { + return nil, fmt.Errorf("load plugin settings %q: %w", pluginID, err) + } + return result, nil +} + +// DeletePluginSettings removes a plugin's settings from the "plugins" bucket. +// It is a no-op if the plugin does not exist. +func (s *Store) DeletePluginSettings(pluginID string) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(pluginsBucket) + return b.Delete([]byte(pluginID)) + }) +} + +// LoadAllPluginSettings returns all plugin settings keyed by plugin ID. +// Returns an empty (non-nil) map if no plugins have saved settings. +func (s *Store) LoadAllPluginSettings() (map[string]map[string]any, error) { + result := make(map[string]map[string]any) + err := s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(pluginsBucket) + return b.ForEach(func(k, v []byte) error { + var vals map[string]any + if err := json.Unmarshal(v, &vals); err != nil { + return fmt.Errorf("unmarshal plugin %q: %w", string(k), err) + } + result[string(k)] = vals + return nil + }) + }) + if err != nil { + return nil, fmt.Errorf("load all plugin settings: %w", err) + } + return result, nil +} diff --git a/internal/settings/store/store_test.go b/internal/settings/store/store_test.go new file mode 100644 index 00000000..b0a3b349 --- /dev/null +++ b/internal/settings/store/store_test.go @@ -0,0 +1,433 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Basic CRUD --- + +func TestOpen_CreatesFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "settings.db") + + s, err := Open(path) + require.NoError(t, err) + defer s.Close() + + _, statErr := os.Stat(path) + assert.NoError(t, statErr, "database file should exist") +} + +func TestOpen_ExistingFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "settings.db") + + // Open, write, close. + s, err := Open(path) + require.NoError(t, err) + require.NoError(t, s.SaveCategory("general", map[string]any{"theme": "dark"})) + require.NoError(t, s.Close()) + + // Reopen and verify data preserved. + s2, err := Open(path) + require.NoError(t, err) + defer s2.Close() + + vals, err := s2.LoadCategory("general") + require.NoError(t, err) + assert.Equal(t, "dark", vals["theme"]) +} + +func TestClose_Idempotent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "settings.db") + + s, err := Open(path) + require.NoError(t, err) + + require.NoError(t, s.Close()) + // Second close should not panic or error. + err = s.Close() + assert.NoError(t, err) + + // Nil store close should not panic. + var nilStore *Store + assert.NoError(t, nilStore.Close()) +} + +func TestSaveAndLoadCategory(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + input := map[string]any{ + "fontSize": float64(14), + "theme": "dark", + "enabled": true, + } + require.NoError(t, s.SaveCategory("editor", input)) + + got, err := s.LoadCategory("editor") + require.NoError(t, err) + assert.Equal(t, input, got) +} + +func TestSaveCategory_Overwrites(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + require.NoError(t, s.SaveCategory("editor", map[string]any{"theme": "light"})) + require.NoError(t, s.SaveCategory("editor", map[string]any{"theme": "dark"})) + + got, err := s.LoadCategory("editor") + require.NoError(t, err) + assert.Equal(t, "dark", got["theme"]) +} + +func TestSaveAndLoadPluginSettings(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + input := map[string]any{"apiKey": "secret123", "timeout": float64(30)} + require.NoError(t, s.SavePluginSettings("my-plugin", input)) + + got, err := s.LoadPluginSettings("my-plugin") + require.NoError(t, err) + assert.Equal(t, input, got) +} + +func TestDeletePluginSettings(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + require.NoError(t, s.SavePluginSettings("my-plugin", map[string]any{"key": "val"})) + require.NoError(t, s.DeletePluginSettings("my-plugin")) + + got, err := s.LoadPluginSettings("my-plugin") + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestDeletePluginSettings_NonExistent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + err = s.DeletePluginSettings("does-not-exist") + assert.NoError(t, err) +} + +func TestLoadCategory_Empty(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + got, err := s.LoadCategory("nonexistent") + require.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got) +} + +func TestLoadAllPluginSettings(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + plugins := map[string]map[string]any{ + "plugin-a": {"keyA": "valA"}, + "plugin-b": {"keyB": float64(42)}, + "plugin-c": {"keyC": true}, + } + for id, vals := range plugins { + require.NoError(t, s.SavePluginSettings(id, vals)) + } + + got, err := s.LoadAllPluginSettings() + require.NoError(t, err) + assert.Equal(t, plugins, got) +} + +func TestLoadAllPluginSettings_Empty(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + got, err := s.LoadAllPluginSettings() + require.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got) +} + +func TestSaveCategory_EmptyMap(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + require.NoError(t, s.SaveCategory("empty", map[string]any{})) + + got, err := s.LoadCategory("empty") + require.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got) +} + +func TestOpen_InvalidPath(t *testing.T) { + t.Parallel() + _, err := Open("/nonexistent/deeply/nested/path/settings.db") + assert.Error(t, err) +} + +// --- Concurrent access --- + +func TestConcurrentWrites(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + cat := fmt.Sprintf("category-%d", i) + err := s.SaveCategory(cat, map[string]any{"index": float64(i)}) + assert.NoError(t, err) + }(i) + } + wg.Wait() + + // Verify all writes landed. + for i := 0; i < 10; i++ { + cat := fmt.Sprintf("category-%d", i) + got, err := s.LoadCategory(cat) + require.NoError(t, err) + assert.Equal(t, float64(i), got["index"]) + } +} + +func TestConcurrentReadWrite(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + // Seed initial data. + require.NoError(t, s.SaveCategory("shared", map[string]any{"counter": float64(0)})) + + var wg sync.WaitGroup + // Writers. + for i := 0; i < 5; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + err := s.SaveCategory("shared", map[string]any{"counter": float64(i)}) + assert.NoError(t, err) + }(i) + } + // Readers. + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + got, err := s.LoadCategory("shared") + assert.NoError(t, err) + assert.NotNil(t, got) + }() + } + wg.Wait() +} + +func TestConcurrentPluginWrites(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + id := fmt.Sprintf("plugin-%d", i) + err := s.SavePluginSettings(id, map[string]any{"id": float64(i)}) + assert.NoError(t, err) + }(i) + } + wg.Wait() + + all, err := s.LoadAllPluginSettings() + require.NoError(t, err) + assert.Len(t, all, 10) +} + +// --- Edge cases --- + +func TestSaveCategory_SpecialCharacters(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + cases := map[string]map[string]any{ + "unicode.category": {"name": "日本語テスト"}, + "dots.in.name": {"a.b": "c.d"}, + "spaces in name": {"empty-key-id": true}, + "emoji-\U0001F680": {"rocket": true}, + "slashes/and\\back": {"path": "/usr/bin"}, + } + for id, vals := range cases { + require.NoError(t, s.SaveCategory(id, vals), "save %q", id) + got, err := s.LoadCategory(id) + require.NoError(t, err, "load %q", id) + assert.Equal(t, vals, got, "round-trip %q", id) + } +} + +func TestSaveCategory_NilValue(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + input := map[string]any{"present": "yes", "absent": nil} + require.NoError(t, s.SaveCategory("niltest", input)) + + got, err := s.LoadCategory("niltest") + require.NoError(t, err) + assert.Equal(t, input, got) +} + +func TestSaveCategory_Idempotent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + vals := map[string]any{"key": "value"} + require.NoError(t, s.SaveCategory("idem", vals)) + require.NoError(t, s.SaveCategory("idem", vals)) + + got, err := s.LoadCategory("idem") + require.NoError(t, err) + assert.Equal(t, vals, got) +} + +func TestDeletePlugin_Idempotent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + require.NoError(t, s.SavePluginSettings("p", map[string]any{"k": "v"})) + require.NoError(t, s.DeletePluginSettings("p")) + require.NoError(t, s.DeletePluginSettings("p")) // second delete is no-op + + got, err := s.LoadPluginSettings("p") + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestSaveCategory_LargeValues(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "settings.db")) + require.NoError(t, err) + defer s.Close() + + largeStr := strings.Repeat("x", 1<<20) // 1 MB + input := map[string]any{"big": largeStr} + require.NoError(t, s.SaveCategory("large", input)) + + got, err := s.LoadCategory("large") + require.NoError(t, err) + assert.Equal(t, largeStr, got["big"]) +} + +// --- Corruption recovery --- + +func TestOpen_CorruptFile_RecreatesClean(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "settings.db") + + // Write garbage to simulate corruption. + require.NoError(t, os.WriteFile(path, []byte("this is not a valid bbolt database"), 0o600)) + + s, err := Open(path) + require.NoError(t, err, "Open should recover from corrupt file") + defer s.Close() + + // Fresh store should work. + require.NoError(t, s.SaveCategory("test", map[string]any{"works": true})) + got, err := s.LoadCategory("test") + require.NoError(t, err) + assert.Equal(t, true, got["works"]) +} + +func TestOpen_CorruptFile_BackupPreserved(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "settings.db") + + garbage := []byte("corrupt data for backup test") + require.NoError(t, os.WriteFile(path, garbage, 0o600)) + + s, err := Open(path) + require.NoError(t, err) + defer s.Close() + + // Find the backup file. + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + var backupFound bool + for _, e := range entries { + if strings.HasPrefix(e.Name(), "settings.db.corrupt.") { + backupFound = true + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + require.NoError(t, err) + assert.Equal(t, garbage, data, "backup should contain original corrupt data") + } + } + assert.True(t, backupFound, "corrupt backup file should exist") +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index d782703f..f3b422d8 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -33,6 +33,7 @@ type Service struct { commit string date string isDev bool + logDir string resource *resource.Resource tracerProvider *sdktrace.TracerProvider @@ -50,13 +51,15 @@ type Service struct { } // New creates a telemetry Service. Call Init to start it. -func New(cfg TelemetryConfig, version, commit, date string, isDev bool) *Service { +// logDir is the directory where log files are stored. +func New(cfg TelemetryConfig, version, commit, date string, isDev bool, logDir string) *Service { return &Service{ cfg: cfg, version: version, commit: commit, date: date, isDev: isDev, + logDir: logDir, } } @@ -125,7 +128,7 @@ func (s *Service) Init(ctx context.Context) error { s.loggerProvider = NewLoggerProvider(s.resource, s.switchableLogExp) var otelLevel *zap.AtomicLevel - s.zapLogger, otelLevel, err = buildZapLogger(s.isDev, s.loggerProvider, s.cfg.LogsShipLevel) + s.zapLogger, otelLevel, err = buildZapLogger(s.isDev, s.loggerProvider, s.cfg.LogsShipLevel, s.logDir) s.otelLevel = otelLevel if err != nil { return err @@ -427,12 +430,7 @@ func (c *levelFilterCore) With(fields []zapcore.Field) zapcore.Core { // // The returned *zap.AtomicLevel (nil when loggerProvider is nil) controls the // OTel core and can be used for hot-toggling the ship level at runtime. -func buildZapLogger(isDev bool, loggerProvider *sdklog.LoggerProvider, shipLevel string) (*zap.Logger, *zap.AtomicLevel, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, nil, fmt.Errorf("cannot determine home directory: %w", err) - } - logDir := filepath.Join(home, ".omniview", "logs") +func buildZapLogger(isDev bool, loggerProvider *sdklog.LoggerProvider, shipLevel string, logDir string) (*zap.Logger, *zap.AtomicLevel, error) { if err := os.MkdirAll(logDir, 0o755); err != nil { return nil, nil, err } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 9d8d7406..56bdb019 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -25,7 +25,7 @@ func TestServiceDisabled(t *testing.T) { t.Cleanup(resetGlobalOTel) cfg := TelemetryConfig{Enabled: false} - svc := New(cfg, "1.0.0", "abc", "2026-03-11", true) + svc := New(cfg, "1.0.0", "abc", "2026-03-11", true, t.TempDir()) err := svc.Init(context.Background()) require.NoError(t, err) @@ -52,7 +52,7 @@ func TestServiceEnabledTracesOnly(t *testing.T) { Profiling: false, OTLPEndpoint: "localhost:14318", } - svc := New(cfg, "1.0.0", "abc", "2026-03-11", true) + svc := New(cfg, "1.0.0", "abc", "2026-03-11", true, t.TempDir()) err := svc.Init(context.Background()) require.NoError(t, err) @@ -77,7 +77,7 @@ func TestApplyConfig_ToggleTracesOnOff(t *testing.T) { Enabled: true, Traces: true, OTLPEndpoint: "localhost:4318", - }, "test", "abc", "2026-01-01", true) + }, "test", "abc", "2026-01-01", true, t.TempDir()) require.NoError(t, svc.Init(context.Background())) defer svc.Shutdown(context.Background()) @@ -131,7 +131,7 @@ func TestApplyConfig_MasterSwitchOff(t *testing.T) { Metrics: true, LogsShip: true, OTLPEndpoint: "localhost:4318", - }, "test", "abc", "2026-01-01", true) + }, "test", "abc", "2026-01-01", true, t.TempDir()) require.NoError(t, svc.Init(context.Background())) defer svc.Shutdown(context.Background()) diff --git a/main.go b/main.go index 49735c88..5fea49ec 100644 --- a/main.go +++ b/main.go @@ -3,21 +3,10 @@ package main import ( "context" "embed" - "encoding/json" "fmt" - "net/url" "os" - "path/filepath" - "time" logging "github.com/omniviewdev/plugin-sdk/log" - "github.com/omniviewdev/plugin-sdk/pkg/config" - sdktypes "github.com/omniviewdev/plugin-sdk/pkg/types" - execsdk "github.com/omniviewdev/plugin-sdk/pkg/v1/exec" - logssdk "github.com/omniviewdev/plugin-sdk/pkg/v1/logs" - metricsdk "github.com/omniviewdev/plugin-sdk/pkg/v1/metric" - networkersdk "github.com/omniviewdev/plugin-sdk/pkg/v1/networker" - sdkresource "github.com/omniviewdev/plugin-sdk/pkg/v1/resource" pkgsettings "github.com/omniviewdev/plugin-sdk/settings" "github.com/wailsapp/wails/v3/pkg/application" @@ -40,7 +29,10 @@ import ( "github.com/omniviewdev/omniview/backend/pkg/plugin/ui" "github.com/omniviewdev/omniview/backend/pkg/plugin/utils" "github.com/omniviewdev/omniview/backend/window" + "github.com/omniviewdev/omniview/internal/appstate" + "github.com/omniviewdev/omniview/internal/bootstrap" coresettings "github.com/omniviewdev/omniview/internal/settings" + settingsstore "github.com/omniviewdev/omniview/internal/settings/store" "github.com/omniviewdev/omniview/internal/telemetry" "github.com/omniviewdev/omniview/internal/version" ) @@ -58,844 +50,76 @@ var assets embed.FS //go:embed build/appicon.png var icon []byte -// pluginRefAdapter adapts plugin.Manager to devserver.PluginRef. -type pluginRefAdapter struct{ mgr plugin.Manager } - -func (a *pluginRefAdapter) GetDevPluginInfo(pluginID string) (bool, string, error) { - info, err := a.mgr.GetPlugin(pluginID) +//nolint:funlen // main function is expected to be long +func main() { + // Initialize unified state directory. + stateDir, err := appstate.New() if err != nil { - return false, "", err - } - return info.DevMode, info.DevPath, nil -} - -// pluginReloaderAdapter adapts plugin.Manager to devserver.PluginReloader. -type pluginReloaderAdapter struct{ mgr plugin.Manager } - -func (a *pluginReloaderAdapter) ReloadPlugin(id string) error { - _, err := a.mgr.ReloadPlugin(id) - return err -} - -// PluginManagerService exposes only the frontend-safe methods of plugin.Manager. -// Internal methods (SetDevServerChecker, SetPluginLogManager, HandlePluginCrash, -// Initialize, Run, Shutdown) are excluded to avoid binding warnings from -// interface/function-type parameters. -type PluginManagerService struct { - mgr plugin.Manager -} - -func (s *PluginManagerService) InstallInDevMode() (*config.PluginMeta, error) { - return s.mgr.InstallInDevMode() -} -func (s *PluginManagerService) InstallFromPathPrompt() (*config.PluginMeta, error) { - return s.mgr.InstallFromPathPrompt() -} -func (s *PluginManagerService) InstallPluginFromPath(path string) (*config.PluginMeta, error) { - return s.mgr.InstallPluginFromPath(path) -} -func (s *PluginManagerService) InstallPluginVersion(pluginID, version string) (*config.PluginMeta, error) { - return s.mgr.InstallPluginVersion(pluginID, version) -} -func (s *PluginManagerService) LoadPlugin(id string, opts *plugin.LoadPluginOptions) (sdktypes.PluginInfo, error) { - return s.mgr.LoadPlugin(id, opts) -} -func (s *PluginManagerService) ReloadPlugin(id string) (sdktypes.PluginInfo, error) { - return s.mgr.ReloadPlugin(id) -} -func (s *PluginManagerService) RetryFailedPlugin(id string) (sdktypes.PluginInfo, error) { - return s.mgr.RetryFailedPlugin(id) -} -func (s *PluginManagerService) UninstallPlugin(id string) (sdktypes.PluginInfo, error) { - return s.mgr.UninstallPlugin(id) -} -func (s *PluginManagerService) GetPlugin(id string) (sdktypes.PluginInfo, error) { - return s.mgr.GetPlugin(id) -} -func (s *PluginManagerService) ListPlugins() []sdktypes.PluginInfo { - return s.mgr.ListPlugins() -} -func (s *PluginManagerService) GetPluginMeta(id string) (config.PluginMeta, error) { - return s.mgr.GetPluginMeta(id) -} -func (s *PluginManagerService) ListPluginMetas() []config.PluginMeta { - return s.mgr.ListPluginMetas() -} -func (s *PluginManagerService) ListAvailablePlugins() ([]registry.AvailablePlugin, error) { - return s.mgr.ListAvailablePlugins() -} -func (s *PluginManagerService) SearchPlugins(query, category, sort string) ([]registry.AvailablePlugin, error) { - return s.mgr.SearchPlugins(query, category, sort) -} -func (s *PluginManagerService) GetPluginReadme(pluginID string) (string, error) { - return s.mgr.GetPluginReadme(pluginID) -} -func (s *PluginManagerService) GetPluginVersions(pluginID string) ([]registry.VersionInfo, error) { - return s.mgr.GetPluginVersions(pluginID) -} -func (s *PluginManagerService) GetPluginReviews(pluginID string, page int) ([]registry.Review, error) { - return s.mgr.GetPluginReviews(pluginID, page) -} -func (s *PluginManagerService) GetPluginDownloadStats(pluginID string) (*registry.DownloadStats, error) { - return s.mgr.GetPluginDownloadStats(pluginID) -} -func (s *PluginManagerService) GetPluginReleaseHistory(pluginID string) ([]registry.VersionInfo, error) { - return s.mgr.GetPluginReleaseHistory(pluginID) -} - -// PluginLogService exposes only frontend-safe methods of pluginlog.Manager. -// Excludes OnEmit (EmitFunc type), Stream (io.Writer), Close, LogDir. -type PluginLogService struct { - mgr *pluginlog.Manager -} - -func (s *PluginLogService) GetLogs(pluginID string, count int) []pluginlog.LogEntry { - return s.mgr.GetLogs(pluginID, count) -} -func (s *PluginLogService) ListStreams() []string { - return s.mgr.ListStreams() -} -func (s *PluginLogService) SearchLogs(pluginID, pattern string) ([]pluginlog.LogEntry, error) { - return s.mgr.SearchLogs(pluginID, pattern) -} -func (s *PluginLogService) Subscribe(pluginID string) int { - return s.mgr.Subscribe(pluginID) -} -func (s *PluginLogService) Unsubscribe(pluginID string) int { - return s.mgr.Unsubscribe(pluginID) -} - -// DevServerService exposes only frontend-safe methods of devserver.DevServerManager. -// The DevServerManager implements ServiceStartup/ServiceShutdown directly, -// but registering it raw causes service/model shadowing. This wrapper separates -// the service identity from the model type. -type DevServerService struct { - mgr *devserver.DevServerManager -} - -func (s *DevServerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - return s.mgr.ServiceStartup(ctx, options) -} -func (s *DevServerService) ServiceShutdown() error { - return s.mgr.ServiceShutdown() -} -func (s *DevServerService) StartDevServer(pluginID string) (devserver.DevServerState, error) { - return s.mgr.StartDevServer(pluginID) -} -func (s *DevServerService) StartDevServerForPath(pluginID, devPath string) (devserver.DevServerState, error) { - return s.mgr.StartDevServerForPath(pluginID, devPath) -} -func (s *DevServerService) StopDevServer(pluginID string) error { - return s.mgr.StopDevServer(pluginID) -} -func (s *DevServerService) RestartDevServer(pluginID string) (devserver.DevServerState, error) { - return s.mgr.RestartDevServer(pluginID) -} -func (s *DevServerService) RebuildPlugin(pluginID string) error { - return s.mgr.RebuildPlugin(pluginID) -} -func (s *DevServerService) GetDevServerState(pluginID string) devserver.DevServerState { - return s.mgr.GetDevServerState(pluginID) -} -func (s *DevServerService) ListDevServerStates() []devserver.DevServerState { - return s.mgr.ListDevServerStates() -} -func (s *DevServerService) GetDevServerLogs(pluginID string, count int) []devserver.LogEntry { - return s.mgr.GetDevServerLogs(pluginID, count) -} -func (s *DevServerService) IsManaged(pluginID string) bool { - return s.mgr.IsManaged(pluginID) -} -func (s *DevServerService) GetExternalPluginInfo(pluginID string) *devserver.DevInfoFile { - return s.mgr.GetExternalPluginInfo(pluginID) -} - -// --------------------------------------------------------------------------- -// ResourceControllerService — explicit delegation (no interface embedding). -// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, -// OnPluginDestroy, Run, SetCrashCallback, Graph, HasPlugin -// --------------------------------------------------------------------------- - -type ResourceControllerService struct { - ctrl resource.Controller -} - -func (s *ResourceControllerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if ss, ok := s.ctrl.(interface { - ServiceStartup(context.Context, application.ServiceOptions) error - }); ok { - return ss.ServiceStartup(ctx, options) - } - return nil -} - -func (s *ResourceControllerService) ServiceShutdown() error { - if ss, ok := s.ctrl.(interface{ ServiceShutdown() error }); ok { - return ss.ServiceShutdown() - } - return nil -} - -// CRUD -func (s *ResourceControllerService) Get(pluginID, connectionID, key string, input sdkresource.GetInput) (*sdkresource.GetResult, error) { - return s.ctrl.Get(pluginID, connectionID, key, input) -} -func (s *ResourceControllerService) List(pluginID, connectionID, key string, input sdkresource.ListInput) (*sdkresource.ListResult, error) { - return s.ctrl.List(pluginID, connectionID, key, input) -} -func (s *ResourceControllerService) Find(pluginID, connectionID, key string, input sdkresource.FindInput) (*sdkresource.FindResult, error) { - return s.ctrl.Find(pluginID, connectionID, key, input) -} -func (s *ResourceControllerService) Create(pluginID, connectionID, key string, input sdkresource.CreateInput) (*sdkresource.CreateResult, error) { - return s.ctrl.Create(pluginID, connectionID, key, input) -} -func (s *ResourceControllerService) Update(pluginID, connectionID, key string, input sdkresource.UpdateInput) (*sdkresource.UpdateResult, error) { - return s.ctrl.Update(pluginID, connectionID, key, input) -} -func (s *ResourceControllerService) Delete(pluginID, connectionID, key string, input sdkresource.DeleteInput) (*sdkresource.DeleteResult, error) { - return s.ctrl.Delete(pluginID, connectionID, key, input) -} - -// Connection lifecycle -func (s *ResourceControllerService) StartConnection(pluginID, connectionID string) (sdktypes.ConnectionStatus, error) { - return s.ctrl.StartConnection(pluginID, connectionID) -} -func (s *ResourceControllerService) StopConnection(pluginID, connectionID string) (sdktypes.Connection, error) { - return s.ctrl.StopConnection(pluginID, connectionID) -} -func (s *ResourceControllerService) CheckConnection(pluginID, connectionID string) (sdktypes.ConnectionStatus, error) { - return s.ctrl.CheckConnection(pluginID, connectionID) -} -func (s *ResourceControllerService) LoadConnections(pluginID string) ([]sdktypes.Connection, error) { - return s.ctrl.LoadConnections(pluginID) -} -func (s *ResourceControllerService) ListConnections(pluginID string) ([]sdktypes.Connection, error) { - return s.ctrl.ListConnections(pluginID) -} -func (s *ResourceControllerService) ListAllConnections() (map[string][]sdktypes.Connection, error) { - return s.ctrl.ListAllConnections() -} -func (s *ResourceControllerService) GetAllConnectionStates() (map[string][]resource.ConnectionState, error) { - return s.ctrl.GetAllConnectionStates() -} -func (s *ResourceControllerService) GetConnection(pluginID, connectionID string) (sdktypes.Connection, error) { - return s.ctrl.GetConnection(pluginID, connectionID) -} -func (s *ResourceControllerService) GetConnectionNamespaces(pluginID, connectionID string) ([]string, error) { - return s.ctrl.GetConnectionNamespaces(pluginID, connectionID) -} -func (s *ResourceControllerService) AddConnection(pluginID string, connection sdktypes.Connection) error { - return s.ctrl.AddConnection(pluginID, connection) -} -func (s *ResourceControllerService) UpdateConnection(pluginID string, connection sdktypes.Connection) (sdktypes.Connection, error) { - return s.ctrl.UpdateConnection(pluginID, connection) -} -func (s *ResourceControllerService) RemoveConnection(pluginID, connectionID string) error { - return s.ctrl.RemoveConnection(pluginID, connectionID) -} - -// Watch lifecycle -func (s *ResourceControllerService) StartConnectionWatch(pluginID, connectionID string) error { - return s.ctrl.StartConnectionWatch(pluginID, connectionID) -} -func (s *ResourceControllerService) StopConnectionWatch(pluginID, connectionID string) error { - return s.ctrl.StopConnectionWatch(pluginID, connectionID) -} -func (s *ResourceControllerService) GetWatchState(pluginID, connectionID string) (*sdkresource.WatchConnectionSummary, error) { - return s.ctrl.GetWatchState(pluginID, connectionID) -} -func (s *ResourceControllerService) EnsureResourceWatch(pluginID, connectionID, resourceKey string) error { - return s.ctrl.EnsureResourceWatch(pluginID, connectionID, resourceKey) -} -func (s *ResourceControllerService) StopResourceWatch(pluginID, connectionID, resourceKey string) error { - return s.ctrl.StopResourceWatch(pluginID, connectionID, resourceKey) -} -func (s *ResourceControllerService) RestartResourceWatch(pluginID, connectionID, resourceKey string) error { - return s.ctrl.RestartResourceWatch(pluginID, connectionID, resourceKey) -} -func (s *ResourceControllerService) IsResourceWatchRunning(pluginID, connectionID, resourceKey string) (bool, error) { - return s.ctrl.IsResourceWatchRunning(pluginID, connectionID, resourceKey) -} - -// Subscriptions -func (s *ResourceControllerService) SubscribeResource(pluginID, connectionID, resourceKey string) error { - return s.ctrl.SubscribeResource(pluginID, connectionID, resourceKey) -} -func (s *ResourceControllerService) UnsubscribeResource(pluginID, connectionID, resourceKey string) error { - return s.ctrl.UnsubscribeResource(pluginID, connectionID, resourceKey) -} - -// Type metadata -func (s *ResourceControllerService) GetResourceGroups(pluginID, connectionID string) map[string]sdkresource.ResourceGroup { - return s.ctrl.GetResourceGroups(pluginID, connectionID) -} -func (s *ResourceControllerService) GetResourceGroup(pluginID, groupID string) (sdkresource.ResourceGroup, error) { - return s.ctrl.GetResourceGroup(pluginID, groupID) -} -func (s *ResourceControllerService) GetResourceTypes(pluginID, connectionID string) map[string]sdkresource.ResourceMeta { - return s.ctrl.GetResourceTypes(pluginID, connectionID) -} -func (s *ResourceControllerService) GetResourceType(pluginID, typeID string) (*sdkresource.ResourceMeta, error) { - return s.ctrl.GetResourceType(pluginID, typeID) -} -func (s *ResourceControllerService) HasResourceType(pluginID, typeID string) bool { - return s.ctrl.HasResourceType(pluginID, typeID) -} -func (s *ResourceControllerService) GetResourceDefinition(pluginID, typeID string) (sdkresource.ResourceDefinition, error) { - return s.ctrl.GetResourceDefinition(pluginID, typeID) -} -func (s *ResourceControllerService) GetResourceCapabilities(pluginID, key string) (*sdkresource.ResourceCapabilities, error) { - return s.ctrl.GetResourceCapabilities(pluginID, key) -} -func (s *ResourceControllerService) GetFilterFields(pluginID, connectionID, key string) ([]sdkresource.FilterField, error) { - return s.ctrl.GetFilterFields(pluginID, connectionID, key) -} -func (s *ResourceControllerService) GetResourceSchema(pluginID, connectionID, key string) (json.RawMessage, error) { - return s.ctrl.GetResourceSchema(pluginID, connectionID, key) -} - -// Actions -func (s *ResourceControllerService) GetActions(pluginID, connectionID, key string) ([]sdkresource.ActionDescriptor, error) { - return s.ctrl.GetActions(pluginID, connectionID, key) -} -func (s *ResourceControllerService) ExecuteAction(pluginID, connectionID, key, actionID string, input sdkresource.ActionInput) (*sdkresource.ActionResult, error) { - return s.ctrl.ExecuteAction(pluginID, connectionID, key, actionID, input) -} -func (s *ResourceControllerService) StreamAction(pluginID, connectionID, key, actionID string, input sdkresource.ActionInput) (string, error) { - return s.ctrl.StreamAction(pluginID, connectionID, key, actionID, input) -} - -// Editor schemas -func (s *ResourceControllerService) GetEditorSchemas(pluginID, connectionID string) ([]sdkresource.EditorSchema, error) { - return s.ctrl.GetEditorSchemas(pluginID, connectionID) -} - -// Relationships -func (s *ResourceControllerService) GetRelationships(pluginID, key string) ([]sdkresource.RelationshipDescriptor, error) { - return s.ctrl.GetRelationships(pluginID, key) -} -func (s *ResourceControllerService) ResolveRelationships(pluginID, connectionID, key, id, namespace string) ([]sdkresource.ResolvedRelationship, error) { - return s.ctrl.ResolveRelationships(pluginID, connectionID, key, id, namespace) -} - -// Health -func (s *ResourceControllerService) GetHealth(pluginID, connectionID, key string, data json.RawMessage) (*sdkresource.ResourceHealth, error) { - return s.ctrl.GetHealth(pluginID, connectionID, key, data) -} -func (s *ResourceControllerService) GetResourceEvents(pluginID, connectionID, key, id, namespace string, limit int32) ([]sdkresource.ResourceEvent, error) { - return s.ctrl.GetResourceEvents(pluginID, connectionID, key, id, namespace, limit) -} - -// ListPlugins -func (s *ResourceControllerService) ListPlugins() ([]string, error) { - return s.ctrl.ListPlugins() -} - -// HasPlugin -func (s *ResourceControllerService) HasPlugin(pluginID string) bool { - return s.ctrl.HasPlugin(pluginID) -} - -// --------------------------------------------------------------------------- -// SettingsControllerService — explicit delegation. -// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, -// OnPluginDestroy, ServiceStartup, ServiceShutdown -// --------------------------------------------------------------------------- - -type SettingsControllerService struct { - ctrl settings.Controller -} - -func (s *SettingsControllerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if ss, ok := s.ctrl.(interface { - ServiceStartup(context.Context, application.ServiceOptions) error - }); ok { - return ss.ServiceStartup(ctx, options) - } - return nil -} -func (s *SettingsControllerService) ServiceShutdown() error { - if ss, ok := s.ctrl.(interface{ ServiceShutdown() error }); ok { - return ss.ServiceShutdown() - } - return nil -} -func (s *SettingsControllerService) ListPlugins() ([]string, error) { - return s.ctrl.ListPlugins() -} -func (s *SettingsControllerService) HasPlugin(pluginID string) bool { - return s.ctrl.HasPlugin(pluginID) -} -func (s *SettingsControllerService) Values() map[string]any { - return s.ctrl.Values() -} -func (s *SettingsControllerService) PluginValues(plugin string) map[string]any { - return s.ctrl.PluginValues(plugin) -} -func (s *SettingsControllerService) ListSettings(plugin string) map[string]pkgsettings.Setting { - return s.ctrl.ListSettings(plugin) -} -func (s *SettingsControllerService) GetSetting(plugin, id string) (pkgsettings.Setting, error) { - return s.ctrl.GetSetting(plugin, id) -} -func (s *SettingsControllerService) SetSetting(plugin, id string, value any) error { - return s.ctrl.SetSetting(plugin, id, value) -} -func (s *SettingsControllerService) SetSettings(plugin string, settingsMap map[string]any) error { - return s.ctrl.SetSettings(plugin, settingsMap) -} - -// --------------------------------------------------------------------------- -// ExecControllerService — explicit delegation. -// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, -// OnPluginDestroy, ServiceStartup, ServiceShutdown -// --------------------------------------------------------------------------- - -type ExecControllerService struct { - ctrl exec.Controller -} - -func (s *ExecControllerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if ss, ok := s.ctrl.(interface { - ServiceStartup(context.Context, application.ServiceOptions) error - }); ok { - return ss.ServiceStartup(ctx, options) - } - return nil -} -func (s *ExecControllerService) ServiceShutdown() error { - if ss, ok := s.ctrl.(interface{ ServiceShutdown() error }); ok { - return ss.ServiceShutdown() + fmt.Fprintf(os.Stderr, "fatal: failed to initialize state directory: %v\n", err) + os.Exit(1) } - return nil -} -func (s *ExecControllerService) CreateSession(plugin, connectionID string, opts execsdk.SessionOptions) (*execsdk.Session, error) { - return s.ctrl.CreateSession(plugin, connectionID, opts) -} -func (s *ExecControllerService) CreateTerminal(opts execsdk.SessionOptions) (*execsdk.Session, error) { - return s.ctrl.CreateTerminal(opts) -} -func (s *ExecControllerService) ListSessions() ([]*execsdk.Session, error) { - return s.ctrl.ListSessions() -} -func (s *ExecControllerService) GetSession(sessionID string) (*execsdk.Session, error) { - return s.ctrl.GetSession(sessionID) -} -func (s *ExecControllerService) AttachSession(sessionID string) (*execsdk.Session, []byte, error) { - return s.ctrl.AttachSession(sessionID) -} -func (s *ExecControllerService) DetachSession(sessionID string) (*execsdk.Session, error) { - return s.ctrl.DetachSession(sessionID) -} -func (s *ExecControllerService) WriteSession(sessionID string, data []byte) error { - return s.ctrl.WriteSession(sessionID, data) -} -func (s *ExecControllerService) CloseSession(sessionID string) error { - return s.ctrl.CloseSession(sessionID) -} -func (s *ExecControllerService) ResizeSession(sessionID string, rows, cols uint16) error { - return s.ctrl.ResizeSession(sessionID, rows, cols) -} -func (s *ExecControllerService) GetHandler(plugin, resource string) *execsdk.Handler { - return s.ctrl.GetHandler(plugin, resource) -} -func (s *ExecControllerService) GetHandlers() map[string]map[string]execsdk.Handler { - return s.ctrl.GetHandlers() -} -func (s *ExecControllerService) GetPluginHandlers(plugin string) map[string]execsdk.Handler { - return s.ctrl.GetPluginHandlers(plugin) -} -func (s *ExecControllerService) ListPlugins() ([]string, error) { - return s.ctrl.ListPlugins() -} -func (s *ExecControllerService) HasPlugin(pluginID string) bool { - return s.ctrl.HasPlugin(pluginID) -} - -// --------------------------------------------------------------------------- -// LogsControllerService — explicit delegation. -// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, -// OnPluginDestroy, ServiceStartup, ServiceShutdown -// --------------------------------------------------------------------------- - -type LogsControllerService struct { - ctrl pluginlogs.Controller -} + defer stateDir.Close() -func (s *LogsControllerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if ss, ok := s.ctrl.(interface { - ServiceStartup(context.Context, application.ServiceOptions) error - }); ok { - return ss.ServiceStartup(ctx, options) - } - return nil -} -func (s *LogsControllerService) ServiceShutdown() error { - if ss, ok := s.ctrl.(interface{ ServiceShutdown() error }); ok { - return ss.ServiceShutdown() - } - return nil -} -func (s *LogsControllerService) GetSupportedResources(pluginID string) []logssdk.Handler { - return s.ctrl.GetSupportedResources(pluginID) -} -func (s *LogsControllerService) CreateSession(plugin, connectionID string, opts logssdk.CreateSessionOptions) (*logssdk.LogSession, error) { - return s.ctrl.CreateSession(plugin, connectionID, opts) -} -func (s *LogsControllerService) GetSession(sessionID string) (*logssdk.LogSession, error) { - return s.ctrl.GetSession(sessionID) -} -func (s *LogsControllerService) ListSessions() ([]*logssdk.LogSession, error) { - return s.ctrl.ListSessions() -} -func (s *LogsControllerService) CloseSession(sessionID string) error { - return s.ctrl.CloseSession(sessionID) -} -func (s *LogsControllerService) SendCommand(sessionID string, cmd logssdk.LogStreamCommand) error { - return s.ctrl.SendCommand(sessionID, cmd) -} -func (s *LogsControllerService) UpdateSessionOptions(sessionID string, opts logssdk.LogSessionOptions) (*logssdk.LogSession, error) { - return s.ctrl.UpdateSessionOptions(sessionID, opts) -} -func (s *LogsControllerService) ListPlugins() ([]string, error) { - return s.ctrl.ListPlugins() -} -func (s *LogsControllerService) HasPlugin(pluginID string) bool { - return s.ctrl.HasPlugin(pluginID) -} - -// --------------------------------------------------------------------------- -// MetricControllerService — explicit delegation. -// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, -// OnPluginDestroy, ServiceStartup, ServiceShutdown -// --------------------------------------------------------------------------- + // Open bbolt settings store. + logDir := stateDir.Logs().ResolvePath("") -type MetricControllerService struct { - ctrl pluginmetric.Controller -} - -func (s *MetricControllerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if ss, ok := s.ctrl.(interface { - ServiceStartup(context.Context, application.ServiceOptions) error - }); ok { - return ss.ServiceStartup(ctx, options) - } - return nil -} -func (s *MetricControllerService) ServiceShutdown() error { - if ss, ok := s.ctrl.(interface{ ServiceShutdown() error }); ok { - return ss.ServiceShutdown() - } - return nil -} -func (s *MetricControllerService) GetProviders() []pluginmetric.MetricProviderSummary { - return s.ctrl.GetProviders() -} -func (s *MetricControllerService) GetProvidersForResource(resourceKey string) []pluginmetric.MetricProviderSummary { - return s.ctrl.GetProvidersForResource(resourceKey) -} -func (s *MetricControllerService) Query(pluginID, connectionID string, req metricsdk.QueryRequest) (*metricsdk.QueryResponse, error) { - return s.ctrl.Query(pluginID, connectionID, req) -} -func (s *MetricControllerService) QueryAll(connectionID, resourceKey, resourceID, namespace string, - resourceData map[string]interface{}, metricIDs []string, - shape metricsdk.MetricShape, startTime, endTime time.Time, step time.Duration, -) (map[string]*metricsdk.QueryResponse, error) { - return s.ctrl.QueryAll(connectionID, resourceKey, resourceID, namespace, resourceData, metricIDs, shape, startTime, endTime, step) -} -func (s *MetricControllerService) Subscribe(pluginID, connectionID string, req pluginmetric.SubscribeRequest) (string, error) { - return s.ctrl.Subscribe(pluginID, connectionID, req) -} -func (s *MetricControllerService) Unsubscribe(subscriptionID string) error { - return s.ctrl.Unsubscribe(subscriptionID) -} -func (s *MetricControllerService) ListPlugins() ([]string, error) { - return s.ctrl.ListPlugins() -} -func (s *MetricControllerService) HasPlugin(pluginID string) bool { - return s.ctrl.HasPlugin(pluginID) -} - -// --------------------------------------------------------------------------- -// NetworkerControllerService — explicit delegation. -// Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, -// OnPluginDestroy, ServiceStartup, ServiceShutdown -// --------------------------------------------------------------------------- - -type NetworkerControllerService struct { - ctrl networker.Controller -} - -func (s *NetworkerControllerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if ss, ok := s.ctrl.(interface { - ServiceStartup(context.Context, application.ServiceOptions) error - }); ok { - return ss.ServiceStartup(ctx, options) - } - return nil -} -func (s *NetworkerControllerService) ServiceShutdown() error { - if ss, ok := s.ctrl.(interface{ ServiceShutdown() error }); ok { - return ss.ServiceShutdown() - } - return nil -} -func (s *NetworkerControllerService) GetSupportedPortForwardTargets(pluginID string) ([]string, error) { - return s.ctrl.GetSupportedPortForwardTargets(pluginID) -} -func (s *NetworkerControllerService) GetPortForwardSession(sessionID string) (*networkersdk.PortForwardSession, error) { - return s.ctrl.GetPortForwardSession(sessionID) -} -func (s *NetworkerControllerService) ListPortForwardSessions(pluginID, connectionID string) ([]*networkersdk.PortForwardSession, error) { - return s.ctrl.ListPortForwardSessions(pluginID, connectionID) -} -func (s *NetworkerControllerService) ListAllPortForwardSessions() ([]*networkersdk.PortForwardSession, error) { - return s.ctrl.ListAllPortForwardSessions() -} -func (s *NetworkerControllerService) FindPortForwardSessions(pluginID, connectionID string, request networkersdk.FindPortForwardSessionRequest) ([]*networkersdk.PortForwardSession, error) { - return s.ctrl.FindPortForwardSessions(pluginID, connectionID, request) -} -func (s *NetworkerControllerService) StartResourcePortForwardingSession(pluginID, connectionID string, opts networkersdk.PortForwardSessionOptions) (*networkersdk.PortForwardSession, error) { - return s.ctrl.StartResourcePortForwardingSession(pluginID, connectionID, opts) -} -func (s *NetworkerControllerService) ClosePortForwardSession(sessionID string) (*networkersdk.PortForwardSession, error) { - return s.ctrl.ClosePortForwardSession(sessionID) -} -func (s *NetworkerControllerService) ListPlugins() ([]string, error) { - return s.ctrl.ListPlugins() -} -func (s *NetworkerControllerService) HasPlugin(pluginID string) bool { - return s.ctrl.HasPlugin(pluginID) -} - -// --------------------------------------------------------------------------- -// DataControllerService — explicit delegation. -// Excluded: ServiceStartup, ServiceShutdown (data.Controller has no plugin -// lifecycle methods since it doesn't embed types.Controller). -// --------------------------------------------------------------------------- - -type DataControllerService struct { - ctrl data.Controller -} - -func (s *DataControllerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if ss, ok := s.ctrl.(interface { - ServiceStartup(context.Context, application.ServiceOptions) error - }); ok { - return ss.ServiceStartup(ctx, options) - } - return nil -} -func (s *DataControllerService) ServiceShutdown() error { - if ss, ok := s.ctrl.(interface{ ServiceShutdown() error }); ok { - return ss.ServiceShutdown() - } - return nil -} -func (s *DataControllerService) Get(pluginID, key string) (any, error) { - return s.ctrl.Get(pluginID, key) -} -func (s *DataControllerService) Set(pluginID, key string, value any) error { - return s.ctrl.Set(pluginID, key, value) -} -func (s *DataControllerService) Delete(pluginID, key string) error { - return s.ctrl.Delete(pluginID, key) -} -func (s *DataControllerService) Keys(pluginID string) ([]string, error) { - return s.ctrl.Keys(pluginID) -} - -// --------------------------------------------------------------------------- -// SettingsProviderService — explicit delegation. -// Excluded: Initialize, RegisterChangeHandler, RegisterSetting, -// RegisterSettings (internal-only methods). -// --------------------------------------------------------------------------- - -type SettingsProviderService struct { - provider pkgsettings.Provider -} - -func (s *SettingsProviderService) LoadSettings() error { - return s.provider.LoadSettings() -} -func (s *SettingsProviderService) SaveSettings() error { - return s.provider.SaveSettings() -} -func (s *SettingsProviderService) ListSettings() pkgsettings.Store { - return s.provider.ListSettings() -} -func (s *SettingsProviderService) Values() map[string]any { - return s.provider.Values() -} -func (s *SettingsProviderService) GetSetting(id string) (pkgsettings.Setting, error) { - return s.provider.GetSetting(id) -} -func (s *SettingsProviderService) GetSettingValue(id string) (any, error) { - return s.provider.GetSettingValue(id) -} -func (s *SettingsProviderService) SetSetting(id string, value any) error { - return s.provider.SetSetting(id, value) -} -func (s *SettingsProviderService) SetSettings(settingsMap map[string]any) error { - return s.provider.SetSettings(settingsMap) -} -func (s *SettingsProviderService) ResetSetting(id string) error { - return s.provider.ResetSetting(id) -} -func (s *SettingsProviderService) GetCategories() []pkgsettings.Category { - return s.provider.GetCategories() -} -func (s *SettingsProviderService) GetCategory(id string) (pkgsettings.Category, error) { - return s.provider.GetCategory(id) -} -func (s *SettingsProviderService) GetCategoryValues(id string) (map[string]interface{}, error) { - return s.provider.GetCategoryValues(id) -} -func (s *SettingsProviderService) GetString(id string) (string, error) { - return s.provider.GetString(id) -} -func (s *SettingsProviderService) GetStringSlice(id string) ([]string, error) { - return s.provider.GetStringSlice(id) -} -func (s *SettingsProviderService) GetInt(id string) (int, error) { - return s.provider.GetInt(id) -} -func (s *SettingsProviderService) GetIntSlice(id string) ([]int, error) { - return s.provider.GetIntSlice(id) -} -func (s *SettingsProviderService) GetFloat(id string) (float64, error) { - return s.provider.GetFloat(id) -} -func (s *SettingsProviderService) GetFloatSlice(id string) ([]float64, error) { - return s.provider.GetFloatSlice(id) -} -func (s *SettingsProviderService) GetBool(id string) (bool, error) { - return s.provider.GetBool(id) -} - -// BootstrapService wraps the startup/shutdown logic that was previously in the -// Wails v2 OnStartup/OnShutdown closures. It implements ServiceStartup and -// ServiceShutdown so the Wails v3 runtime calls it automatically. -type BootstrapService struct { - log logging.Logger - settingsProvider pkgsettings.Provider - telemetrySvc *telemetry.Service - pluginManager plugin.Manager - pluginRegistryClient *registry.Client -} - -func (b *BootstrapService) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error { - // Initialize the settings - if err := b.settingsProvider.Initialize( - ctx, - coresettings.General, - coresettings.Appearance, - coresettings.Terminal, - coresettings.Editor, - coresettings.Developer, - coresettings.Telemetry, - ); err != nil { - b.log.Errorw(ctx, "error while initializing settings system", "error", err) - } - - // Wire telemetry settings hot-toggle: when any setting in the - // "telemetry" category changes, rebuild TelemetryConfig and apply. - telemetryFromSettings := func(vals map[string]any) telemetry.TelemetryConfig { - cfg := b.telemetrySvc.Config() - if v, ok := vals["enabled"].(bool); ok { - cfg.Enabled = v - } - if v, ok := vals["traces"].(bool); ok { - cfg.Traces = v - } - if v, ok := vals["metrics"].(bool); ok { - cfg.Metrics = v - } - if v, ok := vals["logs_ship"].(bool); ok { - cfg.LogsShip = v - } - if v, ok := vals["logs_ship_level"].(string); ok { - cfg.LogsShipLevel = v - } - if v, ok := vals["profiling"].(bool); ok { - cfg.Profiling = v - } - if v, ok := vals["endpoint_otlp"].(string); ok { - cfg.OTLPEndpoint = v - } - if v, ok := vals["endpoint_pyroscope"].(string); ok { - cfg.PyroscopeEndpoint = v - } - if v, ok := vals["auth_header"].(string); ok { - cfg.AuthHeader = v - } - if v, ok := vals["auth_value"].(string); ok { - cfg.AuthValue = v - } - return cfg + settingsStore, err := settingsstore.Open(stateDir.RootDir().ResolvePath("settings.db")) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to open settings store: %v\n", err) } - - b.settingsProvider.RegisterChangeHandler("telemetry", func(vals map[string]any) { - cfg := telemetryFromSettings(vals) - if err := b.telemetrySvc.ApplyConfig(ctx, cfg); err != nil { - b.log.Errorw(ctx, "failed to apply telemetry config change", "error", err) - } else { - b.log.Infow(ctx, "telemetry config updated from settings") - } - }) - - // Apply the persisted telemetry settings immediately so telemetry - // activates on startup (the change handler only fires on changes). - if vals, err := b.settingsProvider.GetCategoryValues("telemetry"); err == nil { - cfg := telemetryFromSettings(vals) - if err := b.telemetrySvc.ApplyConfig(ctx, cfg); err != nil { - b.log.Errorw(ctx, "failed to apply initial telemetry config", "error", err) - } else { - b.log.Infow(ctx, "telemetry initialized from persisted settings", "enabled", cfg.Enabled) + if settingsStore != nil { + defer settingsStore.Close() + if migrateErr := settingsstore.MigrateFromGOB(stateDir.Root(), settingsStore); migrateErr != nil { + fmt.Fprintf(os.Stderr, "warning: settings migration had errors: %v\n", migrateErr) } } - // Apply user-configured marketplace URL to the registry client. - if marketplaceURL, err := b.settingsProvider.GetString("developer.marketplace_url"); err == nil && marketplaceURL != "" { - b.pluginRegistryClient.SetBaseURL(marketplaceURL) - safeHost := marketplaceURL - if u, parseErr := url.Parse(marketplaceURL); parseErr == nil { - safeHost = u.Host + // Seed telemetry config from persisted bbolt settings so that telemetry + // starts with the user's saved preferences rather than defaults. + telemetryCfg := telemetry.DefaultConfig(version.IsDevelopment()) + if settingsStore != nil { + if telVals, loadErr := settingsStore.LoadCategory("telemetry"); loadErr == nil && len(telVals) > 0 { + if v, ok := telVals["enabled"].(bool); ok { + telemetryCfg.Enabled = v + } + if v, ok := telVals["traces"].(bool); ok { + telemetryCfg.Traces = v + } + if v, ok := telVals["metrics"].(bool); ok { + telemetryCfg.Metrics = v + } + if v, ok := telVals["logs_ship"].(bool); ok { + telemetryCfg.LogsShip = v + } + if v, ok := telVals["logs_ship_level"].(string); ok { + telemetryCfg.LogsShipLevel = v + } + if v, ok := telVals["profiling"].(bool); ok { + telemetryCfg.Profiling = v + } + if v, ok := telVals["endpoint_otlp"].(string); ok { + telemetryCfg.OTLPEndpoint = v + } + if v, ok := telVals["endpoint_pyroscope"].(string); ok { + telemetryCfg.PyroscopeEndpoint = v + } + if v, ok := telVals["auth_header"].(string); ok { + telemetryCfg.AuthHeader = v + } + if v, ok := telVals["auth_value"].(string); ok { + telemetryCfg.AuthValue = v + } } - b.log.Infow(ctx, "using custom marketplace URL", "host", safeHost) } - // Controllers now implement ServiceStartup/ServiceShutdown and are - // registered as Wails v3 services, so Wails calls their lifecycle - // methods automatically. - - // Initialize the plugin system - if err := b.pluginManager.Initialize(ctx); err != nil { - b.log.Errorw(ctx, "error while initializing plugin system", "error", err) - } - b.pluginManager.Run(ctx) - - return nil -} - -func (b *BootstrapService) ServiceShutdown() error { - // DevServerManager and controllers have their own ServiceShutdown - // called by Wails v3 automatically. - b.pluginManager.Shutdown() - _ = b.telemetrySvc.Shutdown(context.Background()) - return nil -} - -//nolint:funlen // main function is expected to be long -func main() { // Bootstrap telemetry (tracing, metrics, log shipping, profiling). - telemetryCfg := telemetry.DefaultConfig(version.IsDevelopment()) telemetrySvc := telemetry.New( telemetryCfg, version.Version, version.GitCommit, version.BuildDate, version.IsDevelopment(), + logDir, ) if err := telemetrySvc.Init(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "telemetry init failed, continuing without telemetry: %v\n", err) @@ -910,7 +134,11 @@ func main() { Level: logging.NewLevelController(logging.LevelDebug), }) - diagnosticsClient := diagnostics.NewDiagnosticsClient(version.IsDevelopment()) + if settingsStore == nil { + log.Warnw(context.Background(), "settings persistence disabled; all settings will use defaults") + } + + diagnosticsClient := diagnostics.NewDiagnosticsClient(version.IsDevelopment(), logDir) settingsProvider := pkgsettings.NewProvider(pkgsettings.ProviderOpts{ Logger: zapLogger.Sugar(), @@ -926,9 +154,9 @@ func main() { utilsClient := utils.NewClient() // Setup the plugin systems - resourceController := resource.NewController(log, settingsProvider) + resourceController := resource.NewController(log, settingsProvider, stateDir.PluginStore) - settingsController := settings.NewController(log, settingsProvider) + settingsController := settings.NewController(log, settingsProvider, settingsStore) execController := exec.NewController(log, settingsProvider, resourceController) @@ -938,13 +166,12 @@ func main() { metricController := pluginmetric.NewController(log, settingsProvider, resourceController) - dataController := data.NewController(log) + dataController := data.NewController(log, stateDir.PluginData) // Initialize per-plugin log manager for capturing plugin process stderr. // Created here so it can be bound to Wails for UI access. - home, _ := os.UserHomeDir() pluginLogManager, pluginLogErr := pluginlog.NewManager( - filepath.Join(home, ".omniview", "logs"), + logDir, pluginlog.DefaultRotation(), ) if pluginLogErr != nil { @@ -954,6 +181,8 @@ func main() { pluginRegistryClient := registry.NewClient("") pluginManager := plugin.NewManager( log, + stateDir.RootDir(), + stateDir.Plugins(), resourceController, settingsController, execController, @@ -980,8 +209,10 @@ func main() { devServerManager := devserver.NewDevServerManager( log, - &pluginRefAdapter{mgr: pluginManager}, - &pluginReloaderAdapter{mgr: pluginManager}, + stateDir.RootDir(), + stateDir.Plugins(), + &plugin.PluginRefAdapter{Mgr: pluginManager}, + &plugin.PluginReloaderAdapter{Mgr: pluginManager}, settingsProvider, ) @@ -998,20 +229,21 @@ func main() { appService := NewAppService() // Create the bootstrap service that wraps startup/shutdown logic - bootstrapService := &BootstrapService{ - log: log, - settingsProvider: settingsProvider, - telemetrySvc: telemetrySvc, - pluginManager: pluginManager, - pluginRegistryClient: pluginRegistryClient, + bootstrapSvc := &bootstrap.Service{ + Log: log, + SettingsProvider: settingsProvider, + SettingsStore: settingsStore, + TelemetrySvc: telemetrySvc, + PluginManager: pluginManager, + PluginRegistryClient: pluginRegistryClient, } // Set up plugin asset handler middleware - pluginAssetHandler := NewPluginAssetHandler(log) + pluginAssetHandler := NewPluginAssetHandler(log, stateDir.RootDir()) // Wrap the plugin manager interface in a concrete struct for v3 service // registration (NewService requires a concrete pointer type). - pluginManagerSvc := &PluginManagerService{mgr: pluginManager} + pluginManagerSvc := &plugin.ServiceWrapper{Mgr: pluginManager} // Build the service list. All concrete pointer types use NewService directly. // BootstrapService is registered first so startup logic runs before other @@ -1023,28 +255,38 @@ func main() { // OnPluginStart on all controllers — they need ctx for gRPC streams. services := []application.Service{ // 1. Controllers — need ctx before plugin loading - application.NewService(&ResourceControllerService{ctrl: resourceController}), - application.NewService(&SettingsControllerService{ctrl: settingsController}), - application.NewService(&ExecControllerService{ctrl: execController}), - application.NewService(&NetworkerControllerService{ctrl: networkerController}), - application.NewService(&LogsControllerService{ctrl: logsController}), - application.NewService(&MetricControllerService{ctrl: metricController}), - application.NewService(&DataControllerService{ctrl: dataController}), + application.NewService(&resource.ServiceWrapper{Ctrl: resourceController}), + application.NewService(&settings.ServiceWrapper{Ctrl: settingsController}), + application.NewService(&exec.ServiceWrapper{Ctrl: execController}), + application.NewService(&networker.ServiceWrapper{Ctrl: networkerController}), + application.NewService(&pluginlogs.ServiceWrapper{Ctrl: logsController}), + application.NewService(&pluginmetric.ServiceWrapper{Ctrl: metricController}), + application.NewService(&data.ServiceWrapper{Ctrl: dataController}), application.NewService(ui.NewServiceWrapper(uiManager)), application.NewService(utilsClient), - application.NewService(&DevServerService{mgr: devServerManager}), + application.NewService(&devserver.ServiceWrapper{Mgr: devServerManager}), // 2. Bootstrap — initializes settings, telemetry, loads plugins - application.NewService(bootstrapService), + application.NewService(bootstrapSvc), // 3. Frontend-facing services (no startup order dependency) application.NewService(appService), application.NewService(diagnosticsClient), application.NewService(telemetry.NewTelemetryBinding(telemetrySvc)), - application.NewService(&SettingsProviderService{provider: settingsProvider}), + application.NewService(&coresettings.ServiceWrapper{ + Provider: settingsProvider, + CategoryMeta: map[string]pkgsettings.Category{ + coresettings.General.ID: coresettings.General, + coresettings.Appearance.ID: coresettings.Appearance, + coresettings.Terminal.ID: coresettings.Terminal, + coresettings.Editor.ID: coresettings.Editor, + coresettings.Developer.ID: coresettings.Developer, + coresettings.Telemetry.ID: coresettings.Telemetry, + }, + }), application.NewService(pluginManagerSvc), } if pluginLogManager != nil { - services = append(services, application.NewService(&PluginLogService{mgr: pluginLogManager})) + services = append(services, application.NewService(&pluginlog.ServiceWrapper{Mgr: pluginLogManager})) } // Create the Wails v3 application diff --git a/packages/omniviewdev-runtime/src/api.ts b/packages/omniviewdev-runtime/src/api.ts index 92b71282..1d33d641 100644 --- a/packages/omniviewdev-runtime/src/api.ts +++ b/packages/omniviewdev-runtime/src/api.ts @@ -1,18 +1,18 @@ // v3 generated bindings — paths match `wails3 generate bindings -d packages/omniviewdev-runtime/src/bindings` -export * as ExecClient from './bindings/github.com/omniviewdev/omniview/execcontrollerservice'; -export * as NetworkerClient from './bindings/github.com/omniviewdev/omniview/networkercontrollerservice'; -export * as PluginManager from './bindings/github.com/omniviewdev/omniview/pluginmanagerservice'; -export * as ResourceClient from './bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; -export * as SettingsClient from './bindings/github.com/omniviewdev/omniview/settingscontrollerservice'; -export * as SettingsProvider from './bindings/github.com/omniviewdev/omniview/settingsproviderservice'; +export * as ExecClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/servicewrapper'; +export * as NetworkerClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/servicewrapper'; +export * as PluginManager from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/servicewrapper'; +export * as ResourceClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; +export * as SettingsClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/servicewrapper'; +export * as SettingsProvider from './bindings/github.com/omniviewdev/omniview/internal/settings/servicewrapper'; export * as UtilsClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/utils/client'; export * as UIClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/ui/servicewrapper'; export * as DiagnosticsClient from './bindings/github.com/omniviewdev/omniview/backend/diagnostics/diagnosticsclient'; -export * as LogsClient from './bindings/github.com/omniviewdev/omniview/logscontrollerservice'; -export * as MetricClient from './bindings/github.com/omniviewdev/omniview/metriccontrollerservice'; -export * as DataClient from './bindings/github.com/omniviewdev/omniview/datacontrollerservice'; +export * as LogsClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/servicewrapper'; +export * as MetricClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/servicewrapper'; +export * as DataClient from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/servicewrapper'; -export * as DevServerManager from './bindings/github.com/omniviewdev/omniview/devserverservice'; -export * as PluginLogManager from './bindings/github.com/omniviewdev/omniview/pluginlogservice'; +export * as DevServerManager from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/servicewrapper'; +export * as PluginLogManager from './bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/servicewrapper'; export * from './bindings/github.com/omniviewdev/omniview/appservice'; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/index.ts new file mode 100644 index 00000000..1b03bc0a --- /dev/null +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/index.ts @@ -0,0 +1,7 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/datacontrollerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/servicewrapper.ts similarity index 71% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/datacontrollerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/servicewrapper.ts index ce433c04..6b89e461 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/datacontrollerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/servicewrapper.ts @@ -1,26 +1,31 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper is an explicit delegation wrapper around data.Controller. + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; export function Delete(pluginID: string, key: string): $CancellablePromise { - return $Call.ByID(2784630898, pluginID, key); + return $Call.ByID(3653639550, pluginID, key); } export function Get(pluginID: string, key: string): $CancellablePromise { - return $Call.ByID(1249901663, pluginID, key); + return $Call.ByID(851823419, pluginID, key); } export function Keys(pluginID: string): $CancellablePromise { - return $Call.ByID(1862041893, pluginID).then(($result: any) => { + return $Call.ByID(2991415449, pluginID).then(($result: any) => { return $$createType0($result); }); } export function Set(pluginID: string, key: string, value: any): $CancellablePromise { - return $Call.ByID(1059399179, pluginID, key, value); + return $Call.ByID(2695679023, pluginID, key, value); } // Private type creation functions diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/index.ts index 5448231c..789d0264 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/index.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/index.ts @@ -1,6 +1,11 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; + export { BuildError, DevInfoFile, diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/devserverservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/servicewrapper.ts similarity index 58% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/devserverservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/servicewrapper.ts index 5bc777c8..10026a79 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/devserverservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/devserver/servicewrapper.ts @@ -2,7 +2,7 @@ // This file is automatically generated. DO NOT EDIT /** - * DevServerService exposes only frontend-safe methods of devserver.DevServerManager. + * ServiceWrapper exposes only frontend-safe methods of devserver.DevServerManager. * The DevServerManager implements ServiceStartup/ServiceShutdown directly, * but registering it raw causes service/model shadowing. This wrapper separates * the service identity from the model type. @@ -15,66 +15,66 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as devserver$0 from "./backend/pkg/plugin/devserver/models.js"; +import * as $models from "./models.js"; -export function GetDevServerLogs(pluginID: string, count: number): $CancellablePromise { - return $Call.ByID(56031670, pluginID, count).then(($result: any) => { +export function GetDevServerLogs(pluginID: string, count: number): $CancellablePromise<$models.LogEntry[]> { + return $Call.ByID(712656304, pluginID, count).then(($result: any) => { return $$createType1($result); }); } -export function GetDevServerState(pluginID: string): $CancellablePromise { - return $Call.ByID(2931579078, pluginID).then(($result: any) => { +export function GetDevServerState(pluginID: string): $CancellablePromise<$models.DevServerState> { + return $Call.ByID(4153226260, pluginID).then(($result: any) => { return $$createType2($result); }); } -export function GetExternalPluginInfo(pluginID: string): $CancellablePromise { - return $Call.ByID(4231701063, pluginID).then(($result: any) => { +export function GetExternalPluginInfo(pluginID: string): $CancellablePromise<$models.DevInfoFile | null> { + return $Call.ByID(1268119665, pluginID).then(($result: any) => { return $$createType4($result); }); } export function IsManaged(pluginID: string): $CancellablePromise { - return $Call.ByID(1031994258, pluginID); + return $Call.ByID(1859413816, pluginID); } -export function ListDevServerStates(): $CancellablePromise { - return $Call.ByID(3754746401).then(($result: any) => { +export function ListDevServerStates(): $CancellablePromise<$models.DevServerState[]> { + return $Call.ByID(2707386443).then(($result: any) => { return $$createType5($result); }); } export function RebuildPlugin(pluginID: string): $CancellablePromise { - return $Call.ByID(4157478183, pluginID); + return $Call.ByID(2381063937, pluginID); } -export function RestartDevServer(pluginID: string): $CancellablePromise { - return $Call.ByID(2240108516, pluginID).then(($result: any) => { +export function RestartDevServer(pluginID: string): $CancellablePromise<$models.DevServerState> { + return $Call.ByID(123659362, pluginID).then(($result: any) => { return $$createType2($result); }); } -export function StartDevServer(pluginID: string): $CancellablePromise { - return $Call.ByID(843165717, pluginID).then(($result: any) => { +export function StartDevServer(pluginID: string): $CancellablePromise<$models.DevServerState> { + return $Call.ByID(709599671, pluginID).then(($result: any) => { return $$createType2($result); }); } -export function StartDevServerForPath(pluginID: string, devPath: string): $CancellablePromise { - return $Call.ByID(4039660679, pluginID, devPath).then(($result: any) => { +export function StartDevServerForPath(pluginID: string, devPath: string): $CancellablePromise<$models.DevServerState> { + return $Call.ByID(2105869281, pluginID, devPath).then(($result: any) => { return $$createType2($result); }); } export function StopDevServer(pluginID: string): $CancellablePromise { - return $Call.ByID(1563164227, pluginID); + return $Call.ByID(1371062537, pluginID); } // Private type creation functions -const $$createType0 = devserver$0.LogEntry.createFrom; +const $$createType0 = $models.LogEntry.createFrom; const $$createType1 = $Create.Array($$createType0); -const $$createType2 = devserver$0.DevServerState.createFrom; -const $$createType3 = devserver$0.DevInfoFile.createFrom; +const $$createType2 = $models.DevServerState.createFrom; +const $$createType3 = $models.DevInfoFile.createFrom; const $$createType4 = $Create.Nullable($$createType3); const $$createType5 = $Create.Array($$createType2); diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/index.ts new file mode 100644 index 00000000..1b03bc0a --- /dev/null +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/index.ts @@ -0,0 +1,7 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/execcontrollerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/servicewrapper.ts similarity index 71% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/execcontrollerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/servicewrapper.ts index 0ac2ff41..a807a894 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/execcontrollerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/servicewrapper.ts @@ -1,16 +1,24 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper is an explicit delegation wrapper around exec.Controller. + * Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, + * + * OnPluginDestroy + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as exec$0 from "../plugin-sdk/pkg/v1/exec/models.js"; +import * as exec$0 from "../../../../../plugin-sdk/pkg/v1/exec/models.js"; export function AttachSession(sessionID: string): $CancellablePromise<[exec$0.Session | null, string]> { - return $Call.ByID(1518986335, sessionID).then(($result: any) => { + return $Call.ByID(93318075, sessionID).then(($result: any) => { $result[0] = $$createType1($result[0]); $result[1] = $Create.ByteSlice($result[1]); return $result; @@ -18,73 +26,73 @@ export function AttachSession(sessionID: string): $CancellablePromise<[exec$0.Se } export function CloseSession(sessionID: string): $CancellablePromise { - return $Call.ByID(108486360, sessionID); + return $Call.ByID(1353297556, sessionID); } export function CreateSession(plugin: string, connectionID: string, opts: exec$0.SessionOptions): $CancellablePromise { - return $Call.ByID(1614266952, plugin, connectionID, opts).then(($result: any) => { + return $Call.ByID(608251756, plugin, connectionID, opts).then(($result: any) => { return $$createType1($result); }); } export function CreateTerminal(opts: exec$0.SessionOptions): $CancellablePromise { - return $Call.ByID(331634124, opts).then(($result: any) => { + return $Call.ByID(4182196712, opts).then(($result: any) => { return $$createType1($result); }); } export function DetachSession(sessionID: string): $CancellablePromise { - return $Call.ByID(1895341881, sessionID).then(($result: any) => { + return $Call.ByID(3993344525, sessionID).then(($result: any) => { return $$createType1($result); }); } export function GetHandler(plugin: string, resource: string): $CancellablePromise { - return $Call.ByID(215427448, plugin, resource).then(($result: any) => { + return $Call.ByID(1779192716, plugin, resource).then(($result: any) => { return $$createType3($result); }); } export function GetHandlers(): $CancellablePromise<{ [_ in string]?: { [_ in string]?: exec$0.Handler } }> { - return $Call.ByID(1102421073).then(($result: any) => { + return $Call.ByID(4033362541).then(($result: any) => { return $$createType5($result); }); } export function GetPluginHandlers(plugin: string): $CancellablePromise<{ [_ in string]?: exec$0.Handler }> { - return $Call.ByID(3546807494, plugin).then(($result: any) => { + return $Call.ByID(2619212338, plugin).then(($result: any) => { return $$createType4($result); }); } export function GetSession(sessionID: string): $CancellablePromise { - return $Call.ByID(2974088048, sessionID).then(($result: any) => { + return $Call.ByID(1371268636, sessionID).then(($result: any) => { return $$createType1($result); }); } export function HasPlugin(pluginID: string): $CancellablePromise { - return $Call.ByID(300230163, pluginID); + return $Call.ByID(3881019319, pluginID); } export function ListPlugins(): $CancellablePromise { - return $Call.ByID(17539472).then(($result: any) => { + return $Call.ByID(3006426324).then(($result: any) => { return $$createType6($result); }); } export function ListSessions(): $CancellablePromise<(exec$0.Session | null)[]> { - return $Call.ByID(360069693).then(($result: any) => { + return $Call.ByID(265791345).then(($result: any) => { return $$createType7($result); }); } export function ResizeSession(sessionID: string, rows: number, cols: number): $CancellablePromise { - return $Call.ByID(548917388, sessionID, rows, cols); + return $Call.ByID(1492965384, sessionID, rows, cols); } export function WriteSession(sessionID: string, data: string): $CancellablePromise { - return $Call.ByID(195216577, sessionID, data); + return $Call.ByID(486465741, sessionID, data); } // Private type creation functions diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/index.ts index 09ba5e15..c6462252 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/index.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/index.ts @@ -1,6 +1,11 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; + export { DeprecatedProtocolPayload, LoadPluginOptions, diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/index.ts new file mode 100644 index 00000000..1b03bc0a --- /dev/null +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/index.ts @@ -0,0 +1,7 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/logscontrollerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/servicewrapper.ts similarity index 71% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/logscontrollerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/servicewrapper.ts index 91b66cae..1a1e8788 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/logscontrollerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/servicewrapper.ts @@ -1,58 +1,66 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper is an explicit delegation wrapper around logs.Controller. + * Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, + * + * OnPluginDestroy + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as logs$0 from "../plugin-sdk/pkg/v1/logs/models.js"; +import * as logs$0 from "../../../../../plugin-sdk/pkg/v1/logs/models.js"; export function CloseSession(sessionID: string): $CancellablePromise { - return $Call.ByID(3286870078, sessionID); + return $Call.ByID(2776241946, sessionID); } export function CreateSession(plugin: string, connectionID: string, opts: logs$0.CreateSessionOptions): $CancellablePromise { - return $Call.ByID(1710486702, plugin, connectionID, opts).then(($result: any) => { + return $Call.ByID(1762441314, plugin, connectionID, opts).then(($result: any) => { return $$createType1($result); }); } export function GetSession(sessionID: string): $CancellablePromise { - return $Call.ByID(365161342, sessionID).then(($result: any) => { + return $Call.ByID(1474987050, sessionID).then(($result: any) => { return $$createType1($result); }); } export function GetSupportedResources(pluginID: string): $CancellablePromise { - return $Call.ByID(1885041493, pluginID).then(($result: any) => { + return $Call.ByID(3156347865, pluginID).then(($result: any) => { return $$createType3($result); }); } export function HasPlugin(pluginID: string): $CancellablePromise { - return $Call.ByID(34226137, pluginID); + return $Call.ByID(1444002509, pluginID); } export function ListPlugins(): $CancellablePromise { - return $Call.ByID(3747829186).then(($result: any) => { + return $Call.ByID(3104582886).then(($result: any) => { return $$createType4($result); }); } export function ListSessions(): $CancellablePromise<(logs$0.LogSession | null)[]> { - return $Call.ByID(4219296599).then(($result: any) => { + return $Call.ByID(2249742267).then(($result: any) => { return $$createType5($result); }); } export function SendCommand(sessionID: string, cmd: logs$0.LogStreamCommand): $CancellablePromise { - return $Call.ByID(4186946429, sessionID, cmd); + return $Call.ByID(1212190993, sessionID, cmd); } export function UpdateSessionOptions(sessionID: string, opts: logs$0.LogSessionOptions): $CancellablePromise { - return $Call.ByID(4077718521, sessionID, opts).then(($result: any) => { + return $Call.ByID(2177127885, sessionID, opts).then(($result: any) => { return $$createType1($result); }); } diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/index.ts index 77984853..6051ce43 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/index.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/index.ts @@ -1,6 +1,11 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; + export { MetricProviderSummary, SubscribeRequest diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/metriccontrollerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/servicewrapper.ts similarity index 57% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/metriccontrollerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/servicewrapper.ts index ee5d68f1..8fca3b8f 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/metriccontrollerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/servicewrapper.ts @@ -1,66 +1,75 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper is an explicit delegation wrapper around metric.Controller. + * Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, + * + * OnPluginDestroy + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as metric$0 from "./backend/pkg/plugin/metric/models.js"; +import * as metric$0 from "../../../../../plugin-sdk/pkg/v1/metric/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as metric$1 from "../plugin-sdk/pkg/v1/metric/models.js"; +import * as time$0 from "../../../../../../../time/models.js"; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as time$0 from "../../../time/models.js"; +import * as $models from "./models.js"; -export function GetProviders(): $CancellablePromise { - return $Call.ByID(2838019531).then(($result: any) => { +export function GetProviders(): $CancellablePromise<$models.MetricProviderSummary[]> { + return $Call.ByID(3919346639).then(($result: any) => { return $$createType1($result); }); } -export function GetProvidersForResource(resourceKey: string): $CancellablePromise { - return $Call.ByID(643157078, resourceKey).then(($result: any) => { +export function GetProvidersForResource(resourceKey: string): $CancellablePromise<$models.MetricProviderSummary[]> { + return $Call.ByID(3953821778, resourceKey).then(($result: any) => { return $$createType1($result); }); } export function HasPlugin(pluginID: string): $CancellablePromise { - return $Call.ByID(3044575076, pluginID); + return $Call.ByID(1815388456, pluginID); } export function ListPlugins(): $CancellablePromise { - return $Call.ByID(162304859).then(($result: any) => { + return $Call.ByID(2730149903).then(($result: any) => { return $$createType2($result); }); } -export function Query(pluginID: string, connectionID: string, req: metric$1.QueryRequest): $CancellablePromise { - return $Call.ByID(3357897247, pluginID, connectionID, req).then(($result: any) => { +export function Query(pluginID: string, connectionID: string, req: metric$0.QueryRequest): $CancellablePromise { + return $Call.ByID(3947209283, pluginID, connectionID, req).then(($result: any) => { return $$createType4($result); }); } -export function QueryAll(connectionID: string, resourceKey: string, resourceID: string, $namespace: string, resourceData: { [_ in string]?: any }, metricIDs: string[], shape: metric$1.MetricShape, startTime: time$0.Time, endTime: time$0.Time, step: time$0.Duration): $CancellablePromise<{ [_ in string]?: metric$1.QueryResponse | null }> { - return $Call.ByID(2842472394, connectionID, resourceKey, resourceID, $namespace, resourceData, metricIDs, shape, startTime, endTime, step).then(($result: any) => { +export function QueryAll(connectionID: string, resourceKey: string, resourceID: string, $namespace: string, resourceData: { [_ in string]?: any }, metricIDs: string[], shape: metric$0.MetricShape, startTime: time$0.Time, endTime: time$0.Time, step: time$0.Duration): $CancellablePromise<{ [_ in string]?: metric$0.QueryResponse | null }> { + return $Call.ByID(2480040278, connectionID, resourceKey, resourceID, $namespace, resourceData, metricIDs, shape, startTime, endTime, step).then(($result: any) => { return $$createType5($result); }); } -export function Subscribe(pluginID: string, connectionID: string, req: metric$0.SubscribeRequest): $CancellablePromise { - return $Call.ByID(320253359, pluginID, connectionID, req); +export function Subscribe(pluginID: string, connectionID: string, req: $models.SubscribeRequest): $CancellablePromise { + return $Call.ByID(3738888803, pluginID, connectionID, req); } export function Unsubscribe(subscriptionID: string): $CancellablePromise { - return $Call.ByID(715373378, subscriptionID); + return $Call.ByID(3994236150, subscriptionID); } // Private type creation functions -const $$createType0 = metric$0.MetricProviderSummary.createFrom; +const $$createType0 = $models.MetricProviderSummary.createFrom; const $$createType1 = $Create.Array($$createType0); const $$createType2 = $Create.Array($Create.Any); -const $$createType3 = metric$1.QueryResponse.createFrom; +const $$createType3 = metric$0.QueryResponse.createFrom; const $$createType4 = $Create.Nullable($$createType3); const $$createType5 = $Create.Map($Create.Any, $$createType4); diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/index.ts new file mode 100644 index 00000000..1b03bc0a --- /dev/null +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/index.ts @@ -0,0 +1,7 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/networkercontrollerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/servicewrapper.ts similarity index 71% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/networkercontrollerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/servicewrapper.ts index 5e1efdb4..a8694afd 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/networkercontrollerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/servicewrapper.ts @@ -1,62 +1,70 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper is an explicit delegation wrapper around networker.Controller. + * Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, + * + * OnPluginDestroy + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as networker$0 from "../plugin-sdk/pkg/v1/networker/models.js"; +import * as networker$0 from "../../../../../plugin-sdk/pkg/v1/networker/models.js"; export function ClosePortForwardSession(sessionID: string): $CancellablePromise { - return $Call.ByID(280793032, sessionID).then(($result: any) => { + return $Call.ByID(182596922, sessionID).then(($result: any) => { return $$createType1($result); }); } export function FindPortForwardSessions(pluginID: string, connectionID: string, request: networker$0.FindPortForwardSessionRequest): $CancellablePromise<(networker$0.PortForwardSession | null)[]> { - return $Call.ByID(3429152344, pluginID, connectionID, request).then(($result: any) => { + return $Call.ByID(981100722, pluginID, connectionID, request).then(($result: any) => { return $$createType2($result); }); } export function GetPortForwardSession(sessionID: string): $CancellablePromise { - return $Call.ByID(2765408144, sessionID).then(($result: any) => { + return $Call.ByID(2157304970, sessionID).then(($result: any) => { return $$createType1($result); }); } export function GetSupportedPortForwardTargets(pluginID: string): $CancellablePromise { - return $Call.ByID(2199710208, pluginID).then(($result: any) => { + return $Call.ByID(650882, pluginID).then(($result: any) => { return $$createType3($result); }); } export function HasPlugin(pluginID: string): $CancellablePromise { - return $Call.ByID(1410344939, pluginID); + return $Call.ByID(3624690437, pluginID); } export function ListAllPortForwardSessions(): $CancellablePromise<(networker$0.PortForwardSession | null)[]> { - return $Call.ByID(92526888).then(($result: any) => { + return $Call.ByID(2195697754).then(($result: any) => { return $$createType2($result); }); } export function ListPlugins(): $CancellablePromise { - return $Call.ByID(3677762456).then(($result: any) => { + return $Call.ByID(167463150).then(($result: any) => { return $$createType3($result); }); } export function ListPortForwardSessions(pluginID: string, connectionID: string): $CancellablePromise<(networker$0.PortForwardSession | null)[]> { - return $Call.ByID(521717509, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(2090415403, pluginID, connectionID).then(($result: any) => { return $$createType2($result); }); } export function StartResourcePortForwardingSession(pluginID: string, connectionID: string, opts: networker$0.PortForwardSessionOptions): $CancellablePromise { - return $Call.ByID(4289977092, pluginID, connectionID, opts).then(($result: any) => { + return $Call.ByID(780445954, pluginID, connectionID, opts).then(($result: any) => { return $$createType1($result); }); } diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/index.ts index 2ede70b9..f7d90fe0 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/index.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/index.ts @@ -1,6 +1,11 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; + export { LogEntry } from "./models.js"; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/pluginlogservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/servicewrapper.ts similarity index 66% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/pluginlogservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/servicewrapper.ts index c3490502..3537cf01 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/pluginlogservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/pluginlog/servicewrapper.ts @@ -2,7 +2,7 @@ // This file is automatically generated. DO NOT EDIT /** - * PluginLogService exposes only frontend-safe methods of pluginlog.Manager. + * ServiceWrapper exposes only frontend-safe methods of pluginlog.Manager. * Excludes OnEmit (EmitFunc type), Stream (io.Writer), Close, LogDir. * @module */ @@ -13,35 +13,35 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as pluginlog$0 from "./backend/pkg/plugin/pluginlog/models.js"; +import * as $models from "./models.js"; -export function GetLogs(pluginID: string, count: number): $CancellablePromise { - return $Call.ByID(3633742133, pluginID, count).then(($result: any) => { +export function GetLogs(pluginID: string, count: number): $CancellablePromise<$models.LogEntry[]> { + return $Call.ByID(1867216983, pluginID, count).then(($result: any) => { return $$createType1($result); }); } export function ListStreams(): $CancellablePromise { - return $Call.ByID(1957306775).then(($result: any) => { + return $Call.ByID(3418650705).then(($result: any) => { return $$createType2($result); }); } -export function SearchLogs(pluginID: string, pattern: string): $CancellablePromise { - return $Call.ByID(753912009, pluginID, pattern).then(($result: any) => { +export function SearchLogs(pluginID: string, pattern: string): $CancellablePromise<$models.LogEntry[]> { + return $Call.ByID(4138610615, pluginID, pattern).then(($result: any) => { return $$createType1($result); }); } export function Subscribe(pluginID: string): $CancellablePromise { - return $Call.ByID(358553566, pluginID); + return $Call.ByID(3055914536, pluginID); } export function Unsubscribe(pluginID: string): $CancellablePromise { - return $Call.ByID(3199182151, pluginID); + return $Call.ByID(1502487629, pluginID); } // Private type creation functions -const $$createType0 = pluginlog$0.LogEntry.createFrom; +const $$createType0 = $models.LogEntry.createFrom; const $$createType1 = $Create.Array($$createType0); const $$createType2 = $Create.Array($Create.Any); diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/index.ts index ae8dd12e..5aff3bc3 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/index.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/index.ts @@ -1,6 +1,11 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; + export { ConnectionState, ConnectionStatusPayload diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/resourcecontrollerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper.ts similarity index 76% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/resourcecontrollerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper.ts index 868e3616..1cde844d 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/resourcecontrollerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper.ts @@ -1,57 +1,66 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper is an explicit delegation wrapper around resource.Controller. + * Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, + * + * OnPluginDestroy, Run, SetCrashCallback, Graph + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as json$0 from "../../../encoding/json/models.js"; +import * as json$0 from "../../../../../../../encoding/json/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as resource$1 from "./backend/pkg/plugin/resource/models.js"; +import * as types$0 from "../../../../../plugin-sdk/pkg/types/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as types$0 from "../plugin-sdk/pkg/types/models.js"; +import * as resource$0 from "../../../../../plugin-sdk/pkg/v1/resource/models.js"; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as resource$0 from "../plugin-sdk/pkg/v1/resource/models.js"; +import * as $models from "./models.js"; export function AddConnection(pluginID: string, connection: types$0.Connection): $CancellablePromise { - return $Call.ByID(323717028, pluginID, connection); + return $Call.ByID(4065522324, pluginID, connection); } export function CheckConnection(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(1290919551, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(1688967279, pluginID, connectionID).then(($result: any) => { return $$createType0($result); }); } export function Create(pluginID: string, connectionID: string, key: string, input: resource$0.CreateInput): $CancellablePromise { - return $Call.ByID(2471289469, pluginID, connectionID, key, input).then(($result: any) => { + return $Call.ByID(1613386605, pluginID, connectionID, key, input).then(($result: any) => { return $$createType2($result); }); } export function Delete(pluginID: string, connectionID: string, key: string, input: resource$0.DeleteInput): $CancellablePromise { - return $Call.ByID(1373038698, pluginID, connectionID, key, input).then(($result: any) => { + return $Call.ByID(1477526970, pluginID, connectionID, key, input).then(($result: any) => { return $$createType4($result); }); } export function EnsureResourceWatch(pluginID: string, connectionID: string, resourceKey: string): $CancellablePromise { - return $Call.ByID(2051591842, pluginID, connectionID, resourceKey); + return $Call.ByID(1338477906, pluginID, connectionID, resourceKey); } export function ExecuteAction(pluginID: string, connectionID: string, key: string, actionID: string, input: resource$0.ActionInput): $CancellablePromise { - return $Call.ByID(1114089986, pluginID, connectionID, key, actionID, input).then(($result: any) => { + return $Call.ByID(1844283666, pluginID, connectionID, key, actionID, input).then(($result: any) => { return $$createType6($result); }); } export function Find(pluginID: string, connectionID: string, key: string, input: resource$0.FindInput): $CancellablePromise { - return $Call.ByID(1394089850, pluginID, connectionID, key, input).then(($result: any) => { + return $Call.ByID(4148812394, pluginID, connectionID, key, input).then(($result: any) => { return $$createType8($result); }); } @@ -60,7 +69,7 @@ export function Find(pluginID: string, connectionID: string, key: string, input: * CRUD */ export function Get(pluginID: string, connectionID: string, key: string, input: resource$0.GetInput): $CancellablePromise { - return $Call.ByID(3010951415, pluginID, connectionID, key, input).then(($result: any) => { + return $Call.ByID(194970279, pluginID, connectionID, key, input).then(($result: any) => { return $$createType10($result); }); } @@ -69,25 +78,25 @@ export function Get(pluginID: string, connectionID: string, key: string, input: * Actions */ export function GetActions(pluginID: string, connectionID: string, key: string): $CancellablePromise { - return $Call.ByID(1736730842, pluginID, connectionID, key).then(($result: any) => { + return $Call.ByID(419011114, pluginID, connectionID, key).then(($result: any) => { return $$createType12($result); }); } -export function GetAllConnectionStates(): $CancellablePromise<{ [_ in string]?: resource$1.ConnectionState[] }> { - return $Call.ByID(2891839656).then(($result: any) => { +export function GetAllConnectionStates(): $CancellablePromise<{ [_ in string]?: $models.ConnectionState[] }> { + return $Call.ByID(4153677080).then(($result: any) => { return $$createType15($result); }); } export function GetConnection(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(3091451887, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(950535743, pluginID, connectionID).then(($result: any) => { return $$createType16($result); }); } export function GetConnectionNamespaces(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(117148787, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(537508515, pluginID, connectionID).then(($result: any) => { return $$createType17($result); }); } @@ -96,13 +105,13 @@ export function GetConnectionNamespaces(pluginID: string, connectionID: string): * Editor schemas */ export function GetEditorSchemas(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(574615474, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(1394342402, pluginID, connectionID).then(($result: any) => { return $$createType19($result); }); } export function GetFilterFields(pluginID: string, connectionID: string, key: string): $CancellablePromise { - return $Call.ByID(846915320, pluginID, connectionID, key).then(($result: any) => { + return $Call.ByID(1947262600, pluginID, connectionID, key).then(($result: any) => { return $$createType21($result); }); } @@ -111,7 +120,7 @@ export function GetFilterFields(pluginID: string, connectionID: string, key: str * Health */ export function GetHealth(pluginID: string, connectionID: string, key: string, data: json$0.RawMessage): $CancellablePromise { - return $Call.ByID(3196416417, pluginID, connectionID, key, data).then(($result: any) => { + return $Call.ByID(1644192689, pluginID, connectionID, key, data).then(($result: any) => { return $$createType23($result); }); } @@ -120,31 +129,31 @@ export function GetHealth(pluginID: string, connectionID: string, key: string, d * Relationships */ export function GetRelationships(pluginID: string, key: string): $CancellablePromise { - return $Call.ByID(1704500202, pluginID, key).then(($result: any) => { + return $Call.ByID(2699561690, pluginID, key).then(($result: any) => { return $$createType25($result); }); } export function GetResourceCapabilities(pluginID: string, key: string): $CancellablePromise { - return $Call.ByID(349911617, pluginID, key).then(($result: any) => { + return $Call.ByID(1474609809, pluginID, key).then(($result: any) => { return $$createType27($result); }); } export function GetResourceDefinition(pluginID: string, typeID: string): $CancellablePromise { - return $Call.ByID(303232926, pluginID, typeID).then(($result: any) => { + return $Call.ByID(4077050094, pluginID, typeID).then(($result: any) => { return $$createType28($result); }); } export function GetResourceEvents(pluginID: string, connectionID: string, key: string, id: string, $namespace: string, limit: number): $CancellablePromise { - return $Call.ByID(3557154150, pluginID, connectionID, key, id, $namespace, limit).then(($result: any) => { + return $Call.ByID(3171791798, pluginID, connectionID, key, id, $namespace, limit).then(($result: any) => { return $$createType30($result); }); } export function GetResourceGroup(pluginID: string, groupID: string): $CancellablePromise { - return $Call.ByID(883761498, pluginID, groupID).then(($result: any) => { + return $Call.ByID(114848330, pluginID, groupID).then(($result: any) => { return $$createType31($result); }); } @@ -153,29 +162,29 @@ export function GetResourceGroup(pluginID: string, groupID: string): $Cancellabl * Type metadata */ export function GetResourceGroups(pluginID: string, connectionID: string): $CancellablePromise<{ [_ in string]?: resource$0.ResourceGroup }> { - return $Call.ByID(361444235, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(4290498491, pluginID, connectionID).then(($result: any) => { return $$createType32($result); }); } export function GetResourceSchema(pluginID: string, connectionID: string, key: string): $CancellablePromise { - return $Call.ByID(2340247220, pluginID, connectionID, key); + return $Call.ByID(137675652, pluginID, connectionID, key); } export function GetResourceType(pluginID: string, typeID: string): $CancellablePromise { - return $Call.ByID(1933014139, pluginID, typeID).then(($result: any) => { + return $Call.ByID(2078766795, pluginID, typeID).then(($result: any) => { return $$createType34($result); }); } export function GetResourceTypes(pluginID: string, connectionID: string): $CancellablePromise<{ [_ in string]?: resource$0.ResourceMeta }> { - return $Call.ByID(1749788824, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(3311395752, pluginID, connectionID).then(($result: any) => { return $$createType35($result); }); } export function GetWatchState(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(3415900307, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(42345827, pluginID, connectionID).then(($result: any) => { return $$createType37($result); }); } @@ -184,31 +193,31 @@ export function GetWatchState(pluginID: string, connectionID: string): $Cancella * HasPlugin */ export function HasPlugin(pluginID: string): $CancellablePromise { - return $Call.ByID(298991208, pluginID); + return $Call.ByID(854008408, pluginID); } export function HasResourceType(pluginID: string, typeID: string): $CancellablePromise { - return $Call.ByID(2438176183, pluginID, typeID); + return $Call.ByID(3547410887, pluginID, typeID); } export function IsResourceWatchRunning(pluginID: string, connectionID: string, resourceKey: string): $CancellablePromise { - return $Call.ByID(1484591543, pluginID, connectionID, resourceKey); + return $Call.ByID(3516169863, pluginID, connectionID, resourceKey); } export function List(pluginID: string, connectionID: string, key: string, input: resource$0.ListInput): $CancellablePromise { - return $Call.ByID(2720302497, pluginID, connectionID, key, input).then(($result: any) => { + return $Call.ByID(3093409425, pluginID, connectionID, key, input).then(($result: any) => { return $$createType39($result); }); } export function ListAllConnections(): $CancellablePromise<{ [_ in string]?: types$0.Connection[] }> { - return $Call.ByID(193139961).then(($result: any) => { + return $Call.ByID(1598984489).then(($result: any) => { return $$createType41($result); }); } export function ListConnections(pluginID: string): $CancellablePromise { - return $Call.ByID(1313312490, pluginID).then(($result: any) => { + return $Call.ByID(1775572154, pluginID).then(($result: any) => { return $$createType40($result); }); } @@ -217,36 +226,36 @@ export function ListConnections(pluginID: string): $CancellablePromise { - return $Call.ByID(1994505295).then(($result: any) => { + return $Call.ByID(3217612415).then(($result: any) => { return $$createType17($result); }); } export function LoadConnections(pluginID: string): $CancellablePromise { - return $Call.ByID(2825857474, pluginID).then(($result: any) => { + return $Call.ByID(2037329938, pluginID).then(($result: any) => { return $$createType40($result); }); } export function RemoveConnection(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(4277958737, pluginID, connectionID); + return $Call.ByID(421198049, pluginID, connectionID); } export function ResolveRelationships(pluginID: string, connectionID: string, key: string, id: string, $namespace: string): $CancellablePromise { - return $Call.ByID(2178039126, pluginID, connectionID, key, id, $namespace).then(($result: any) => { + return $Call.ByID(3987850310, pluginID, connectionID, key, id, $namespace).then(($result: any) => { return $$createType43($result); }); } export function RestartResourceWatch(pluginID: string, connectionID: string, resourceKey: string): $CancellablePromise { - return $Call.ByID(1683997851, pluginID, connectionID, resourceKey); + return $Call.ByID(555702059, pluginID, connectionID, resourceKey); } /** * Connection lifecycle */ export function StartConnection(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(102380759, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(3276991559, pluginID, connectionID).then(($result: any) => { return $$createType0($result); }); } @@ -255,46 +264,46 @@ export function StartConnection(pluginID: string, connectionID: string): $Cancel * Watch lifecycle */ export function StartConnectionWatch(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(3263327310, pluginID, connectionID); + return $Call.ByID(3389223006, pluginID, connectionID); } export function StopConnection(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(739223609, pluginID, connectionID).then(($result: any) => { + return $Call.ByID(794258089, pluginID, connectionID).then(($result: any) => { return $$createType16($result); }); } export function StopConnectionWatch(pluginID: string, connectionID: string): $CancellablePromise { - return $Call.ByID(2312638644, pluginID, connectionID); + return $Call.ByID(1661153284, pluginID, connectionID); } export function StopResourceWatch(pluginID: string, connectionID: string, resourceKey: string): $CancellablePromise { - return $Call.ByID(3825993976, pluginID, connectionID, resourceKey); + return $Call.ByID(3981374696, pluginID, connectionID, resourceKey); } export function StreamAction(pluginID: string, connectionID: string, key: string, actionID: string, input: resource$0.ActionInput): $CancellablePromise { - return $Call.ByID(3335970527, pluginID, connectionID, key, actionID, input); + return $Call.ByID(2990116431, pluginID, connectionID, key, actionID, input); } /** * Subscriptions */ export function SubscribeResource(pluginID: string, connectionID: string, resourceKey: string): $CancellablePromise { - return $Call.ByID(600780239, pluginID, connectionID, resourceKey); + return $Call.ByID(2856546527, pluginID, connectionID, resourceKey); } export function UnsubscribeResource(pluginID: string, connectionID: string, resourceKey: string): $CancellablePromise { - return $Call.ByID(1661481870, pluginID, connectionID, resourceKey); + return $Call.ByID(3710015102, pluginID, connectionID, resourceKey); } export function Update(pluginID: string, connectionID: string, key: string, input: resource$0.UpdateInput): $CancellablePromise { - return $Call.ByID(1751256212, pluginID, connectionID, key, input).then(($result: any) => { + return $Call.ByID(2408192164, pluginID, connectionID, key, input).then(($result: any) => { return $$createType45($result); }); } export function UpdateConnection(pluginID: string, connection: types$0.Connection): $CancellablePromise { - return $Call.ByID(2879271972, pluginID, connection).then(($result: any) => { + return $Call.ByID(2131988692, pluginID, connection).then(($result: any) => { return $$createType16($result); }); } @@ -313,7 +322,7 @@ const $$createType9 = resource$0.GetResult.createFrom; const $$createType10 = $Create.Nullable($$createType9); const $$createType11 = resource$0.ActionDescriptor.createFrom; const $$createType12 = $Create.Array($$createType11); -const $$createType13 = resource$1.ConnectionState.createFrom; +const $$createType13 = $models.ConnectionState.createFrom; const $$createType14 = $Create.Array($$createType13); const $$createType15 = $Create.Map($Create.Any, $$createType14); const $$createType16 = types$0.Connection.createFrom; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/pluginmanagerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/servicewrapper.ts similarity index 72% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/pluginmanagerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/servicewrapper.ts index 6ce02889..0b78e26f 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/pluginmanagerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/servicewrapper.ts @@ -2,7 +2,7 @@ // This file is automatically generated. DO NOT EDIT /** - * PluginManagerService exposes only the frontend-safe methods of plugin.Manager. + * ServiceWrapper exposes only the frontend-safe methods of plugin.Manager. * Internal methods (SetDevServerChecker, SetPluginLogManager, HandlePluginCrash, * Initialize, Run, Shutdown) are excluded to avoid binding warnings from * interface/function-type parameters. @@ -15,125 +15,126 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as plugin$0 from "./backend/pkg/plugin/models.js"; +import * as registry$0 from "./registry/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as registry$0 from "./backend/pkg/plugin/registry/models.js"; +import * as config$0 from "../../../../plugin-sdk/pkg/config/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as config$0 from "../plugin-sdk/pkg/config/models.js"; +import * as types$0 from "../../../../plugin-sdk/pkg/types/models.js"; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as types$0 from "../plugin-sdk/pkg/types/models.js"; +import * as $models from "./models.js"; export function GetPlugin(id: string): $CancellablePromise { - return $Call.ByID(4036673098, id).then(($result: any) => { + return $Call.ByID(3150413499, id).then(($result: any) => { return $$createType0($result); }); } export function GetPluginDownloadStats(pluginID: string): $CancellablePromise { - return $Call.ByID(2726517273, pluginID).then(($result: any) => { + return $Call.ByID(1152913314, pluginID).then(($result: any) => { return $$createType2($result); }); } export function GetPluginMeta(id: string): $CancellablePromise { - return $Call.ByID(499197031, id).then(($result: any) => { + return $Call.ByID(111194374, id).then(($result: any) => { return $$createType3($result); }); } export function GetPluginReadme(pluginID: string): $CancellablePromise { - return $Call.ByID(2763449240, pluginID); + return $Call.ByID(3945933305, pluginID); } export function GetPluginReleaseHistory(pluginID: string): $CancellablePromise { - return $Call.ByID(1156861621, pluginID).then(($result: any) => { + return $Call.ByID(2859775056, pluginID).then(($result: any) => { return $$createType5($result); }); } export function GetPluginReviews(pluginID: string, page: number): $CancellablePromise { - return $Call.ByID(933402483, pluginID, page).then(($result: any) => { + return $Call.ByID(2209599792, pluginID, page).then(($result: any) => { return $$createType7($result); }); } export function GetPluginVersions(pluginID: string): $CancellablePromise { - return $Call.ByID(997632447, pluginID).then(($result: any) => { + return $Call.ByID(3963363730, pluginID).then(($result: any) => { return $$createType5($result); }); } export function InstallFromPathPrompt(): $CancellablePromise { - return $Call.ByID(467309803).then(($result: any) => { + return $Call.ByID(2477486666).then(($result: any) => { return $$createType8($result); }); } export function InstallInDevMode(): $CancellablePromise { - return $Call.ByID(830591135).then(($result: any) => { + return $Call.ByID(976517756).then(($result: any) => { return $$createType8($result); }); } export function InstallPluginFromPath(path: string): $CancellablePromise { - return $Call.ByID(1186389510, path).then(($result: any) => { + return $Call.ByID(1020548995, path).then(($result: any) => { return $$createType8($result); }); } export function InstallPluginVersion(pluginID: string, version: string): $CancellablePromise { - return $Call.ByID(2959706071, pluginID, version).then(($result: any) => { + return $Call.ByID(2004740384, pluginID, version).then(($result: any) => { return $$createType8($result); }); } export function ListAvailablePlugins(): $CancellablePromise { - return $Call.ByID(588186274).then(($result: any) => { + return $Call.ByID(873697101).then(($result: any) => { return $$createType10($result); }); } export function ListPluginMetas(): $CancellablePromise { - return $Call.ByID(3279236650).then(($result: any) => { + return $Call.ByID(637134283).then(($result: any) => { return $$createType11($result); }); } export function ListPlugins(): $CancellablePromise { - return $Call.ByID(1059385665).then(($result: any) => { + return $Call.ByID(550607756).then(($result: any) => { return $$createType12($result); }); } -export function LoadPlugin(id: string, opts: plugin$0.LoadPluginOptions | null): $CancellablePromise { - return $Call.ByID(815485296, id, opts).then(($result: any) => { +export function LoadPlugin(id: string, opts: $models.LoadPluginOptions | null): $CancellablePromise { + return $Call.ByID(288660195, id, opts).then(($result: any) => { return $$createType0($result); }); } export function ReloadPlugin(id: string): $CancellablePromise { - return $Call.ByID(3455220157, id).then(($result: any) => { + return $Call.ByID(1771013494, id).then(($result: any) => { return $$createType0($result); }); } export function RetryFailedPlugin(id: string): $CancellablePromise { - return $Call.ByID(4058095161, id).then(($result: any) => { + return $Call.ByID(2433379724, id).then(($result: any) => { return $$createType0($result); }); } export function SearchPlugins(query: string, category: string, sort: string): $CancellablePromise { - return $Call.ByID(4050877057, query, category, sort).then(($result: any) => { + return $Call.ByID(2783597264, query, category, sort).then(($result: any) => { return $$createType10($result); }); } export function UninstallPlugin(id: string): $CancellablePromise { - return $Call.ByID(1540344876, id).then(($result: any) => { + return $Call.ByID(1369550929, id).then(($result: any) => { return $$createType0($result); }); } diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/index.ts new file mode 100644 index 00000000..1b03bc0a --- /dev/null +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/index.ts @@ -0,0 +1,7 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/settingscontrollerservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/servicewrapper.ts similarity index 69% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/settingscontrollerservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/servicewrapper.ts index ba7bd2f3..22b93fce 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/settingscontrollerservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/servicewrapper.ts @@ -1,52 +1,60 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper is an explicit delegation wrapper around settings.Controller. + * Excluded: OnPluginInit, OnPluginStart, OnPluginStop, OnPluginShutdown, + * + * OnPluginDestroy + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as settings$0 from "../plugin-sdk/settings/models.js"; +import * as settings$0 from "../../../../../plugin-sdk/settings/models.js"; export function GetSetting(plugin: string, id: string): $CancellablePromise { - return $Call.ByID(1209436200, plugin, id).then(($result: any) => { + return $Call.ByID(3867104420, plugin, id).then(($result: any) => { return $$createType0($result); }); } export function HasPlugin(pluginID: string): $CancellablePromise { - return $Call.ByID(440913115, pluginID); + return $Call.ByID(3795411887, pluginID); } export function ListPlugins(): $CancellablePromise { - return $Call.ByID(1187124872).then(($result: any) => { + return $Call.ByID(1053995964).then(($result: any) => { return $$createType1($result); }); } export function ListSettings(plugin: string): $CancellablePromise<{ [_ in string]?: settings$0.Setting }> { - return $Call.ByID(2802109877, plugin).then(($result: any) => { + return $Call.ByID(3231741321, plugin).then(($result: any) => { return $$createType2($result); }); } export function PluginValues(plugin: string): $CancellablePromise<{ [_ in string]?: any }> { - return $Call.ByID(2715948825, plugin).then(($result: any) => { + return $Call.ByID(2597562645, plugin).then(($result: any) => { return $$createType3($result); }); } export function SetSetting(plugin: string, id: string, value: any): $CancellablePromise { - return $Call.ByID(2603413180, plugin, id, value); + return $Call.ByID(1481837168, plugin, id, value); } export function SetSettings(plugin: string, settingsMap: { [_ in string]?: any }): $CancellablePromise { - return $Call.ByID(381415389, plugin, settingsMap); + return $Call.ByID(230212281, plugin, settingsMap); } export function Values(): $CancellablePromise<{ [_ in string]?: any }> { - return $Call.ByID(2715455302).then(($result: any) => { + return $Call.ByID(2408440370).then(($result: any) => { return $$createType3($result); }); } diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/index.ts index 5439b18d..57856350 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/index.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/index.ts @@ -2,30 +2,8 @@ // This file is automatically generated. DO NOT EDIT import * as AppService from "./appservice.js"; -import * as DataControllerService from "./datacontrollerservice.js"; -import * as DevServerService from "./devserverservice.js"; -import * as ExecControllerService from "./execcontrollerservice.js"; -import * as LogsControllerService from "./logscontrollerservice.js"; -import * as MetricControllerService from "./metriccontrollerservice.js"; -import * as NetworkerControllerService from "./networkercontrollerservice.js"; -import * as PluginLogService from "./pluginlogservice.js"; -import * as PluginManagerService from "./pluginmanagerservice.js"; -import * as ResourceControllerService from "./resourcecontrollerservice.js"; -import * as SettingsControllerService from "./settingscontrollerservice.js"; -import * as SettingsProviderService from "./settingsproviderservice.js"; export { - AppService, - DataControllerService, - DevServerService, - ExecControllerService, - LogsControllerService, - MetricControllerService, - NetworkerControllerService, - PluginLogService, - PluginManagerService, - ResourceControllerService, - SettingsControllerService, - SettingsProviderService + AppService }; export { diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/internal/settings/index.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/internal/settings/index.ts new file mode 100644 index 00000000..1b03bc0a --- /dev/null +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/internal/settings/index.ts @@ -0,0 +1,7 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as ServiceWrapper from "./servicewrapper.js"; +export { + ServiceWrapper +}; diff --git a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/settingsproviderservice.ts b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/internal/settings/servicewrapper.ts similarity index 57% rename from packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/settingsproviderservice.ts rename to packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/internal/settings/servicewrapper.ts index fd1718c5..861ba147 100644 --- a/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/settingsproviderservice.ts +++ b/packages/omniviewdev-runtime/src/bindings/github.com/omniviewdev/omniview/internal/settings/servicewrapper.ts @@ -1,104 +1,127 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ServiceWrapper exposes only frontend-safe methods of pkgsettings.Provider. + * Excluded: RegisterChangeHandler, RegisterSetting, RegisterSettings + * + * (internal-only methods). + * @module + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as settings$0 from "../plugin-sdk/settings/models.js"; +import * as settings$0 from "../../../plugin-sdk/settings/models.js"; export function GetBool(id: string): $CancellablePromise { - return $Call.ByID(199975759, id); + return $Call.ByID(122243240, id); } +/** + * GetCategories returns all category metadata for the UI settings navigation. + */ export function GetCategories(): $CancellablePromise { - return $Call.ByID(1339187527).then(($result: any) => { + return $Call.ByID(269082212).then(($result: any) => { return $$createType1($result); }); } +/** + * GetCategory returns a single settings category by ID, including full metadata + * (Label, Icon, Description) from the registered category definitions and live + * setting values from the in-memory provider. + */ export function GetCategory(id: string): $CancellablePromise { - return $Call.ByID(2732589519, id).then(($result: any) => { + return $Call.ByID(1421156656, id).then(($result: any) => { return $$createType0($result); }); } +/** + * GetCategoryValues returns a flat map of setting values for a single category. + */ export function GetCategoryValues(id: string): $CancellablePromise<{ [_ in string]?: any }> { - return $Call.ByID(3158210645, id).then(($result: any) => { + return $Call.ByID(3143996982, id).then(($result: any) => { return $$createType2($result); }); } export function GetFloat(id: string): $CancellablePromise { - return $Call.ByID(3278855775, id); + return $Call.ByID(1695819138, id); } export function GetFloatSlice(id: string): $CancellablePromise { - return $Call.ByID(588704383, id).then(($result: any) => { + return $Call.ByID(1827194808, id).then(($result: any) => { return $$createType3($result); }); } export function GetInt(id: string): $CancellablePromise { - return $Call.ByID(3084011556, id); + return $Call.ByID(72059321, id); } export function GetIntSlice(id: string): $CancellablePromise { - return $Call.ByID(1052115198, id).then(($result: any) => { + return $Call.ByID(62418781, id).then(($result: any) => { return $$createType4($result); }); } export function GetSetting(id: string): $CancellablePromise { - return $Call.ByID(250150593, id).then(($result: any) => { + return $Call.ByID(2222012564, id).then(($result: any) => { return $$createType5($result); }); } export function GetSettingValue(id: string): $CancellablePromise { - return $Call.ByID(4025421518, id); + return $Call.ByID(2753368941, id); } export function GetString(id: string): $CancellablePromise { - return $Call.ByID(1187755918, id); + return $Call.ByID(3825211425, id); } export function GetStringSlice(id: string): $CancellablePromise { - return $Call.ByID(1832879132, id).then(($result: any) => { + return $Call.ByID(2121298005, id).then(($result: any) => { return $$createType6($result); }); } export function ListSettings(): $CancellablePromise { - return $Call.ByID(89725144).then(($result: any) => { + return $Call.ByID(3038957305).then(($result: any) => { return $$createType7($result); }); } +/** + * LoadSettings is a no-op reload trigger for the frontend. With bbolt-backed + * persistence, settings are always in memory — this exists so the frontend's + * "reload" button still has a valid binding. It re-reads from the in-memory + * store (which is already current). + */ export function LoadSettings(): $CancellablePromise { - return $Call.ByID(2308717928); -} - -export function ResetSetting(id: string): $CancellablePromise { - return $Call.ByID(2245976448, id); -} - -export function SaveSettings(): $CancellablePromise { - return $Call.ByID(3807115937); + return $Call.ByID(3369612893); } export function SetSetting(id: string, value: any): $CancellablePromise { - return $Call.ByID(2825655029, id, value); + return $Call.ByID(252556992, id, value); } export function SetSettings(settingsMap: { [_ in string]?: any }): $CancellablePromise { - return $Call.ByID(2820745458, settingsMap); + return $Call.ByID(1704369097, settingsMap); } +/** + * Values returns a flat map of all setting values keyed by "category.settingID". + * This is a host-side convenience for the frontend — it was removed from the SDK + * Provider interface (plugins don't need it) but the UI settings context uses it + * to populate the full settings state. + */ export function Values(): $CancellablePromise<{ [_ in string]?: any }> { - return $Call.ByID(3413962943).then(($result: any) => { + return $Call.ByID(2936263106).then(($result: any) => { return $$createType2($result); }); } diff --git a/packages/omniviewdev-runtime/src/context/plugins/PluginContextProvider.tsx b/packages/omniviewdev-runtime/src/context/plugins/PluginContextProvider.tsx index b209e2c4..0746a31b 100644 --- a/packages/omniviewdev-runtime/src/context/plugins/PluginContextProvider.tsx +++ b/packages/omniviewdev-runtime/src/context/plugins/PluginContextProvider.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { PluginContext } from './PluginContext'; -import { PluginValues } from '../../bindings/github.com/omniviewdev/omniview/settingscontrollerservice'; +import { PluginValues } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/settings/servicewrapper'; import { PluginMeta } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/config/models'; -import { GetPluginMeta } from '../../bindings/github.com/omniviewdev/omniview/pluginmanagerservice'; +import { GetPluginMeta } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/servicewrapper'; import { parseAppError } from '../../errors/parseAppError'; export type PluginContextProviderProps = { diff --git a/packages/omniviewdev-runtime/src/context/settings/SettingsContext.tsx b/packages/omniviewdev-runtime/src/context/settings/SettingsContext.tsx index 936f84f5..dbebe50f 100644 --- a/packages/omniviewdev-runtime/src/context/settings/SettingsContext.tsx +++ b/packages/omniviewdev-runtime/src/context/settings/SettingsContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useState } from 'react'; -import { Values } from '../../bindings/github.com/omniviewdev/omniview/settingsproviderservice'; +import { Values } from '../../bindings/github.com/omniviewdev/omniview/internal/settings/servicewrapper'; // Define the context type export interface SettingsContextType { diff --git a/packages/omniviewdev-runtime/src/hooks/connection/useConnection.tsx b/packages/omniviewdev-runtime/src/hooks/connection/useConnection.tsx index 2829801a..a0553c46 100644 --- a/packages/omniviewdev-runtime/src/hooks/connection/useConnection.tsx +++ b/packages/omniviewdev-runtime/src/hooks/connection/useConnection.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { GetConnection, UpdateConnection, RemoveConnection, StartConnection, StopConnection } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetConnection, UpdateConnection, RemoveConnection, StartConnection, StopConnection } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import type { Connection } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/types/models'; import { useSnackbar } from '../../hooks/snackbar/useSnackbar'; import { createErrorHandler } from '../../errors/parseAppError'; @@ -55,7 +55,7 @@ export const useConnection = ({ pluginID: explicitPluginID, connectionID }: UseC onSuccess(data, { name }) { showSnackbar({ message: `Connection ${name} successfully updated`, status: 'success' }); // Update the list and detail - queryClient.setQueryData(queryKey, connection); + queryClient.setQueryData(queryKey, data); queryClient.setQueriesData( { queryKey: [pluginID, 'connection', 'list'] }, (previous: Connection[] | undefined) => previous?.map(conn => conn.id === connectionID ? data : conn), diff --git a/packages/omniviewdev-runtime/src/hooks/connection/useConnectionNamespaces.ts b/packages/omniviewdev-runtime/src/hooks/connection/useConnectionNamespaces.ts index 426061ee..ebac0528 100644 --- a/packages/omniviewdev-runtime/src/hooks/connection/useConnectionNamespaces.ts +++ b/packages/omniviewdev-runtime/src/hooks/connection/useConnectionNamespaces.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { GetConnectionNamespaces } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetConnectionNamespaces } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseConnectionOptions = { diff --git a/packages/omniviewdev-runtime/src/hooks/connection/useConnectionStatus.ts b/packages/omniviewdev-runtime/src/hooks/connection/useConnectionStatus.ts index 4cb425c9..4e0cadb6 100644 --- a/packages/omniviewdev-runtime/src/hooks/connection/useConnectionStatus.ts +++ b/packages/omniviewdev-runtime/src/hooks/connection/useConnectionStatus.ts @@ -5,8 +5,8 @@ import { GetAllConnectionStates, StopConnection, StartConnectionWatch, -} from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; -import { RetryFailedPlugin } from '../../bindings/github.com/omniviewdev/omniview/pluginmanagerservice'; +} from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; +import { RetryFailedPlugin } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/servicewrapper'; import type { Connection } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/types/models'; import { WatchState } from '../../types/watch'; import type { WatchStateEvent } from '../../types/watch'; diff --git a/packages/omniviewdev-runtime/src/hooks/connection/useConnections.ts b/packages/omniviewdev-runtime/src/hooks/connection/useConnections.ts index 5f094b4a..c7694f30 100644 --- a/packages/omniviewdev-runtime/src/hooks/connection/useConnections.ts +++ b/packages/omniviewdev-runtime/src/hooks/connection/useConnections.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; -import { ListConnections, StartConnectionWatch, StopConnectionWatch } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { ListConnections, StartConnectionWatch, StopConnectionWatch } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import type { Connection } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/types/models'; import { useSnackbar } from '../../hooks/snackbar/useSnackbar'; import { createErrorHandler } from '../../errors/parseAppError'; @@ -45,16 +45,16 @@ export const useConnections = ({ plugin: explicitPlugin }: UseConnectionsOptions const connections = ev.data as Connection[]; console.log("got update to connections", connections) queryClient.setQueryData(queryKey, connections) - }, []); + }, [queryClient, queryKey]); - // *Only on mount*, we want subscribe to new resources, updates and deletes + // Subscribe to connection sync events from the backend. React.useEffect(() => { const syncCloser = Events.On(`${plugin}/connection/sync`, onConnectionSync); return () => { syncCloser() }; - }, []); + }, [plugin, onConnectionSync]); // === Queries === // diff --git a/packages/omniviewdev-runtime/src/hooks/data/usePluginData.ts b/packages/omniviewdev-runtime/src/hooks/data/usePluginData.ts index 4e927481..71cbe45e 100644 --- a/packages/omniviewdev-runtime/src/hooks/data/usePluginData.ts +++ b/packages/omniviewdev-runtime/src/hooks/data/usePluginData.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Get, Set } from '../../bindings/github.com/omniviewdev/omniview/datacontrollerservice'; +import { Get, Set } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/data/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UsePluginDataResult = { diff --git a/packages/omniviewdev-runtime/src/hooks/exec/useExecSession.ts b/packages/omniviewdev-runtime/src/hooks/exec/useExecSession.ts index d1f41711..0955d278 100644 --- a/packages/omniviewdev-runtime/src/hooks/exec/useExecSession.ts +++ b/packages/omniviewdev-runtime/src/hooks/exec/useExecSession.ts @@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { useSnackbar } from '../../hooks/snackbar/useSnackbar'; import { parseAppError, showAppError } from '../../errors/parseAppError'; import { SessionOptions } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/exec/models'; -import { CreateSession } from '../../bindings/github.com/omniviewdev/omniview/execcontrollerservice'; +import { CreateSession } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/exec/servicewrapper'; import { useBottomDrawer } from '../drawer'; import { useResolvedPluginId } from '../useResolvedPluginId'; diff --git a/packages/omniviewdev-runtime/src/hooks/logs/useLogSession.ts b/packages/omniviewdev-runtime/src/hooks/logs/useLogSession.ts index 27142a7d..ed47618d 100644 --- a/packages/omniviewdev-runtime/src/hooks/logs/useLogSession.ts +++ b/packages/omniviewdev-runtime/src/hooks/logs/useLogSession.ts @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { useSnackbar } from '../snackbar/useSnackbar'; import { createErrorHandler } from '../../errors/parseAppError'; -import { CreateSession, CloseSession } from '../../bindings/github.com/omniviewdev/omniview/logscontrollerservice'; +import { CreateSession, CloseSession } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/logs/servicewrapper'; import { CreateSessionOptions, LogSessionOptions } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/logs/models'; import { useBottomDrawer } from '../drawer'; import { useResolvedPluginId } from '../useResolvedPluginId'; diff --git a/packages/omniviewdev-runtime/src/hooks/metric/useMetricProviders.ts b/packages/omniviewdev-runtime/src/hooks/metric/useMetricProviders.ts index 078f053f..069b0049 100644 --- a/packages/omniviewdev-runtime/src/hooks/metric/useMetricProviders.ts +++ b/packages/omniviewdev-runtime/src/hooks/metric/useMetricProviders.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { GetProviders, GetProvidersForResource, -} from '../../bindings/github.com/omniviewdev/omniview/metriccontrollerservice'; +} from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/servicewrapper'; import type { MetricProviderSummary } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/models'; /** diff --git a/packages/omniviewdev-runtime/src/hooks/metric/useMetricStream.ts b/packages/omniviewdev-runtime/src/hooks/metric/useMetricStream.ts index c3ebc365..82d95aca 100644 --- a/packages/omniviewdev-runtime/src/hooks/metric/useMetricStream.ts +++ b/packages/omniviewdev-runtime/src/hooks/metric/useMetricStream.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Events } from '@wailsio/runtime'; -import { Subscribe, Unsubscribe } from '../../bindings/github.com/omniviewdev/omniview/metriccontrollerservice'; +import { Subscribe, Unsubscribe } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/servicewrapper'; import type { MetricResult } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/metric/models'; import { useResolvedPluginId } from '../useResolvedPluginId'; diff --git a/packages/omniviewdev-runtime/src/hooks/metric/useResourceMetrics.ts b/packages/omniviewdev-runtime/src/hooks/metric/useResourceMetrics.ts index 150e0297..e674fe03 100644 --- a/packages/omniviewdev-runtime/src/hooks/metric/useResourceMetrics.ts +++ b/packages/omniviewdev-runtime/src/hooks/metric/useResourceMetrics.ts @@ -1,5 +1,5 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query'; -import { QueryAll } from '../../bindings/github.com/omniviewdev/omniview/metriccontrollerservice'; +import { QueryAll } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/servicewrapper'; import type { QueryResponse } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/metric/models'; import type { MetricProviderSummary } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/metric/models'; import { useMetricProvidersForResource } from './useMetricProviders'; diff --git a/packages/omniviewdev-runtime/src/hooks/networker/usePortForwardSessions.ts b/packages/omniviewdev-runtime/src/hooks/networker/usePortForwardSessions.ts index 2512a801..9cd4fd75 100644 --- a/packages/omniviewdev-runtime/src/hooks/networker/usePortForwardSessions.ts +++ b/packages/omniviewdev-runtime/src/hooks/networker/usePortForwardSessions.ts @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ClosePortForwardSession, ListAllPortForwardSessions, -} from '../../bindings/github.com/omniviewdev/omniview/networkercontrollerservice'; +} from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/servicewrapper'; import { Browser, Events } from '@wailsio/runtime'; import { useSnackbar } from '../snackbar'; import { createErrorHandler, parseAppError } from '../../errors/parseAppError'; diff --git a/packages/omniviewdev-runtime/src/hooks/networker/useResourcePortForwarder.tsx b/packages/omniviewdev-runtime/src/hooks/networker/useResourcePortForwarder.tsx index 9572c120..d3f57940 100644 --- a/packages/omniviewdev-runtime/src/hooks/networker/useResourcePortForwarder.tsx +++ b/packages/omniviewdev-runtime/src/hooks/networker/useResourcePortForwarder.tsx @@ -6,7 +6,7 @@ import { ClosePortForwardSession, FindPortForwardSessions, StartResourcePortForwardingSession, -} from '../../bindings/github.com/omniviewdev/omniview/networkercontrollerservice'; +} from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/networker/servicewrapper'; import { useSnackbar } from '../snackbar'; import { createErrorHandler, parseAppError } from '../../errors/parseAppError'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -73,40 +73,6 @@ export function useResourcePortForwarder({ pluginID: explicitPluginID, connectio } }); - // const forward: PortForwardResourceFunction = React.useCallback(async (opts) => { - // const sessionOpts = PortForwardSessionOptions.createFrom({ - // local_port: opts.localPort || 0, - // remote_port: opts.remotePort, - // protocol: opts.protocol || 'TCP', - // connection_type: 'RESOURCE', - // connection: { - // resource_data: opts.resource, - // connection_id: connectionID, - // plugin_id: pluginID, - // resource_id: opts.resourceId, - // resource_key: opts.resourceKey, - // }, - // labels: opts.labels ?? {}, - // params: opts.parameters ?? {}, - // }); - // - // try { - // const session = await StartResourcePortForwardingSession(pluginID, connectionID, sessionOpts); - // setSessions([...sessions, session]); - // if (opts.openInBrowser) { - // BrowserOpenURL(`http://localhost:${session.local_port}`); - // } - // - // return session; - // } catch (e) { - // if (e instanceof Error) { - // showSnackbar(`Failed to start port forwarding sessions: ${e.message}`, 'error'); - // } - // - // throw e; - // } - // }, [pluginID, connectionID, sessions]); - const closeMutation = useMutation({ mutationFn: async ({ opts }: { opts: { sessionID: string } }) => ClosePortForwardSession(opts.sessionID), onError: createErrorHandler(showSnackbar, 'Failed to close port forwarding session'), @@ -118,19 +84,6 @@ export function useResourcePortForwarder({ pluginID: explicitPluginID, connectio } }); - // const close = React.useCallback(async (sessionId: string) => { - // try { - // await ClosePortForwardSession(sessionId); - // setSessions(sessions.filter((s) => s.id !== sessionId)); - // } catch (e) { - // if (e instanceof Error) { - // showSnackbar(`Failed to close port forwarding sessions: ${e.message}`, 'error'); - // } - // - // throw e; - // } - // }, [sessions]); - return { sessions, forward: forwardMutation.mutateAsync, diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useEditorSchemas.ts b/packages/omniviewdev-runtime/src/hooks/resource/useEditorSchemas.ts index 6419a539..e83bc2d2 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useEditorSchemas.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useEditorSchemas.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { GetEditorSchemas } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetEditorSchemas } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseEditorSchemasOptions = { diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResource.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResource.ts index c1223eb9..cc56f053 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResource.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResource.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from '../../hooks/snackbar/useSnackbar'; import { createErrorHandler } from '../../errors/parseAppError'; import { UpdateInput, DeleteInput, GetInput } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/resource/models'; -import { Get, Update, Delete } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { Get, Update, Delete } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseResourceOptions = { diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResourceActions.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResourceActions.ts index 57806803..ea7993ea 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResourceActions.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResourceActions.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from '../../hooks/snackbar/useSnackbar'; import { showAppError } from '../../errors/parseAppError'; import { ActionInput } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/resource/models'; -import { GetActions, ExecuteAction } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetActions, ExecuteAction } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseResourceActionsOptions = { diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResourceGroups.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResourceGroups.ts index 2cd9444b..e0e04fc5 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResourceGroups.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResourceGroups.ts @@ -1,13 +1,12 @@ import { useQuery } from '@tanstack/react-query'; // Underlying client -import { GetResourceGroups } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetResourceGroups } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseResourceGroupsOptions = { /** - * The ID of the category responsible for this resource - * @example "appearance" + * The ID of the plugin */ pluginID?: string; /** @@ -17,8 +16,7 @@ type UseResourceGroupsOptions = { }; /** - * Interact with a category of settings from the global settings provider. Intended for use in the settings UI - if - * you need to read or write settings from a specific plugin, use the `@hooks/settings/useSettings` hook instead. + * Fetch the resource groups for a given plugin and connection. */ export const useResourceGroups = ({ pluginID: explicitPluginID, connectionID }: UseResourceGroupsOptions) => { const pluginID = useResolvedPluginId(explicitPluginID); @@ -32,7 +30,7 @@ export const useResourceGroups = ({ pluginID: explicitPluginID, connectionID }: return { /** - * The current settings from the provider. + * The resource groups for the given plugin and connection. */ groups, }; diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResourceMutations.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResourceMutations.ts index c97b1283..9a1b6114 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResourceMutations.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResourceMutations.ts @@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { useSnackbar } from '../../hooks/snackbar/useSnackbar'; import { showAppError } from '../../errors/parseAppError'; import { CreateInput, UpdateInput, DeleteInput } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/resource/models'; -import { Create, Update, Delete } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { Create, Update, Delete } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type ResourceMutationOptions = { diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResourceSearch.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResourceSearch.ts index a293e15b..84a23f2a 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResourceSearch.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResourceSearch.ts @@ -2,7 +2,7 @@ import { useQueries } from '@tanstack/react-query'; // Types import { ListInput } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/resource/models'; -import { List } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { List } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseResourceSearchOptions = { diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResourceType.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResourceType.ts index c742f605..a6c8385f 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResourceType.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResourceType.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; // Underlying client -import { GetResourceType } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetResourceType } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseResourceTypesOptions = { diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResourceTypes.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResourceTypes.ts index 56a860ab..d7e95925 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResourceTypes.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResourceTypes.ts @@ -1,13 +1,12 @@ import { useQuery } from '@tanstack/react-query'; // Underlying client -import { GetResourceTypes } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetResourceTypes } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { useResolvedPluginId } from '../useResolvedPluginId'; type UseResourceTypesOptions = { /** - * The ID of the category responsible for this resource - * @example "appearance" + * The ID of the plugin */ pluginID?: string; /** @@ -17,12 +16,11 @@ type UseResourceTypesOptions = { }; /** - * Interact with a category of settings from the global settings provider. Intended for use in the settings UI - if - * you need to read or write settings from a specific plugin, use the `@hooks/settings/useSettings` hook instead. + * Fetch the resource types for a given plugin and connection. */ export const useResourceTypes = ({ pluginID: explicitPluginID, connectionID }: UseResourceTypesOptions) => { const pluginID = useResolvedPluginId(explicitPluginID); - const queryKey = [pluginID, 'resources', 'list']; + const queryKey = [pluginID, 'resources', 'list', connectionID]; const types = useQuery({ queryKey, @@ -32,7 +30,7 @@ export const useResourceTypes = ({ pluginID: explicitPluginID, connectionID }: U return { /** - * The current settings from the provider. + * The resource types for the given plugin and connection. */ types, }; diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useResources.ts b/packages/omniviewdev-runtime/src/hooks/resource/useResources.ts index a3bd2b10..8224e412 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useResources.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useResources.ts @@ -9,7 +9,7 @@ import { WatchState } from '../../types/watch'; import type { WatchStateEvent } from '../../types/watch'; // Underlying client -import { List, Create, SubscribeResource, UnsubscribeResource } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { List, Create, SubscribeResource, UnsubscribeResource } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { Events } from '@wailsio/runtime'; import { useResolvedPluginId } from '../useResolvedPluginId'; import { useEventBatcher } from './useEventBatcher'; diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useStreamAction.ts b/packages/omniviewdev-runtime/src/hooks/resource/useStreamAction.ts index 887a5a1d..a758b15f 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useStreamAction.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useStreamAction.ts @@ -3,7 +3,7 @@ import { useSnackbar } from '../snackbar/useSnackbar'; import { showAppError } from '../../errors/parseAppError'; import { useOperations } from '../operations/useOperations'; import { ActionInput } from '../../bindings/github.com/omniviewdev/plugin-sdk/pkg/v1/resource/models'; -import { StreamAction } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { StreamAction } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { Events } from '@wailsio/runtime'; import { useResolvedPluginId } from '../useResolvedPluginId'; diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.test.ts b/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.test.ts index 6d691a78..ac0c1248 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.test.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.test.ts @@ -14,7 +14,7 @@ vi.mock('../useResolvedPluginId', () => ({ // Mock GetWatchState — controlled via mockGetWatchState. let mockGetWatchState: vi.Mock; -vi.mock('../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice', () => ({ +vi.mock('../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper', () => ({ get GetWatchState() { return mockGetWatchState; }, })); diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts b/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts index b24daf51..61e4a296 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { produce } from 'immer'; -import { GetWatchState } from '../../bindings/github.com/omniviewdev/omniview/resourcecontrollerservice'; +import { GetWatchState } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { Events } from '@wailsio/runtime'; import { useResolvedPluginId } from '../useResolvedPluginId'; import type { diff --git a/pkg/settings/.golangci.yml b/pkg/settings/.golangci.yml deleted file mode 100644 index 8bbf8a29..00000000 --- a/pkg/settings/.golangci.yml +++ /dev/null @@ -1,209 +0,0 @@ -version: "2" -linters: - default: none - enable: - - asasalint - - asciicheck - - bidichk - - bodyclose - - cyclop - - dupl - - durationcheck - - errcheck - - errname - - errorlint - - exhaustive - - exhaustruct - - forbidigo - - funlen - - gocheckcompilerdirectives - - gochecknoglobals - - gochecknoinits - - gochecksumtype - - gocognit - - goconst - - gocritic - - gocyclo - - godot - - gomoddirectives - - gomodguard - - goprintffuncname - - gosec - - govet - - ineffassign - - lll - - loggercheck - - makezero - - mirror - - mnd - - musttag - - nakedret - - nestif - - nilerr - - nilnil - - noctx - - nolintlint - - nonamedreturns - - nosprintfhostport - - perfsprint - - predeclared - - promlinter - - protogetter - - reassign - - revive - - rowserrcheck - - sloglint - - sqlclosecheck - - staticcheck - - tagalign - - testableexamples - - testifylint - - testpackage - - tparallel - - unconvert - - unparam - - unused - - usestdlibvars - - wastedassign - - whitespace - settings: - cyclop: - max-complexity: 30 - package-average: 10 - errcheck: - check-type-assertions: true - exhaustive: - check: - - switch - - map - exhaustruct: - exclude: - - ^net/http.Client$ - - ^net/http.Cookie$ - - ^net/http.Request$ - - ^net/http.Response$ - - ^net/http.Server$ - - ^net/http.Transport$ - - ^net/url.URL$ - - ^os/exec.Cmd$ - - ^reflect.StructField$ - - ^github.com/Shopify/sarama.Config$ - - ^github.com/Shopify/sarama.ProducerMessage$ - - ^github.com/mitchellh/mapstructure.DecoderConfig$ - - ^github.com/prometheus/client_golang/.+Opts$ - - ^github.com/spf13/cobra.Command$ - - ^github.com/spf13/cobra.CompletionOptions$ - - ^github.com/stretchr/testify/mock.Mock$ - - ^github.com/testcontainers/testcontainers-go.+Request$ - - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ - - ^golang.org/x/tools/go/analysis.Analyzer$ - - ^google.golang.org/protobuf/.+Options$ - - ^gopkg.in/yaml.v3.Node$ - funlen: - lines: 100 - statements: 50 - ignore-comments: true - gocognit: - min-complexity: 20 - gocritic: - settings: - captLocal: - paramsOnly: false - underef: - skipRecvDeref: false - mnd: - ignored-functions: - - flag.Arg - - flag.Duration.* - - flag.Float.* - - flag.Int.* - - flag.Uint.* - - os.Chmod - - os.Mkdir.* - - os.OpenFile - - os.WriteFile - - prometheus.ExponentialBuckets.* - - prometheus.LinearBuckets - gomodguard: - blocked: - modules: - - github.com/golang/protobuf: - recommendations: - - google.golang.org/protobuf - reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules - - github.com/satori/go.uuid: - recommendations: - - github.com/google/uuid - reason: satori's package is not maintained - - github.com/gofrs/uuid: - recommendations: - - github.com/google/uuid - reason: gofrs' package is not go module - govet: - disable: - - fieldalignment - enable-all: true - settings: - shadow: - strict: true - nakedret: - max-func-lines: 0 - nolintlint: - require-explanation: true - require-specific: true - allow-no-explanation: - - funlen - - gocognit - - lll - rowserrcheck: - packages: - - github.com/jmoiron/sqlx - exclusions: - generated: lax - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - rules: - - linters: - - godot - source: (noinspection|TODO) - - linters: - - gocritic - source: //noinspection - - linters: - - bodyclose - - dupl - - funlen - - goconst - - gosec - - noctx - path: _test\.go - paths: - - third_party$ - - builtin$ - - examples$ -issues: - max-same-issues: 50 -formatters: - enable: - - gci - - goimports - settings: - gci: - sections: - - standard - - default - - prefix(github.com/omniviewdev) - - blank - - dot - goimports: - local-prefixes: - - github.com/omniviewdev - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ diff --git a/pkg/settings/go.mod b/pkg/settings/go.mod deleted file mode 100644 index 325119be..00000000 --- a/pkg/settings/go.mod +++ /dev/null @@ -1,12 +0,0 @@ -module github.com/omniviewdev/settings - -go 1.23.8 - -require go.uber.org/zap v1.27.0 - -require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/stretchr/testify v1.10.0 // indirect - go.uber.org/multierr v1.11.0 // indirect -) diff --git a/pkg/settings/go.sum b/pkg/settings/go.sum deleted file mode 100644 index 7699b0b0..00000000 --- a/pkg/settings/go.sum +++ /dev/null @@ -1,10 +0,0 @@ -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/settings/provider.go b/pkg/settings/provider.go deleted file mode 100644 index 8a0422b8..00000000 --- a/pkg/settings/provider.go +++ /dev/null @@ -1,718 +0,0 @@ -package settings - -import ( - "context" - "encoding/gob" - "errors" - "fmt" - "reflect" - "strings" - - "go.uber.org/zap" -) - -var ( - ErrSettingNotFound = errors.New("setting not found") - ErrSettingTypeMismatch = errors.New("setting type mismatch") - ErrSettingCategoryNotFound = errors.New("setting category not found") - ErrInvalidSettingID = errors.New("invalid setting ID") -) - -//nolint:gochecknoglobals // don't want to have to specify this in every function -var nilSetting = Setting{} - -// The settings store is a map of maps. The first map is the category of the settings, and the second -// map is the settings themselves. The key of the first map is the category name, and the key of the -// second map is the setting ID. -type Store map[string]Category - -// Category is a group of settings. This is used to group settings together in the UI. -type Category struct { - Settings map[string]Setting `json:"settings"` - ID string `json:"id"` - Label string `json:"label"` - Description string `json:"description"` - Icon string `json:"icon"` -} - -// Provider manages the settings for the application. This is used to load and save settings -// to and from local storage. -type Provider interface { - // Initialize initializes the settings provider with a set of base settings. Add the wails - // context so we can eventually dispatch events when settings change. - Initialize(ctx context.Context, categories ...Category) error - - // LoadSettings loads the settings from local storage - LoadSettings() error - - // SaveSettings saves the settings to local storage - SaveSettings() error - - // ListSettings returns the settings store - ListSettings() Store - - // Values returns all of the values in the store as a map - Values() map[string]any - - // GetSetting returns the setting by ID. This ID should be in the form of a dot separated string - // that represents the path to the setting. For example, "appearance.theme" - GetSetting(id string) (Setting, error) - - // GetSettingValue returns the value of the setting by ID - GetSettingValue(id string) (any, error) - - // SetSetting sets the value of the setting by ID - SetSetting(id string, value any) error - - // SetSettings sets multiple settings at once - SetSettings(settings map[string]any) error - - // ResetSetting resets the value of the setting by ID to the default value - ResetSetting(id string) error - - // RegisterSetting registers a setting with the provider - RegisterSetting(categoryID string, setting Setting) error - - // RegisterSettings registers a list of settings with the provider to a category - RegisterSettings(categoryID string, settings ...Setting) error - - // RegisterChangeHandler registers a callback that fires after settings in the - // given category are saved. Only one handler per category. - RegisterChangeHandler(categoryID string, fn CategoryChangeFunc) - - // GetCategories returns a list of all categories, with the settings removed. This - // is intended for use in the UI to display the categories menu. - GetCategories() []Category - - // GetCategorySettings returns the settings by category - GetCategory(id string) (Category, error) - - // GetCategoryValues returns a map of the values of the settings by category - GetCategoryValues(id string) (map[string]interface{}, error) - - // GetString returns the value of the setting by ID as a string. - // This is a convenience method for getting a string setting. - GetString(id string) (string, error) - - // GetStringSlice returns the value of the setting by ID as a string slice. - // This is a convenience method for getting a string slice setting. - GetStringSlice(id string) ([]string, error) - - // GetInt returns the value of the setting by ID as an int. - // This is a convenience method for getting an int setting. - GetInt(id string) (int, error) - - // GetIntSlice returns the value of the setting by ID as an int slice. - // This is a convenience method for getting an int slice setting. - GetIntSlice(id string) ([]int, error) - - // GetFloat returns the value of the setting by ID as a float64. - // This is a convenience method for getting a float setting. - GetFloat(id string) (float64, error) - - // GetFloatSlice returns the value of the setting by ID as a float64 slice. - // This is a convenience method for getting a float slice setting. - GetFloatSlice(id string) ([]float64, error) - - // GetBool returns the value of the setting by ID as a bool. - // This is a convenience method for getting a bool setting. - GetBool(id string) (bool, error) -} - -// ProviderOpts are the options for creating a new settings provider. -type ProviderOpts struct { - // Logger is the logger for the provider - Logger *zap.SugaredLogger - // PluginID is the ID of the plugin - PluginID string - // PluginSettings - PluginSettings []Category -} - -func NewProvider(opts ProviderOpts) Provider { - provider := &provider{ - logger: opts.Logger, - pluginID: opts.PluginID, - } - - if len(opts.PluginSettings) > 0 { - if err := provider.Initialize(context.Background(), opts.PluginSettings...); err != nil { - // if we can't initialize settings, don't start up - panic(err) - } - } - - return provider -} - -// CategoryChangeFunc is called after settings in a category are saved. -// The map contains all current setting values for the category. -type CategoryChangeFunc func(values map[string]any) - -type provider struct { - ctx context.Context - pluginID string - logger *zap.SugaredLogger - store Store - changeHandlers map[string]CategoryChangeFunc -} - -// define custom merge behavior to make sure we don't overwrite any existing settings, -// but update the values of any settings that already exist -// -// TODO - right now, if the new setting has a different type than the existing setting, -// the new setting will overwrite the existing setting. We should probably throw an error -// if this happens. -func (p *provider) mergeSettings(categories ...Category) { - if p.store == nil { - p.store = make(Store) - } - for _, category := range categories { - currentCategory, ok := p.store[category.ID] - if !ok { - // we don't have this category, go ahead and full assign it and set the settings - // to their defaults - newSettings := make(map[string]Setting, len(category.Settings)) - for _, setting := range category.Settings { - setting.Value = setting.Default - newSettings[setting.ID] = setting - } - category.Settings = newSettings - p.store[category.ID] = category - continue - } - - // if we've gotten here, the category exists - // lets update the category info first - currentCategory.Label = category.Label - currentCategory.Description = category.Description - currentCategory.Icon = category.Icon - - for id, setting := range category.Settings { - current, ok := currentCategory.Settings[id] - if !ok { - // we don't have this setting, go ahead and full assign it with a default - // and move on - setting.Value = setting.Default - currentCategory.Settings[id] = setting - continue - } - - // now for the merge behavior. We'll want to make sure that the setting type is the same - // as the existing setting, so we'll need to use reflection here. - // if there's a type mismatch, don't fail, but log an error - // TODO - we should probably do some behavior here to try to convert the value to the - // correct type, but for now, we'll just log an error - if reflect.TypeOf(setting.Type) != reflect.TypeOf(current.Type) { - // log an error and continue - p.logger.Errorf( - "setting type mismatch: %s. currently has %s, tried to assign %s", - id, - reflect.TypeOf(current.Type), - reflect.TypeOf(setting.Type), - ) - continue - } - - var toCheck interface{} - if setting.Value != nil { - toCheck = setting.Value - } else { - toCheck = setting.Default - } - - if reflect.TypeOf(current.Value) != reflect.TypeOf(toCheck) { - // log an error and continue - p.logger.Errorf( - "setting value mismatch: %s. currently has %s, tried to assign %s", - id, - reflect.TypeOf(current.Value), - reflect.TypeOf(toCheck), - ) - continue - } - - current.Label = setting.Label - current.Description = setting.Description - current.Default = setting.Default - current.Validator = setting.Validator - current.Options = setting.Options - - currentCategory.Settings[id] = current - } - - p.store[category.ID] = currentCategory - } -} - -func (p *provider) Initialize(ctx context.Context, categories ...Category) error { - if p.store != nil { - return errors.New("settings provider already initialized") - } - - p.ctx = ctx - - // load in, merge, and resave the settings to make sure we have the latest - // ready to go - if err := p.LoadSettings(); err != nil { - return err - } - - if len(categories) == 0 { - // nothing to do - return nil - } - - p.mergeSettings(categories...) - if err := p.SaveSettings(); err != nil { - return err - } - return nil -} - -func (p *provider) SaveSettings() error { - gob.Register(Store{}) - gob.Register(Setting{}) - gob.Register(SettingOption{}) - gob.Register(Category{}) - gob.Register([]interface{}{}) - - store, err := GetStore(p.pluginID) - if err != nil { - return err - } - defer store.Close() - - encoder := gob.NewEncoder(store) - return encoder.Encode(p.store) -} - -func (p *provider) LoadSettings() error { - gob.Register(Store{}) - gob.Register(Setting{}) - gob.Register(SettingOption{}) - gob.Register(Category{}) - gob.Register([]interface{}{}) - - store, err := GetStore(p.pluginID) - if err != nil { - return err - } - defer store.Close() - - // if the file is empty, we'll initialize the state with an empty map - fileInfo, err := store.Stat() - if err != nil { - return err - } - - // nothing to decode if the file is empty - if fileInfo.Size() == 0 { - p.logger.Debugw("settings store is empty, initializing") - encoder := gob.NewEncoder(store) - return encoder.Encode(p.store) - } - - // proceed with decoding since the file is not empty - decoder := gob.NewDecoder(store) - err = decoder.Decode(&p.store) - if err != nil { - return err - } - - return nil -} - -func (p *provider) ListSettings() Store { - return p.store -} - -func (p *provider) Values() map[string]any { - m := make(map[string]any, len(p.store)) - for categoryID, category := range p.store { - for settingID, setting := range category.Settings { - m[fmt.Sprintf("%s.%s", categoryID, settingID)] = setting.Value - } - } - - return m -} - -func (p *provider) GetSetting(id string) (Setting, error) { - category, id, err := p.parseSettingID(id) - if err != nil { - return nilSetting, err - } - setting, ok := p.store[category].Settings[id] - if !ok { - return nilSetting, ErrSettingNotFound - } - return setting, nil -} - -func (p *provider) GetSettingValue(id string) (any, error) { - setting, err := p.GetSetting(id) - if err != nil { - return nil, err - } - return setting.Value, nil -} - -func (p *provider) SetSettings(settings map[string]any) error { - // Stage: validate all settings before mutating any state. - type staged struct { - setting Setting - category string - id string - } - entries := make([]staged, 0, len(settings)) - for rawID, value := range settings { - setting, err := p.GetSetting(rawID) - if err != nil { - return err - } - if err = setting.SetValue(value); err != nil { - return err - } - cat, id, err := p.parseSettingID(rawID) - if err != nil { - return err - } - entries = append(entries, staged{setting: setting, category: cat, id: id}) - } - - // Record original values for rollback, then apply. - type original struct { - category string - id string - setting Setting - } - originals := make([]original, 0, len(entries)) - changedCategories := make(map[string]struct{}, len(entries)) - for _, e := range entries { - originals = append(originals, original{ - category: e.category, - id: e.id, - setting: p.store[e.category].Settings[e.id], - }) - p.store[e.category].Settings[e.id] = e.setting - changedCategories[e.category] = struct{}{} - } - if err := p.SaveSettings(); err != nil { - // Rollback in-memory state to match persisted state. - for _, o := range originals { - p.store[o.category].Settings[o.id] = o.setting - } - return err - } - p.notifyChangeHandlers(changedCategories) - return nil -} - -// private method so we can save after a bulk vs individual setting change. -func (p *provider) setSetting(id string, value any) error { - setting, err := p.GetSetting(id) - if err != nil { - return err - } - if err = setting.SetValue(value); err != nil { - return err - } - - category, id, err := p.parseSettingID(id) - if err != nil { - return err - } - - p.store[category].Settings[id] = setting - return nil -} - -func (p *provider) SetSetting(id string, value any) error { - if err := p.setSetting(id, value); err != nil { - return err - } - if err := p.SaveSettings(); err != nil { - return err - } - if cat, _, err := p.parseSettingID(id); err == nil { - p.notifyChangeHandlers(map[string]struct{}{cat: {}}) - } - return nil -} - -func (p *provider) ResetSetting(id string) error { - category, settingKey, err := p.parseSettingID(id) - if err != nil { - return err - } - - setting, ok := p.store[category].Settings[settingKey] - if !ok { - return ErrSettingNotFound - } - setting.ResetValue() - p.store[category].Settings[settingKey] = setting - if err := p.SaveSettings(); err != nil { - return err - } - p.notifyChangeHandlers(map[string]struct{}{category: {}}) - return nil -} - -func (p *provider) HasSetting(id string) bool { - _, err := p.GetSetting(id) - return err == nil -} - -func (p *provider) RegisterSetting(category string, setting Setting) error { - found, ok := p.store[category] - if !ok { - return ErrSettingCategoryNotFound - } - found.Settings[setting.ID] = setting - p.store[category] = found - return nil -} - -func (p *provider) RegisterSettings(category string, settings ...Setting) error { - for _, setting := range settings { - if err := p.RegisterSetting(category, setting); err != nil { - return err - } - } - return nil -} - -func (p *provider) RegisterChangeHandler(categoryID string, fn CategoryChangeFunc) { - if p.changeHandlers == nil { - p.changeHandlers = make(map[string]CategoryChangeFunc) - } - p.changeHandlers[categoryID] = fn -} - -func (p *provider) notifyChangeHandlers(changedCategories map[string]struct{}) { - for cat := range changedCategories { - fn, ok := p.changeHandlers[cat] - if !ok { - continue - } - vals, err := p.GetCategoryValues(cat) - if err != nil { - p.logger.Warnw("failed to get category values for change handler", "category", cat, "error", err) - continue - } - go func(category string, handler CategoryChangeFunc, values map[string]any) { - handler(values) - }(cat, fn, vals) - } -} - -func (p *provider) GetCategories() []Category { - categories := make([]Category, 0, len(p.store)) - for category := range p.store { - // copy and remove the settings so we don't expose them - copied := p.store[category] - copied.Settings = nil - categories = append(categories, copied) - } - return categories -} - -func (p *provider) GetCategory(category string) (Category, error) { - settings, ok := p.store[category] - if !ok { - return Category{}, ErrSettingCategoryNotFound - } - return settings, nil -} - -func (p *provider) GetCategoryValues(category string) (map[string]interface{}, error) { - cat, err := p.GetCategory(category) - if err != nil { - return nil, err - } - - values := make(map[string]interface{}, len(cat.Settings)) - for id, setting := range cat.Settings { - values[id] = setting.Value - } - return values, nil -} - -func (p *provider) parseSettingID(id string) (string, string, error) { - // if we have a pluginID on the provider, the category will always be "plugin" - if p.pluginID != "" { - return "plugin", id, nil - } - - parts := strings.Split(id, ".") - //nolint:gomnd // self-explanatory - if len(parts) != 2 { - return "", "", ErrInvalidSettingID - } - if parts[0] == "" || parts[1] == "" { - return "", "", ErrInvalidSettingID - } - return parts[0], parts[1], nil -} - -// ============================================= UTILS ============================================= // - -// GetString returns the value of the setting by ID as a string. -func (p *provider) GetString(id string) (string, error) { - setting, err := p.GetSetting(id) - if err != nil { - return "", err - } - if setting.Type != Text { - return "", ErrSettingTypeMismatch - } - val, ok := setting.Value.(string) - if !ok { - return "", ErrSettingTypeMismatch - } - return val, nil -} - -// GetStringSlice returns the value of the setting by ID as a string slice. -func (p *provider) GetStringSlice(id string) ([]string, error) { - setting, err := p.GetSetting(id) - if err != nil { - return nil, err - } - if setting.Type != Text { - return nil, ErrSettingTypeMismatch - } - - var strs []string - switch v := setting.Value.(type) { - case []string: - return v, nil - case []interface{}: - for _, item := range v { - if str, valOk := item.(string); valOk { - strs = append(strs, str) - } else { - return nil, errors.New("expected []string, but item is not a string") - } - } - return strs, nil - default: - return nil, fmt.Errorf("expected []string or []interface{}, got %T", setting.Value) - } -} - -// GetInt returns the value of the setting by ID as an int. -func (p *provider) GetInt(id string) (int, error) { - setting, err := p.GetSetting(id) - if err != nil { - return 0, err - } - if setting.Type != Integer { - return 0, ErrSettingTypeMismatch - } - val, ok := setting.Value.(int) - if !ok { - return 0, ErrSettingTypeMismatch - } - return val, nil -} - -// GetIntSlice returns the value of the setting by ID as an int slice. -func (p *provider) GetIntSlice(id string) ([]int, error) { - setting, err := p.GetSetting(id) - if err != nil { - return nil, err - } - if setting.Type != Integer { - return nil, ErrSettingTypeMismatch - } - - var vals []int - if slice, ok := setting.Value.([]interface{}); ok { - for _, item := range slice { - switch item := item.(type) { - case int: - vals = append(vals, item) - case int32: - vals = append(vals, int(item)) - case int64: - vals = append(vals, int(item)) - case uint: - vals = append(vals, int(item)) - case uint32: - vals = append(vals, int(item)) - case uint64: - vals = append(vals, int(item)) - default: - return nil, errors.New("expected []int, but item is not an int") - } - } - } else { - return nil, fmt.Errorf("expected []int, got %T", setting.Value) - } - - return vals, nil -} - -// GetFloat returns the value of the setting by ID as a float64. -func (p *provider) GetFloat(id string) (float64, error) { - setting, err := p.GetSetting(id) - if err != nil { - return 0, err - } - if setting.Type != Float { - return 0, ErrSettingTypeMismatch - } - val, ok := setting.Value.(float64) - if !ok { - return 0, ErrSettingTypeMismatch - } - return val, nil -} - -// GetFloatSlice returns the value of the setting by ID as a float64 slice. -func (p *provider) GetFloatSlice(id string) ([]float64, error) { - setting, err := p.GetSetting(id) - if err != nil { - return nil, err - } - if setting.Type != Float { - return nil, ErrSettingTypeMismatch - } - - var vals []float64 - if slice, ok := setting.Value.([]interface{}); ok { - for _, item := range slice { - switch item := item.(type) { - case float64: - vals = append(vals, item) - case float32: - vals = append(vals, float64(item)) - default: - return nil, errors.New("expected []float64, but item is not a float") - } - } - } else { - return nil, fmt.Errorf("expected []float64, got %T", setting.Value) - } - - return vals, nil -} - -// GetBool returns the value of the setting by ID as a bool. -func (p *provider) GetBool(id string) (bool, error) { - setting, err := p.GetSetting(id) - if err != nil { - return false, err - } - if setting.Type != Toggle { - return false, ErrSettingTypeMismatch - } - val, ok := setting.Value.(bool) - if !ok { - return false, ErrSettingTypeMismatch - } - return val, nil -} diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go deleted file mode 100644 index 52b331df..00000000 --- a/pkg/settings/setting.go +++ /dev/null @@ -1,120 +0,0 @@ -package settings - -import ( - "fmt" - "reflect" -) - -type Setting struct { - // ID is the unique identifier of the setting - ID string `json:"id"` - // Label is the human readable label of the setting - Label string `json:"label"` - // Description is the human readable description of the setting - Description string `json:"description"` - // Type is the type of the setting - Type SettingType `json:"type"` - // Value is the value of the setting - Value interface{} `json:"value"` - // Default is the default value of the setting - Default interface{} `json:"default"` - // Validator is an optional function to validate the setting, which should return an error - // if the value is invalid - Validator func(interface{}) error `json:"-"` - // Options is an optional list of options for a select setting - Options []SettingOption `json:"options"` - // FileSelection is an optional setting for file selection - FileSelection *SettingFileSelection `json:"fileSelection"` - // Sensitive is a flag to indicate if the setting is sensitive and should not be - // shown in the UI, nor allowed to be used by any other plugin. - Sensitive bool `json:"sensitive"` -} - -// Validate checks if the value of the setting is valid using an optional -// validation function. If no validation function is provided, it returns nil. -func (s *Setting) Validate(value interface{}) error { - if s.Validator != nil { - return s.Validator(value) - } - return nil -} - -// SetValue sets the value of the setting and validates it using the -// validator function defined. -func (s *Setting) SetValue(value interface{}) error { - if err := s.Validate(value); err != nil { - return err - } - s.set(value) - return nil -} - -// GetValue returns the value of the setting. -func (s *Setting) GetValue() interface{} { - if s.Sensitive { - // Don't expose to the UI - return nil - } - - if s.Value == nil { - return s.Default - } - - return s.Value -} - -// ResetValue resets the value of the setting to its default value. -func (s *Setting) ResetValue() { - s.Value = s.Default -} - -//========================================= PRIVATE METHODS ================================== - -// tryConvertSlice attempts to convert a slice of any type to a slice of interface{}. -func tryConvertSlice(value any) ([]interface{}, bool) { - valReflect := reflect.ValueOf(value) - if valReflect.Kind() != reflect.Slice { - return nil, false - } - result := make([]interface{}, valReflect.Len()) - for i := 0; i < valReflect.Len(); i++ { - result[i] = valReflect.Index(i).Interface() - } - return result, true -} - -// set performs the main setting logic, doing any necessary conversion to try to satisfy any easily -// convertible types. This is a private method so we can save after a bulk vs individual setting change. -func (s *Setting) set(value any) error { - currentVal := s.Value - currType := reflect.TypeOf(currentVal) - valType := reflect.TypeOf(value) - - if currType.Kind() == reflect.Slice && valType.Kind() == reflect.Slice { - // convert slice elements one by one if the destination is []interface{} - if slice, ok := tryConvertSlice(value); ok { - value = slice - } - } else if currType.ConvertibleTo(valType) { - // convert the value to the type of the current value - value = reflect.ValueOf(value).Convert(currType).Interface() - } else if currType.Kind() == reflect.Float64 && valType.Kind() == reflect.Int { - // convert int to float64 - value = float64(value.(int)) - } else if currType.Kind() == reflect.Int && valType.Kind() == reflect.Float64 { - // convert float64 to int - value = int(value.(float64)) - } else if !reflect.TypeOf(value).AssignableTo(currType) { - // for non-slice types, fall back to the original type check - return fmt.Errorf( - "setting type mismatch for setting '%s'. currently has %s, tried to assign %s", - s.ID, - reflect.TypeOf(currentVal).String(), - reflect.TypeOf(value).String(), - ) - } - - // all good, assign the value - s.Value = value - return nil -} diff --git a/pkg/settings/types.go b/pkg/settings/types.go deleted file mode 100644 index 0b1048a2..00000000 --- a/pkg/settings/types.go +++ /dev/null @@ -1,61 +0,0 @@ -package settings - -type SettingType string - -const ( - // Text is the type for a text field entry. - Text SettingType = "text" - // Integer is the type for an integer field entry. - Integer SettingType = "integer" - // Float is the type for a float field entry. - Float SettingType = "float" - // Toggle is the type for a toggle field entry. - Toggle SettingType = "toggle" - // Color is the type for a color field entry. - Color SettingType = "color" - // DateTime is the type for a date time field entry. - DateTime SettingType = "datetime" - // SettingTypePassword is the type for a password field entry. - Password SettingType = "password" -) - -type SettingFileSelection struct { - // Whether file selection should be allowed. - Enabled bool `json:"enabled"` - // Allow the selection of folders. - AllowFolders bool `json:"allowFolders"` - // The allowed extensions that should be selectable. - Extensions []string `json:"extensions"` - // Multiple files can be selected. - Multiple bool `json:"multiple"` - // Whether the file selection should be saved as a relative path. - Relative bool `json:"relative"` - // DefaultPath is the default path for the file selection. - DefaultPath string `json:"defaultPath"` -} - -// AllSettingTypes is a list of all setting types. Necessary for Wails -// to bind the enums. -// -//nolint:gochecknoglobals // this is a necessary for enum binding -var AllSettingTypes = []struct { - Value SettingType - TSName string -}{ - {Text, "TEXT"}, - {Integer, "INTEGER"}, - {Float, "FLOAT"}, - {Toggle, "TOGGLE"}, - {Color, "COLOR"}, - {DateTime, "DATETIME"}, - {Password, "PASSWORD"}, -} - -type SettingOption struct { - // Label is the human readable label of the option - Label string `json:"label"` - // Description is an optional human readable description of the option - Description string `json:"description"` - // Value is the value of the option - Value interface{} `json:"value"` -} diff --git a/pkg/settings/utils.go b/pkg/settings/utils.go deleted file mode 100644 index 37d5eaa4..00000000 --- a/pkg/settings/utils.go +++ /dev/null @@ -1,64 +0,0 @@ -package settings - -import ( - "errors" - "os" - "path/filepath" -) - -// TODO - move this to a globally usable package. -const ( - BaseDir = ".omniview" - StoreFile = "settings" -) - -// TODO - move this to a globally usable package. -func getBaseStorePath(pluginID string) (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - if pluginID != "" { - return filepath.Join(homeDir, BaseDir, "plugins", pluginID), nil - } - return filepath.Join(homeDir, BaseDir), nil -} - -// GetStorePath returns the path to the settings store. -func GetStorePath(pluginID string) (string, error) { - base, err := getBaseStorePath(pluginID) - if err != nil { - return "", err - } - - return filepath.Join(base, StoreFile), nil -} - -// GetStore returns the settings store, initializing it if necessary. -func GetStore(pluginID string) (*os.File, error) { - storePath, err := GetStorePath(pluginID) - if err != nil { - return nil, err - } - - // check if the parent directory needs to be created - base := filepath.Dir(storePath) - if _, err = os.Stat(base); errors.Is(err, os.ErrNotExist) { - if err = os.MkdirAll(base, 0755); err != nil { - return nil, err - } - } - - return os.OpenFile(storePath, os.O_CREATE|os.O_RDWR, 0600) -} - -// RemoveStore removes the settings store from the system. This should -// only ever be used when a user decides to reset to factory settings. -func RemoveStore(pluginID string) error { - storePath, err := GetStorePath(pluginID) - if err != nil { - return err - } - - return os.Remove(storePath) -} diff --git a/plugin_asset_handler.go b/plugin_asset_handler.go index 59ba8a33..01066a2a 100644 --- a/plugin_asset_handler.go +++ b/plugin_asset_handler.go @@ -3,11 +3,11 @@ package main import ( "fmt" "net/http" - "os" - "path/filepath" + "path" "regexp" "strings" + "github.com/omniviewdev/omniview/internal/appstate" logging "github.com/omniviewdev/plugin-sdk/log" "github.com/wailsapp/mimetype" ) @@ -16,13 +16,15 @@ import ( // It is used as middleware in the Wails v3 AssetOptions to handle // requests for plugin-specific static files (JS, CSS, images, fonts). type PluginAssetHandler struct { - logger logging.Logger + logger logging.Logger + stateRoot *appstate.ScopedRoot } // NewPluginAssetHandler creates a new PluginAssetHandler. -func NewPluginAssetHandler(logger logging.Logger) *PluginAssetHandler { +func NewPluginAssetHandler(logger logging.Logger, stateRoot *appstate.ScopedRoot) *PluginAssetHandler { return &PluginAssetHandler{ - logger: logger, + logger: logger, + stateRoot: stateRoot, } } @@ -85,37 +87,23 @@ func (h *PluginAssetHandler) ServeHTTP(res http.ResponseWriter, req *http.Reques respondUnauthorized() return } - requestedFilename = strings.TrimPrefix(requestedFilename, "/_") + requestedFilename = strings.TrimPrefix(requestedFilename, "/_/") - requestedFilename = filepath.Clean(requestedFilename) + // Normalize the path to prevent traversal via sequences like "foo/../bar". + requestedFilename = path.Clean(requestedFilename) h.logger.Debugw(ctx, "requested file", "path", requestedFilename) - if !isAllowed(requestedFilename) { + if !isAllowed("/"+requestedFilename) { respondUnauthorized() return } - homeDir, err := os.UserHomeDir() - if err != nil { - h.logger.Errorw(ctx, "failed to get home directory", "error", err) - res.WriteHeader(http.StatusInternalServerError) - return - } - - toFetch := filepath.Join(homeDir, ".omniview", requestedFilename) - // Containment check: ensure resolved path stays under ~/.omniview - omniviewRoot := filepath.Join(homeDir, ".omniview") - if !strings.HasPrefix(toFetch, omniviewRoot+string(filepath.Separator)) { - respondUnauthorized() - return - } - h.logger.Infow(ctx, "fetching file", "path", toFetch) - - fileData, err := os.ReadFile(toFetch) + // ScopedRoot enforces containment — no manual checks needed. + fileData, err := h.stateRoot.ReadFile(requestedFilename) if err != nil { res.WriteHeader(http.StatusBadRequest) - if _, err = fmt.Fprintf(res, "Could not load file %s", toFetch); err != nil { + if _, err = fmt.Fprintf(res, "Could not load file %s", requestedFilename); err != nil { h.logger.Errorw(ctx, "error serving file", "error", err) } return