Add Electron menu and tray bridge
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Snider 2026-04-15 19:25:16 +01:00
parent 723116acb7
commit 569a3427dc
4 changed files with 364 additions and 9 deletions

View file

@ -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;
})();`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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