18 August 2024

Electron on Linux: open files in a new window if there isn't one in the current workspace

One thing I’ve found annoying about the way my editor works is when using workspaces and opening new files from the desktop: if there was already a window open in some other workspace, the file would silently open in that window and nothing else would happen.

After a bit of research, there are a couple of commands you can use to facilitate matching up windows with workspaces and figuring out if there is an app window on the current workspace.

  • Get the current workspace ID:

    xprop -root _NET_CURRENT_DESKTOP

    Example output:

    _NET_CURRENT_DESKTOP(CARDINAL) = 0
  • List current open windows, including which workspace they’re on:

    wmctrl -lp

    Example output:

    0x00c00003 -1 1689   mint Bottom Panel
    0x00e00006 -1 1716   mint Desktop
    0x03400004  0 2304   mint Execute and get the output of a shell command in node.js - Stack Overflow - Brave
    0x00e00430  0 1716   mint hogg-blake software ltd
    0x03c00006  0 3255   mint gus@mint ~/Pictures/teeth/resize75
    0x04400003  0 5414   mint currentWorkspaceHasWindow.js (~/projects/edita/src/platforms/electron/mainProcess/utils) - Edita
    0x05200006  0 5658   mint gus@mint ~
    0x05200192  0 5658   mint gus@mint ~/projects/edita

    The first field above is the window ID, which we can get access to in Electron by parsing the output of BrowserWindow.getNativeWindowHandle() (docs). More on that below.

    The second field is the workspace number, and we can ignore everything else.

getNativeWindowHandle

This is the load-bearing Electron feature, as it allows us to reliably match up application windows (BrowserWindow instances) with the output of X window management commands. (I also considered using a special identifier in the window title as a workaround, but that’s just ugly.)

On Linux, the method returns a plain Buffer containing an unsigned long. This requires a bit of massaging to get it to look like the 0x... IDs we see in wmctrl -lp output:

getWindowId(window) {
	/*
	getNativeWindowHandle returns a Buffer containing window ID as an
	unsigned long
	
	window IDs from wmctrl are in hex. we can convert the buffer to a
	hex string with toString.
	
	trying this, it seems that the hex bytes are reversed so e.g.
	0x05a00003 (actual ID) becomes 0300a005
	
	so we reverse them and add 0x to get our X window ID.
	*/
	
	let handle = window.getNativeWindowHandle();
	let str = handle.toString("hex");
	let parts = str.match(/w{2}/g);
	
	parts.reverse();
	
	let id = "0x" + parts.join("");
	
	return id;
}

In modern Node versions with Array.toReversed, this could be a one-liner:

getWindowId(window) {
	return "0x" + window.getNativeWindowHandle().toString("hex").match(/w{2}/g).toReversed().join("");
}

The other main addition to the Electron code is a function for mapping windows to workspaces:

mapWindowsToWorkspaces() {
	return new Map(cmdSync(`wmctrl -lp`).trim().split("\n").map((line) => {
		let [id, workspace] = line.split(/s+/);
		let window = this.appWindows.find(w => this.getWindowId(w) === id);
		
		return window ? [window, workspace] : null;
	}).filter(Boolean));
}

The above is a slightly cleaned-up version of my original solution. The reason we create a Map as opposed to having a getWorkspaceId(window) is to avoid calling wmctrl once for each window, but the difference is probably negligible.

Putting all this together, here’s how I implemented the behaviour in Edita (view on GitLab):

diff --git a/src/platforms/electron/mainProcess/App.js b/src/platforms/electron/mainProcess/App.js
index fc3ed0db..f7f225ea 100644
--- a/src/platforms/electron/mainProcess/App.js
+++ b/src/platforms/electron/mainProcess/App.js
@@ -10,6 +10,7 @@ let path = require("path");
 let windowStateKeeper = require("electron-window-state");
 let {removeInPlace} = require("./utils/arrayMethods");
 let getConfig = require("./utils/getConfig");
+let cmdSync = require("./utils/cmdSync");
 let fs = require("./modules/fs");
 let ipcMain = require("./modules/ipcMain");
 let mimeTypes = require("./modules/mimeTypes");
@@ -28,6 +29,7 @@ class App {
 		this.closeWithoutConfirming = new WeakSet();
 		this.dialogOpeners = new WeakMap();
 		this.dialogsByAppWindowAndName = new WeakMap();
+		this.perWindowConfig = new WeakMap();
 		
 		this.dataDir = fs(this.config.userDataDir);
 		this.buildDir = fs(__dirname, "..", "..", config.dev ? "electron-dev" : "electron");
@@ -54,6 +56,13 @@ class App {
 		await this.init();
 	}
 	
+	getPerWindowConfig(window) {
+		return {
+			...this.config,
+			...this.perWindowConfig.get(window),
+		};
+	}
+	
 	async init() {
 		ipc(this);
 		
@@ -153,7 +162,24 @@ class App {
 			let files = config.files.map(p => path.resolve(config.cwd, p));
 			
 			if (files.length > 0) {
-				ipcMain.sendToRenderer(this.lastFocusedWindow, "open", files);
+				// if there's a window on the current workspace, open the
+				// file in it - otherwise create a new window
+				
+				let currentWorkspace = this.getCurrentWorkspace();
+				let windowsToWorkspaces = this.mapWindowsToWorkspaces();
+				let openInExistingWindow = null;
+				
+				if (windowsToWorkspaces.get(this.lastFocusedWindow) === currentWorkspace) {
+					openInExistingWindow = this.lastFocusedWindow;
+				} else {
+					openInExistingWindow = this.appWindows.find(w => windowsToWorkspaces.get(w) === currentWorkspace);
+				}
+				
+				if (openInExistingWindow) {
+					ipcMain.sendToRenderer(openInExistingWindow, "open", files);
+				} else {
+					this.createAppWindow(files);
+				}
 			} else {
 				this.createAppWindow();
 			}
@@ -162,7 +188,82 @@ class App {
 		await this.mkdirs();
 	}
 	
-	createAppWindow() {
+	getCurrentWorkspace() {
+		let currentWorkspaceRaw = cmdSync(`xprop -root _NET_CURRENT_DESKTOP`);
+		
+		/*
+		e.g.
+		
+		_NET_CURRENT_DESKTOP(CARDINAL) = 0
+		*/
+		
+		return currentWorkspaceRaw.replace("_NET_CURRENT_DESKTOP(CARDINAL) = ", "").trim();
+	}
+	
+	getWindowId(window) {
+		/*
+		getNativeWindowHandle returns a Buffer containing window ID as an
+		unsigned long
+		
+		window IDs from wmctrl are in hex. we can convert the buffer to a
+		hex string with toString.
+		
+		trying this, it seems that the hex bytes are reversed so e.g.
+		0x05a00003 (actual ID) becomes 0300a005
+		
+		so we reverse them and add 0x to get our X window ID.
+		*/
+		
+		let handle = window.getNativeWindowHandle();
+		let str = handle.toString("hex");
+		let parts = str.match(/w{2}/g);
+		
+		parts.reverse();
+		
+		let id = "0x" + parts.join("");
+		
+		return id;
+	}
+	
+	mapWindowsToWorkspaces() {
+		/*
+		list all X windows and the workspace they're on (second field below)
+		and match them to our windows using the id (first field)
+		*/
+		
+		let openWindowsRaw = cmdSync(`wmctrl -lp`);
+		
+		/*
+		e.g.
+		
+		0x00c00003 -1 1689   mint Bottom Panel
+		0x00e00006 -1 1716   mint Desktop
+		0x03400004  0 2304   mint Execute and get the output of a shell command in node.js - Stack Overflow - Brave
+		0x00e00430  0 1716   mint hogg-blake software ltd
+		0x03c00006  0 3255   mint gus@mint ~/Pictures/teeth/resize75
+		0x04400003  0 5414   mint currentWorkspaceHasWindow.js (~/projects/edita/src/platforms/electron/mainProcess/utils) - Edita
+		0x05200006  0 5658   mint gus@mint ~
+		0x05200192  0 5658   mint gus@mint ~/projects/edita
+		*/
+		
+		let windows = openWindowsRaw.trim().split("\n").map((line) => {
+			let [id, workspace] = line.split(/s+/);
+			
+			let window = this.appWindows.find(w => this.getWindowId(w) === id);
+			
+			return {window, workspace};
+		}).filter(r => r.window);
+		
+		let map = new Map();
+		
+		for (let {window, workspace} of windows) {
+			map.set(window, workspace);
+		}
+		
+		return map;
+	}
+	
+	createAppWindow(openFiles=null) {
 		let {
 			x = 0,
 			y = 0,
@@ -185,6 +286,12 @@ class App {
 			backgroundColor: "#edecea",
 		});
 		
+		if (openFiles) {
+			this.perWindowConfig.set(browserWindow, {
+				files: openFiles,
+			});
+		}
+		
 		winState.manage(browserWindow);
 		
 		browserWindow.loadURL("app://-/main.html");
diff --git a/src/platforms/electron/mainProcess/ipc/init.js b/src/platforms/electron/mainProcess/ipc/init.js
index 125dda04..69a31f8f 100644
--- a/src/platforms/electron/mainProcess/ipc/init.js
+++ b/src/platforms/electron/mainProcess/ipc/init.js
@@ -1,11 +1,12 @@
 module.exports = function(app) {
 	return {
 		init(e) {
-			let {config} = app;
+			let window = app.browserWindowFromEvent(e);
+			let config = app.getPerWindowConfig(window);
 			
 			return {
 				config,
-				isMainWindow: app.browserWindowFromEvent(e) === app.mainWindow,
+				isMainWindow: window === app.mainWindow,
 			};
 		},
 	};
diff --git a/src/platforms/electron/mainProcess/utils/cmdSync.js b/src/platforms/electron/mainProcess/utils/cmdSync.js
new file mode 100644
index 00000000..472ef446
--- /dev/null
+++ b/src/platforms/electron/mainProcess/utils/cmdSync.js
@@ -0,0 +1,7 @@
+let child_process = require("child_process");
+
+function cmd(c) {
+	return child_process.execSync(c).toString();
+}
+
+module.exports = cmd;