refactor(ax): align public APIs with AX principles
Some checks failed
Security Scan / security (push) Failing after 42s
Test / test (push) Failing after 1m31s

This commit is contained in:
Virgil 2026-03-31 05:13:43 +00:00
parent bd58099c17
commit da22bedbc6
44 changed files with 324 additions and 509 deletions

View file

@ -1,10 +1,7 @@
// pkg/browser/register.go
package browser
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/browser/service.go
package browser
import (
@ -7,23 +6,18 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the browser service.
type Options struct{}
// Service is a core.Service that delegates browser/file-open operations
// to the platform. It is stateless — no queries, no actions.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}

View file

@ -1,42 +1,28 @@
// pkg/contextmenu/messages.go
package contextmenu
import "errors"
// ErrMenuNotFound is returned when attempting to remove or get a menu
// that does not exist in the registry.
var ErrMenuNotFound = errors.New("contextmenu: menu not found")
var ErrorMenuNotFound = errors.New("contextmenu: menu not found")
// --- Queries ---
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
type QueryGet struct {
Name string `json:"name"`
}
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
type QueryList struct{}
// --- Tasks ---
// TaskAdd registers a context menu. Result: nil
// If a menu with the same name already exists it is replaced (remove + re-add).
type TaskAdd struct {
Name string `json:"name"`
Menu ContextMenuDef `json:"menu"`
}
// TaskRemove unregisters a context menu. Result: nil
// Returns ErrMenuNotFound if the menu does not exist.
type TaskRemove struct {
Name string `json:"name"`
}
// --- Actions ---
// ActionItemClicked is broadcast when a context menu item is clicked.
// The Data field is populated from the CSS --custom-contextmenu-data property
// on the element that triggered the context menu.
type ActionItemClicked struct {
MenuName string `json:"menuName"`
ActionID string `json:"actionId"`

View file

@ -1,16 +1,13 @@
// pkg/contextmenu/register.go
package contextmenu
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
menus: make(map[string]ContextMenuDef),
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
registeredMenus: make(map[string]ContextMenuDef),
}, nil
}
}

View file

@ -8,26 +8,20 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the context menu service.
type Options struct{}
// Service is a core.Service managing context menus via IPC.
// It maintains an in-memory registry of menus (map[string]ContextMenuDef)
// and delegates platform-level registration to the Platform interface.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
menus map[string]ContextMenuDef
platform Platform
registeredMenus map[string]ContextMenuDef
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -45,19 +39,17 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// queryGet returns a single menu definition by name, or nil if not found.
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
menu, ok := s.menus[q.Name]
menu, ok := s.registeredMenus[q.Name]
if !ok {
return nil
}
return &menu
}
// queryList returns a copy of all registered menus.
func (s *Service) queryList() map[string]ContextMenuDef {
result := make(map[string]ContextMenuDef, len(s.menus))
for k, v := range s.menus {
result := make(map[string]ContextMenuDef, len(s.registeredMenus))
for k, v := range s.registeredMenus {
result[k] = v
}
return result
@ -78,9 +70,9 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
func (s *Service) taskAdd(t TaskAdd) error {
// If menu already exists, remove it first (replace semantics)
if _, exists := s.menus[t.Name]; exists {
if _, exists := s.registeredMenus[t.Name]; exists {
_ = s.platform.Remove(t.Name)
delete(s.menus, t.Name)
delete(s.registeredMenus, t.Name)
}
// Register on platform with a callback that broadcasts ActionItemClicked
@ -95,13 +87,13 @@ func (s *Service) taskAdd(t TaskAdd) error {
return coreerr.E("contextmenu.taskAdd", "platform add failed", err)
}
s.menus[t.Name] = t.Menu
s.registeredMenus[t.Name] = t.Menu
return nil
}
func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.menus[t.Name]; !exists {
return ErrMenuNotFound
if _, exists := s.registeredMenus[t.Name]; !exists {
return ErrorMenuNotFound
}
err := s.platform.Remove(t.Name)
@ -109,6 +101,6 @@ func (s *Service) taskRemove(t TaskRemove) error {
return coreerr.E("contextmenu.taskRemove", "platform remove failed", err)
}
delete(s.menus, t.Name)
delete(s.registeredMenus, t.Name)
return nil
}

View file

@ -171,7 +171,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrMenuNotFound)
assert.ErrorIs(t, err, ErrorMenuNotFound)
}
func TestQueryGet_Good(t *testing.T) {

View file

@ -1,14 +1,9 @@
// pkg/dialog/messages.go
package dialog
// TaskOpenFile shows an open file dialog. Result: []string (paths)
type TaskOpenFile struct{ Opts OpenFileOptions }
type TaskOpenFile struct{ Options OpenFileOptions }
// TaskSaveFile shows a save file dialog. Result: string (path)
type TaskSaveFile struct{ Opts SaveFileOptions }
type TaskSaveFile struct{ Options SaveFileOptions }
// TaskOpenDirectory shows a directory picker. Result: string (path)
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
type TaskMessageDialog struct{ Opts MessageDialogOptions }
type TaskMessageDialog struct{ Options MessageDialogOptions }

View file

@ -3,10 +3,10 @@ package dialog
// Platform abstracts the native dialog backend.
type Platform interface {
OpenFile(opts OpenFileOptions) ([]string, error)
SaveFile(opts SaveFileOptions) (string, error)
OpenDirectory(opts OpenDirectoryOptions) (string, error)
MessageDialog(opts MessageDialogOptions) (string, error)
OpenFile(options OpenFileOptions) ([]string, error)
SaveFile(options SaveFileOptions) (string, error)
OpenDirectory(options OpenDirectoryOptions) (string, error)
MessageDialog(options MessageDialogOptions) (string, error)
}
// DialogType represents the type of message dialog.

View file

@ -7,16 +7,13 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the dialog service.
type Options struct{}
// Service is a core.Service managing native dialogs via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -26,13 +23,11 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -40,16 +35,16 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFile:
paths, err := s.platform.OpenFile(t.Opts)
paths, err := s.platform.OpenFile(t.Options)
return paths, true, err
case TaskSaveFile:
path, err := s.platform.SaveFile(t.Opts)
path, err := s.platform.SaveFile(t.Options)
return path, true, err
case TaskOpenDirectory:
path, err := s.platform.OpenDirectory(t.Opts)
path, err := s.platform.OpenDirectory(t.Options)
return path, true, err
case TaskMessageDialog:
button, err := s.platform.MessageDialog(t.Opts)
button, err := s.platform.MessageDialog(t.Options)
return button, true, err
default:
return nil, false, nil

View file

@ -11,18 +11,18 @@ import (
)
type mockPlatform struct {
openFilePaths []string
saveFilePath string
openDirPath string
messageButton string
openFileErr error
saveFileErr error
openDirErr error
messageErr error
lastOpenOpts OpenFileOptions
lastSaveOpts SaveFileOptions
lastDirOpts OpenDirectoryOptions
lastMsgOpts MessageDialogOptions
openFilePaths []string
saveFilePath string
openDirPath string
messageButton string
openFileErr error
saveFileErr error
openDirErr error
messageErr error
lastOpenOpts OpenFileOptions
lastSaveOpts SaveFileOptions
lastDirOpts OpenDirectoryOptions
lastMsgOpts MessageDialogOptions
}
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
@ -70,7 +70,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
result, handled, err := c.PERFORM(TaskOpenFile{
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true},
})
require.NoError(t, err)
assert.True(t, handled)
@ -83,7 +83,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
func TestTaskSaveFile_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskSaveFile{
Opts: SaveFileOptions{Filename: "out.txt"},
Options: SaveFileOptions{Filename: "out.txt"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -93,7 +93,7 @@ func TestTaskSaveFile_Good(t *testing.T) {
func TestTaskOpenDirectory_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskOpenDirectory{
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
Options: OpenDirectoryOptions{Title: "Pick Dir"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -105,7 +105,7 @@ func TestTaskMessageDialog_Good(t *testing.T) {
mock.messageButton = "Yes"
result, handled, err := c.PERFORM(TaskMessageDialog{
Opts: MessageDialogOptions{
Options: MessageDialogOptions{
Type: DialogQuestion, Title: "Confirm",
Message: "Sure?", Buttons: []string{"Yes", "No"},
},

View file

@ -7,10 +7,10 @@ import (
"path/filepath"
"runtime"
"forge.lthn.ai/core/config"
"forge.lthn.ai/core/go/pkg/core"
coreerr "forge.lthn.ai/core/go-log"
"encoding/json"
"forge.lthn.ai/core/config"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/browser"
"forge.lthn.ai/core/gui/pkg/contextmenu"
@ -41,10 +41,9 @@ type Service struct {
*core.ServiceRuntime[Options]
wailsApp *application.App
app App
config Options
configData map[string]map[string]any
cfg *config.Config // config instance for file persistence
events *WSEventManager
configFile *config.Config // config instance for file persistence
events *WSEventManager
}
// New is the constructor for the display service.
@ -117,7 +116,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
case window.ActionWindowResized:
if s.events != nil {
s.events.Emit(Event{Type: EventWindowResize, Window: m.Name,
Data: map[string]any{"w": m.W, "h": m.H}})
Data: map[string]any{"w": m.Width, "h": m.Height}})
}
case window.ActionWindowFocused:
if s.events != nil {
@ -491,7 +490,7 @@ func (s *Service) handleTrayAction(actionID string) {
details := fmt.Sprintf("OS: %s\nArch: %s\nPlatform: %s %s",
info.OS, info.Arch, info.Platform.Name, info.Platform.Version)
_, _, _ = s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Options: dialog.MessageDialogOptions{
Type: dialog.DialogInfo, Title: "Environment",
Message: details, Buttons: []string{"OK"},
},
@ -513,23 +512,23 @@ func guiConfigPath() string {
}
func (s *Service) loadConfig() {
if s.cfg != nil {
if s.configFile != nil {
return // Already loaded (e.g., via loadConfigFrom in tests)
}
s.loadConfigFrom(guiConfigPath())
}
func (s *Service) loadConfigFrom(path string) {
cfg, err := config.New(config.WithPath(path))
configFile, err := config.New(config.WithPath(path))
if err != nil {
// Non-critical — continue with empty configData
return
}
s.cfg = cfg
s.configFile = configFile
for _, section := range []string{"window", "systray", "menu"} {
var data map[string]any
if err := cfg.Get(section, &data); err == nil && data != nil {
if err := configFile.Get(section, &data); err == nil && data != nil {
s.configData[section] = data
}
}
@ -551,16 +550,16 @@ func (s *Service) handleConfigQuery(c *core.Core, q core.Query) (any, bool, erro
func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case window.TaskSaveConfig:
s.configData["window"] = t.Value
s.persistSection("window", t.Value)
s.configData["window"] = t.Config
s.persistSection("window", t.Config)
return nil, true, nil
case systray.TaskSaveConfig:
s.configData["systray"] = t.Value
s.persistSection("systray", t.Value)
s.configData["systray"] = t.Config
s.persistSection("systray", t.Config)
return nil, true, nil
case menu.TaskSaveConfig:
s.configData["menu"] = t.Value
s.persistSection("menu", t.Value)
s.configData["menu"] = t.Config
s.persistSection("menu", t.Config)
return nil, true, nil
default:
return nil, false, nil
@ -568,11 +567,11 @@ func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error)
}
func (s *Service) persistSection(key string, value map[string]any) {
if s.cfg == nil {
if s.configFile == nil {
return
}
_ = s.cfg.Set(key, value)
_ = s.cfg.Commit()
_ = s.configFile.Set(key, value)
_ = s.configFile.Commit()
}
// --- Service accessors ---
@ -589,8 +588,8 @@ func (s *Service) windowService() *window.Service {
// --- Window Management (delegates via IPC) ---
// OpenWindow creates a new window via IPC.
func (s *Service) OpenWindow(opts ...window.WindowOption) error {
_, _, err := s.Core().PERFORM(window.TaskOpenWindow{Opts: opts})
func (s *Service) OpenWindow(options ...window.WindowOption) error {
_, _, err := s.Core().PERFORM(window.TaskOpenWindow{Options: options})
return err
}
@ -625,7 +624,7 @@ func (s *Service) SetWindowPosition(name string, x, y int) error {
// SetWindowSize resizes a window via IPC.
func (s *Service) SetWindowSize(name string, width, height int) error {
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, W: width, H: height})
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height})
return err
}
@ -634,7 +633,7 @@ func (s *Service) SetWindowBounds(name string, x, y, width, height int) error {
if _, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y}); err != nil {
return err
}
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, W: width, H: height})
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height})
return err
}
@ -815,17 +814,17 @@ type CreateWindowOptions struct {
}
// CreateWindow creates a new window with the specified options.
func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, error) {
if opts.Name == "" {
func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, error) {
if options.Name == "" {
return nil, coreerr.E("display.CreateWindow", "window name is required", nil)
}
result, _, err := s.Core().PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{
window.WithName(opts.Name),
window.WithTitle(opts.Title),
window.WithURL(opts.URL),
window.WithSize(opts.Width, opts.Height),
window.WithPosition(opts.X, opts.Y),
Options: []window.WindowOption{
window.WithName(options.Name),
window.WithTitle(options.Title),
window.WithURL(options.URL),
window.WithSize(options.Width, options.Height),
window.WithPosition(options.X, options.Y),
},
})
if err != nil {
@ -994,7 +993,7 @@ func ptr[T any](v T) *T { return &v }
func (s *Service) handleNewWorkspace() {
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{
Options: []window.WindowOption{
window.WithName("workspace-new"),
window.WithTitle("New Workspace"),
window.WithURL("/workspace/new"),
@ -1017,7 +1016,7 @@ func (s *Service) handleListWorkspaces() {
func (s *Service) handleNewFile() {
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{
Options: []window.WindowOption{
window.WithName("editor"),
window.WithTitle("New File - Editor"),
window.WithURL("/#/developer/editor?new=true"),
@ -1028,7 +1027,7 @@ func (s *Service) handleNewFile() {
func (s *Service) handleOpenFile() {
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{
Opts: dialog.OpenFileOptions{
Options: dialog.OpenFileOptions{
Title: "Open File",
AllowMultiple: false,
},
@ -1041,7 +1040,7 @@ func (s *Service) handleOpenFile() {
return
}
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{
Options: []window.WindowOption{
window.WithName("editor"),
window.WithTitle(paths[0] + " - Editor"),
window.WithURL("/#/developer/editor?file=" + paths[0]),
@ -1053,7 +1052,7 @@ func (s *Service) handleOpenFile() {
func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) }
func (s *Service) handleOpenEditor() {
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{
Options: []window.WindowOption{
window.WithName("editor"),
window.WithTitle("Editor"),
window.WithURL("/#/developer/editor"),
@ -1063,7 +1062,7 @@ func (s *Service) handleOpenEditor() {
}
func (s *Service) handleOpenTerminal() {
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{
Options: []window.WindowOption{
window.WithName("terminal"),
window.WithTitle("Terminal"),
window.WithURL("/#/developer/terminal"),

View file

@ -104,7 +104,7 @@ func TestConfigTask_Good(t *testing.T) {
_, c := newTestDisplayService(t)
newCfg := map[string]any{"default_width": 800}
_, handled, err := c.PERFORM(window.TaskSaveConfig{Value: newCfg})
_, handled, err := c.PERFORM(window.TaskSaveConfig{Config: newCfg})
require.NoError(t, err)
assert.True(t, handled)
@ -121,7 +121,7 @@ func TestServiceConclave_Good(t *testing.T) {
// Open a window via IPC
result, handled, err := c.PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{window.WithName("main")},
Options: []window.WindowOption{window.WithName("main")},
})
require.NoError(t, err)
assert.True(t, handled)
@ -413,7 +413,7 @@ func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
// Open a window — this should trigger ActionWindowOpened
// which HandleIPCEvents should convert to a WS event
result, handled, err := c.PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{window.WithName("test")},
Options: []window.WindowOption{window.WithName("test")},
})
require.NoError(t, err)
assert.True(t, handled)
@ -493,7 +493,7 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) {
c.ServiceStartup(context.Background(), nil)
_, handled, err := c.PERFORM(window.TaskSaveConfig{
Value: map[string]any{"default_width": 1920},
Config: map[string]any{"default_width": 1920},
})
require.NoError(t, err)
assert.True(t, handled)

View file

@ -1,10 +1,7 @@
// pkg/dock/register.go
package dock
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/dock/service.go
package dock
import (
@ -7,30 +6,23 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the dock service.
type Options struct{}
// Service is a core.Service managing dock/taskbar operations via IPC.
// It embeds ServiceRuntime for Core access and delegates to Platform.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
// --- Query Handlers ---
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryVisible:
@ -40,8 +32,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskShowIcon:

View file

@ -1,40 +1,25 @@
// pkg/keybinding/messages.go
package keybinding
import "errors"
// ErrAlreadyRegistered is returned when attempting to add a binding
// that already exists. Callers must TaskRemove first to rebind.
var ErrAlreadyRegistered = errors.New("keybinding: accelerator already registered")
var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered")
// BindingInfo describes a registered keyboard shortcut.
type BindingInfo struct {
Accelerator string `json:"accelerator"`
Description string `json:"description"`
}
// --- Queries ---
// QueryList returns all registered bindings. Result: []BindingInfo
type QueryList struct{}
// --- Tasks ---
// TaskAdd registers a new keyboard shortcut. Result: nil
// Returns ErrAlreadyRegistered if the accelerator is already bound.
type TaskAdd struct {
Accelerator string `json:"accelerator"`
Description string `json:"description"`
}
// TaskRemove unregisters a keyboard shortcut. Result: nil
type TaskRemove struct {
Accelerator string `json:"accelerator"`
}
// --- Actions ---
// ActionTriggered is broadcast when a registered shortcut is activated.
type ActionTriggered struct {
Accelerator string `json:"accelerator"`
}

View file

@ -1,16 +1,13 @@
// pkg/keybinding/register.go
package keybinding
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
bindings: make(map[string]BindingInfo),
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
registeredBindings: make(map[string]BindingInfo),
}, nil
}
}

View file

@ -8,26 +8,20 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the keybinding service.
type Options struct{}
// Service is a core.Service managing keyboard shortcuts via IPC.
// It maintains an in-memory registry of bindings and delegates
// platform-level registration to the Platform interface.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
bindings map[string]BindingInfo
platform Platform
registeredBindings map[string]BindingInfo
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -43,10 +37,9 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// queryList reads from the in-memory registry (not platform.GetAll()).
func (s *Service) queryList() []BindingInfo {
result := make([]BindingInfo, 0, len(s.bindings))
for _, info := range s.bindings {
result := make([]BindingInfo, 0, len(s.registeredBindings))
for _, info := range s.registeredBindings {
result = append(result, info)
}
return result
@ -66,8 +59,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
func (s *Service) taskAdd(t TaskAdd) error {
if _, exists := s.bindings[t.Accelerator]; exists {
return ErrAlreadyRegistered
if _, exists := s.registeredBindings[t.Accelerator]; exists {
return ErrorAlreadyRegistered
}
// Register on platform with a callback that broadcasts ActionTriggered
@ -78,7 +71,7 @@ func (s *Service) taskAdd(t TaskAdd) error {
return coreerr.E("keybinding.taskAdd", "platform add failed", err)
}
s.bindings[t.Accelerator] = BindingInfo{
s.registeredBindings[t.Accelerator] = BindingInfo{
Accelerator: t.Accelerator,
Description: t.Description,
}
@ -86,7 +79,7 @@ func (s *Service) taskAdd(t TaskAdd) error {
}
func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.bindings[t.Accelerator]; !exists {
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil)
}
@ -95,6 +88,6 @@ func (s *Service) taskRemove(t TaskRemove) error {
return coreerr.E("keybinding.taskRemove", "platform remove failed", err)
}
delete(s.bindings, t.Accelerator)
delete(s.registeredBindings, t.Accelerator)
return nil
}

View file

@ -99,7 +99,7 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
// Second add with same accelerator should fail
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrAlreadyRegistered)
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
}
func TestTaskRemove_Good(t *testing.T) {

View file

@ -1,10 +1,7 @@
// pkg/lifecycle/register.go
package lifecycle
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/lifecycle/service.go
package lifecycle
import (
@ -7,22 +6,15 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the lifecycle service.
type Options struct{}
// Service is a core.Service that registers platform lifecycle callbacks
// and broadcasts corresponding IPC Actions. It implements both Startable
// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
cancels []func()
}
// OnStartup registers a platform callback for each EventType and for file-open.
// Each callback broadcasts the corresponding Action via s.Core().ACTION().
func (s *Service) OnStartup(ctx context.Context) error {
// Register fire-and-forget event callbacks
eventActions := map[EventType]func(){
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
@ -38,7 +30,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
s.cancels = append(s.cancels, cancel)
}
// Register file-open callback (carries data)
cancel := s.platform.OnOpenedWithFile(func(path string) {
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
})
@ -47,7 +38,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
// OnShutdown cancels all registered platform callbacks.
func (s *Service) OnShutdown(ctx context.Context) error {
for _, cancel := range s.cancels {
cancel()
@ -56,8 +46,6 @@ func (s *Service) OnShutdown(ctx context.Context) error {
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}

View file

@ -4,8 +4,8 @@ package mcp
import (
"context"
"forge.lthn.ai/core/gui/pkg/dialog"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/dialog"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -22,7 +22,7 @@ type DialogOpenFileOutput struct {
}
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
Title: input.Title,
Directory: input.Directory,
Filters: input.Filters,
@ -51,7 +51,7 @@ type DialogSaveFileOutput struct {
}
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
Title: input.Title,
Directory: input.Directory,
Filename: input.Filename,
@ -78,7 +78,7 @@ type DialogOpenDirectoryOutput struct {
}
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
Title: input.Title,
Directory: input.Directory,
}})
@ -104,7 +104,7 @@ type DialogConfirmOutput struct {
}
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
Type: dialog.DialogQuestion,
Title: input.Title,
Message: input.Message,
@ -131,7 +131,7 @@ type DialogPromptOutput struct {
}
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
Type: dialog.DialogInfo,
Title: input.Title,
Message: input.Message,

View file

@ -21,7 +21,7 @@ type NotificationShowOutput struct {
}
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
Title: input.Title,
Message: input.Message,
Subtitle: input.Subtitle,

View file

@ -89,22 +89,22 @@ type WindowCreateOutput struct {
}
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
opts := []window.WindowOption{
options := []window.WindowOption{
window.WithName(input.Name),
}
if input.Title != "" {
opts = append(opts, window.WithTitle(input.Title))
options = append(options, window.WithTitle(input.Title))
}
if input.URL != "" {
opts = append(opts, window.WithURL(input.URL))
options = append(options, window.WithURL(input.URL))
}
if input.Width > 0 || input.Height > 0 {
opts = append(opts, window.WithSize(input.Width, input.Height))
options = append(options, window.WithSize(input.Width, input.Height))
}
if input.X != 0 || input.Y != 0 {
opts = append(opts, window.WithPosition(input.X, input.Y))
options = append(options, window.WithPosition(input.X, input.Y))
}
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Opts: opts})
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Options: options})
if err != nil {
return nil, WindowCreateOutput{}, err
}
@ -163,7 +163,7 @@ type WindowSizeOutput struct {
}
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
if err != nil {
return nil, WindowSizeOutput{}, err
}
@ -188,7 +188,7 @@ func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, inpu
if err != nil {
return nil, WindowBoundsOutput{}, err
}
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
if err != nil {
return nil, WindowBoundsOutput{}, err
}

View file

@ -1,16 +1,9 @@
package menu
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// QueryGetAppMenu returns the current app menu item descriptors.
// Result: []MenuItem
type QueryGetAppMenu struct{}
// TaskSetAppMenu sets the application menu. OnClick closures work because
// core/go IPC is in-process (no serialisation boundary).
type TaskSetAppMenu struct{ Items []MenuItem }
// TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }
type TaskSaveConfig struct{ Config map[string]any }

View file

@ -2,7 +2,6 @@ package menu
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -6,24 +6,21 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the menu service.
type Options struct{}
// Service is a core.Service managing application menus via IPC.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
items []MenuItem // last-set menu items for QueryGetAppMenu
menuItems []MenuItem
showDevTools bool
}
// OnStartup queries config and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if mCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(mCfg)
if menuConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(menuConfig)
}
}
s.Core().RegisterQuery(s.handleQuery)
@ -31,20 +28,18 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
if v, ok := cfg["show_dev_tools"]; ok {
func (s *Service) applyConfig(configData map[string]any) {
if v, ok := configData["show_dev_tools"]; ok {
if show, ok := v.(bool); ok {
s.showDevTools = show
}
}
}
// ShowDevTools returns whether developer tools menu items should be shown.
func (s *Service) ShowDevTools() bool {
return s.showDevTools
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -52,7 +47,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryGetAppMenu:
return s.items, true, nil
return s.menuItems, true, nil
default:
return nil, false, nil
}
@ -61,7 +56,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetAppMenu:
s.items = t.Items
s.menuItems = t.Items
s.manager.SetApplicationMenu(t.Items)
return nil, true, nil
default:
@ -69,7 +64,6 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
}
// Manager returns the underlying menu Manager.
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -1,14 +1,9 @@
// pkg/notification/messages.go
package notification
// QueryPermission checks notification authorisation. Result: PermissionStatus
type QueryPermission struct{}
// TaskSend sends a notification. Falls back to dialog if platform fails.
type TaskSend struct{ Opts NotificationOptions }
type TaskSend struct{ Options NotificationOptions }
// TaskRequestPermission requests notification authorisation. Result: bool (granted)
type TaskRequestPermission struct{}
// ActionNotificationClicked is broadcast when a notification is clicked (future).
type ActionNotificationClicked struct{ ID string }

View file

@ -3,7 +3,7 @@ package notification
// Platform abstracts the native notification backend.
type Platform interface {
Send(opts NotificationOptions) error
Send(options NotificationOptions) error
RequestPermission() (bool, error)
CheckPermission() (bool, error)
}

View file

@ -10,16 +10,13 @@ import (
"forge.lthn.ai/core/gui/pkg/dialog"
)
// Options holds configuration for the notification service.
type Options struct{}
// Service is a core.Service managing notifications via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -29,14 +26,12 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -54,7 +49,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSend:
return nil, true, s.send(t.Opts)
return nil, true, s.send(t.Options)
case TaskRequestPermission:
granted, err := s.platform.RequestPermission()
return granted, true, err
@ -64,24 +59,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
// send attempts native notification, falls back to dialog via IPC.
func (s *Service) send(opts NotificationOptions) error {
func (s *Service) send(options NotificationOptions) error {
// Generate ID if not provided
if opts.ID == "" {
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
if options.ID == "" {
options.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
}
if err := s.platform.Send(opts); err != nil {
if err := s.platform.Send(options); err != nil {
// Fallback: show as dialog via IPC
return s.fallbackDialog(opts)
return s.fallbackDialog(options)
}
return nil
}
// fallbackDialog shows a dialog via IPC when native notifications fail.
func (s *Service) fallbackDialog(opts NotificationOptions) error {
func (s *Service) fallbackDialog(options NotificationOptions) error {
// Map severity to dialog type
var dt dialog.DialogType
switch opts.Severity {
switch options.Severity {
case SeverityWarning:
dt = dialog.DialogWarning
case SeverityError:
@ -90,15 +85,15 @@ func (s *Service) fallbackDialog(opts NotificationOptions) error {
dt = dialog.DialogInfo
}
msg := opts.Message
if opts.Subtitle != "" {
msg = opts.Subtitle + "\n\n" + msg
msg := options.Message
if options.Subtitle != "" {
msg = options.Subtitle + "\n\n" + msg
}
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Options: dialog.MessageDialogOptions{
Type: dt,
Title: opts.Title,
Title: options.Title,
Message: msg,
Buttons: []string{"OK"},
},

View file

@ -66,7 +66,7 @@ func TestRegister_Good(t *testing.T) {
func TestTaskSend_Good(t *testing.T) {
mock, c := newTestService(t)
_, handled, err := c.PERFORM(TaskSend{
Opts: NotificationOptions{Title: "Test", Message: "Hello"},
Options: NotificationOptions{Title: "Test", Message: "Hello"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -87,7 +87,7 @@ func TestTaskSend_Fallback_Good(t *testing.T) {
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(TaskSend{
Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
})
assert.True(t, handled)
assert.NoError(t, err) // fallback succeeds even though platform failed

View file

@ -1,30 +1,17 @@
package systray
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// --- Tasks ---
// TaskSetTrayIcon sets the tray icon.
type TaskSetTrayIcon struct{ Data []byte }
// TaskSetTrayMenu sets the tray menu items.
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
// TaskShowPanel shows the tray panel window.
type TaskShowPanel struct{}
// TaskHidePanel hides the tray panel window.
type TaskHidePanel struct{}
// TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }
type TaskSaveConfig struct{ Config map[string]any }
// --- Actions ---
// ActionTrayClicked is broadcast when the tray icon is clicked.
type ActionTrayClicked struct{}
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
type ActionTrayMenuItemClicked struct{ ActionID string }

View file

@ -2,7 +2,6 @@ package systray
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -6,10 +6,8 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the systray service.
type Options struct{}
// Service is a core.Service managing the system tray via IPC.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
@ -17,33 +15,31 @@ type Service struct {
iconPath string
}
// OnStartup queries config and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if tCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(tCfg)
if trayConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(trayConfig)
}
}
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
tooltip, _ := cfg["tooltip"].(string)
func (s *Service) applyConfig(configData map[string]any) {
tooltip, _ := configData["tooltip"].(string)
if tooltip == "" {
tooltip = "Core"
}
_ = s.manager.Setup(tooltip, tooltip)
if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
if iconPath, ok := configData["icon"].(string); ok && iconPath != "" {
// Icon loading is deferred to when assets are available.
// Store the path for later use.
s.iconPath = iconPath
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -78,7 +74,6 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
return s.manager.SetMenu(t.Items)
}
// Manager returns the underlying systray Manager.
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -47,7 +47,7 @@ type Options struct {
// Service is a core.Service managing webview interactions via IPC.
type Service struct {
*core.ServiceRuntime[Options]
opts Options
options Options
connections map[string]connector
mu sync.RWMutex
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
@ -55,19 +55,19 @@ type Service struct {
}
// Register creates a factory closure with the given options.
func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
func Register(optionFns ...func(*Options)) func(*core.Core) (any, error) {
o := Options{
DebugURL: "http://localhost:9222",
Timeout: 30 * time.Second,
ConsoleLimit: 1000,
}
for _, fn := range opts {
for _, fn := range optionFns {
fn(&o)
}
return func(c *core.Core) (any, error) {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
opts: o,
options: o,
connections: make(map[string]connector),
newConn: defaultNewConn(o),
}
@ -77,7 +77,7 @@ func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
}
// defaultNewConn creates real go-webview connections.
func defaultNewConn(opts Options) func(string, string) (connector, error) {
func defaultNewConn(options Options) func(string, string) (connector, error) {
return func(debugURL, windowName string) (connector, error) {
// Enumerate targets, match by title/URL containing window name
targets, err := gowebview.ListTargets(debugURL)
@ -105,8 +105,8 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
}
wv, err := gowebview.New(
gowebview.WithDebugURL(debugURL),
gowebview.WithTimeout(opts.Timeout),
gowebview.WithConsoleLimit(opts.ConsoleLimit),
gowebview.WithTimeout(options.Timeout),
gowebview.WithConsoleLimit(options.ConsoleLimit),
)
if err != nil {
return nil, err
@ -201,7 +201,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
if conn, ok := s.connections[windowName]; ok {
return conn, nil
}
conn, err := s.newConn(s.opts.DebugURL, windowName)
conn, err := s.newConn(s.options.DebugURL, windowName)
if err != nil {
return nil, err
}
@ -373,17 +373,17 @@ type realConnector struct {
wv *gowebview.Webview
}
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
func (r *realConnector) Hover(sel string) error {

View file

@ -1,6 +1,5 @@
package window
// WindowInfo contains information about a window.
type WindowInfo struct {
Name string `json:"name"`
Title string `json:"title"`
@ -12,103 +11,70 @@ type WindowInfo struct {
Focused bool `json:"focused"`
}
// --- Queries (read-only) ---
// QueryWindowList returns all tracked windows. Result: []WindowInfo
type QueryWindowList struct{}
// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found)
type QueryWindowByName struct{ Name string }
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// --- Tasks (side-effects) ---
type TaskOpenWindow struct{ Options []WindowOption }
// TaskOpenWindow creates a new window. Result: WindowInfo
type TaskOpenWindow struct{ Opts []WindowOption }
// TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed.
type TaskCloseWindow struct{ Name string }
// TaskSetPosition moves a window.
type TaskSetPosition struct {
Name string
X, Y int
}
// TaskSetSize resizes a window.
type TaskSetSize struct {
Name string
W, H int
Name string
Width, Height int
}
// TaskMaximise maximises a window.
type TaskMaximise struct{ Name string }
// TaskMinimise minimises a window.
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 }
// --- Actions (broadcasts) ---
type TaskSaveConfig struct{ Config map[string]any }
type ActionWindowOpened struct{ Name string }
type ActionWindowClosed struct{ Name string }
@ -119,15 +85,15 @@ type ActionWindowMoved struct {
}
type ActionWindowResized struct {
Name string
W, H int
Name string
Width, Height int
}
type ActionWindowFocused struct{ Name string }
type ActionWindowBlurred struct{ Name string }
type ActionFilesDropped struct {
Name string `json:"name"` // window name
Name string `json:"name"` // window name
Paths []string `json:"paths"`
TargetID string `json:"targetId,omitempty"`
}

View file

@ -10,11 +10,11 @@ func NewMockPlatform() *MockPlatform {
return &MockPlatform{}
}
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (m *MockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
w := &MockWindow{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
name: options.Name, title: options.Title, url: options.URL,
width: options.Width, height: options.Height,
x: options.X, y: options.Y,
}
m.Windows = append(m.Windows, w)
return w
@ -38,28 +38,30 @@ type MockWindow struct {
fileDropHandlers []func(paths []string, targetID string)
}
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 }
func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true }
func (w *MockWindow) Restore() { w.maximised = false }
func (w *MockWindow) Minimise() {}
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() { w.closed = true }
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
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 }
func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true }
func (w *MockWindow) Restore() { w.maximised = false }
func (w *MockWindow) Minimise() {}
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() { w.closed = true }
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
w.fileDropHandlers = append(w.fileDropHandlers, handler)
}

View file

@ -1,4 +1,3 @@
// pkg/window/mock_test.go
package window
type mockPlatform struct {
@ -9,11 +8,11 @@ func newMockPlatform() *mockPlatform {
return &mockPlatform{}
}
func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (m *mockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
w := &mockWindow{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
name: options.Name, title: options.Title, url: options.URL,
width: options.Width, height: options.Height,
x: options.X, y: options.Y,
}
m.windows = append(m.windows, w)
return w
@ -39,28 +38,30 @@ type mockWindow struct {
fileDropHandlers []func(paths []string, targetID string)
}
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 }
func (w *mockWindow) IsFocused() bool { return w.focused }
func (w *mockWindow) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true }
func (w *mockWindow) Restore() { w.maximised = false }
func (w *mockWindow) Minimise() { w.minimised = true }
func (w *mockWindow) Focus() { w.focused = true }
func (w *mockWindow) Close() { w.closed = true }
func (w *mockWindow) Show() { w.visible = true }
func (w *mockWindow) Hide() { w.visible = false }
func (w *mockWindow) Fullscreen() { w.fullscreened = true }
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
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 }
func (w *mockWindow) IsFocused() bool { return w.focused }
func (w *mockWindow) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true }
func (w *mockWindow) Restore() { w.maximised = false }
func (w *mockWindow) Minimise() { w.minimised = true }
func (w *mockWindow) Focus() { w.focused = true }
func (w *mockWindow) Close() { w.closed = true }
func (w *mockWindow) Show() { w.visible = true }
func (w *mockWindow) Hide() { w.visible = false }
func (w *mockWindow) Fullscreen() { w.fullscreened = true }
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}
func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
w.fileDropHandlers = append(w.fileDropHandlers, handler)
}

View file

@ -5,13 +5,13 @@ package window
type WindowOption func(*Window) error
// ApplyOptions creates a Window and applies all options in order.
func ApplyOptions(opts ...WindowOption) (*Window, error) {
func ApplyOptions(options ...WindowOption) (*Window, error) {
w := &Window{}
for _, opt := range opts {
if opt == nil {
for _, option := range options {
if option == nil {
continue
}
if err := opt(w); err != nil {
if err := option(w); err != nil {
return nil, err
}
}

View file

@ -3,25 +3,25 @@ package window
// Platform abstracts the windowing backend (Wails v3).
type Platform interface {
CreateWindow(opts PlatformWindowOptions) PlatformWindow
CreateWindow(options PlatformWindowOptions) PlatformWindow
GetWindows() []PlatformWindow
}
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
type PlatformWindowOptions struct {
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8 // RGBA
DisableResize bool
EnableFileDrop bool
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8 // RGBA
DisableResize bool
EnableFileDrop bool
}
// PlatformWindow is a live window handle from the backend.

View file

@ -2,8 +2,6 @@ package window
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -7,61 +7,51 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the window service.
type Options struct{}
// Service is a core.Service managing window lifecycle via IPC.
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
}
// OnStartup queries config from the display orchestrator and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
// Query config — display registers its handler before us (registration order guarantee).
// If display is not registered, handled=false and we skip config.
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if wCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(wCfg)
if windowConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(windowConfig)
}
}
// Register QUERY and TASK handlers manually.
// ACTION handler (HandleIPCEvents) is auto-registered by WithService —
// do NOT call RegisterAction here or actions will double-fire.
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
if w, ok := cfg["default_width"]; ok {
if _, ok := w.(int); ok {
func (s *Service) applyConfig(configData map[string]any) {
if width, ok := configData["default_width"]; ok {
if _, ok := width.(int); ok {
// TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended
}
}
if h, ok := cfg["default_height"]; ok {
if _, ok := h.(int); ok {
if height, ok := configData["default_height"]; ok {
if _, ok := height.(int); ok {
// TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended
}
}
if sf, ok := cfg["state_file"]; ok {
if _, ok := sf.(string); ok {
if stateFile, ok := configData["state_file"]; ok {
if _, ok := stateFile.(string); ok {
// TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended
}
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
// --- Query Handlers ---
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) {
case QueryWindowList:
@ -123,7 +113,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
case TaskSetPosition:
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
case TaskSetSize:
return nil, true, s.taskSetSize(t.Name, t.W, t.H)
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height)
case TaskMaximise:
return nil, true, s.taskMaximise(t.Name)
case TaskMinimise:
@ -155,7 +145,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
pw, err := s.manager.Open(t.Opts...)
pw, err := s.manager.Open(t.Options...)
if err != nil {
return nil, true, err
}
@ -189,7 +179,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
if data := e.Data; data != nil {
w, _ := data["w"].(int)
h, _ := data["h"].(int)
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h})
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
@ -227,13 +217,13 @@ func (s *Service) taskSetPosition(name string, x, y int) error {
return nil
}
func (s *Service) taskSetSize(name string, w, h int) error {
func (s *Service) taskSetSize(name string, width, height int) error {
pw, ok := s.manager.Get(name)
if !ok {
return coreerr.E("window.taskSetSize", "window not found: "+name, nil)
}
pw.SetSize(w, h)
s.manager.State().UpdateSize(name, w, h)
pw.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
return nil
}

View file

@ -31,7 +31,7 @@ func TestRegister_Good(t *testing.T) {
func TestTaskOpenWindow_Good(t *testing.T) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
Opts: []WindowOption{WithName("test"), WithURL("/")},
Options: []WindowOption{WithName("test"), WithURL("/")},
})
require.NoError(t, err)
assert.True(t, handled)
@ -49,8 +49,8 @@ func TestTaskOpenWindow_Bad(t *testing.T) {
func TestQueryWindowList_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("a")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("b")}})
result, handled, err := c.QUERY(QueryWindowList{})
require.NoError(t, err)
@ -61,7 +61,7 @@ func TestQueryWindowList_Good(t *testing.T) {
func TestQueryWindowByName_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
require.NoError(t, err)
@ -80,7 +80,7 @@ func TestQueryWindowByName_Bad(t *testing.T) {
func TestTaskCloseWindow_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"})
require.NoError(t, err)
@ -100,7 +100,7 @@ func TestTaskCloseWindow_Bad(t *testing.T) {
func TestTaskSetPosition_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
require.NoError(t, err)
@ -114,9 +114,9 @@ func TestTaskSetPosition_Good(t *testing.T) {
func TestTaskSetSize_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600})
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", Width: 800, Height: 600})
require.NoError(t, err)
assert.True(t, handled)
@ -128,7 +128,7 @@ func TestTaskSetSize_Good(t *testing.T) {
func TestTaskMaximise_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskMaximise{Name: "test"})
require.NoError(t, err)
@ -144,7 +144,7 @@ func TestFileDrop_Good(t *testing.T) {
// Open a window
result, _, _ := c.PERFORM(TaskOpenWindow{
Opts: []WindowOption{WithName("drop-test")},
Options: []WindowOption{WithName("drop-test")},
})
info := result.(WindowInfo)
assert.Equal(t, "drop-test", info.Name)
@ -179,7 +179,7 @@ func TestFileDrop_Good(t *testing.T) {
func TestTaskMinimise_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskMinimise{Name: "test"})
require.NoError(t, err)
@ -202,7 +202,7 @@ func TestTaskMinimise_Bad(t *testing.T) {
func TestTaskFocus_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskFocus{Name: "test"})
require.NoError(t, err)
@ -225,7 +225,7 @@ func TestTaskFocus_Bad(t *testing.T) {
func TestTaskRestore_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
// First maximise, then restore
_, _, _ = c.PERFORM(TaskMaximise{Name: "test"})
@ -256,7 +256,7 @@ func TestTaskRestore_Bad(t *testing.T) {
func TestTaskSetTitle_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"})
require.NoError(t, err)
@ -278,7 +278,7 @@ func TestTaskSetTitle_Bad(t *testing.T) {
func TestTaskSetVisibility_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true})
require.NoError(t, err)
@ -307,7 +307,7 @@ func TestTaskSetVisibility_Bad(t *testing.T) {
func TestTaskFullscreen_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
// Enter fullscreen
_, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true})
@ -337,8 +337,8 @@ func TestTaskFullscreen_Bad(t *testing.T) {
func TestTaskSaveLayout_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor"), WithSize(960, 1080), WithPosition(0, 0)}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("terminal"), WithSize(960, 1080), WithPosition(960, 0)}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(960, 1080), WithPosition(0, 0)}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(960, 1080), WithPosition(960, 0)}})
_, handled, err := c.PERFORM(TaskSaveLayout{Name: "coding"})
require.NoError(t, err)
@ -374,8 +374,8 @@ func TestTaskSaveLayout_Bad(t *testing.T) {
func TestTaskRestoreLayout_Good(t *testing.T) {
svc, c := newTestWindowService(t)
// Open windows
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor"), WithSize(800, 600), WithPosition(0, 0)}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("terminal"), WithSize(800, 600), WithPosition(0, 0)}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(800, 600), WithPosition(0, 0)}})
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(800, 600), WithPosition(0, 0)}})
// Save a layout with specific positions
_, _, _ = c.PERFORM(TaskSaveLayout{Name: "coding"})

View file

@ -16,28 +16,28 @@ func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
}
func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (wp *WailsPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
wOpts := application.WebviewWindowOptions{
Name: opts.Name,
Title: opts.Title,
URL: opts.URL,
Width: opts.Width,
Height: opts.Height,
X: opts.X,
Y: opts.Y,
MinWidth: opts.MinWidth,
MinHeight: opts.MinHeight,
MaxWidth: opts.MaxWidth,
MaxHeight: opts.MaxHeight,
Frameless: opts.Frameless,
Hidden: opts.Hidden,
AlwaysOnTop: opts.AlwaysOnTop,
DisableResize: opts.DisableResize,
EnableFileDrop: opts.EnableFileDrop,
BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]),
Name: options.Name,
Title: options.Title,
URL: options.URL,
Width: options.Width,
Height: options.Height,
X: options.X,
Y: options.Y,
MinWidth: options.MinWidth,
MinHeight: options.MinHeight,
MaxWidth: options.MaxWidth,
MaxHeight: options.MaxHeight,
Frameless: options.Frameless,
Hidden: options.Hidden,
AlwaysOnTop: options.AlwaysOnTop,
DisableResize: options.DisableResize,
EnableFileDrop: options.EnableFileDrop,
BackgroundColour: application.NewRGBA(options.BackgroundColour[0], options.BackgroundColour[1], options.BackgroundColour[2], options.BackgroundColour[3]),
}
w := wp.app.Window.NewWithOptions(wOpts)
return &wailsWindow{w: w, title: opts.Title}
return &wailsWindow{w: w, title: options.Title}
}
func (wp *WailsPlatform) GetWindows() []PlatformWindow {
@ -58,14 +58,14 @@ type wailsWindow struct {
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.title = title; ww.w.SetTitle(title) }
func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) }
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.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) {
ww.w.SetBackgroundColour(application.NewRGBA(r, g, b, a))
@ -140,4 +140,3 @@ var _ PlatformWindow = (*wailsWindow)(nil)
// Ensure WailsPlatform satisfies Platform at compile time.
var _ Platform = (*WailsPlatform)(nil)

View file

@ -9,19 +9,19 @@ import (
// Window is CoreGUI's own window descriptor — NOT a Wails type alias.
type Window struct {
Name string
Title string
URL string
Width, Height int
X, Y int
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8
DisableResize bool
EnableFileDrop bool
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8
DisableResize bool
EnableFileDrop bool
}
// ToPlatformOptions converts a Window to PlatformWindowOptions for the backend.
@ -68,8 +68,8 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager {
}
// Open creates a window using functional options, applies saved state, and tracks it.
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
w, err := ApplyOptions(opts...)
func (m *Manager) Open(options ...WindowOption) (PlatformWindow, error) {
w, err := ApplyOptions(options...)
if err != nil {
return nil, coreerr.E("window.Manager.Open", "failed to apply options", err)
}