GetWindowTitle was returning info.Name (the window's identifier) instead of the actual title. Added Title() to the PlatformWindow interface and Title field to WindowInfo so the real title flows through queries. Added wsRequire() helper and input validation for all webview:* WS message cases — window name is required for every webview action, and selectors/URLs are validated where they'd cause errors if empty. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1087 lines
31 KiB
Go
1087 lines
31 KiB
Go
package display
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
|
|
"forge.lthn.ai/core/go-config"
|
|
"forge.lthn.ai/core/go/pkg/core"
|
|
"encoding/json"
|
|
|
|
"forge.lthn.ai/core/gui/pkg/browser"
|
|
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
|
"forge.lthn.ai/core/gui/pkg/dialog"
|
|
"forge.lthn.ai/core/gui/pkg/dock"
|
|
"forge.lthn.ai/core/gui/pkg/environment"
|
|
"forge.lthn.ai/core/gui/pkg/keybinding"
|
|
"forge.lthn.ai/core/gui/pkg/lifecycle"
|
|
"forge.lthn.ai/core/gui/pkg/menu"
|
|
"forge.lthn.ai/core/gui/pkg/notification"
|
|
"forge.lthn.ai/core/gui/pkg/screen"
|
|
"forge.lthn.ai/core/gui/pkg/systray"
|
|
"forge.lthn.ai/core/gui/pkg/webview"
|
|
"forge.lthn.ai/core/gui/pkg/window"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
)
|
|
|
|
// Options holds configuration for the display service.
|
|
type Options struct{}
|
|
|
|
// WindowInfo is an alias for window.WindowInfo (backward compatibility).
|
|
type WindowInfo = window.WindowInfo
|
|
|
|
// Service manages windowing, dialogs, and other visual elements.
|
|
// It orchestrates sub-services (window, systray, menu) via IPC and bridges
|
|
// IPC actions to WebSocket events for TypeScript apps.
|
|
type Service struct {
|
|
*core.ServiceRuntime[Options]
|
|
wailsApp *application.App
|
|
app App
|
|
config Options
|
|
configData map[string]map[string]any
|
|
cfg *config.Config // go-config instance for file persistence
|
|
events *WSEventManager
|
|
}
|
|
|
|
// New is the constructor for the display service.
|
|
func New() (*Service, error) {
|
|
return &Service{
|
|
configData: map[string]map[string]any{
|
|
"window": {},
|
|
"systray": {},
|
|
"menu": {},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Register creates a factory closure that captures the Wails app.
|
|
// Pass nil for testing without a Wails runtime.
|
|
func Register(wailsApp *application.App) func(*core.Core) (any, error) {
|
|
return func(c *core.Core) (any, error) {
|
|
s, err := New()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
|
s.wailsApp = wailsApp
|
|
return s, nil
|
|
}
|
|
}
|
|
|
|
// OnStartup loads config and registers IPC handlers synchronously.
|
|
// CRITICAL: config handlers MUST be registered before returning —
|
|
// sub-services depend on them during their own OnStartup.
|
|
func (s *Service) OnStartup(ctx context.Context) error {
|
|
s.loadConfig()
|
|
|
|
// Register config query/task handlers — available NOW for sub-services
|
|
s.Core().RegisterQuery(s.handleConfigQuery)
|
|
s.Core().RegisterTask(s.handleConfigTask)
|
|
|
|
// Initialise Wails wrappers if app is available (nil in tests)
|
|
if s.wailsApp != nil {
|
|
s.app = newWailsApp(s.wailsApp)
|
|
s.events = NewWSEventManager()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
|
// It bridges sub-service IPC actions to WebSocket events for TS apps.
|
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|
switch m := msg.(type) {
|
|
case core.ActionServiceStartup:
|
|
// All services have completed OnStartup — safe to PERFORM on sub-services
|
|
s.buildMenu()
|
|
s.setupTray()
|
|
case window.ActionWindowOpened:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWindowCreate, Window: m.Name,
|
|
Data: map[string]any{"name": m.Name}})
|
|
}
|
|
case window.ActionWindowClosed:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWindowClose, Window: m.Name,
|
|
Data: map[string]any{"name": m.Name}})
|
|
}
|
|
case window.ActionWindowMoved:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWindowMove, Window: m.Name,
|
|
Data: map[string]any{"x": m.X, "y": m.Y}})
|
|
}
|
|
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}})
|
|
}
|
|
case window.ActionWindowFocused:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWindowFocus, Window: m.Name})
|
|
}
|
|
case window.ActionWindowBlurred:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWindowBlur, Window: m.Name})
|
|
}
|
|
case systray.ActionTrayClicked:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventTrayClick})
|
|
}
|
|
case systray.ActionTrayMenuItemClicked:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventTrayMenuItemClick,
|
|
Data: map[string]any{"actionId": m.ActionID}})
|
|
}
|
|
s.handleTrayAction(m.ActionID)
|
|
case environment.ActionThemeChanged:
|
|
if s.events != nil {
|
|
theme := "light"
|
|
if m.IsDark {
|
|
theme = "dark"
|
|
}
|
|
s.events.Emit(Event{Type: EventThemeChange,
|
|
Data: map[string]any{"isDark": m.IsDark, "theme": theme}})
|
|
}
|
|
case notification.ActionNotificationClicked:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventNotificationClick,
|
|
Data: map[string]any{"id": m.ID}})
|
|
}
|
|
case screen.ActionScreensChanged:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventScreenChange,
|
|
Data: map[string]any{"screens": m.Screens}})
|
|
}
|
|
case keybinding.ActionTriggered:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventKeybindingTriggered,
|
|
Data: map[string]any{"accelerator": m.Accelerator}})
|
|
}
|
|
case window.ActionFilesDropped:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWindowFileDrop, Window: m.Name,
|
|
Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}})
|
|
}
|
|
case dock.ActionVisibilityChanged:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventDockVisibility,
|
|
Data: map[string]any{"visible": m.Visible}})
|
|
}
|
|
case lifecycle.ActionApplicationStarted:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventAppStarted})
|
|
}
|
|
case lifecycle.ActionOpenedWithFile:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventAppOpenedWithFile,
|
|
Data: map[string]any{"path": m.Path}})
|
|
}
|
|
case lifecycle.ActionWillTerminate:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventAppWillTerminate})
|
|
}
|
|
case lifecycle.ActionDidBecomeActive:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventAppActive})
|
|
}
|
|
case lifecycle.ActionDidResignActive:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventAppInactive})
|
|
}
|
|
case lifecycle.ActionPowerStatusChanged:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventSystemPowerChange})
|
|
}
|
|
case lifecycle.ActionSystemSuspend:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventSystemSuspend})
|
|
}
|
|
case lifecycle.ActionSystemResume:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventSystemResume})
|
|
}
|
|
case contextmenu.ActionItemClicked:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventContextMenuClick,
|
|
Data: map[string]any{
|
|
"menuName": m.MenuName,
|
|
"actionId": m.ActionID,
|
|
"data": m.Data,
|
|
}})
|
|
}
|
|
case webview.ActionConsoleMessage:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWebviewConsole, Window: m.Window,
|
|
Data: map[string]any{"message": m.Message}})
|
|
}
|
|
case webview.ActionException:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventWebviewException, Window: m.Window,
|
|
Data: map[string]any{"exception": m.Exception}})
|
|
}
|
|
case ActionIDECommand:
|
|
if s.events != nil {
|
|
s.events.Emit(Event{Type: EventIDECommand,
|
|
Data: map[string]any{"command": m.Command}})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WSMessage represents a command received from a WebSocket client.
|
|
type WSMessage struct {
|
|
Action string `json:"action"`
|
|
Data map[string]any `json:"data,omitempty"`
|
|
}
|
|
|
|
// wsRequire extracts a string field from WS data and returns an error if it is empty.
|
|
func wsRequire(data map[string]any, key string) (string, error) {
|
|
v, _ := data[key].(string)
|
|
if v == "" {
|
|
return "", fmt.Errorf("ws: missing required field %q", key)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// handleWSMessage bridges WebSocket commands to IPC calls.
|
|
func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
|
|
var result any
|
|
var handled bool
|
|
var err error
|
|
|
|
switch msg.Action {
|
|
case "keybinding:add":
|
|
accelerator, _ := msg.Data["accelerator"].(string)
|
|
description, _ := msg.Data["description"].(string)
|
|
result, handled, err = s.Core().PERFORM(keybinding.TaskAdd{
|
|
Accelerator: accelerator, Description: description,
|
|
})
|
|
case "keybinding:remove":
|
|
accelerator, _ := msg.Data["accelerator"].(string)
|
|
result, handled, err = s.Core().PERFORM(keybinding.TaskRemove{
|
|
Accelerator: accelerator,
|
|
})
|
|
case "keybinding:list":
|
|
result, handled, err = s.Core().QUERY(keybinding.QueryList{})
|
|
case "browser:open-url":
|
|
url, _ := msg.Data["url"].(string)
|
|
result, handled, err = s.Core().PERFORM(browser.TaskOpenURL{URL: url})
|
|
case "browser:open-file":
|
|
path, _ := msg.Data["path"].(string)
|
|
result, handled, err = s.Core().PERFORM(browser.TaskOpenFile{Path: path})
|
|
case "dock:show":
|
|
result, handled, err = s.Core().PERFORM(dock.TaskShowIcon{})
|
|
case "dock:hide":
|
|
result, handled, err = s.Core().PERFORM(dock.TaskHideIcon{})
|
|
case "dock:badge":
|
|
label, _ := msg.Data["label"].(string)
|
|
result, handled, err = s.Core().PERFORM(dock.TaskSetBadge{Label: label})
|
|
case "dock:badge-remove":
|
|
result, handled, err = s.Core().PERFORM(dock.TaskRemoveBadge{})
|
|
case "dock:visible":
|
|
result, handled, err = s.Core().QUERY(dock.QueryVisible{})
|
|
case "contextmenu:add":
|
|
name, _ := msg.Data["name"].(string)
|
|
menuJSON, _ := json.Marshal(msg.Data["menu"])
|
|
var menuDef contextmenu.ContextMenuDef
|
|
_ = json.Unmarshal(menuJSON, &menuDef)
|
|
result, handled, err = s.Core().PERFORM(contextmenu.TaskAdd{
|
|
Name: name, Menu: menuDef,
|
|
})
|
|
case "contextmenu:remove":
|
|
name, _ := msg.Data["name"].(string)
|
|
result, handled, err = s.Core().PERFORM(contextmenu.TaskRemove{Name: name})
|
|
case "contextmenu:get":
|
|
name, _ := msg.Data["name"].(string)
|
|
result, handled, err = s.Core().QUERY(contextmenu.QueryGet{Name: name})
|
|
case "contextmenu:list":
|
|
result, handled, err = s.Core().QUERY(contextmenu.QueryList{})
|
|
case "webview:eval":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
script, _ := msg.Data["script"].(string)
|
|
result, handled, err = s.Core().PERFORM(webview.TaskEvaluate{Window: w, Script: script})
|
|
case "webview:click":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().PERFORM(webview.TaskClick{Window: w, Selector: sel})
|
|
case "webview:type":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
text, _ := msg.Data["text"].(string)
|
|
result, handled, err = s.Core().PERFORM(webview.TaskType{Window: w, Selector: sel, Text: text})
|
|
case "webview:navigate":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
url, e := wsRequire(msg.Data, "url")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().PERFORM(webview.TaskNavigate{Window: w, URL: url})
|
|
case "webview:screenshot":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().PERFORM(webview.TaskScreenshot{Window: w})
|
|
case "webview:scroll":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
x, _ := msg.Data["x"].(float64)
|
|
y, _ := msg.Data["y"].(float64)
|
|
result, handled, err = s.Core().PERFORM(webview.TaskScroll{Window: w, X: int(x), Y: int(y)})
|
|
case "webview:hover":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().PERFORM(webview.TaskHover{Window: w, Selector: sel})
|
|
case "webview:select":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
val, _ := msg.Data["value"].(string)
|
|
result, handled, err = s.Core().PERFORM(webview.TaskSelect{Window: w, Selector: sel, Value: val})
|
|
case "webview:check":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
checked, _ := msg.Data["checked"].(bool)
|
|
result, handled, err = s.Core().PERFORM(webview.TaskCheck{Window: w, Selector: sel, Checked: checked})
|
|
case "webview:upload":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
pathsRaw, _ := msg.Data["paths"].([]any)
|
|
var paths []string
|
|
for _, p := range pathsRaw {
|
|
if ps, ok := p.(string); ok {
|
|
paths = append(paths, ps)
|
|
}
|
|
}
|
|
result, handled, err = s.Core().PERFORM(webview.TaskUploadFile{Window: w, Selector: sel, Paths: paths})
|
|
case "webview:viewport":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
width, _ := msg.Data["width"].(float64)
|
|
height, _ := msg.Data["height"].(float64)
|
|
result, handled, err = s.Core().PERFORM(webview.TaskSetViewport{Window: w, Width: int(width), Height: int(height)})
|
|
case "webview:clear-console":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().PERFORM(webview.TaskClearConsole{Window: w})
|
|
case "webview:console":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
level, _ := msg.Data["level"].(string)
|
|
limit := 100
|
|
if l, ok := msg.Data["limit"].(float64); ok {
|
|
limit = int(l)
|
|
}
|
|
result, handled, err = s.Core().QUERY(webview.QueryConsole{Window: w, Level: level, Limit: limit})
|
|
case "webview:query":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().QUERY(webview.QuerySelector{Window: w, Selector: sel})
|
|
case "webview:query-all":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := wsRequire(msg.Data, "selector")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().QUERY(webview.QuerySelectorAll{Window: w, Selector: sel})
|
|
case "webview:dom-tree":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, _ := msg.Data["selector"].(string) // selector optional for dom-tree (defaults to root)
|
|
result, handled, err = s.Core().QUERY(webview.QueryDOMTree{Window: w, Selector: sel})
|
|
case "webview:url":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().QUERY(webview.QueryURL{Window: w})
|
|
case "webview:title":
|
|
w, e := wsRequire(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
result, handled, err = s.Core().QUERY(webview.QueryTitle{Window: w})
|
|
default:
|
|
return nil, false, nil
|
|
}
|
|
|
|
return result, handled, err
|
|
}
|
|
|
|
// handleTrayAction processes tray menu item clicks.
|
|
func (s *Service) handleTrayAction(actionID string) {
|
|
switch actionID {
|
|
case "open-desktop":
|
|
// Show all windows
|
|
infos := s.ListWindowInfos()
|
|
for _, info := range infos {
|
|
_, _, _ = s.Core().PERFORM(window.TaskFocus{Name: info.Name})
|
|
}
|
|
case "close-desktop":
|
|
// Hide all windows — future: add TaskHideWindow
|
|
case "env-info":
|
|
// Query environment info via IPC and show as dialog
|
|
result, handled, _ := s.Core().QUERY(environment.QueryInfo{})
|
|
if handled {
|
|
info := result.(environment.EnvironmentInfo)
|
|
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{
|
|
Type: dialog.DialogInfo, Title: "Environment",
|
|
Message: details, Buttons: []string{"OK"},
|
|
},
|
|
})
|
|
}
|
|
case "quit":
|
|
if s.app != nil {
|
|
s.app.Quit()
|
|
}
|
|
}
|
|
}
|
|
|
|
func guiConfigPath() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return filepath.Join(".core", "gui", "config.yaml")
|
|
}
|
|
return filepath.Join(home, ".core", "gui", "config.yaml")
|
|
}
|
|
|
|
func (s *Service) loadConfig() {
|
|
if s.cfg != 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))
|
|
if err != nil {
|
|
// Non-critical — continue with empty configData
|
|
return
|
|
}
|
|
s.cfg = cfg
|
|
|
|
for _, section := range []string{"window", "systray", "menu"} {
|
|
var data map[string]any
|
|
if err := cfg.Get(section, &data); err == nil && data != nil {
|
|
s.configData[section] = data
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) handleConfigQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|
switch q.(type) {
|
|
case window.QueryConfig:
|
|
return s.configData["window"], true, nil
|
|
case systray.QueryConfig:
|
|
return s.configData["systray"], true, nil
|
|
case menu.QueryConfig:
|
|
return s.configData["menu"], true, nil
|
|
default:
|
|
return nil, false, nil
|
|
}
|
|
}
|
|
|
|
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)
|
|
return nil, true, nil
|
|
case systray.TaskSaveConfig:
|
|
s.configData["systray"] = t.Value
|
|
s.persistSection("systray", t.Value)
|
|
return nil, true, nil
|
|
case menu.TaskSaveConfig:
|
|
s.configData["menu"] = t.Value
|
|
s.persistSection("menu", t.Value)
|
|
return nil, true, nil
|
|
default:
|
|
return nil, false, nil
|
|
}
|
|
}
|
|
|
|
func (s *Service) persistSection(key string, value map[string]any) {
|
|
if s.cfg == nil {
|
|
return
|
|
}
|
|
_ = s.cfg.Set(key, value)
|
|
_ = s.cfg.Commit()
|
|
}
|
|
|
|
// --- Service accessors ---
|
|
|
|
// windowService returns the window service from Core, or nil if not registered.
|
|
func (s *Service) windowService() *window.Service {
|
|
svc, err := core.ServiceFor[*window.Service](s.Core(), "window")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// --- 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})
|
|
return err
|
|
}
|
|
|
|
// GetWindowInfo returns information about a window via IPC.
|
|
func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, error) {
|
|
result, handled, err := s.Core().QUERY(window.QueryWindowByName{Name: name})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !handled {
|
|
return nil, fmt.Errorf("window service not available")
|
|
}
|
|
info, _ := result.(*window.WindowInfo)
|
|
return info, nil
|
|
}
|
|
|
|
// ListWindowInfos returns information about all tracked windows via IPC.
|
|
func (s *Service) ListWindowInfos() []window.WindowInfo {
|
|
result, handled, _ := s.Core().QUERY(window.QueryWindowList{})
|
|
if !handled {
|
|
return nil
|
|
}
|
|
list, _ := result.([]window.WindowInfo)
|
|
return list
|
|
}
|
|
|
|
// SetWindowPosition moves a window via IPC.
|
|
func (s *Service) SetWindowPosition(name string, x, y int) error {
|
|
_, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y})
|
|
return err
|
|
}
|
|
|
|
// 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})
|
|
return err
|
|
}
|
|
|
|
// SetWindowBounds sets both position and size of a window via IPC.
|
|
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})
|
|
return err
|
|
}
|
|
|
|
// MaximizeWindow maximizes a window via IPC.
|
|
func (s *Service) MaximizeWindow(name string) error {
|
|
_, _, err := s.Core().PERFORM(window.TaskMaximise{Name: name})
|
|
return err
|
|
}
|
|
|
|
// MinimizeWindow minimizes a window via IPC.
|
|
func (s *Service) MinimizeWindow(name string) error {
|
|
_, _, err := s.Core().PERFORM(window.TaskMinimise{Name: name})
|
|
return err
|
|
}
|
|
|
|
// FocusWindow brings a window to the front via IPC.
|
|
func (s *Service) FocusWindow(name string) error {
|
|
_, _, err := s.Core().PERFORM(window.TaskFocus{Name: name})
|
|
return err
|
|
}
|
|
|
|
// CloseWindow closes a window via IPC.
|
|
func (s *Service) CloseWindow(name string) error {
|
|
_, _, err := s.Core().PERFORM(window.TaskCloseWindow{Name: name})
|
|
return err
|
|
}
|
|
|
|
// RestoreWindow restores a maximized/minimized window.
|
|
// Uses direct Manager access (no IPC task for restore yet).
|
|
func (s *Service) RestoreWindow(name string) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
pw, ok := ws.Manager().Get(name)
|
|
if !ok {
|
|
return fmt.Errorf("window not found: %s", name)
|
|
}
|
|
pw.Restore()
|
|
return nil
|
|
}
|
|
|
|
// SetWindowVisibility shows or hides a window.
|
|
// Uses direct Manager access (no IPC task for visibility yet).
|
|
func (s *Service) SetWindowVisibility(name string, visible bool) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
pw, ok := ws.Manager().Get(name)
|
|
if !ok {
|
|
return fmt.Errorf("window not found: %s", name)
|
|
}
|
|
pw.SetVisibility(visible)
|
|
return nil
|
|
}
|
|
|
|
// SetWindowAlwaysOnTop sets whether a window stays on top.
|
|
// Uses direct Manager access (no IPC task for always-on-top yet).
|
|
func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
pw, ok := ws.Manager().Get(name)
|
|
if !ok {
|
|
return fmt.Errorf("window not found: %s", name)
|
|
}
|
|
pw.SetAlwaysOnTop(alwaysOnTop)
|
|
return nil
|
|
}
|
|
|
|
// SetWindowTitle changes a window's title.
|
|
// Uses direct Manager access (no IPC task for title yet).
|
|
func (s *Service) SetWindowTitle(name string, title string) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
pw, ok := ws.Manager().Get(name)
|
|
if !ok {
|
|
return fmt.Errorf("window not found: %s", name)
|
|
}
|
|
pw.SetTitle(title)
|
|
return nil
|
|
}
|
|
|
|
// SetWindowFullscreen sets a window to fullscreen mode.
|
|
// Uses direct Manager access (no IPC task for fullscreen yet).
|
|
func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
pw, ok := ws.Manager().Get(name)
|
|
if !ok {
|
|
return fmt.Errorf("window not found: %s", name)
|
|
}
|
|
if fullscreen {
|
|
pw.Fullscreen()
|
|
} else {
|
|
pw.UnFullscreen()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetWindowBackgroundColour sets the background colour of a window.
|
|
// Uses direct Manager access (no IPC task for background colour yet).
|
|
func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
pw, ok := ws.Manager().Get(name)
|
|
if !ok {
|
|
return fmt.Errorf("window not found: %s", name)
|
|
}
|
|
pw.SetBackgroundColour(r, g, b, a)
|
|
return nil
|
|
}
|
|
|
|
// GetFocusedWindow returns the name of the currently focused window.
|
|
func (s *Service) GetFocusedWindow() string {
|
|
infos := s.ListWindowInfos()
|
|
for _, info := range infos {
|
|
if info.Focused {
|
|
return info.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetWindowTitle returns the title of a window by name.
|
|
func (s *Service) GetWindowTitle(name string) (string, error) {
|
|
info, err := s.GetWindowInfo(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info == nil {
|
|
return "", fmt.Errorf("window not found: %s", name)
|
|
}
|
|
return info.Title, nil
|
|
}
|
|
|
|
// ResetWindowState clears saved window positions.
|
|
func (s *Service) ResetWindowState() error {
|
|
ws := s.windowService()
|
|
if ws != nil {
|
|
ws.Manager().State().Clear()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetSavedWindowStates returns all saved window states.
|
|
func (s *Service) GetSavedWindowStates() map[string]window.WindowState {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]window.WindowState)
|
|
for _, name := range ws.Manager().State().ListStates() {
|
|
if state, ok := ws.Manager().State().GetState(name); ok {
|
|
result[name] = state
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// CreateWindowOptions contains options for creating a new window.
|
|
type CreateWindowOptions struct {
|
|
Name string `json:"name"`
|
|
Title string `json:"title,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
X int `json:"x,omitempty"`
|
|
Y int `json:"y,omitempty"`
|
|
Width int `json:"width,omitempty"`
|
|
Height int `json:"height,omitempty"`
|
|
}
|
|
|
|
// CreateWindow creates a new window with the specified options.
|
|
func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, error) {
|
|
if opts.Name == "" {
|
|
return nil, fmt.Errorf("window name is required")
|
|
}
|
|
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),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info := result.(window.WindowInfo)
|
|
return &info, nil
|
|
}
|
|
|
|
// --- Layout delegation ---
|
|
|
|
// SaveLayout saves the current window arrangement as a named layout.
|
|
func (s *Service) SaveLayout(name string) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
states := make(map[string]window.WindowState)
|
|
for _, n := range ws.Manager().List() {
|
|
if pw, ok := ws.Manager().Get(n); ok {
|
|
x, y := pw.Position()
|
|
w, h := pw.Size()
|
|
states[n] = window.WindowState{X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised()}
|
|
}
|
|
}
|
|
return ws.Manager().Layout().SaveLayout(name, states)
|
|
}
|
|
|
|
// RestoreLayout applies a saved layout.
|
|
func (s *Service) RestoreLayout(name string) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
layout, ok := ws.Manager().Layout().GetLayout(name)
|
|
if !ok {
|
|
return fmt.Errorf("layout not found: %s", name)
|
|
}
|
|
for wName, state := range layout.Windows {
|
|
if pw, ok := ws.Manager().Get(wName); ok {
|
|
pw.SetPosition(state.X, state.Y)
|
|
pw.SetSize(state.Width, state.Height)
|
|
if state.Maximized {
|
|
pw.Maximise()
|
|
} else {
|
|
pw.Restore()
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListLayouts returns all saved layout names with metadata.
|
|
func (s *Service) ListLayouts() []window.LayoutInfo {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return nil
|
|
}
|
|
return ws.Manager().Layout().ListLayouts()
|
|
}
|
|
|
|
// DeleteLayout removes a saved layout by name.
|
|
func (s *Service) DeleteLayout(name string) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
ws.Manager().Layout().DeleteLayout(name)
|
|
return nil
|
|
}
|
|
|
|
// GetLayout returns a specific layout by name.
|
|
func (s *Service) GetLayout(name string) *window.Layout {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return nil
|
|
}
|
|
layout, ok := ws.Manager().Layout().GetLayout(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return &layout
|
|
}
|
|
|
|
// --- Tiling/snapping delegation ---
|
|
|
|
// TileWindows arranges windows in a tiled layout.
|
|
func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
return ws.Manager().TileWindows(mode, windowNames, 1920, 1080) // TODO: use actual screen size
|
|
}
|
|
|
|
// SnapWindow snaps a window to a screen edge or corner.
|
|
func (s *Service) SnapWindow(name string, position window.SnapPosition) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
return ws.Manager().SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size
|
|
}
|
|
|
|
// StackWindows arranges windows in a cascade pattern.
|
|
func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
return ws.Manager().StackWindows(windowNames, offsetX, offsetY)
|
|
}
|
|
|
|
// ApplyWorkflowLayout applies a predefined layout for a specific workflow.
|
|
func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
|
|
ws := s.windowService()
|
|
if ws == nil {
|
|
return fmt.Errorf("window service not available")
|
|
}
|
|
return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), 1920, 1080)
|
|
}
|
|
|
|
// GetEventManager returns the event manager for WebSocket event subscriptions.
|
|
func (s *Service) GetEventManager() *WSEventManager {
|
|
return s.events
|
|
}
|
|
|
|
// --- Menu (handlers stay in display, structure delegated via IPC) ---
|
|
|
|
func (s *Service) buildMenu() {
|
|
items := []menu.MenuItem{
|
|
{Role: ptr(menu.RoleAppMenu)},
|
|
{Role: ptr(menu.RoleFileMenu)},
|
|
{Role: ptr(menu.RoleViewMenu)},
|
|
{Role: ptr(menu.RoleEditMenu)},
|
|
{Label: "Workspace", Children: []menu.MenuItem{
|
|
{Label: "New...", OnClick: s.handleNewWorkspace},
|
|
{Label: "List", OnClick: s.handleListWorkspaces},
|
|
}},
|
|
{Label: "Developer", Children: []menu.MenuItem{
|
|
{Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile},
|
|
{Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile},
|
|
{Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile},
|
|
{Type: "separator"},
|
|
{Label: "Editor", OnClick: s.handleOpenEditor},
|
|
{Label: "Terminal", OnClick: s.handleOpenTerminal},
|
|
{Type: "separator"},
|
|
{Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun},
|
|
{Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild},
|
|
}},
|
|
{Role: ptr(menu.RoleWindowMenu)},
|
|
{Role: ptr(menu.RoleHelpMenu)},
|
|
}
|
|
|
|
// On non-macOS, remove the AppMenu role
|
|
if runtime.GOOS != "darwin" {
|
|
items = items[1:] // skip AppMenu
|
|
}
|
|
|
|
_, _, _ = s.Core().PERFORM(menu.TaskSetAppMenu{Items: items})
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
// --- Menu handler methods ---
|
|
|
|
func (s *Service) handleNewWorkspace() {
|
|
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
|
Opts: []window.WindowOption{
|
|
window.WithName("workspace-new"),
|
|
window.WithTitle("New Workspace"),
|
|
window.WithURL("/workspace/new"),
|
|
window.WithSize(500, 400),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Service) handleListWorkspaces() {
|
|
ws := s.Core().Service("workspace")
|
|
if ws == nil {
|
|
return
|
|
}
|
|
lister, ok := ws.(interface{ ListWorkspaces() []string })
|
|
if !ok {
|
|
return
|
|
}
|
|
_ = lister.ListWorkspaces()
|
|
}
|
|
|
|
func (s *Service) handleNewFile() {
|
|
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
|
Opts: []window.WindowOption{
|
|
window.WithName("editor"),
|
|
window.WithTitle("New File - Editor"),
|
|
window.WithURL("/#/developer/editor?new=true"),
|
|
window.WithSize(1200, 800),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Service) handleOpenFile() {
|
|
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{
|
|
Opts: dialog.OpenFileOptions{
|
|
Title: "Open File",
|
|
AllowMultiple: false,
|
|
},
|
|
})
|
|
if err != nil || !handled {
|
|
return
|
|
}
|
|
paths, ok := result.([]string)
|
|
if !ok || len(paths) == 0 {
|
|
return
|
|
}
|
|
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
|
Opts: []window.WindowOption{
|
|
window.WithName("editor"),
|
|
window.WithTitle(paths[0] + " - Editor"),
|
|
window.WithURL("/#/developer/editor?file=" + paths[0]),
|
|
window.WithSize(1200, 800),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) }
|
|
func (s *Service) handleOpenEditor() {
|
|
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
|
Opts: []window.WindowOption{
|
|
window.WithName("editor"),
|
|
window.WithTitle("Editor"),
|
|
window.WithURL("/#/developer/editor"),
|
|
window.WithSize(1200, 800),
|
|
},
|
|
})
|
|
}
|
|
func (s *Service) handleOpenTerminal() {
|
|
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
|
Opts: []window.WindowOption{
|
|
window.WithName("terminal"),
|
|
window.WithTitle("Terminal"),
|
|
window.WithURL("/#/developer/terminal"),
|
|
window.WithSize(800, 500),
|
|
},
|
|
})
|
|
}
|
|
func (s *Service) handleRun() { _ = s.Core().ACTION(ActionIDECommand{Command: "run"}) }
|
|
func (s *Service) handleBuild() { _ = s.Core().ACTION(ActionIDECommand{Command: "build"}) }
|
|
|
|
// --- Tray (setup delegated via IPC) ---
|
|
|
|
func (s *Service) setupTray() {
|
|
_, _, _ = s.Core().PERFORM(systray.TaskSetTrayMenu{Items: []systray.TrayMenuItem{
|
|
{Label: "Open Desktop", ActionID: "open-desktop"},
|
|
{Label: "Close Desktop", ActionID: "close-desktop"},
|
|
{Type: "separator"},
|
|
{Label: "Environment Info", ActionID: "env-info"},
|
|
{Type: "separator"},
|
|
{Label: "Quit", ActionID: "quit"},
|
|
}})
|
|
}
|