bond-wm
Version:
An X Window Manager built on web technologies.
324 lines (277 loc) • 10.1 kB
text/typescript
import { app, ipcMain } from "electron";
import { execSync } from "node:child_process";
import { EventEmitter } from "node:events";
import * as dbus from "@particle/dbus-next";
import { interface as dbusInterface, RequestNameReply, Variant, Message } from "/dbus-next";
import { log, logError } from "./log";
export interface Notification {
id: number;
appName: string;
summary: string;
body: string;
appIcon?: string;
expireTimeout: number;
actions?: NotificationAction[];
timestamp: number;
}
export interface NotificationAction {
id: string;
label: string;
}
export interface DBusHints {
[key: string]: Variant;
}
// IPC messages for notifications
export const NotificationIPCMessages = {
NewNotification: "notification:new",
CloseNotification: "notification:close",
ClearAllNotifications: "notification:clear-all",
NotificationAction: "notification:action",
NotificationClosed: "notification:user-closed",
RequestNotifications: "notification:request-all",
} as const;
export class NotificationServer {
private bus = dbus.sessionBus();
private activeNotifications = new Map<number, Notification>();
private notificationInterface: NotificationInterface;
private broadcastCallback: (channel: string, ...args: unknown[]) => void;
constructor(broadcastCallback: (channel: string, ...args: unknown[]) => void) {
this.broadcastCallback = broadcastCallback;
this.notificationInterface = new NotificationInterface(
this.handleNotification.bind(this),
this.parseActions.bind(this),
this.bus // Pass bus to interface
);
this.setupIPCHandlers();
}
private setupIPCHandlers(): void {
// Handler for requesting all notifications
ipcMain.on(NotificationIPCMessages.RequestNotifications, (event) => {
const notifications = Array.from(this.activeNotifications.values());
notifications.forEach((notification) => {
event.reply(NotificationIPCMessages.NewNotification, notification);
});
});
// Handler for when user closes a notification
ipcMain.on(NotificationIPCMessages.NotificationClosed, (event, id: number) => {
if (this.activeNotifications.has(id)) {
this.activeNotifications.delete(id);
this.emitNotificationClosed(id, 2); // 2 = dismissed by user
this.broadcastToAllDesktops(NotificationIPCMessages.CloseNotification, id);
}
});
ipcMain.on(
NotificationIPCMessages.NotificationAction,
(event, data: { notificationId: number; actionId: string }) => {
const notification = this.activeNotifications.get(data.notificationId);
if (notification) {
try {
this.emitActionInvoked(data.notificationId, data.actionId);
this.activeNotifications.delete(data.notificationId);
this.broadcastToAllDesktops(NotificationIPCMessages.CloseNotification, data.notificationId);
} catch (error) {
logError(`Error processing action:`, error);
}
} else {
logError(`Warning: Notification ${data.notificationId} not found for action ${data.actionId}`);
}
}
);
// Handler for clearing all notifications
ipcMain.on(NotificationIPCMessages.ClearAllNotifications, () => {
this.activeNotifications.clear();
this.broadcastToAllDesktops(NotificationIPCMessages.ClearAllNotifications);
});
}
private broadcastToAllDesktops(channel: string, ...args: unknown[]): void {
if (this.broadcastCallback) {
this.broadcastCallback(channel, ...args);
}
}
private emitActionInvoked(notificationId: number, actionId: string): void {
try {
if (!this.notificationInterface) {
throw new Error("Notification interface is not initialized");
}
this.notificationInterface.emitActionInvoked(notificationId, actionId);
try {
const cmd = `dbus-send --session --type=signal /org/freedesktop/Notifications org.freedesktop.Notifications.ActionInvoked uint32:${notificationId} string:"${actionId}"`;
execSync(cmd);
} catch (err) {
logError(`Error sending signal via dbus-send:`, err);
}
} catch (error) {
logError("Error emitting ActionInvoked signal:", error);
}
}
private emitNotificationClosed(notificationId: number, reason: number = 3): void {
try {
if (!this.notificationInterface) {
throw new Error("Notification interface is not initialized");
}
this.notificationInterface.emitNotificationClosed(notificationId, reason);
} catch (error) {
logError("Warning: Error emitting NotificationClosed signal:", error);
}
}
public async start(): Promise<void> {
try {
log("Starting notification server...");
const dbusObj = await this.bus.getProxyObject("org.freedesktop.DBus", "/org/freedesktop/DBus");
const dbusInterface = dbusObj.getInterface("org.freedesktop.DBus");
const names = await dbusInterface.ListNames();
if (names.includes("org.freedesktop.Notifications")) {
log("Notification service already running");
return;
}
this.bus.export("/org/freedesktop/Notifications", this.notificationInterface);
const result = await this.bus.requestName("org.freedesktop.Notifications", 0x4);
if (result === RequestNameReply.PRIMARY_OWNER) {
log("Notification service registered successfully");
// Test signal emission to verify functionality
setTimeout(() => {
log(" Testing D-Bus signal emission...");
try {
this.emitActionInvoked(999, "test-action");
this.emitNotificationClosed(999, 1);
} catch (error) {
logError("Error in signal test:", error);
}
}, 2000);
} else {
logError("Warning: Could not register service. Code:", result);
}
} catch (error) {
logError("Error starting DBus server:", error);
}
}
private handleNotification(notification: Notification): void {
this.activeNotifications.set(notification.id, notification);
this.broadcastToAllDesktops(NotificationIPCMessages.NewNotification, notification);
}
private parseActions(actions: string[]): NotificationAction[] {
const parsedActions: NotificationAction[] = [];
// Actions come in pairs: [id, label, id, label, ...]
for (let i = 0; i < actions.length; i += 2) {
if (actions[i + 1]) {
parsedActions.push({
id: actions[i],
label: actions[i + 1],
});
}
}
return parsedActions;
}
}
class NotificationInterface extends dbusInterface.Interface {
private notificationCounter = 1;
public emitter: EventEmitter;
private bus: dbus.MessageBus; // Store the bus instance for signal emission
constructor(
private notifyCallback: (notification: Notification) => void,
private parseActions: (actions: string[]) => NotificationAction[],
bus: dbus.MessageBus
) {
super("org.freedesktop.Notifications");
this.bus = bus;
this.emitter = new EventEmitter();
}
// Methods to emit signals directly
emitActionInvoked(notificationId: number, actionId: string): void {
// Send signal message directly via bus
const signalMessage = new Message({
type: dbus.MessageType.SIGNAL,
path: "/org/freedesktop/Notifications",
interface: "org.freedesktop.Notifications",
member: "ActionInvoked",
signature: "us",
body: [notificationId, actionId],
});
if (this.bus && this.bus.send) {
this.bus.send(signalMessage);
} else {
throw new Error("Bus not available in interface instance");
}
this.emitter.emit("ActionInvoked", notificationId, actionId);
}
emitNotificationClosed(notificationId: number, reason: number): void {
// Send signal message directly via bus
const signalMessage = new Message({
type: dbus.MessageType.SIGNAL,
path: "/org/freedesktop/Notifications",
interface: "org.freedesktop.Notifications",
member: "NotificationClosed",
signature: "uu",
body: [notificationId, reason],
});
if (this.bus && this.bus.send) {
this.bus.send(signalMessage);
} else {
throw new Error("Bus not available in interface instance");
}
this.emitter.emit("NotificationClosed", notificationId, reason);
}
Notify(
appName: string,
replacesId: number,
appIcon: string,
summary: string,
body: string,
actions: string[],
hints: DBusHints,
expireTimeout: number
): number {
const id = replacesId > 0 ? replacesId : this.notificationCounter++;
const notification: Notification = {
id,
appName: String(appName),
summary: String(summary),
body: String(body),
appIcon: appIcon ? String(appIcon) : undefined,
expireTimeout,
actions: this.parseActions(actions),
timestamp: Date.now(),
};
this.notifyCallback(notification);
return id;
}
CloseNotification(id: number): void {
log(`D-Bus call to CloseNotification for ID: ${id}`);
// Note: Actual closing will be handled via IPC or timeout
// The client application already knows the notification was closed
}
GetCapabilities(): string[] {
return ["body", "actions", "persistence", "action-icons", "body-markup", "body-hyperlinks"];
}
GetServerInformation(): [string, string, string, string] {
return ["Bond WM Notifications", "Bond WM", app.getVersion(), "1.2"];
}
}
NotificationInterface.configureMembers({
methods: {
Notify: {
inSignature: "susssasa{sv}i",
outSignature: "u",
},
CloseNotification: {
inSignature: "u",
outSignature: "",
},
GetCapabilities: {
inSignature: "",
outSignature: "as",
},
GetServerInformation: {
inSignature: "",
outSignature: "ssss",
},
},
signals: {
ActionInvoked: {
signature: "us",
},
NotificationClosed: {
signature: "uu",
},
},
});