Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions kitchen/electrobun.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ export default {
bundleCEF: true,
bundleWGPU: true,
icon: "icon.iconset/icon_256x256.png",
// X11 / GTK WM_CLASS hint applied to every window. gnome-shell
// resolves the app icon via WM_CLASS -> .desktop StartupWMClass,
// so this MUST match what your .desktop file declares. When
// unset the wrapper uses the literal "ElectrobunKitchenSink-dev"
// which was the old hardcoded default. Setting it here to the
// kitchen app's intended class so the example's .desktop icon
// resolves correctly.
wmClass: "electrobun-kitchen",
chromiumFlags: {
// "show-paint-rects": true,
// "show-composited-layer-borders": true,
Expand Down
22 changes: 22 additions & 0 deletions package/src/bun/ElectrobunConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,28 @@ export interface ElectrobunConfig {
* @example "assets/icon.png"
*/
icon?: string;

/**
* X11 / GTK `WM_CLASS` hint applied to every window the app
* creates. gnome-shell and other freedesktop.org-compliant
* panels resolve a window's icon by matching this class
* against `StartupWMClass` in installed `.desktop` files --
* if the class doesn't match any `.desktop`, the panel falls
* back to a generic placeholder icon.
*
* When unset, Electrobun uses the literal
* `"ElectrobunKitchenSink-dev"` (a leftover from the example
* template). Apps SHOULD set this to a unique value matching
* their `.desktop` file's `StartupWMClass=` line so multiple
* Electrobun apps installed on the same system don't collide
* on the same icon.
*
* Recommended value: a sanitized lowercase form of your app
* name or identifier, e.g. `"my-app"` or `"com.example.myapp"`.
*
* @example "my-app"
*/
wmClass?: string;
};
};

Expand Down
13 changes: 12 additions & 1 deletion package/src/bun/core/BrowserWindow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ffi } from "../proc/native";
import { ffi, native, toCString } from "../proc/native";
import electrobunEventEmitter from "../events/eventEmitter";
import { BrowserView } from "./BrowserView";
import { type Pointer } from "bun:ffi";
Expand All @@ -11,6 +11,17 @@ import { WGPUView } from "./WGPUView";

const buildConfig = await BuildConfig.get();

// Linux-only: forward the user-configured `wmClass` from build.json
// to the native wrapper BEFORE the first window is created. The
// wrapper stores it in a process-global that createX11Window /
// createGTKWindow read on every window creation. macOS / Windows
// expose setLinuxWmClass as a no-op so we can call it unconditionally
// without gating by `process.platform`. When wmClass is unset the
// wrapper keeps its hardcoded "ElectrobunKitchenSink-dev" default.
if (process.platform === "linux" && buildConfig.wmClass && native?.symbols.setLinuxWmClass) {
native.symbols.setLinuxWmClass(toCString(buildConfig.wmClass));
}

export type WindowOptionsType<T = undefined> = {
trafficLightOffset?: {
x: number;
Expand Down
9 changes: 9 additions & 0 deletions package/src/bun/core/BuildConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ export type BuildConfigType = {
exitOnLastWindowClosed?: boolean;
[key: string]: unknown;
};
/**
* X11 / GTK `WM_CLASS` hint applied to every window on Linux. Set
* by the CLI from `electrobun.config.ts` `build.linux.wmClass`. The
* bun side reads this on first window creation and forwards it to
* the native wrapper via `setLinuxWmClass()`. Has no effect on
* macOS or Windows targets — those platforms use other mechanisms
* (CFBundleIdentifier on macOS, AUMID on Windows).
*/
wmClass?: string;
};

let buildConfig: BuildConfigType | null = null;
Expand Down
12 changes: 12 additions & 0 deletions package/src/bun/proc/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ export const native = (() => {
args: [FFIType.ptr],
returns: FFIType.void,
},
// Linux-only: override the X11 / GTK WM_CLASS hint applied
// to every window. Must be called BEFORE the first window
// is created (the class is read inside createGTKWindow /
// createX11Window from a process-global, set once at app
// startup). Other platforms expose this symbol as a no-op
// so the bun-side caller doesn't need to gate by platform.
setLinuxWmClass: {
args: [
FFIType.cstring, // null-terminated UTF-8 class name
],
returns: FFIType.void,
},
closeWindow: {
args: [
FFIType.ptr, // window ptr
Expand Down
33 changes: 31 additions & 2 deletions package/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2339,7 +2339,15 @@ ${utiDecls}
console.log(`WARNING: Linux icon not found: ${iconSourcePath}`);
}

// Create desktop file template for Linux
// Create desktop file template for Linux.
// StartupWMClass MUST match the WM_CLASS that the native
// wrapper sets on the window -- both now derive from the same
// source: linux.wmClass ?? config.app.name. When the user
// overrides linux.wmClass the .desktop and the window stay
// in sync automatically.
const linuxWmClass =
(config.build?.linux as { wmClass?: string } | undefined)
?.wmClass ?? config.app.name;
const desktopContent = `[Desktop Entry]
Version=1.0
Type=Application
Expand All @@ -2348,7 +2356,7 @@ Comment=${config.app.description || `${config.app.name} application`}
Exec=launcher
Icon=appIcon.png
Terminal=false
StartupWMClass=${config.app.name}
StartupWMClass=${linuxWmClass}
Categories=Utility;Application;
`;

Expand Down Expand Up @@ -3694,6 +3702,27 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
buildJsonObj["chromiumFlags"] = platformConfig.chromiumFlags;
}

// Linux-only: forward the X11/GTK `wmClass` hint into build.json
// so the bun runtime can pass it to the native wrapper via
// setLinuxWmClass(). The .desktop file (generated below) already
// uses `config.app.name` as StartupWMClass -- the WM_CLASS on the
// window MUST match that string for gnome-shell's icon lookup to
// resolve the .desktop entry. So we default to config.app.name
// when the user hasn't set linux.wmClass explicitly.
//
// Without this default the wrapper falls back to the hardcoded
// "ElectrobunKitchenSink-dev" literal and gnome-shell can't find
// the .desktop -- every Electrobun app on Linux shows a generic
// placeholder icon.
if (targetOS === "linux") {
const linuxConfig = config.build?.linux as
| { wmClass?: string }
| undefined;
// Explicit config wins, else derive from app.name (same value
// the .desktop StartupWMClass already uses at line ~2123).
buildJsonObj["wmClass"] = linuxConfig?.wmClass ?? config.app.name;
}

const buildJsonContent = JSON.stringify(buildJsonObj);

await Bun.write(
Expand Down
57 changes: 52 additions & 5 deletions package/src/native/linux/nativeWrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ static QuitRequestedHandler g_quitRequestedHandler = nullptr;
static std::atomic<bool> g_shutdownComplete{false};
static std::atomic<bool> g_eventLoopStopping{false};

// X11 / GTK window class hint applied to every window Electrobun creates.
//
// Defaults to "ElectrobunKitchenSink-dev" for backward compatibility with
// apps whose .desktop StartupWMClass already targets that literal. Apps
// can call setLinuxWmClass() at startup (before any window is created)
// to override the class with something app-specific so gnome-shell /
// GNOME's icon theme lookup resolves the .desktop entry correctly via
// WM_CLASS -> StartupWMClass.
//
// Why a static rather than a parameter on createGTKWindow / createX11Window:
// the call sites are reached deep inside electrobun's window plumbing
// from FFI (`createWindowWithFrameAndStyleFromWorker`), so threading a
// new arg through every level would touch ten files for what is really
// a process-global setting. The setter pattern keeps the change small.
//
// Thread safety: written once at app startup from the bun main thread,
// read on the main GTK thread inside createGTKWindow / createX11Window.
// No concurrent writes in practice; we use std::mutex anyway so a future
// caller can update it from a worker without UB.
static std::mutex g_wm_class_mutex;
static std::string g_linux_wm_class = "ElectrobunKitchenSink-dev";

// Read the current X11/GTK class hint. Returns a copy under the mutex so
// callers can use the c_str() lifetime safely without holding the lock.
static std::string getLinuxWmClassCopy() {
std::lock_guard<std::mutex> lock(g_wm_class_mutex);
return g_linux_wm_class;
}

// Self-pipe for async-signal-safe signal handling.
// Signal handler writes to pipe, GLib IO watch reads and dispatches.
static int g_signal_pipe[2] = {-1, -1};
Expand Down Expand Up @@ -116,6 +145,17 @@ using electrobun::OperationGuard;
// Ensure the exported functions have appropriate visibility
#define ELECTROBUN_EXPORT __attribute__((visibility("default")))

// Apps call this once at startup to override the default WM_CLASS. Empty
// or null input is silently ignored — the default literal stays in place
// so existing .desktop entries that match "ElectrobunKitchenSink-dev"
// continue to work. See the block near g_linux_wm_class (above the CEF
// includes) for the static variable + getLinuxWmClassCopy() reader.
extern "C" ELECTROBUN_EXPORT void setLinuxWmClass(const char* class_name) {
if (!class_name || class_name[0] == '\0') return;
std::lock_guard<std::mutex> lock(g_wm_class_mutex);
g_linux_wm_class = class_name;
}

// X11 Error Handler (non-fatal errors are common in WebKit/GTK)
static int x11_error_handler(Display* display, XErrorEvent* error) {
// Only log severe errors, ignore common ones like BadWindow for destroyed widgets
Expand Down Expand Up @@ -6052,10 +6092,14 @@ void* createX11Window(uint32_t windowId, double x, double y, double width, doubl
// Set window title
XStoreName(display, x11_window, title);

// Set WM_CLASS for proper taskbar icon matching
// Set WM_CLASS for proper taskbar icon matching. The class
// string is read from the global g_linux_wm_class which the
// app can override via setLinuxWmClass() at startup. Default
// is "ElectrobunKitchenSink-dev" for backward compat.
const std::string wm_class_str = getLinuxWmClassCopy();
XClassHint class_hint;
class_hint.res_name = (char*)"ElectrobunKitchenSink-dev";
class_hint.res_class = (char*)"ElectrobunKitchenSink-dev";
class_hint.res_name = (char*)wm_class_str.c_str();
class_hint.res_class = (char*)wm_class_str.c_str();
XSetClassHint(display, x11_window, &class_hint);

// Set window protocols for close button
Expand Down Expand Up @@ -6160,8 +6204,11 @@ ELECTROBUN_EXPORT void* createGTKWindow(uint32_t windowId, double x, double y, d

gtk_window_set_title(GTK_WINDOW(window), title);

// Set WM_CLASS for proper taskbar icon matching
gtk_window_set_wmclass(GTK_WINDOW(window), "ElectrobunKitchenSink-dev", "ElectrobunKitchenSink-dev");
// Set WM_CLASS for proper taskbar icon matching. Reads from the
// global g_linux_wm_class set via setLinuxWmClass() at startup.
// Default is "ElectrobunKitchenSink-dev" for backward compat.
const std::string gtk_wm_class_str = getLinuxWmClassCopy();
gtk_window_set_wmclass(GTK_WINDOW(window), gtk_wm_class_str.c_str(), gtk_wm_class_str.c_str());

gtk_window_set_default_size(GTK_WINDOW(window), (int)width, (int)height);

Expand Down
9 changes: 9 additions & 0 deletions package/src/native/macos/nativeWrapper.mm
Original file line number Diff line number Diff line change
Expand Up @@ -7347,6 +7347,15 @@ - (BOOL)canBecomeMainWindow { return YES; }
});
}

// Linux-only knob, exposed as a no-op on macOS so the bun-side FFI
// binding can be loaded unconditionally on every platform without
// gating by `process.platform`. macOS uses CFBundleIdentifier (set at
// .app bundle creation time) for icon resolution — there's no runtime
// equivalent of X11's WM_CLASS to override.
extern "C" void setLinuxWmClass(const char *class_name) {
(void)class_name;
}

extern "C" void closeWindow(NSWindow *window) {
dispatch_sync(dispatch_get_main_queue(), ^{
[window close];
Expand Down
9 changes: 9 additions & 0 deletions package/src/native/win/nativeWrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9419,6 +9419,15 @@ ELECTROBUN_EXPORT void setWindowTitle(NSWindow *window, const char *title) {
});
}

// Linux-only knob, exposed as a no-op on Windows so the bun-side FFI
// binding can be loaded unconditionally on every platform without
// gating by `process.platform`. Windows uses the AUMID
// (Application User Model ID) for taskbar grouping and icon resolution
// — there's no runtime equivalent of X11's WM_CLASS to override.
ELECTROBUN_EXPORT void setLinuxWmClass(const char *class_name) {
(void)class_name;
}

ELECTROBUN_EXPORT void closeWindow(NSWindow *window) {
// On Windows, NSWindow* is actually HWND
HWND hwnd = reinterpret_cast<HWND>(window);
Expand Down