924 lines
29 KiB
Go
924 lines
29 KiB
Go
package display
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
|
|
"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"
|
|
"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{}
|
|
|
|
// 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
|
|
configData map[string]map[string]any
|
|
configFile *config.Config // config instance for file persistence
|
|
events *WebSocketEventManager
|
|
}
|
|
|
|
// NewService constructs the display service.
|
|
func NewService() (*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 := NewService()
|
|
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 = NewWebSocketEventManager()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// core.WithService auto-registers this handler to bridge 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:
|
|
s.emit(Event{Type: EventWindowCreate, Window: m.Name,
|
|
Data: map[string]any{"name": m.Name}})
|
|
case window.ActionWindowClosed:
|
|
s.emit(Event{Type: EventWindowClose, Window: m.Name,
|
|
Data: map[string]any{"name": m.Name}})
|
|
case window.ActionWindowMoved:
|
|
s.emit(Event{Type: EventWindowMove, Window: m.Name,
|
|
Data: map[string]any{"x": m.X, "y": m.Y}})
|
|
case window.ActionWindowResized:
|
|
s.emit(Event{Type: EventWindowResize, Window: m.Name,
|
|
Data: map[string]any{"width": m.Width, "height": m.Height}})
|
|
case window.ActionWindowFocused:
|
|
s.emit(Event{Type: EventWindowFocus, Window: m.Name})
|
|
case window.ActionWindowBlurred:
|
|
s.emit(Event{Type: EventWindowBlur, Window: m.Name})
|
|
case systray.ActionTrayClicked:
|
|
s.emit(Event{Type: EventTrayClick})
|
|
case systray.ActionTrayMenuItemClicked:
|
|
s.emit(Event{Type: EventTrayMenuItemClick,
|
|
Data: map[string]any{"actionId": m.ActionID}})
|
|
s.handleTrayAction(m.ActionID)
|
|
case environment.ActionThemeChanged:
|
|
theme := "light"
|
|
if m.IsDark {
|
|
theme = "dark"
|
|
}
|
|
s.emit(Event{Type: EventThemeChange,
|
|
Data: map[string]any{"isDark": m.IsDark, "theme": theme}})
|
|
case notification.ActionNotificationClicked:
|
|
s.emit(Event{Type: EventNotificationClick,
|
|
Data: map[string]any{"id": m.ID}})
|
|
case screen.ActionScreensChanged:
|
|
s.emit(Event{Type: EventScreenChange,
|
|
Data: map[string]any{"screens": m.Screens}})
|
|
case keybinding.ActionTriggered:
|
|
s.emit(Event{Type: EventKeybindingTriggered,
|
|
Data: map[string]any{"accelerator": m.Accelerator}})
|
|
case window.ActionFilesDropped:
|
|
s.emit(Event{Type: EventWindowFileDrop, Window: m.Name,
|
|
Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}})
|
|
case dock.ActionVisibilityChanged:
|
|
s.emit(Event{Type: EventDockVisibility,
|
|
Data: map[string]any{"visible": m.Visible}})
|
|
case lifecycle.ActionApplicationStarted:
|
|
s.emit(Event{Type: EventAppStarted})
|
|
case lifecycle.ActionOpenedWithFile:
|
|
s.emit(Event{Type: EventAppOpenedWithFile,
|
|
Data: map[string]any{"path": m.Path}})
|
|
case lifecycle.ActionWillTerminate:
|
|
s.emit(Event{Type: EventAppWillTerminate})
|
|
case lifecycle.ActionDidBecomeActive:
|
|
s.emit(Event{Type: EventAppActive})
|
|
case lifecycle.ActionDidResignActive:
|
|
s.emit(Event{Type: EventAppInactive})
|
|
case lifecycle.ActionPowerStatusChanged:
|
|
s.emit(Event{Type: EventSystemPowerChange})
|
|
case lifecycle.ActionSystemSuspend:
|
|
s.emit(Event{Type: EventSystemSuspend})
|
|
case lifecycle.ActionSystemResume:
|
|
s.emit(Event{Type: EventSystemResume})
|
|
case contextmenu.ActionItemClicked:
|
|
s.emit(Event{Type: EventContextMenuClick,
|
|
Data: map[string]any{
|
|
"menuName": m.MenuName,
|
|
"actionId": m.ActionID,
|
|
"data": m.Data,
|
|
}})
|
|
case webview.ActionConsoleMessage:
|
|
s.emit(Event{Type: EventWebviewConsole, Window: m.Window,
|
|
Data: map[string]any{"message": m.Message}})
|
|
case webview.ActionException:
|
|
s.emit(Event{Type: EventWebviewException, Window: m.Window,
|
|
Data: map[string]any{"exception": m.Exception}})
|
|
case ActionIDECommand:
|
|
s.emit(Event{Type: EventIDECommand,
|
|
Data: map[string]any{"command": m.Command}})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) emit(event Event) {
|
|
if s.events != nil {
|
|
s.events.Emit(event)
|
|
}
|
|
}
|
|
|
|
// WebSocketMessage represents a command received from a WebSocket client.
|
|
type WebSocketMessage struct {
|
|
Action string `json:"action"`
|
|
Data map[string]any `json:"data,omitempty"`
|
|
}
|
|
|
|
// requireWebSocketField extracts a string field from WebSocket data and returns an error if it is empty.
|
|
func requireWebSocketField(data map[string]any, key string) (string, error) {
|
|
v, _ := data[key].(string)
|
|
if v == "" {
|
|
return "", coreerr.E("display.requireWebSocketField", "missing required field \""+key+"\"", nil)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// handleWebSocketMessage bridges WebSocket commands to IPC calls.
|
|
func (s *Service) handleWebSocketMessage(msg WebSocketMessage) (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 := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
url, e := requireWebSocketField(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 := requireWebSocketField(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 := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(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 := requireWebSocketField(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 := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(msg.Data, "window")
|
|
if e != nil {
|
|
return nil, false, e
|
|
}
|
|
sel, e := requireWebSocketField(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 := requireWebSocketField(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 := requireWebSocketField(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 := requireWebSocketField(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":
|
|
infos := s.ListWindowInfos()
|
|
for _, info := range infos {
|
|
_, _, _ = s.Core().PERFORM(window.TaskSetVisibility{Name: info.Name, Visible: true})
|
|
_, _, _ = s.Core().PERFORM(window.TaskFocus{Name: info.Name})
|
|
}
|
|
case "close-desktop":
|
|
infos := s.ListWindowInfos()
|
|
for _, info := range infos {
|
|
_, _, _ = s.Core().PERFORM(window.TaskSetVisibility{Name: info.Name, Visible: false})
|
|
}
|
|
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 := "OS: " + info.OS + "\nArch: " + info.Arch + "\nPlatform: " +
|
|
info.Platform.Name + " " + info.Platform.Version
|
|
_, _, _ = s.Core().PERFORM(dialog.TaskMessageDialog{
|
|
Options: 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.configFile != nil {
|
|
return // Already loaded (e.g., via loadConfigFrom in tests)
|
|
}
|
|
s.loadConfigFrom(guiConfigPath())
|
|
}
|
|
|
|
func (s *Service) loadConfigFrom(path string) {
|
|
configFile, err := config.New(config.WithPath(path))
|
|
if err != nil {
|
|
// Non-critical: continue with empty configData
|
|
return
|
|
}
|
|
s.configFile = configFile
|
|
|
|
for _, section := range []string{"window", "systray", "menu"} {
|
|
var data map[string]any
|
|
if err := configFile.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.Config
|
|
s.persistSection("window", t.Config)
|
|
return nil, true, nil
|
|
case systray.TaskSaveConfig:
|
|
s.configData["systray"] = t.Config
|
|
s.persistSection("systray", t.Config)
|
|
return nil, true, nil
|
|
case menu.TaskSaveConfig:
|
|
s.configData["menu"] = t.Config
|
|
s.persistSection("menu", t.Config)
|
|
return nil, true, nil
|
|
default:
|
|
return nil, false, nil
|
|
}
|
|
}
|
|
|
|
func (s *Service) persistSection(key string, value map[string]any) {
|
|
if s.configFile == nil {
|
|
return
|
|
}
|
|
_ = s.configFile.Set(key, value)
|
|
_ = s.configFile.Commit()
|
|
}
|
|
|
|
// --- Service accessors ---
|
|
|
|
func (s *Service) performWindowTask(operation string, task core.Task) (any, error) {
|
|
result, handled, err := s.Core().PERFORM(task)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !handled {
|
|
return nil, coreerr.E(operation, "window service not available", nil)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// --- Window Management (delegates via IPC) ---
|
|
|
|
// OpenWindow opens a window using manager defaults.
|
|
// Example: s.OpenWindow(window.Window{})
|
|
func (s *Service) OpenWindow(spec window.Window) error {
|
|
_, err := s.performWindowTask("display.OpenWindow", window.TaskOpenWindow{Window: spec})
|
|
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, coreerr.E("display.GetWindowInfo", "window service not available", nil)
|
|
}
|
|
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 []window.WindowInfo{}
|
|
}
|
|
list, _ := result.([]window.WindowInfo)
|
|
return list
|
|
}
|
|
|
|
// Example: s.SetWindowPosition("editor", 100, 200)
|
|
// Use SetWindowBounds when you are changing position and size together.
|
|
func (s *Service) SetWindowPosition(name string, x, y int) error {
|
|
_, err := s.performWindowTask("display.SetWindowPosition", window.TaskSetPosition{Name: name, X: x, Y: y})
|
|
return err
|
|
}
|
|
|
|
// Example: s.SetWindowSize("editor", 1280, 720)
|
|
// Use SetWindowBounds when you are changing position and size together.
|
|
func (s *Service) SetWindowSize(name string, width, height int) error {
|
|
_, err := s.performWindowTask("display.SetWindowSize", window.TaskSetSize{Name: name, Width: width, Height: height})
|
|
return err
|
|
}
|
|
|
|
// Example: s.SetWindowBounds("editor", 100, 200, 1280, 720)
|
|
func (s *Service) SetWindowBounds(name string, x, y, width, height int) error {
|
|
_, err := s.performWindowTask("display.SetWindowBounds", window.TaskSetBounds{
|
|
Name: name, X: x, Y: y, Width: width, Height: height,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// MaximizeWindow maximizes a window via IPC.
|
|
func (s *Service) MaximizeWindow(name string) error {
|
|
_, err := s.performWindowTask("display.MaximizeWindow", window.TaskMaximize{Name: name})
|
|
return err
|
|
}
|
|
|
|
// MinimizeWindow minimizes a window via IPC.
|
|
func (s *Service) MinimizeWindow(name string) error {
|
|
_, err := s.performWindowTask("display.MinimizeWindow", window.TaskMinimize{Name: name})
|
|
return err
|
|
}
|
|
|
|
// FocusWindow brings a window to the front via IPC.
|
|
func (s *Service) FocusWindow(name string) error {
|
|
_, err := s.performWindowTask("display.FocusWindow", window.TaskFocus{Name: name})
|
|
return err
|
|
}
|
|
|
|
// CloseWindow closes a window via IPC.
|
|
func (s *Service) CloseWindow(name string) error {
|
|
_, err := s.performWindowTask("display.CloseWindow", window.TaskCloseWindow{Name: name})
|
|
return err
|
|
}
|
|
|
|
// RestoreWindow restores a maximized/minimized window.
|
|
func (s *Service) RestoreWindow(name string) error {
|
|
_, err := s.performWindowTask("display.RestoreWindow", window.TaskRestore{Name: name})
|
|
return err
|
|
}
|
|
|
|
// SetWindowVisibility shows or hides a window.
|
|
func (s *Service) SetWindowVisibility(name string, visible bool) error {
|
|
_, err := s.performWindowTask("display.SetWindowVisibility", window.TaskSetVisibility{Name: name, Visible: visible})
|
|
return err
|
|
}
|
|
|
|
// SetWindowAlwaysOnTop sets whether a window stays on top.
|
|
func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
|
|
_, err := s.performWindowTask("display.SetWindowAlwaysOnTop", window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop})
|
|
return err
|
|
}
|
|
|
|
// SetWindowTitle changes a window's title.
|
|
func (s *Service) SetWindowTitle(name string, title string) error {
|
|
_, err := s.performWindowTask("display.SetWindowTitle", window.TaskSetTitle{Name: name, Title: title})
|
|
return err
|
|
}
|
|
|
|
// SetWindowFullscreen sets a window to fullscreen mode.
|
|
func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
|
|
_, err := s.performWindowTask("display.SetWindowFullscreen", window.TaskFullscreen{Name: name, Fullscreen: fullscreen})
|
|
return err
|
|
}
|
|
|
|
// SetWindowBackgroundColour sets the background colour of a window.
|
|
func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error {
|
|
_, err := s.performWindowTask("display.SetWindowBackgroundColour", window.TaskSetBackgroundColour{
|
|
Name: name, Red: r, Green: g, Blue: b, Alpha: a,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Example: focused := s.GetFocusedWindow()
|
|
func (s *Service) GetFocusedWindow() string {
|
|
infos := s.ListWindowInfos()
|
|
for _, info := range infos {
|
|
if info.Focused {
|
|
return info.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Example: title, err := s.GetWindowTitle("editor")
|
|
func (s *Service) GetWindowTitle(name string) (string, error) {
|
|
info, err := s.GetWindowInfo(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info == nil {
|
|
return "", coreerr.E("display.GetWindowTitle", "window not found: "+name, nil)
|
|
}
|
|
return info.Title, nil
|
|
}
|
|
|
|
// Example: s.ResetWindowState()
|
|
func (s *Service) ResetWindowState() error {
|
|
_, err := s.performWindowTask("display.ResetWindowState", window.TaskResetWindowState{})
|
|
return err
|
|
}
|
|
|
|
// Example: states := s.GetSavedWindowStates()
|
|
func (s *Service) GetSavedWindowStates() map[string]window.WindowState {
|
|
result, handled, _ := s.Core().QUERY(window.QuerySavedWindowStates{})
|
|
if !handled {
|
|
return map[string]window.WindowState{}
|
|
}
|
|
saved, _ := result.(map[string]window.WindowState)
|
|
if saved == nil {
|
|
return map[string]window.WindowState{}
|
|
}
|
|
out := make(map[string]window.WindowState, len(saved))
|
|
for name, state := range saved {
|
|
out[name] = state
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CreateWindow opens a named window and returns its info.
|
|
// Example: s.CreateWindow(window.Window{Name: "editor", Title: "Editor", URL: "/#/editor"})
|
|
func (s *Service) CreateWindow(spec window.Window) (*window.WindowInfo, error) {
|
|
if spec.Name == "" {
|
|
return nil, coreerr.E("display.CreateWindow", "window name is required", nil)
|
|
}
|
|
result, err := s.performWindowTask("display.CreateWindow", window.TaskOpenWindow{
|
|
Window: spec,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, ok := result.(window.WindowInfo)
|
|
if !ok {
|
|
return nil, coreerr.E("display.CreateWindow", "unexpected result type from window create task", nil)
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// --- Layout delegation ---
|
|
|
|
// Example: s.SaveLayout("coding")
|
|
func (s *Service) SaveLayout(name string) error {
|
|
_, err := s.performWindowTask("display.SaveLayout", window.TaskSaveLayout{Name: name})
|
|
return err
|
|
}
|
|
|
|
// Example: s.RestoreLayout("coding")
|
|
func (s *Service) RestoreLayout(name string) error {
|
|
_, err := s.performWindowTask("display.RestoreLayout", window.TaskRestoreLayout{Name: name})
|
|
return err
|
|
}
|
|
|
|
// ListLayouts returns all saved layout names with metadata.
|
|
func (s *Service) ListLayouts() []window.LayoutInfo {
|
|
result, handled, _ := s.Core().QUERY(window.QueryLayoutList{})
|
|
if !handled {
|
|
return []window.LayoutInfo{}
|
|
}
|
|
layouts, _ := result.([]window.LayoutInfo)
|
|
return layouts
|
|
}
|
|
|
|
// Example: s.DeleteLayout("coding")
|
|
func (s *Service) DeleteLayout(name string) error {
|
|
_, err := s.performWindowTask("display.DeleteLayout", window.TaskDeleteLayout{Name: name})
|
|
return err
|
|
}
|
|
|
|
// GetLayout returns a specific layout by name.
|
|
func (s *Service) GetLayout(name string) *window.Layout {
|
|
result, handled, _ := s.Core().QUERY(window.QueryLayoutGet{Name: name})
|
|
if !handled {
|
|
return nil
|
|
}
|
|
layout, _ := result.(*window.Layout)
|
|
return layout
|
|
}
|
|
|
|
// --- Tiling/snapping delegation ---
|
|
|
|
// Example: s.TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"})
|
|
func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error {
|
|
_, err := s.performWindowTask("display.TileWindows", window.TaskTileWindows{Mode: mode.String(), Windows: windowNames})
|
|
return err
|
|
}
|
|
|
|
// Example: s.SnapWindow("editor", window.SnapRight)
|
|
func (s *Service) SnapWindow(name string, position window.SnapPosition) error {
|
|
_, err := s.performWindowTask("display.SnapWindow", window.TaskSnapWindow{Name: name, Position: position.String()})
|
|
return err
|
|
}
|
|
|
|
// Example: s.StackWindows([]string{"editor", "terminal"}, 24, 24)
|
|
func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error {
|
|
_, err := s.performWindowTask("display.StackWindows", window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY})
|
|
return err
|
|
}
|
|
|
|
// Example: s.ApplyWorkflowLayout(window.WorkflowCoding)
|
|
func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
|
|
_, err := s.performWindowTask("display.ApplyWorkflowLayout", window.TaskApplyWorkflow{
|
|
Workflow: workflow.String(),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// GetWebSocketEventManager returns the event manager for WebSocket event subscriptions.
|
|
func (s *Service) GetWebSocketEventManager() *WebSocketEventManager {
|
|
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.CreateWindow(window.Window{
|
|
Name: "workspace-new",
|
|
Title: "New Workspace",
|
|
URL: "/workspace/new",
|
|
Width: 500,
|
|
Height: 400,
|
|
})
|
|
}
|
|
|
|
func (s *Service) handleListWorkspaces() {
|
|
workspaceService := s.Core().Service("workspace")
|
|
if workspaceService == nil {
|
|
return
|
|
}
|
|
workspaceLister, ok := workspaceService.(interface{ ListWorkspaces() []string })
|
|
if !ok {
|
|
return
|
|
}
|
|
_ = workspaceLister.ListWorkspaces()
|
|
}
|
|
|
|
func (s *Service) handleNewFile() {
|
|
_, _ = s.CreateWindow(window.Window{
|
|
Name: "editor",
|
|
Title: "New File - Editor",
|
|
URL: "/#/developer/editor?new=true",
|
|
Width: 1200,
|
|
Height: 800,
|
|
})
|
|
}
|
|
|
|
func (s *Service) handleOpenFile() {
|
|
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{
|
|
Options: dialog.OpenFileOptions{
|
|
Title: "Open File",
|
|
AllowMultiple: false,
|
|
},
|
|
})
|
|
if err != nil || !handled {
|
|
return
|
|
}
|
|
paths, ok := result.([]string)
|
|
if !ok || len(paths) == 0 {
|
|
return
|
|
}
|
|
_, _ = s.CreateWindow(window.Window{
|
|
Name: "editor",
|
|
Title: paths[0] + " - Editor",
|
|
URL: "/#/developer/editor?file=" + paths[0],
|
|
Width: 1200,
|
|
Height: 800,
|
|
})
|
|
}
|
|
|
|
func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) }
|
|
func (s *Service) handleOpenEditor() {
|
|
_, _ = s.CreateWindow(window.Window{
|
|
Name: "editor",
|
|
Title: "Editor",
|
|
URL: "/#/developer/editor",
|
|
Width: 1200,
|
|
Height: 800,
|
|
})
|
|
}
|
|
func (s *Service) handleOpenTerminal() {
|
|
_, _ = s.CreateWindow(window.Window{
|
|
Name: "terminal",
|
|
Title: "Terminal",
|
|
URL: "/#/developer/terminal",
|
|
Width: 800,
|
|
Height: 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"},
|
|
}})
|
|
}
|