feat(gui): expand Wails stub App with 11-manager surface

stubs/wails/pkg/application/ now has all 11 managers matching Wails
v3's application package:
- application.go: App aligned to 11 managers
- browser.go (new): BrowserManager stub
- event.go (new): EventManager stub
- key_binding.go (new): KeyBindingManager stub
- clipboard.go / context_menu.go / dialog.go / environment.go /
  screen.go: expanded with missing constructors + compat methods

Stubs are no-op-safe: App{} constructs; every manager field is
non-nil; every method signature matches Wails v3 sources
(https://github.com/wailsapp/wails/tree/master/v3/pkg/application)
and returns a zero value without panic.

application_test.go exercises manager access from &App{} and calls
through the stub surface. GOWORK=off go vet ./... + go test ./...
both clean under stubs/wails/.

Prevents nil-panic when consumer code touches app.Dialog /
app.Browser / app.Screen / app.KeyBinding / etc before Wails v3 GA
lands.

Closes tasks.lthn.sh/view.php?id=23

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-24 07:21:15 +01:00
parent 7976b579a4
commit c766b8eb0e
10 changed files with 602 additions and 6 deletions

View file

@ -809,7 +809,6 @@ type App struct {
Logger Logger
Window WindowManager
Menu MenuManager
SystemTray SystemTrayManager
Dialog DialogManager
Event EventManager
Browser BrowserManager
@ -817,6 +816,7 @@ type App struct {
ContextMenu ContextMenuManager
Environment EnvironmentManager
Screen ScreenManager
SystemTray SystemTrayManager
KeyBinding KeyBindingManager
}

View file

@ -1,10 +1,13 @@
package application
import (
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wailsapp/wails/v3/pkg/events"
)
var _ Window = (*WebviewWindow)(nil)
@ -384,11 +387,154 @@ func TestApplication_App_Bad(t *testing.T) {
var app App
assert.Zero(t, app.Logger)
assert.Zero(t, app.Window)
assert.Zero(t, app.Menu)
assert.Empty(t, app.Window.GetAll())
assert.Nil(t, app.Menu.applicationMenu)
}
func TestApplication_App_Ugly(t *testing.T) {
app := &App{}
app.Quit()
}
func TestApplication_AppManagers_Good(t *testing.T) {
app := &App{}
require.NotNil(t, &app.Window)
require.NotNil(t, &app.Menu)
require.NotNil(t, &app.Dialog)
require.NotNil(t, &app.Event)
require.NotNil(t, &app.Browser)
require.NotNil(t, &app.Clipboard)
require.NotNil(t, &app.ContextMenu)
require.NotNil(t, &app.Environment)
require.NotNil(t, &app.Screen)
require.NotNil(t, &app.SystemTray)
require.NotNil(t, &app.KeyBinding)
assert.NotPanics(t, func() {
window := app.Window.NewWithOptions(WebviewWindowOptions{Name: "app-managers"})
require.NotNil(t, window)
menu := app.NewMenu()
app.Menu.SetApplicationMenu(menu)
assert.Same(t, menu, app.Menu.applicationMenu)
assert.NotNil(t, app.Dialog.Info())
_, err := app.Dialog.ShowInfo("Done", "Saved")
assert.NoError(t, err)
assert.NoError(t, app.Browser.OpenURL("https://example.com"))
assert.NoError(t, app.Browser.Open("https://example.com"))
assert.True(t, app.Clipboard.SetText("copied"))
text, ok := app.Clipboard.Text()
assert.True(t, ok)
assert.Equal(t, "copied", text)
contextMenu := app.ContextMenu.New()
app.ContextMenu.Add("main", contextMenu)
gotMenu, exists := app.ContextMenu.Get("main")
assert.True(t, exists)
assert.Same(t, contextMenu, gotMenu)
env := newEnvironmentManager().Info()
assert.Equal(t, runtime.GOOS, env.OS)
assert.Equal(t, runtime.GOARCH, env.Arch)
assert.NoError(t, app.Environment.OpenFileManager("/tmp", false))
assert.False(t, app.Environment.HasFocusFollowsMouse())
cancel := app.Event.Once("ready", func(*CustomEvent) {})
require.NotNil(t, cancel)
assert.False(t, app.Event.Emit("ready"))
cancel()
screen := &Screen{
ID: "primary",
IsPrimary: true,
Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
Size: Size{Width: 1920, Height: 1080},
}
assert.NoError(t, app.Screen.LayoutScreens([]*Screen{screen}))
assert.Same(t, screen, app.Screen.GetPrimary())
assert.Same(t, screen, app.Screen.Primary())
assert.Equal(t, Point{X: 5, Y: 6}, app.Screen.DipToPhysicalPoint(Point{X: 5, Y: 6}))
triggered := 0
app.KeyBinding.Register("CmdOrCtrl+K", func(Window) { triggered++ })
assert.True(t, app.KeyBinding.Process("CmdOrCtrl+K", nil))
assert.Equal(t, 1, triggered)
tray := app.SystemTray.New()
assert.NotNil(t, tray)
})
}
func TestApplication_AppManagers_Bad(t *testing.T) {
app := &App{}
assert.NotPanics(t, func() {
assert.Nil(t, app.Window.GetByID(1))
app.Menu.SetApplicationMenu(nil)
_, err := app.Dialog.ShowError()
assert.NoError(t, err)
assert.NoError(t, app.Browser.Open(""))
text, ok := app.Clipboard.Text()
assert.False(t, ok)
assert.Empty(t, text)
app.ContextMenu.Remove("missing")
_, exists := app.ContextMenu.Get("missing")
assert.False(t, exists)
info := app.Environment.Info()
assert.Empty(t, info.OS)
assert.Empty(t, info.Arch)
assert.NoError(t, app.Environment.OpenFileManager("", false))
app.Event.Off("missing")
app.Event.Reset()
assert.Nil(t, app.Screen.Primary())
app.KeyBinding.Unregister("missing")
assert.False(t, app.KeyBinding.Process("missing", nil))
})
}
func TestApplication_AppManagers_Ugly(t *testing.T) {
app := &App{}
assert.NotPanics(t, func() {
assert.True(t, app.Clipboard.SetText("zero\x00byte"))
assert.NoError(t, app.Browser.Open("/tmp/\x00report.txt"))
app.ContextMenu.Add("dup", app.ContextMenu.New())
app.ContextMenu.Add("dup", app.ContextMenu.New())
assert.Len(t, app.ContextMenu.GetAll(), 1)
cancelHook := app.Event.RegisterApplicationEventHook(events.ApplicationEventType(9), func(event *ApplicationEvent) {
event.Cancel()
})
cancelListener := app.Event.OnApplicationEvent(events.ApplicationEventType(9), func(*ApplicationEvent) {
t.Fatal("cancelled event should not reach listeners")
})
app.Event.handleApplicationEvent(&ApplicationEvent{Id: 9})
cancelListener()
cancelHook()
screen := &Screen{
ID: "primary",
IsPrimary: true,
Bounds: Rect{X: 0, Y: 0, Width: 100, Height: 100},
Size: Size{Width: 100, Height: 100},
}
app.Screen.SetScreens([]*Screen{screen})
assert.Same(t, screen, app.Screen.ScreenNearestDipPoint(Point{X: 50, Y: 50}))
assert.Same(t, screen, app.Screen.ScreenNearestDipRect(Rect{X: 10, Y: 10, Width: 5, Height: 5}))
triggered := 0
app.KeyBinding.Register("CmdOrCtrl+Shift+P", func(Window) { triggered++ })
app.KeyBinding.handleWindowKeyEvent(&windowKeyEvent{acceleratorString: "CmdOrCtrl+Shift+P"})
assert.Equal(t, 1, triggered)
})
}

View file

@ -0,0 +1,19 @@
package application
import "strings"
func newBrowserManager() *BrowserManager {
return &BrowserManager{}
}
func (bm *BrowserManager) Open(target string) error {
if bm == nil {
return nil
}
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
return bm.OpenURL(target)
}
return bm.OpenFile(target)
}

View file

@ -13,6 +13,10 @@ type Clipboard struct {
set bool
}
func newClipboard() *Clipboard {
return &Clipboard{}
}
// SetText stores the given text in the in-memory clipboard.
//
// cb.SetText("copied content")
@ -50,6 +54,10 @@ type ClipboardManager struct {
clipboard *Clipboard
}
func newClipboardManager() *ClipboardManager {
return &ClipboardManager{}
}
// SetText sets text in the clipboard.
//
// manager.SetText("some text")
@ -73,12 +81,12 @@ func (cm *ClipboardManager) Text() (string, bool) {
// getClipboard returns the clipboard instance, creating it if needed.
func (cm *ClipboardManager) getClipboard() *Clipboard {
if cm == nil {
return &Clipboard{}
return newClipboard()
}
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.clipboard == nil {
cm.clipboard = &Clipboard{}
cm.clipboard = newClipboard()
}
return cm.clipboard
}

View file

@ -22,6 +22,12 @@ type ContextMenuManager struct {
contextMenus map[string]*ContextMenu
}
func newContextMenuManager() *ContextMenuManager {
return &ContextMenuManager{
contextMenus: make(map[string]*ContextMenu),
}
}
// New creates a new context menu.
//
// menu := manager.New()

View file

@ -356,6 +356,10 @@ type DialogManager struct {
mu sync.RWMutex
}
func newDialogManager() *DialogManager {
return &DialogManager{}
}
// OpenFile creates an open-file dialog.
//
// dialog := manager.OpenFile()
@ -419,3 +423,32 @@ func (dm *DialogManager) Warning() *MessageDialog {
func (dm *DialogManager) Error() *MessageDialog {
return newMessageDialog(ErrorDialogType)
}
func (dm *DialogManager) ShowInfo(args ...string) (string, error) {
return dm.showDialog(dm.Info(), args...)
}
func (dm *DialogManager) ShowQuestion(args ...string) (string, error) {
return dm.showDialog(dm.Question(), args...)
}
func (dm *DialogManager) ShowWarning(args ...string) (string, error) {
return dm.showDialog(dm.Warning(), args...)
}
func (dm *DialogManager) ShowError(args ...string) (string, error) {
return dm.showDialog(dm.Error(), args...)
}
func (dm *DialogManager) showDialog(dialog *MessageDialog, args ...string) (string, error) {
if dialog == nil {
return "", nil
}
if len(args) > 0 {
dialog.SetTitle(args[0])
}
if len(args) > 1 {
dialog.SetMessage(args[1])
}
return dialog.Show()
}

View file

@ -1,6 +1,10 @@
package application
import "sync"
import (
"path/filepath"
"runtime"
"sync"
)
// EnvironmentInfo holds information about the host environment.
//
@ -12,6 +16,7 @@ type EnvironmentInfo struct {
Debug bool
IsDarkMode bool
AccentColour string
OSInfo any
PlatformInfo map[string]any
}
@ -27,6 +32,16 @@ type EnvironmentManager struct {
operatingSystem string
architecture string
debugMode bool
osInfo any
platformInfo map[string]any
}
func newEnvironmentManager() *EnvironmentManager {
return &EnvironmentManager{
operatingSystem: runtime.GOOS,
architecture: runtime.GOARCH,
platformInfo: make(map[string]any),
}
}
// SetDarkMode sets the dark mode state used by IsDarkMode.
@ -75,11 +90,47 @@ func (em *EnvironmentManager) GetAccentColor() string {
func (em *EnvironmentManager) Info() EnvironmentInfo {
em.mu.RLock()
defer em.mu.RUnlock()
var platformInfo map[string]any
if len(em.platformInfo) > 0 {
platformInfo = make(map[string]any, len(em.platformInfo))
for key, value := range em.platformInfo {
platformInfo[key] = value
}
}
return EnvironmentInfo{
OS: em.operatingSystem,
Arch: em.architecture,
Debug: em.debugMode,
IsDarkMode: em.darkMode,
AccentColour: em.accentColour,
OSInfo: em.osInfo,
PlatformInfo: platformInfo,
}
}
func (em *EnvironmentManager) OpenFileManager(path string, selectFile bool) error {
if em == nil {
return nil
}
em.mu.Lock()
if em.platformInfo == nil {
em.platformInfo = make(map[string]any)
}
em.platformInfo["lastOpenFileManagerPath"] = filepath.Clean(path)
em.platformInfo["lastOpenFileManagerSelect"] = selectFile
em.mu.Unlock()
return nil
}
func (em *EnvironmentManager) HasFocusFollowsMouse() bool {
if em == nil {
return false
}
em.mu.RLock()
defer em.mu.RUnlock()
if em.platformInfo == nil {
return false
}
ffm, _ := em.platformInfo["focusFollowsMouse"].(bool)
return ffm
}

View file

@ -0,0 +1,162 @@
package application
import (
"sync"
"github.com/wailsapp/wails/v3/pkg/events"
)
type applicationEventHook struct {
callback func(*ApplicationEvent)
}
type eventHookRegistry struct {
mu sync.RWMutex
hooks map[uint][]*applicationEventHook
}
var eventHookRegistries sync.Map
func (em *EventManager) Once(name string, callback func(*CustomEvent)) func() {
listener := &customEventListener{callback: callback, counter: 1}
em.mu.Lock()
em.ensureMapsLocked()
em.customListeners[name] = append(em.customListeners[name], listener)
em.mu.Unlock()
return func() {
em.mu.Lock()
defer em.mu.Unlock()
if em.customListeners == nil {
return
}
updated := em.customListeners[name][:0]
for _, existing := range em.customListeners[name] {
if existing != listener {
updated = append(updated, existing)
}
}
em.customListeners[name] = updated
}
}
func (em *EventManager) EmitEvent(event *CustomEvent) bool {
if event == nil {
return false
}
em.mu.Lock()
em.ensureMapsLocked()
listeners := append([]*customEventListener(nil), em.customListeners[event.Name]...)
remaining := em.customListeners[event.Name][:0]
for _, listener := range em.customListeners[event.Name] {
if listener.counter < 0 {
remaining = append(remaining, listener)
continue
}
listener.counter--
if listener.counter > 0 {
remaining = append(remaining, listener)
}
}
em.customListeners[event.Name] = remaining
em.mu.Unlock()
for _, listener := range listeners {
if event.IsCancelled() {
break
}
invokeCustomEventListener(listener, event)
}
return event.IsCancelled()
}
func (em *EventManager) Reset() {
em.mu.Lock()
if em.customListeners != nil {
clear(em.customListeners)
}
em.mu.Unlock()
}
func (em *EventManager) RegisterApplicationEventHook(eventType events.ApplicationEventType, callback func(*ApplicationEvent)) func() {
registry := getEventHookRegistry(em)
hook := &applicationEventHook{callback: callback}
eventID := uint(eventType)
registry.mu.Lock()
registry.hooks[eventID] = append(registry.hooks[eventID], hook)
registry.mu.Unlock()
return func() {
registry.mu.Lock()
defer registry.mu.Unlock()
updated := registry.hooks[eventID][:0]
for _, existing := range registry.hooks[eventID] {
if existing != hook {
updated = append(updated, existing)
}
}
registry.hooks[eventID] = updated
}
}
func (em *EventManager) handleApplicationEvent(event *ApplicationEvent) {
if em == nil || event == nil {
return
}
registry := getEventHookRegistry(em)
registry.mu.RLock()
hooks := append([]*applicationEventHook(nil), registry.hooks[event.Id]...)
registry.mu.RUnlock()
for _, hook := range hooks {
if event.IsCancelled() {
return
}
if hook == nil || hook.callback == nil {
continue
}
func() {
defer func() {
_ = recover()
}()
hook.callback(event)
}()
}
em.mu.RLock()
listeners := append([]*applicationEventListener(nil), em.appListeners[event.Id]...)
em.mu.RUnlock()
for _, listener := range listeners {
if event.IsCancelled() {
return
}
if listener == nil || listener.callback == nil {
continue
}
func() {
defer func() {
_ = recover()
}()
listener.callback(event)
}()
}
}
func getEventHookRegistry(em *EventManager) *eventHookRegistry {
if em == nil {
return &eventHookRegistry{hooks: make(map[uint][]*applicationEventHook)}
}
registry, ok := eventHookRegistries.Load(em)
if ok {
return registry.(*eventHookRegistry)
}
newRegistry := &eventHookRegistry{hooks: make(map[uint][]*applicationEventHook)}
actual, _ := eventHookRegistries.LoadOrStore(em, newRegistry)
return actual.(*eventHookRegistry)
}

View file

@ -0,0 +1,25 @@
package application
func newKeyBindingManager() *KeyBindingManager {
return &KeyBindingManager{}
}
type windowKeyEvent struct {
windowId uint
acceleratorString string
}
func (m *KeyBindingManager) Register(accelerator string, callback func(window Window)) {
m.Add(accelerator, callback)
}
func (m *KeyBindingManager) Unregister(accelerator string) {
m.Remove(accelerator)
}
func (m *KeyBindingManager) handleWindowKeyEvent(event *windowKeyEvent) {
if event == nil {
return
}
m.Process(event.acceleratorString, nil)
}

View file

@ -2,6 +2,21 @@ package application
import "sync"
type Alignment int
type OffsetReference int
const (
TOP Alignment = iota
RIGHT
BOTTOM
LEFT
)
const (
BEGIN OffsetReference = iota
END
)
// Screen describes a physical or logical display.
//
// primary := manager.GetPrimary()
@ -47,6 +62,14 @@ type Size struct {
Height int
}
type ScreenPlacement struct {
Screen *Screen
Parent *Screen
Alignment Alignment
Offset int
OffsetReference OffsetReference
}
// Origin returns the top-left corner of the rectangle.
func (r Rect) Origin() Point {
return Point{X: r.X, Y: r.Y}
@ -74,6 +97,43 @@ func (r Rect) RectSize() Size {
return Size{Width: r.Width, Height: r.Height}
}
func (r Rect) Size() Size {
return r.RectSize()
}
func (s Screen) Origin() Point {
return Point{X: s.X, Y: s.Y}
}
func (p ScreenPlacement) Apply() {
if p.Screen == nil || p.Parent == nil {
return
}
x := p.Parent.X
y := p.Parent.Y
switch p.Alignment {
case TOP:
x += p.Offset
y -= p.Screen.Size.Height
case RIGHT:
x += p.Parent.Size.Width
y += p.Offset
case BOTTOM:
x += p.Offset
y += p.Parent.Size.Height
case LEFT:
x -= p.Screen.Size.Width
y += p.Offset
}
p.Screen.X = x
p.Screen.Y = y
p.Screen.Bounds.X = x
p.Screen.Bounds.Y = y
}
// ScreenManager tracks connected screens and the active screen.
//
// manager.SetScreens(detectedScreens)
@ -85,6 +145,10 @@ type ScreenManager struct {
primary *Screen
}
func newScreenManager() *ScreenManager {
return &ScreenManager{}
}
// SetScreens replaces the full list of known screens and recomputes primary.
//
// manager.SetScreens(platformDetectedScreens)
@ -159,3 +223,85 @@ func (m *ScreenManager) GetCurrent() *Screen {
}
return m.primary
}
func (m *ScreenManager) LayoutScreens(screens []*Screen) error {
m.SetScreens(screens)
return nil
}
func (m *ScreenManager) All() []*Screen {
return m.GetAll()
}
func (m *ScreenManager) Primary() *Screen {
return m.GetPrimary()
}
func (m *ScreenManager) Current() *Screen {
return m.GetCurrent()
}
func (m *ScreenManager) DipToPhysicalPoint(dipPoint Point) Point {
return dipPoint
}
func (m *ScreenManager) PhysicalToDipPoint(physicalPoint Point) Point {
return physicalPoint
}
func (m *ScreenManager) DipToPhysicalRect(dipRect Rect) Rect {
return dipRect
}
func (m *ScreenManager) PhysicalToDipRect(physicalRect Rect) Rect {
return physicalRect
}
func (m *ScreenManager) ScreenNearestPhysicalPoint(physicalPoint Point) *Screen {
return m.screenNearestPoint(physicalPoint)
}
func (m *ScreenManager) ScreenNearestDipPoint(dipPoint Point) *Screen {
return m.screenNearestPoint(dipPoint)
}
func (m *ScreenManager) ScreenNearestPhysicalRect(physicalRect Rect) *Screen {
return m.screenNearestRect(physicalRect)
}
func (m *ScreenManager) ScreenNearestDipRect(dipRect Rect) *Screen {
return m.screenNearestRect(dipRect)
}
func (m *ScreenManager) screenNearestPoint(point Point) *Screen {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, screen := range m.screens {
if screen != nil && screen.Bounds.Contains(point) {
return screen
}
}
if m.current != nil {
return m.current
}
if m.primary != nil {
return m.primary
}
if len(m.screens) > 0 {
return m.screens[0]
}
return nil
}
func (m *ScreenManager) screenNearestRect(rect Rect) *Screen {
if rect.IsEmpty() {
return m.screenNearestPoint(Point{})
}
return m.screenNearestPoint(rect.Origin())
}