diff --git a/pkg/display/preload.go b/pkg/display/preload.go index d2ef8abf..3ca214ac 100644 --- a/pkg/display/preload.go +++ b/pkg/display/preload.go @@ -478,6 +478,105 @@ func (s *Service) injectElectronShim() string { } const listeners = new Map(); const toEventName = (channel) => "__core_electron__:" + channel; + const invokeBridge = (route, payload) => (globalThis.__coreBridge?.invoke?.(route, payload) ?? Promise.resolve({ route, payload })); + const toInteger = (value) => { + const number = Number(value); + return Number.isFinite(number) ? Math.trunc(number) : 0; + }; + const toBase64 = (value) => { + if (typeof value === "string") { + if (value.startsWith("data:")) { + const commaIndex = value.indexOf(","); + return commaIndex >= 0 ? value.slice(commaIndex + 1) : value; + } + return value; + } + if (value instanceof Uint8Array) { + let binary = ""; + for (let i = 0; i < value.length; i++) { + binary += String.fromCharCode(value[i]); + } + return btoa(binary); + } + if (value instanceof ArrayBuffer) { + return toBase64(new Uint8Array(value)); + } + if (ArrayBuffer.isView && ArrayBuffer.isView(value)) { + return toBase64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); + } + return ""; + }; + const roleMap = { + appmenu: 0, + filemenu: 1, + editmenu: 2, + viewmenu: 3, + windowmenu: 4, + helpmenu: 5 + }; + const menuRoleToCore = (role) => { + const key = String(role ?? "").toLowerCase(); + return Object.prototype.hasOwnProperty.call(roleMap, key) ? roleMap[key] : undefined; + }; + const menuChildren = (item) => { + const rawChildren = Array.isArray(item?.children) ? item.children : Array.isArray(item?.submenu) ? item.submenu : []; + return rawChildren.map((child) => menuItemToCore(child)).filter(Boolean); + }; + const menuItemToCore = (item) => { + if (!item || typeof item !== "object") { + return null; + } + const mapped = { + label: String(item.label ?? ""), + accelerator: String(item.accelerator ?? ""), + type: String(item.type ?? "normal"), + checked: !!item.checked, + disabled: !!item.disabled, + tooltip: String(item.tooltip ?? "") + }; + const role = menuRoleToCore(item.role); + if (role !== undefined) { + mapped.role = role; + } + const children = menuChildren(item); + if (children.length > 0) { + mapped.children = children; + } + return mapped; + }; + const trayItemToCore = (item) => { + if (!item || typeof item !== "object") { + return null; + } + const mapped = { + label: String(item.label ?? ""), + type: String(item.type ?? "normal"), + checked: !!item.checked, + disabled: !!item.disabled, + tooltip: String(item.tooltip ?? "") + }; + const actionId = String(item.actionId ?? item.action_id ?? item.id ?? ""); + if (actionId) { + mapped.action_id = actionId; + } + const submenu = Array.isArray(item.submenu) ? item.submenu.map((child) => trayItemToCore(child)).filter(Boolean) : []; + if (submenu.length > 0) { + mapped.submenu = submenu; + } + return mapped; + }; + const normalizeMenuTemplate = (template) => Array.isArray(template) ? template.map((item) => menuItemToCore(item)).filter(Boolean) : []; + const normalizeTrayTemplate = (template) => Array.isArray(template) ? template.map((item) => trayItemToCore(item)).filter(Boolean) : []; + const createMenu = (template) => { + const normalized = normalizeMenuTemplate(template); + return { + template: normalized, + items: normalized, + toJSON() { + return normalized; + } + }; + }; const ipcRenderer = { send(channel, ...args) { globalThis.dispatchEvent(new CustomEvent(toEventName(channel), { detail: args })); @@ -553,11 +652,108 @@ func (s *Service) injectElectronShim() string { return Promise.resolve('granted'); } } + class Menu { + constructor(template = []) { + this.template = normalizeMenuTemplate(template); + this.items = this.template; + } + append(item) { + const mapped = menuItemToCore(item); + if (mapped) { + this.template.push(mapped); + this.items = this.template; + } + return this; + } + popup() { + return Promise.resolve(this); + } + toJSON() { + return this.template; + } + static buildFromTemplate(template = []) { + return createMenu(template); + } + static setApplicationMenu(menu) { + return invokeBridge('menu.setAppMenu', { task: { items: normalizeMenuTemplate(menu?.template ?? menu?.items ?? menu) } }); + } + } + class Tray { + constructor(image) { + this.image = image ?? null; + if (image !== undefined) { + this.setImage(image); + } + } + setImage(image) { + this.image = image; + const data = toBase64(image); + if (data) { + return invokeBridge('systray.setIcon', { task: { data } }); + } + return Promise.resolve(undefined); + } + setToolTip(tooltip) { + this.tooltip = String(tooltip ?? ""); + return invokeBridge('systray.setTooltip', { task: { tooltip: this.tooltip } }); + } + setTitle(label) { + this.title = String(label ?? ""); + return invokeBridge('systray.setLabel', { task: { label: this.title } }); + } + setContextMenu(menu) { + const normalized = normalizeTrayTemplate(menu?.template ?? menu?.items ?? menu); + this.menu = normalized; + return invokeBridge('systray.setMenu', { task: { items: normalized } }); + } + destroy() {} + } class BrowserWindow { constructor(options = {}) { this.options = options; this.id = options.id || ('core-window-' + Math.random().toString(36).slice(2)); - invokeBridge('window.open', { name: this.id, options }); + const backgroundColor = String(options.backgroundColor ?? options.backgroundColour ?? ""); + const parsedColour = (() => { + if (!backgroundColor) { + return undefined; + } + const hex = backgroundColor.replace(/^#/, ""); + if (hex.length === 6 || hex.length === 8) { + const offset = hex.length === 8 ? 2 : 0; + const alpha = hex.length === 8 ? parseInt(hex.slice(0, 2), 16) : 255; + const red = parseInt(hex.slice(offset, offset + 2), 16); + const green = parseInt(hex.slice(offset + 2, offset + 4), 16); + const blue = parseInt(hex.slice(offset + 4, offset + 6), 16); + if ([red, green, blue, alpha].every((value) => Number.isFinite(value))) { + return [red, green, blue, alpha]; + } + } + return undefined; + })(); + const windowSpec = { + Name: String(options.name ?? this.id ?? ""), + Title: String(options.title ?? options.name ?? ""), + URL: String(options.url ?? ""), + HTML: String(options.html ?? ""), + JS: String(options.js ?? ""), + Width: toInteger(options.width), + Height: toInteger(options.height), + X: toInteger(options.x), + Y: toInteger(options.y), + MinWidth: toInteger(options.minWidth), + MinHeight: toInteger(options.minHeight), + MaxWidth: toInteger(options.maxWidth), + MaxHeight: toInteger(options.maxHeight), + Frameless: options.frame === false || !!options.frameless, + Hidden: options.show === false || !!options.hidden, + AlwaysOnTop: !!options.alwaysOnTop, + DisableResize: options.resizable === false || !!options.disableResize, + EnableFileDrop: !!options.enableFileDrop + }; + if (parsedColour) { + windowSpec.BackgroundColour = parsedColour; + } + invokeBridge('window.open', { task: { window: windowSpec } }); } loadURL(url) { return invokeBridge('webview.navigate', { name: this.id, url }); } show() { return invokeBridge('window.setVisibility', { name: this.id, visible: true }); } @@ -565,7 +761,7 @@ func (s *Service) injectElectronShim() string { close() { return invokeBridge('window.close', { name: this.id }); } } globalThis.Notification = globalThis.Notification || CoreNotification; - globalThis.electron = { ipcRenderer, shell, clipboard, dialog, BrowserWindow, Notification: CoreNotification }; + globalThis.electron = { ipcRenderer, shell, clipboard, dialog, Menu, Tray, BrowserWindow, Notification: CoreNotification }; globalThis.require = (name) => name === "electron" ? globalThis.electron : undefined; })();` } diff --git a/pkg/menu/service.go b/pkg/menu/service.go index f6ac3b2b..80123df7 100644 --- a/pkg/menu/service.go +++ b/pkg/menu/service.go @@ -26,7 +26,7 @@ func (s *Service) OnStartup(_ context.Context) core.Result { } s.Core().RegisterQuery(s.handleQuery) s.Core().Action("menu.setAppMenu", func(_ context.Context, opts core.Options) core.Result { - t, _ := opts.Get("task").Value.(TaskSetAppMenu) + t := taskSetAppMenuFromOptions(opts) s.menuItems = t.Items s.manager.SetApplicationMenu(t.Items) return core.Result{OK: true} @@ -62,3 +62,31 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) Manager() *Manager { return s.manager } + +func taskSetAppMenuFromOptions(opts core.Options) TaskSetAppMenu { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetAppMenu: + return value + case map[string]any: + var decoded TaskSetAppMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + + var decoded TaskSetAppMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetAppMenu{} +} + +func optsToMap(opts core.Options) map[string]any { + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + return items +} diff --git a/pkg/systray/service.go b/pkg/systray/service.go index 3176dd33..992937cb 100644 --- a/pkg/systray/service.go +++ b/pkg/systray/service.go @@ -26,23 +26,23 @@ func (s *Service) OnStartup(_ context.Context) core.Result { } s.Core().RegisterQuery(s.handleQuery) s.Core().Action("systray.setIcon", func(_ context.Context, opts core.Options) core.Result { - t, _ := opts.Get("task").Value.(TaskSetTrayIcon) + t := taskSetTrayIconFromOptions(opts) return core.Result{Value: nil, OK: true}.New(s.manager.SetIcon(t.Data)) }) s.Core().Action("systray.setTooltip", func(_ context.Context, opts core.Options) core.Result { - t, _ := opts.Get("task").Value.(TaskSetTrayTooltip) + t := taskSetTrayTooltipFromOptions(opts) return core.Result{Value: nil, OK: true}.New(s.manager.SetTooltip(t.Tooltip)) }) s.Core().Action("systray.setLabel", func(_ context.Context, opts core.Options) core.Result { - t, _ := opts.Get("task").Value.(TaskSetTrayLabel) + t := taskSetTrayLabelFromOptions(opts) return core.Result{Value: nil, OK: true}.New(s.manager.SetLabel(t.Label)) }) s.Core().Action("systray.setMenu", func(_ context.Context, opts core.Options) core.Result { - t, _ := opts.Get("task").Value.(TaskSetTrayMenu) + t := taskSetTrayMenuFromOptions(opts) return core.Result{Value: nil, OK: true}.New(s.taskSetTrayMenu(t)) }) s.Core().Action("systray.showMessage", func(_ context.Context, opts core.Options) core.Result { - t, _ := opts.Get("task").Value.(TaskShowMessage) + t := taskShowMessageFromOptions(opts) if err := s.manager.ShowMessage(t.Title, t.Message); err == nil { return core.Result{OK: true} } else { @@ -115,3 +115,106 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error { func (s *Service) Manager() *Manager { return s.manager } + +func taskSetTrayIconFromOptions(opts core.Options) TaskSetTrayIcon { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayIcon: + return value + case map[string]any: + var decoded TaskSetTrayIcon + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayIcon + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayIcon{} +} + +func taskSetTrayTooltipFromOptions(opts core.Options) TaskSetTrayTooltip { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayTooltip: + return value + case map[string]any: + var decoded TaskSetTrayTooltip + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayTooltip + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayTooltip{} +} + +func taskSetTrayLabelFromOptions(opts core.Options) TaskSetTrayLabel { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayLabel: + return value + case map[string]any: + var decoded TaskSetTrayLabel + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayLabel + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayLabel{} +} + +func taskSetTrayMenuFromOptions(opts core.Options) TaskSetTrayMenu { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayMenu: + return value + case map[string]any: + var decoded TaskSetTrayMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayMenu{} +} + +func taskShowMessageFromOptions(opts core.Options) TaskShowMessage { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskShowMessage: + return value + case map[string]any: + var decoded TaskShowMessage + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskShowMessage + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskShowMessage{} +} + +func optsToMap(opts core.Options) map[string]any { + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + return items +} diff --git a/pkg/window/service.go b/pkg/window/service.go index eb906915..e5edd07e 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -116,7 +116,7 @@ func (s *Service) queryWindowByName(name string) *WindowInfo { func (s *Service) registerTaskActions() { c := s.Core() c.Action("window.open", func(_ context.Context, opts core.Options) core.Result { - t, _ := opts.Get("task").Value.(TaskOpenWindow) + t := taskOpenWindowFromOptions(opts) return s.taskOpenWindow(t) }) c.Action("window.close", func(_ context.Context, opts core.Options) core.Result { @@ -272,6 +272,34 @@ func (s *Service) registerTaskActions() { }) } +func taskOpenWindowFromOptions(opts core.Options) TaskOpenWindow { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskOpenWindow: + return value + case map[string]any: + var decoded TaskOpenWindow + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + + var decoded TaskOpenWindow + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskOpenWindow{} +} + +func optsToMap(opts core.Options) map[string]any { + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + return items +} + func (s *Service) primaryScreenArea() (int, int, int, int) { const fallbackX = 0 const fallbackY = 0