fix(display): correct GetWindowTitle and add WS input validation
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 16:25:29 +00:00
parent ed44b6f7f9
commit f884d698b2
7 changed files with 319 additions and 35 deletions

View file

@ -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.

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -28,6 +28,7 @@ type PlatformWindowOptions struct {
type PlatformWindow interface {
// Identity
Name() string
Title() string
// Queries
Position() (int, int)

View file

@ -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

View file

@ -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) {