From 6a4edb00900de7bbab12cb41c811b352403e4831 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 18 Apr 2026 08:54:22 +0100 Subject: [PATCH] Harden layout persistence path handling --- .core/TODO.md | 1 - pkg/window/layout.go | 120 +++++++++++++++++++++++++++++++++----- pkg/window/layout_test.go | 21 +++++++ 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/.core/TODO.md b/.core/TODO.md index 42905e2f..e69de29b 100644 --- a/.core/TODO.md +++ b/.core/TODO.md @@ -1 +0,0 @@ -- @bug pkg/window/layout.go:29 — layout persistence has the same hard-coded config-dir assumption and needs an explicit file-path override for restricted runtimes. diff --git a/pkg/window/layout.go b/pkg/window/layout.go index 373f4e88..585ffe28 100644 --- a/pkg/window/layout.go +++ b/pkg/window/layout.go @@ -2,6 +2,7 @@ package window import ( + "io/fs" "sync" "time" @@ -10,6 +11,8 @@ import ( coreerr "dappco.re/go/core/log" ) +const layoutFileEnv = "WINDOW_LAYOUT_FILE" + // Layout is a named window arrangement. type Layout struct { Name string `json:"name"` @@ -28,13 +31,18 @@ type LayoutInfo struct { // LayoutManager persists named window arrangements to ~/.config/Core/layouts.json. type LayoutManager struct { - configDir string - layouts map[string]Layout - mu sync.RWMutex + configDir string + layoutPath string + layouts map[string]Layout + mu sync.RWMutex } // NewLayoutManager creates a LayoutManager loading from the default config directory. +// WINDOW_LAYOUT_FILE overrides the directory-based default when present. func NewLayoutManager() *LayoutManager { + if layoutFile := core.Env(layoutFileEnv); layoutFile != "" { + return NewLayoutManagerWithPath(layoutFile) + } lm := &LayoutManager{ layouts: make(map[string]Layout), } @@ -56,36 +64,116 @@ func NewLayoutManagerWithDir(configDir string) *LayoutManager { return lm } +// NewLayoutManagerWithPath creates a LayoutManager loading from a custom layout file. +// Useful for tests and restricted runtimes that need an explicit writable target. +func NewLayoutManagerWithPath(path string) *LayoutManager { + lm := &LayoutManager{ + layoutPath: path, + layouts: make(map[string]Layout), + } + lm.load() + return lm +} + func (lm *LayoutManager) filePath() string { + if lm.layoutPath != "" { + return lm.layoutPath + } return core.JoinPath(lm.configDir, "layouts.json") } +func (lm *LayoutManager) dataDir() string { + if lm.layoutPath != "" { + return core.PathDir(lm.layoutPath) + } + return lm.configDir +} + +// SetPath switches the manager to a custom layout file path. +func (lm *LayoutManager) SetPath(path string) { + if path == "" { + return + } + lm.mu.Lock() + lm.layoutPath = path + lm.layouts = make(map[string]Layout) + lm.mu.Unlock() + lm.load() +} + func (lm *LayoutManager) load() { - if lm.configDir == "" { + if lm.configDir == "" && lm.layoutPath == "" { return } content, err := coreio.Local.Read(lm.filePath()) if err != nil { + if core.Is(err, fs.ErrNotExist) { + return + } + core.Error( + "window layout load failed", + "path", lm.filePath(), + "err", core.E("window.LayoutManager.load", "failed to read window layouts", err), + ) + return + } + loaded := make(map[string]Layout) + if result := core.JSONUnmarshalString(content, &loaded); !result.OK { + if decodeErr, ok := result.Value.(error); ok { + core.Error( + "window layout load failed", + "path", lm.filePath(), + "err", core.E("window.LayoutManager.load", "failed to decode window layouts", decodeErr), + ) + } return } lm.mu.Lock() - defer lm.mu.Unlock() - _ = core.JSONUnmarshalString(content, &lm.layouts) + lm.layouts = loaded + lm.mu.Unlock() } -func (lm *LayoutManager) save() { - if lm.configDir == "" { - return +func (lm *LayoutManager) save() error { + if lm.configDir == "" && lm.layoutPath == "" { + return nil } lm.mu.RLock() - result := core.JSONMarshal(lm.layouts) + filePath := lm.filePath() + layouts := make(map[string]Layout, len(lm.layouts)) + for name, layout := range lm.layouts { + layouts[name] = layout + } + result := core.JSONMarshal(layouts) lm.mu.RUnlock() if !result.OK { - return + marshalErr, _ := result.Value.(error) + core.Error( + "window layout save failed", + "path", filePath, + "err", core.E("window.LayoutManager.save", "failed to encode window layouts", marshalErr), + ) + return core.E("window.LayoutManager.save", "failed to encode window layouts", marshalErr) } data := result.Value.([]byte) - _ = coreio.Local.EnsureDir(lm.configDir) - _ = coreio.Local.Write(lm.filePath(), string(data)) + if dir := lm.dataDir(); dir != "" { + if err := coreio.Local.EnsureDir(dir); err != nil { + core.Error( + "window layout save failed", + "path", filePath, + "err", core.E("window.LayoutManager.save", "failed to create window layout directory", err), + ) + return core.E("window.LayoutManager.save", "failed to create window layout directory", err) + } + } + if err := coreio.Local.Write(filePath, string(data)); err != nil { + core.Error( + "window layout save failed", + "path", filePath, + "err", core.E("window.LayoutManager.save", "failed to write window layouts", err), + ) + return core.E("window.LayoutManager.save", "failed to write window layouts", err) + } + return nil } // SaveLayout creates or updates a named layout. @@ -108,7 +196,9 @@ func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowS } lm.layouts[name] = layout lm.mu.Unlock() - lm.save() + if err := lm.save(); err != nil { + return err + } return nil } @@ -139,5 +229,5 @@ func (lm *LayoutManager) DeleteLayout(name string) { lm.mu.Lock() delete(lm.layouts, name) lm.mu.Unlock() - lm.save() + _ = lm.save() } diff --git a/pkg/window/layout_test.go b/pkg/window/layout_test.go index cc9f2bd4..7d812961 100644 --- a/pkg/window/layout_test.go +++ b/pkg/window/layout_test.go @@ -1,6 +1,8 @@ package window import ( + "os" + "path/filepath" "testing" "time" @@ -57,3 +59,22 @@ func TestLayoutManager_SaveLayout_Ugly(t *testing.T) { assert.Greater(t, second.UpdatedAt, first.UpdatedAt) assert.Equal(t, 1024, second.Windows["main"].Width) } + +func TestLayoutManager_NewLayoutManagerWithPathEnv_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "custom", "layouts.json") + t.Setenv(layoutFileEnv, path) + + lm := NewLayoutManager() + + require.NotNil(t, lm) + assert.Equal(t, path, lm.filePath()) + assert.Equal(t, filepath.Dir(path), lm.dataDir()) + + require.NoError(t, lm.SaveLayout("coding", map[string]WindowState{ + "main": {Width: 800, Height: 600}, + })) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), `"coding"`) +}