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;