Add Electron menu and tray bridge
This commit is contained in:
parent
723116acb7
commit
569a3427dc
4 changed files with 364 additions and 9 deletions
|
|
@ -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;
|
||||
})();`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue