From f884d698b2c3b6620ad170db604101ec2a47c88d Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 16:25:29 +0000 Subject: [PATCH] fix(display): correct GetWindowTitle and add WS input validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetWindowTitle was returning info.Name (the window's identifier) instead of the actual title. Added Title() to the PlatformWindow interface and Title field to WindowInfo so the real title flows through queries. Added wsRequire() helper and input validation for all webview:* WS message cases — window name is required for every webview action, and selectors/URLs are validated where they'd cause errors if empty. Co-Authored-By: Claude Opus 4.6 --- pkg/display/display.go | 148 +++++++++++++++++++++++++++++------- pkg/window/messages.go | 53 +++++++++++++ pkg/window/mock_platform.go | 1 + pkg/window/mock_test.go | 1 + pkg/window/platform.go | 1 + pkg/window/service.go | 141 +++++++++++++++++++++++++++++++++- pkg/window/wails.go | 9 ++- 7 files changed, 319 insertions(+), 35 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index 7d6ddfb..236b068 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -237,6 +237,15 @@ type WSMessage struct { Data map[string]any `json:"data,omitempty"` } +// wsRequire extracts a string field from WS data and returns an error if it is empty. +func wsRequire(data map[string]any, key string) (string, error) { + v, _ := data[key].(string) + if v == "" { + return "", fmt.Errorf("ws: missing required field %q", key) + } + return v, nil +} + // handleWSMessage bridges WebSocket commands to IPC calls. func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { var result any @@ -291,47 +300,98 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { case "contextmenu:list": result, handled, err = s.Core().QUERY(contextmenu.QueryList{}) case "webview:eval": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } script, _ := msg.Data["script"].(string) result, handled, err = s.Core().PERFORM(webview.TaskEvaluate{Window: w, Script: script}) case "webview:click": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskClick{Window: w, Selector: sel}) case "webview:type": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } text, _ := msg.Data["text"].(string) result, handled, err = s.Core().PERFORM(webview.TaskType{Window: w, Selector: sel, Text: text}) case "webview:navigate": - w, _ := msg.Data["window"].(string) - url, _ := msg.Data["url"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + url, e := wsRequire(msg.Data, "url") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskNavigate{Window: w, URL: url}) case "webview:screenshot": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskScreenshot{Window: w}) case "webview:scroll": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } x, _ := msg.Data["x"].(float64) y, _ := msg.Data["y"].(float64) result, handled, err = s.Core().PERFORM(webview.TaskScroll{Window: w, X: int(x), Y: int(y)}) case "webview:hover": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskHover{Window: w, Selector: sel}) case "webview:select": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } val, _ := msg.Data["value"].(string) result, handled, err = s.Core().PERFORM(webview.TaskSelect{Window: w, Selector: sel, Value: val}) case "webview:check": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } checked, _ := msg.Data["checked"].(bool) result, handled, err = s.Core().PERFORM(webview.TaskCheck{Window: w, Selector: sel, Checked: checked}) case "webview:upload": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } pathsRaw, _ := msg.Data["paths"].([]any) var paths []string for _, p := range pathsRaw { @@ -341,15 +401,24 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { } result, handled, err = s.Core().PERFORM(webview.TaskUploadFile{Window: w, Selector: sel, Paths: paths}) case "webview:viewport": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } width, _ := msg.Data["width"].(float64) height, _ := msg.Data["height"].(float64) result, handled, err = s.Core().PERFORM(webview.TaskSetViewport{Window: w, Width: int(width), Height: int(height)}) case "webview:clear-console": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskClearConsole{Window: w}) case "webview:console": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } level, _ := msg.Data["level"].(string) limit := 100 if l, ok := msg.Data["limit"].(float64); ok { @@ -357,22 +426,43 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { } result, handled, err = s.Core().QUERY(webview.QueryConsole{Window: w, Level: level, Limit: limit}) case "webview:query": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QuerySelector{Window: w, Selector: sel}) case "webview:query-all": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QuerySelectorAll{Window: w, Selector: sel}) case "webview:dom-tree": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, _ := msg.Data["selector"].(string) // selector optional for dom-tree (defaults to root) result, handled, err = s.Core().QUERY(webview.QueryDOMTree{Window: w, Selector: sel}) case "webview:url": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QueryURL{Window: w}) case "webview:title": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QueryTitle{Window: w}) default: return nil, false, nil @@ -685,7 +775,7 @@ func (s *Service) GetWindowTitle(name string) (string, error) { if info == nil { return "", fmt.Errorf("window not found: %s", name) } - return info.Name, nil // Wails v3 doesn't expose a title getter + return info.Title, nil } // ResetWindowState clears saved window positions. diff --git a/pkg/window/messages.go b/pkg/window/messages.go index f663b4b..b5d1a13 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -3,6 +3,7 @@ package window // WindowInfo contains information about a window. type WindowInfo struct { Name string `json:"name"` + Title string `json:"title"` X int `json:"x"` Y int `json:"y"` Width int `json:"width"` @@ -52,6 +53,58 @@ type TaskMinimise struct{ Name string } // TaskFocus brings a window to the front. type TaskFocus struct{ Name string } +// TaskRestore restores a maximised or minimised window to its normal state. +type TaskRestore struct{ Name string } + +// TaskSetTitle changes a window's title. +type TaskSetTitle struct { + Name string + Title string +} + +// TaskSetVisibility shows or hides a window. +type TaskSetVisibility struct { + Name string + Visible bool +} + +// TaskFullscreen enters or exits fullscreen mode. +type TaskFullscreen struct { + Name string + Fullscreen bool +} + +// --- Layout Queries --- + +// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo +type QueryLayoutList struct{} + +// QueryLayoutGet returns a layout by name. Result: *Layout (nil if not found) +type QueryLayoutGet struct{ Name string } + +// --- Layout Tasks --- + +// TaskSaveLayout saves the current window arrangement as a named layout. Result: bool +type TaskSaveLayout struct{ Name string } + +// TaskRestoreLayout restores a saved layout by name. +type TaskRestoreLayout struct{ Name string } + +// TaskDeleteLayout removes a saved layout by name. +type TaskDeleteLayout struct{ Name string } + +// TaskTileWindows arranges windows in a tiling mode. +type TaskTileWindows struct { + Mode string // "left-right", "grid", "left-half", "right-half", etc. + Windows []string // window names; empty = all +} + +// TaskSnapWindow snaps a window to a screen edge/corner. +type TaskSnapWindow struct { + Name string // window name + Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center" +} + // TaskSaveConfig persists this service's config section via the display orchestrator. type TaskSaveConfig struct{ Value map[string]any } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index f460d77..9dde9a6 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -39,6 +39,7 @@ type MockWindow struct { } func (w *MockWindow) Name() string { return w.name } +func (w *MockWindow) Title() string { return w.title } func (w *MockWindow) Position() (int, int) { return w.x, w.y } func (w *MockWindow) Size() (int, int) { return w.width, w.height } func (w *MockWindow) IsMaximised() bool { return w.maximised } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 4b989ef..72d54ca 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -38,6 +38,7 @@ type mockWindow struct { } func (w *mockWindow) Name() string { return w.name } +func (w *mockWindow) Title() string { return w.title } func (w *mockWindow) Position() (int, int) { return w.x, w.y } func (w *mockWindow) Size() (int, int) { return w.width, w.height } func (w *mockWindow) IsMaximised() bool { return w.maximised } diff --git a/pkg/window/platform.go b/pkg/window/platform.go index f8e62ee..ae4e2e6 100644 --- a/pkg/window/platform.go +++ b/pkg/window/platform.go @@ -28,6 +28,7 @@ type PlatformWindowOptions struct { type PlatformWindow interface { // Identity Name() string + Title() string // Queries Position() (int, int) diff --git a/pkg/window/service.go b/pkg/window/service.go index 85286e5..040ab95 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -68,6 +68,14 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { return s.queryWindowList(), true, nil case QueryWindowByName: return s.queryWindowByName(q.Name), true, nil + case QueryLayoutList: + return s.manager.Layout().ListLayouts(), true, nil + case QueryLayoutGet: + l, ok := s.manager.Layout().GetLayout(q.Name) + if !ok { + return (*Layout)(nil), true, nil + } + return &l, true, nil default: return nil, false, nil } @@ -81,7 +89,7 @@ func (s *Service) queryWindowList() []WindowInfo { x, y := pw.Position() w, h := pw.Size() result = append(result, WindowInfo{ - Name: name, X: x, Y: y, Width: w, Height: h, + Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised(), Focused: pw.IsFocused(), }) @@ -98,7 +106,7 @@ func (s *Service) queryWindowByName(name string) *WindowInfo { x, y := pw.Position() w, h := pw.Size() return &WindowInfo{ - Name: name, X: x, Y: y, Width: w, Height: h, + Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised(), Focused: pw.IsFocused(), } @@ -122,6 +130,25 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskMinimise(t.Name) case TaskFocus: return nil, true, s.taskFocus(t.Name) + case TaskRestore: + return nil, true, s.taskRestore(t.Name) + case TaskSetTitle: + return nil, true, s.taskSetTitle(t.Name, t.Title) + case TaskSetVisibility: + return nil, true, s.taskSetVisibility(t.Name, t.Visible) + case TaskFullscreen: + return nil, true, s.taskFullscreen(t.Name, t.Fullscreen) + case TaskSaveLayout: + return nil, true, s.taskSaveLayout(t.Name) + case TaskRestoreLayout: + return nil, true, s.taskRestoreLayout(t.Name) + case TaskDeleteLayout: + s.manager.Layout().DeleteLayout(t.Name) + return nil, true, nil + case TaskTileWindows: + return nil, true, s.taskTileWindows(t.Mode, t.Windows) + case TaskSnapWindow: + return nil, true, s.taskSnapWindow(t.Name, t.Position) default: return nil, false, nil } @@ -134,7 +161,7 @@ func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { } x, y := pw.Position() w, h := pw.Size() - info := WindowInfo{Name: pw.Name(), X: x, Y: y, Width: w, Height: h} + info := WindowInfo{Name: pw.Name(), Title: pw.Title(), X: x, Y: y, Width: w, Height: h} // Attach platform event listeners that convert to IPC actions s.trackWindow(pw) @@ -238,6 +265,114 @@ func (s *Service) taskFocus(name string) error { return nil } +func (s *Service) taskRestore(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Restore() + s.manager.State().UpdateMaximized(name, false) + return nil +} + +func (s *Service) taskSetTitle(name, title string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetTitle(title) + return nil +} + +func (s *Service) taskSetVisibility(name string, visible bool) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetVisibility(visible) + return nil +} + +func (s *Service) taskFullscreen(name string, fullscreen bool) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + if fullscreen { + pw.Fullscreen() + } else { + pw.UnFullscreen() + } + return nil +} + +func (s *Service) taskSaveLayout(name string) error { + windows := s.queryWindowList() + states := make(map[string]WindowState, len(windows)) + for _, w := range windows { + states[w.Name] = WindowState{ + X: w.X, Y: w.Y, Width: w.Width, Height: w.Height, + Maximized: w.Maximized, + } + } + return s.manager.Layout().SaveLayout(name, states) +} + +func (s *Service) taskRestoreLayout(name string) error { + layout, ok := s.manager.Layout().GetLayout(name) + if !ok { + return fmt.Errorf("layout not found: %s", name) + } + for winName, state := range layout.Windows { + pw, found := s.manager.Get(winName) + if !found { + continue + } + pw.SetPosition(state.X, state.Y) + pw.SetSize(state.Width, state.Height) + if state.Maximized { + pw.Maximise() + } + } + return nil +} + +var tileModeMap = map[string]TileMode{ + "left-half": TileModeLeftHalf, "right-half": TileModeRightHalf, + "top-half": TileModeTopHalf, "bottom-half": TileModeBottomHalf, + "top-left": TileModeTopLeft, "top-right": TileModeTopRight, + "bottom-left": TileModeBottomLeft, "bottom-right": TileModeBottomRight, + "left-right": TileModeLeftRight, "grid": TileModeGrid, +} + +func (s *Service) taskTileWindows(mode string, names []string) error { + tm, ok := tileModeMap[mode] + if !ok { + return fmt.Errorf("unknown tile mode: %s", mode) + } + if len(names) == 0 { + names = s.manager.List() + } + // Default screen size — callers can query screen_primary for actual values. + return s.manager.TileWindows(tm, names, 1920, 1080) +} + +var snapPosMap = map[string]SnapPosition{ + "left": SnapLeft, "right": SnapRight, + "top": SnapTop, "bottom": SnapBottom, + "top-left": SnapTopLeft, "top-right": SnapTopRight, + "bottom-left": SnapBottomLeft, "bottom-right": SnapBottomRight, + "center": SnapCenter, "centre": SnapCenter, +} + +func (s *Service) taskSnapWindow(name, position string) error { + pos, ok := snapPosMap[position] + if !ok { + return fmt.Errorf("unknown snap position: %s", position) + } + return s.manager.SnapWindow(name, pos, 1920, 1080) +} + // Manager returns the underlying window Manager for direct access. func (s *Service) Manager() *Manager { return s.manager diff --git a/pkg/window/wails.go b/pkg/window/wails.go index 7d9fbb1..1d2a722 100644 --- a/pkg/window/wails.go +++ b/pkg/window/wails.go @@ -37,7 +37,7 @@ func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]), } w := wp.app.Window.NewWithOptions(wOpts) - return &wailsWindow{w: w} + return &wailsWindow{w: w, title: opts.Title} } func (wp *WailsPlatform) GetWindows() []PlatformWindow { @@ -52,16 +52,19 @@ func (wp *WailsPlatform) GetWindows() []PlatformWindow { } // wailsWindow wraps *application.WebviewWindow to implement PlatformWindow. +// It stores the title locally because Wails v3 does not expose a title getter. type wailsWindow struct { - w *application.WebviewWindow + w *application.WebviewWindow + title string } func (ww *wailsWindow) Name() string { return ww.w.Name() } +func (ww *wailsWindow) Title() string { return ww.title } func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() } func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() } func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() } func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() } -func (ww *wailsWindow) SetTitle(title string) { ww.w.SetTitle(title) } +func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) } func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) } func (ww *wailsWindow) SetSize(width, height int) { ww.w.SetSize(width, height) } func (ww *wailsWindow) SetBackgroundColour(r, g, b, a uint8) {