Skip to content

Commit 40a513a

Browse files
committed
feat: add Linux GTK4 menu backend so Menu.setApplicationMenu shows a working application menu bar
1 parent 27a129d commit 40a513a

7 files changed

Lines changed: 966 additions & 1 deletion

File tree

src/main/api/menu.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { UnsupportedPlatformError } from '../../common/errors';
22
import { currentPlatform } from '../../common/platform';
3+
import { linuxMenuRealizer } from '../platform/linux/gtk-menu';
34
import type { NativeMenuItemSpec } from '../platform/macos/cocoa-menu';
45
import * as cocoaMenu from '../platform/macos/cocoa-menu';
56

@@ -78,6 +79,9 @@ const getRealizer = (): MenuRealizer => {
7879
if (currentPlatform() === 'macos') {
7980
return macosRealizer;
8081
}
82+
if (currentPlatform() === 'linux') {
83+
return linuxMenuRealizer;
84+
}
8185
throw new UnsupportedPlatformError(`Menu is not supported on ${currentPlatform()} yet`);
8286
};
8387

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { dlopen, FFIType } from 'bun:ffi';
2+
import { UnsupportedPlatformError } from '../../../common/errors';
3+
import { currentPlatform } from '../../../common/platform';
4+
5+
/**
6+
* Loads the GIO `GMenu`/`GAction` model symbols and the GTK 4
7+
* `GtkPopoverMenuBar`/`GtkBox` widget symbols behind Sambar's Linux `Menu`
8+
* backend.
9+
*
10+
* A GTK 4 application menu bar is built from a backend-neutral *model* (`GMenu`,
11+
* a `GMenuModel`) wired to an *action group* (`GSimpleActionGroup`): each
12+
* clickable item names a `GAction` (e.g. `"sambar.menu-0"`), and activating
13+
* that action — via a click, an accelerator, or `g_action_group_activate_action`
14+
* — fires the action's `activate` signal. A `GtkPopoverMenuBar` renders the
15+
* model; the action group is inserted into the window under the `"sambar"`
16+
* prefix with `gtk_widget_insert_action_group`.
17+
*
18+
* Declared separately from the loaders so unit tests can assert ABI shapes (arg
19+
* arrays, return types) without `dlopen` on a non-Linux host.
20+
*
21+
* Convention (matches the existing Linux loaders): `gboolean` is modelled as
22+
* {@link FFIType.i32}; all GObject/GTK handles are real pointers
23+
* ({@link FFIType.pointer}); `cstring` args are NUL-terminated UTF-8 strings;
24+
* nullable pointer args (`param_type`, `param`) are passed as `null`.
25+
*
26+
* Only callable on Linux — throws {@link UnsupportedPlatformError} otherwise so
27+
* the module stays safely importable on macOS for unit testing.
28+
*/
29+
30+
const LIBGIO_PATH = 'libgio-2.0.so.0';
31+
const LIBGTK_PATH = 'libgtk-4.so.1';
32+
33+
/** The GIO `GMenu`/`GAction` FFI symbol descriptor table (from `libgio-2.0.so.0`). */
34+
export const GMENU_FFI_SYMBOLS = {
35+
g_menu_new: {
36+
args: [],
37+
returns: FFIType.pointer,
38+
},
39+
// (menu, label, detailed_action /*e.g. "sambar.menu-0"*/) -> void
40+
g_menu_append: {
41+
args: [FFIType.pointer, FFIType.cstring, FFIType.cstring],
42+
returns: FFIType.void,
43+
},
44+
// (menu, label, submenu /*GMenuModel*/) -> void
45+
g_menu_append_submenu: {
46+
args: [FFIType.pointer, FFIType.cstring, FFIType.pointer],
47+
returns: FFIType.void,
48+
},
49+
// (menu, label /*null*/, section /*GMenuModel*/) -> void; renders a divider.
50+
g_menu_append_section: {
51+
args: [FFIType.pointer, FFIType.cstring, FFIType.pointer],
52+
returns: FFIType.void,
53+
},
54+
g_simple_action_group_new: {
55+
args: [],
56+
returns: FFIType.pointer,
57+
},
58+
// (name, parameter_type /*GVariantType* | null*/) -> GSimpleAction*
59+
g_simple_action_new: {
60+
args: [FFIType.cstring, FFIType.pointer],
61+
returns: FFIType.pointer,
62+
},
63+
g_simple_action_set_enabled: {
64+
args: [FFIType.pointer, FFIType.i32],
65+
returns: FFIType.void,
66+
},
67+
// (action_map /*GActionMap*/, action /*GAction*/) -> void
68+
g_action_map_add_action: {
69+
args: [FFIType.pointer, FFIType.pointer],
70+
returns: FFIType.void,
71+
},
72+
// (action_group, name, parameter /*GVariant* | null*/) -> void
73+
g_action_group_activate_action: {
74+
args: [FFIType.pointer, FFIType.cstring, FFIType.pointer],
75+
returns: FFIType.void,
76+
},
77+
} as const;
78+
79+
/** The GTK 4 menu-bar/box FFI symbol descriptor table (from `libgtk-4.so.1`). */
80+
export const GTK_MENU_FFI_SYMBOLS = {
81+
// (orientation /*GtkOrientation; vertical=1*/, spacing) -> GtkBox*
82+
gtk_box_new: {
83+
args: [FFIType.i32, FFIType.i32],
84+
returns: FFIType.pointer,
85+
},
86+
gtk_box_append: {
87+
args: [FFIType.pointer, FFIType.pointer],
88+
returns: FFIType.void,
89+
},
90+
// (model /*GMenuModel*/) -> GtkPopoverMenuBar* (a GtkWidget)
91+
gtk_popover_menu_bar_new_from_model: {
92+
args: [FFIType.pointer],
93+
returns: FFIType.pointer,
94+
},
95+
// (widget, prefix /*e.g. "sambar"*/, group /*GActionGroup* | null*/) -> void
96+
gtk_widget_insert_action_group: {
97+
args: [FFIType.pointer, FFIType.cstring, FFIType.pointer],
98+
returns: FFIType.void,
99+
},
100+
} as const;
101+
102+
const cache: {
103+
gio: ReturnType<typeof dlopen<typeof GMENU_FFI_SYMBOLS>> | undefined;
104+
gtk: ReturnType<typeof dlopen<typeof GTK_MENU_FFI_SYMBOLS>> | undefined;
105+
} = { gio: undefined, gtk: undefined };
106+
107+
const requireLinux = (fn: string): void => {
108+
const platform = currentPlatform();
109+
if (platform !== 'linux') {
110+
throw new UnsupportedPlatformError(
111+
`${fn}() is only supported on Linux; current platform is ${platform}`,
112+
);
113+
}
114+
};
115+
116+
/** Open `libgio-2.0.so.0` and expose the `GMenu`/`GAction` model symbols. */
117+
export const loadGMenuFFI = () => {
118+
requireLinux('loadGMenuFFI');
119+
if (cache.gio) {
120+
return cache.gio;
121+
}
122+
const ffi = dlopen(LIBGIO_PATH, GMENU_FFI_SYMBOLS);
123+
cache.gio = ffi;
124+
return ffi;
125+
};
126+
127+
/** Open `libgtk-4.so.1` and expose the menu-bar/box widget symbols. */
128+
export const loadGtkMenuFFI = () => {
129+
requireLinux('loadGtkMenuFFI');
130+
if (cache.gtk) {
131+
return cache.gtk;
132+
}
133+
const ffi = dlopen(LIBGTK_PATH, GTK_MENU_FFI_SYMBOLS);
134+
cache.gtk = ffi;
135+
return ffi;
136+
};
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { JSCallback, type Pointer } from 'bun:ffi';
2+
import type { MenuRealizer } from '../../api/menu';
3+
import type { NativeMenuItemSpec } from '../macos/cocoa-menu';
4+
import { cstr } from '../cstr';
5+
import { G_CONNECT_DEFAULT, loadGObjectFFI } from './gobject-ffi';
6+
import { loadGMenuFFI } from './gtk-menu-ffi';
7+
8+
/**
9+
* Builds native GTK 4 application menus from the backend-neutral menu spec and
10+
* routes item clicks back to JS — the Linux equivalent of `cocoa-menu.ts`.
11+
*
12+
* A GTK menu is a `GMenu` *model* paired with a `GSimpleActionGroup`. Each
13+
* clickable item is given a uniquely named `GSimpleAction` (e.g. `menu-0`); the
14+
* model entry references it as `"sambar.menu-0"`. Activating the action — by a
15+
* click, an accelerator, or `g_action_group_activate_action` — emits the
16+
* action's `activate` signal, which fires the item's JS `onClick`. This mirrors
17+
* the macOS `sambarMenuAction:` registry pattern.
18+
*
19+
* One action group is shared by the whole tree (submenus add their actions to
20+
* the SAME group), so a single `gtk_widget_insert_action_group(window,
21+
* "sambar", group)` makes every item live.
22+
*
23+
* JSCallback lifecycle: the `activate` thunks are LONG-LIVED — they must stay
24+
* reachable for the menu's lifetime or GObject jumps into freed memory on the
25+
* next click (a past SIGSEGV class). Every thunk is therefore retained in the
26+
* per-menu {@link MenuEntry} (held by {@link menuEntries}) and NEVER closed
27+
* synchronously inside its own invocation. In v1 they are not closed at all.
28+
*
29+
* The native GIO/GObject calls are funnelled through an injectable
30+
* {@link Bindings} so the realizer's tree-walking, action-naming, and
31+
* click-routing logic is unit-testable on a non-Linux host without `dlopen`.
32+
*/
33+
34+
/** ABI shape for `GSimpleAction::activate`: `(action, parameter, user_data) -> void`. */
35+
export const ACTION_ACTIVATE_CB_DEF = { args: ['ptr', 'ptr', 'ptr'], returns: 'void' } as const;
36+
37+
/** The action-group namespace prefix inserted into the window. */
38+
const ACTION_GROUP_PREFIX = 'sambar';
39+
40+
let actionCounter = 0;
41+
42+
/** A fresh, process-unique action name like `menu-0`, `menu-1`, … */
43+
export const actionName = (): string => `menu-${actionCounter++}`;
44+
45+
/** Namespace an action name for a `GMenu` `detailed_action` (e.g. `sambar.menu-0`). */
46+
export const detailedAction = (name: string): string => `${ACTION_GROUP_PREFIX}.${name}`;
47+
48+
/**
49+
* The native operations the realizer needs. Real implementation wraps GIO +
50+
* GObject FFI and {@link JSCallback}; tests inject a recording fake. Handles are
51+
* `bigint` here (the fake uses tagged numbers); the real binding casts to/from
52+
* `Pointer`.
53+
*/
54+
export type Bindings = {
55+
gMenuNew(): bigint;
56+
gMenuAppend(menu: bigint, label: string, detailed: string): void;
57+
gMenuAppendSubmenu(menu: bigint, label: string, submenu: bigint): void;
58+
gMenuAppendSection(menu: bigint, section: bigint): void;
59+
gSimpleActionGroupNew(): bigint;
60+
gSimpleActionNew(name: string): bigint;
61+
gSimpleActionSetEnabled(action: bigint, enabled: number): void;
62+
gActionMapAddAction(group: bigint, action: bigint): void;
63+
/** Connect a retained `activate` handler to `action`; returns the retained thunk. */
64+
connectActivate(action: bigint, thunk: () => void): unknown;
65+
/** Programmatically fire `detailed` on `group` (the testing/verification path). */
66+
activateAction(group: bigint, detailed: string, parameter: bigint | null): void;
67+
};
68+
69+
/** The realized native artefacts for one top-level menu, kept in {@link menuEntries}. */
70+
export type MenuEntry = {
71+
/** The top-level `GMenu` model pointer (also the realizer's bigint handle). */
72+
readonly model: bigint;
73+
/** The `GSimpleActionGroup` shared by the whole tree. */
74+
readonly group: bigint;
75+
/** Action names (e.g. `menu-0`) in realization order, for lookup/verification. */
76+
readonly actionNames: string[];
77+
/** Retained activate thunks — kept alive for the menu's lifetime. */
78+
readonly retained: unknown[];
79+
/** Count of retained thunks (one per clickable item). */
80+
readonly retainedCount: number;
81+
};
82+
83+
const menuEntries = new Map<bigint, MenuEntry>();
84+
85+
let currentAppMenu: { readonly model: bigint; readonly group: bigint } | undefined;
86+
87+
let injectedBindings: Bindings | undefined;
88+
89+
/** The real GIO/GObject-backed bindings (constructed lazily on Linux). */
90+
const realBindings = (): Bindings => {
91+
const gio = loadGMenuFFI();
92+
const gobject = loadGObjectFFI();
93+
const asPtr = (h: bigint): Pointer => Number(h) as unknown as Pointer;
94+
const asHandle = (p: Pointer | null): bigint => BigInt(p === null ? 0 : (p as unknown as number));
95+
return {
96+
gMenuNew: () => asHandle(gio.symbols.g_menu_new()),
97+
gMenuAppend: (menu, label, detailed) =>
98+
gio.symbols.g_menu_append(asPtr(menu), cstr(label), cstr(detailed)),
99+
gMenuAppendSubmenu: (menu, label, submenu) =>
100+
gio.symbols.g_menu_append_submenu(asPtr(menu), cstr(label), asPtr(submenu)),
101+
gMenuAppendSection: (menu, section) =>
102+
gio.symbols.g_menu_append_section(asPtr(menu), null, asPtr(section)),
103+
gSimpleActionGroupNew: () => asHandle(gio.symbols.g_simple_action_group_new()),
104+
gSimpleActionNew: (name) => asHandle(gio.symbols.g_simple_action_new(cstr(name), null)),
105+
gSimpleActionSetEnabled: (action, enabled) =>
106+
gio.symbols.g_simple_action_set_enabled(asPtr(action), enabled),
107+
gActionMapAddAction: (group, action) =>
108+
gio.symbols.g_action_map_add_action(asPtr(group), asPtr(action)),
109+
connectActivate: (action, thunk) => {
110+
const callback = new JSCallback(
111+
(_action: Pointer, _parameter: Pointer, _userData: Pointer): void => {
112+
thunk();
113+
},
114+
ACTION_ACTIVATE_CB_DEF,
115+
);
116+
gobject.symbols.g_signal_connect_data(
117+
asPtr(action),
118+
cstr('activate'),
119+
callback.ptr,
120+
null,
121+
null,
122+
G_CONNECT_DEFAULT,
123+
);
124+
return callback;
125+
},
126+
activateAction: (group, detailed, parameter) =>
127+
gio.symbols.g_action_group_activate_action(
128+
asPtr(group),
129+
cstr(detailed),
130+
parameter === null ? null : asPtr(parameter),
131+
),
132+
};
133+
};
134+
135+
const bindings = (): Bindings => injectedBindings ?? realBindings();
136+
137+
/** Override the native bindings. Test-only. */
138+
export const setBindingsForTesting = (fake: Bindings | undefined): void => {
139+
injectedBindings = fake;
140+
};
141+
142+
type WalkContext = {
143+
readonly b: Bindings;
144+
readonly group: bigint;
145+
readonly actionNames: string[];
146+
readonly retained: unknown[];
147+
};
148+
149+
/** Append every spec in `items` to the `model`, wiring actions into the shared context. */
150+
const appendItems = (
151+
ctx: WalkContext,
152+
model: bigint,
153+
items: ReadonlyArray<NativeMenuItemSpec>,
154+
): void => {
155+
for (const spec of items) {
156+
if (spec.type === 'separator') {
157+
ctx.b.gMenuAppendSection(model, ctx.b.gMenuNew());
158+
continue;
159+
}
160+
if (spec.type === 'submenu' && spec.submenu !== undefined) {
161+
const child = ctx.b.gMenuNew();
162+
appendItems(ctx, child, spec.submenu);
163+
ctx.b.gMenuAppendSubmenu(model, spec.label, child);
164+
continue;
165+
}
166+
if (spec.type === 'normal' && spec.onClick !== undefined) {
167+
const name = actionName();
168+
const action = ctx.b.gSimpleActionNew(name);
169+
ctx.b.gSimpleActionSetEnabled(action, spec.enabled ? 1 : 0);
170+
const retained = ctx.b.connectActivate(action, spec.onClick);
171+
ctx.b.gActionMapAddAction(ctx.group, action);
172+
ctx.actionNames.push(name);
173+
ctx.retained.push(retained);
174+
ctx.b.gMenuAppend(model, spec.label, detailedAction(name));
175+
continue;
176+
}
177+
// A normal item with no onClick: a static, inert label (e.g. a heading).
178+
ctx.b.gMenuAppend(model, spec.label, detailedAction(actionName()));
179+
}
180+
};
181+
182+
/** Build a `GMenu` model + shared `GSimpleActionGroup` for `items`; returns the model handle. */
183+
const realize = (items: ReadonlyArray<NativeMenuItemSpec>): bigint => {
184+
const b = bindings();
185+
const model = b.gMenuNew();
186+
const group = b.gSimpleActionGroupNew();
187+
const ctx: WalkContext = { b, group, actionNames: [], retained: [] };
188+
appendItems(ctx, model, items);
189+
menuEntries.set(model, {
190+
model,
191+
group,
192+
actionNames: ctx.actionNames,
193+
retained: ctx.retained,
194+
retainedCount: ctx.retained.length,
195+
});
196+
return model;
197+
};
198+
199+
/** Install `menuHandle` as the current application menu (applied to future windows). */
200+
const setApplicationMenu = (menuHandle: bigint): void => {
201+
const entry = menuEntries.get(menuHandle);
202+
if (entry === undefined) {
203+
throw new Error(`setApplicationMenu: unknown menu handle ${menuHandle}`);
204+
}
205+
currentAppMenu = { model: entry.model, group: entry.group };
206+
};
207+
208+
/** The realized artefacts for a handle, or `undefined`. Used by tests + verification. */
209+
export const getMenuEntry = (handle: bigint): MenuEntry | undefined => menuEntries.get(handle);
210+
211+
/**
212+
* The model + action group of the current application menu, or `undefined` if
213+
* none is set. Read by {@link LinuxWindow} construction to attach a menu bar.
214+
*/
215+
export const getCurrentAppMenu = (): { model: bigint; group: bigint } | undefined => currentAppMenu;
216+
217+
/** Replace the current application menu state directly. @internal */
218+
export const setCurrentAppMenu = (menu: { model: bigint; group: bigint } | undefined): void => {
219+
currentAppMenu = menu;
220+
};
221+
222+
/** Clear the stored application menu. Test-only. */
223+
export const resetCurrentAppMenuForTesting = (): void => {
224+
currentAppMenu = undefined;
225+
};
226+
227+
/** The Linux native menu realizer (GMenu + GSimpleActionGroup + GtkPopoverMenuBar). */
228+
export const linuxMenuRealizer: MenuRealizer = {
229+
realize,
230+
setApplicationMenu,
231+
};

0 commit comments

Comments
 (0)