Skip to content

Commit 027ca88

Browse files
committed
fix(electron): disable hardware acceleration + serve via HTTP on linux
Flickering and invisible-window issues on Linux were caused by: 1. Hardware acceleration conflicts with Linux compositors — disabled via app.disableHardwareAcceleration() for Linux builds. 2. Next.js static export loaded via file:// protocol fails in Electron due to CSP, router, and relative-path issues. Now serves the built frontend from a local HTTP server on an ephemeral port. 3. Window now uses show:false + ready-to-show to prevent white flash, and sets backgroundColor to match the dark theme (#0a0a0a). Also changed package.json scripts to use the system /usr/bin/electron binary directly (the npm electron binary often fails to download in restricted environments).
1 parent 2194725 commit 027ca88

2 files changed

Lines changed: 123 additions & 30 deletions

File tree

apps/desktop-electron/package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
"main": "dist/main.js",
66
"scripts": {
77
"build": "tsc",
8-
"dev": "tsc && electron . --dev",
9-
"build:app": "pnpm build && electron-builder",
10-
"build:app:linux": "pnpm build && electron-builder --linux",
11-
"build:app:mac": "pnpm build && electron-builder --mac",
12-
"build:app:win": "pnpm build && electron-builder --win",
13-
"start": "pnpm build && electron .",
8+
"dev": "tsc && /usr/bin/electron . --dev",
9+
"build:app": "pnpm build && /usr/bin/electron-builder",
10+
"build:app:linux": "pnpm build && /usr/bin/electron-builder --linux",
11+
"build:app:mac": "pnpm build && /usr/bin/electron-builder --mac",
12+
"build:app:win": "pnpm build && /usr/bin/electron-builder --win",
13+
"start": "pnpm build && /usr/bin/electron .",
1414
"typecheck": "tsc --noEmit"
1515
},
1616
"dependencies": {

apps/desktop-electron/src/main.ts

Lines changed: 117 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,138 @@ import {
44
ipcMain,
55
shell,
66
dialog,
7-
protocol,
87
} from "electron";
98
import * as path from "node:path";
9+
import * as http from "node:http";
10+
import * as fs from "node:fs";
11+
import * as net from "node:net";
1012
import {
1113
launchSidecar,
1214
shutdownSidecar,
1315
getSidecarPort,
14-
SidecarReady,
15-
SidecarOutput,
16-
SidecarExit,
1716
} from "./sidecar";
1817

1918
const isDev = process.argv.includes("--dev");
2019
const gotTheLock = app.requestSingleInstanceLock();
2120

21+
if (process.platform === "linux") {
22+
app.disableHardwareAcceleration();
23+
}
24+
2225
let mainWindow: BrowserWindow | null = null;
2326
let pendingAuthCallback: {
2427
success: boolean;
2528
token?: string;
2629
error?: string;
2730
} | null = null;
28-
29-
function getFrontendPath(): string {
30-
if (isDev) {
31-
return "http://127.0.0.1:3000";
31+
let staticServer: http.Server | null = null;
32+
33+
function findFrontendDir(): string {
34+
const candidates = [
35+
path.join(__dirname, "../frontend"),
36+
path.join(__dirname, "../../desktop-ui/out"),
37+
];
38+
for (const dir of candidates) {
39+
if (fs.existsSync(path.join(dir, "index.html"))) {
40+
return dir;
41+
}
3242
}
33-
return path.join(__dirname, "../frontend/index.html");
43+
throw new Error(
44+
`Frontend build not found. Tried: ${candidates.join(", ")}. ` +
45+
`Run pnpm --filter @openlinear/desktop-ui build:electron first.`
46+
);
47+
}
48+
49+
async function pickFreePort(): Promise<number> {
50+
return new Promise((resolve, reject) => {
51+
const srv = net.createServer();
52+
srv.listen(0, "127.0.0.1", () => {
53+
const addr = srv.address();
54+
if (addr && typeof addr === "object" && addr.port) {
55+
const port = addr.port;
56+
srv.close(() => resolve(port));
57+
} else {
58+
srv.close(() => reject(new Error("Failed to get ephemeral port")));
59+
}
60+
});
61+
srv.on("error", (err) => reject(err));
62+
});
63+
}
64+
65+
async function startStaticServer(): Promise<number> {
66+
if (isDev) return 3000;
67+
const staticDir = findFrontendDir();
68+
const port = await pickFreePort();
69+
70+
staticServer = http.createServer((req, res) => {
71+
const reqPath = decodeURIComponent(req.url || "/");
72+
let filePath = path.join(staticDir, reqPath);
73+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
74+
filePath = path.join(staticDir, "index.html");
75+
}
76+
fs.readFile(filePath, (err, data) => {
77+
if (err) {
78+
res.writeHead(404);
79+
res.end("Not found");
80+
return;
81+
}
82+
const ext = path.extname(filePath);
83+
const mime: Record<string, string> = {
84+
".html": "text/html",
85+
".js": "application/javascript",
86+
".css": "text/css",
87+
".json": "application/json",
88+
".png": "image/png",
89+
".jpg": "image/jpeg",
90+
".svg": "image/svg+xml",
91+
".woff2": "font/woff2",
92+
};
93+
res.writeHead(200, { "Content-Type": mime[ext] || "application/octet-stream" });
94+
res.end(data);
95+
});
96+
});
97+
98+
return new Promise((resolve, reject) => {
99+
staticServer!.listen(port, "127.0.0.1", () => {
100+
console.log(`[Static] Serving ${staticDir} on http://127.0.0.1:${port}`);
101+
resolve(port);
102+
});
103+
staticServer!.on("error", reject);
104+
});
34105
}
35106

36-
function createWindow(): BrowserWindow {
107+
function createWindow(url: string): BrowserWindow {
37108
const win = new BrowserWindow({
38109
width: 1200,
39110
height: 800,
40111
minWidth: 800,
41112
minHeight: 500,
42113
center: true,
43-
frame: false,
44-
titleBarStyle: "hidden",
114+
show: false,
115+
backgroundColor: "#0a0a0a",
116+
frame: process.platform === "darwin" ? false : true,
117+
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
45118
webPreferences: {
46119
preload: path.join(__dirname, "preload.js"),
47120
contextIsolation: true,
48121
nodeIntegration: false,
49122
sandbox: true,
50123
allowRunningInsecureContent: false,
51124
experimentalFeatures: false,
125+
offscreen: false,
52126
},
53127
});
54128

55-
const frontendPath = getFrontendPath();
56-
if (frontendPath.startsWith("http")) {
57-
win.loadURL(frontendPath);
58-
} else {
59-
win.loadFile(frontendPath);
129+
win.loadURL(url);
130+
131+
win.once("ready-to-show", () => {
132+
win.show();
133+
win.focus();
134+
console.log("[Window] Ready and shown");
135+
});
136+
137+
if (isDev) {
138+
win.webContents.openDevTools();
60139
}
61140

62141
return win;
@@ -70,7 +149,7 @@ function emitToWindow(channel: string, payload: unknown): void {
70149

71150
function focusMainWindow(): void {
72151
if (!mainWindow || mainWindow.isDestroyed()) {
73-
mainWindow = createWindow();
152+
return;
74153
}
75154
if (mainWindow.isMinimized()) mainWindow.restore();
76155
mainWindow.show();
@@ -120,7 +199,13 @@ app.whenReady().then(async () => {
120199
app.setAsDefaultProtocolClient("openlinear");
121200
}
122201

123-
mainWindow = createWindow();
202+
const staticPort = await startStaticServer();
203+
const frontendUrl = isDev
204+
? "http://127.0.0.1:3000"
205+
: `http://127.0.0.1:${staticPort}`;
206+
207+
console.log(`[App] Loading frontend from ${frontendUrl}`);
208+
mainWindow = createWindow(frontendUrl);
124209

125210
try {
126211
const port = await launchSidecar(emitToWindow);
@@ -136,12 +221,16 @@ app.whenReady().then(async () => {
136221

137222
app.on("activate", () => {
138223
if (BrowserWindow.getAllWindows().length === 0) {
139-
mainWindow = createWindow();
224+
mainWindow = createWindow(frontendUrl);
140225
}
141226
});
142227
});
143228

144229
app.on("window-all-closed", () => {
230+
if (staticServer) {
231+
staticServer.close();
232+
staticServer = null;
233+
}
145234
if (process.platform !== "darwin") {
146235
shutdownSidecar();
147236
app.quit();
@@ -150,6 +239,10 @@ app.on("window-all-closed", () => {
150239

151240
app.on("before-quit", () => {
152241
shutdownSidecar();
242+
if (staticServer) {
243+
staticServer.close();
244+
staticServer = null;
245+
}
153246
});
154247

155248
app.on("open-url", (_event, url) => {
@@ -234,10 +327,10 @@ ipcMain.handle("get-arch", () => {
234327
const storeCache = new Map<string, Map<string, unknown>>();
235328

236329
ipcMain.handle("store-load", async (_event, filename: string) => {
237-
const fs = await import("node:fs/promises");
330+
const fsPromises = await import("node:fs/promises");
238331
const storePath = path.join(app.getPath("userData"), filename);
239332
try {
240-
const data = await fs.readFile(storePath, "utf-8");
333+
const data = await fsPromises.readFile(storePath, "utf-8");
241334
const parsed = JSON.parse(data) as Record<string, unknown>;
242335
storeCache.set(filename, new Map(Object.entries(parsed)));
243336
} catch {
@@ -271,12 +364,12 @@ ipcMain.handle(
271364
);
272365

273366
ipcMain.handle("store-save", async (_event, filename: string) => {
274-
const fs = await import("node:fs/promises");
367+
const fsPromises = await import("node:fs/promises");
275368
const store = storeCache.get(filename);
276369
if (!store) return;
277370
const storePath = path.join(app.getPath("userData"), filename);
278371
const data = Object.fromEntries(store.entries());
279-
await fs.writeFile(storePath, JSON.stringify(data, null, 2), "utf-8");
372+
await fsPromises.writeFile(storePath, JSON.stringify(data, null, 2), "utf-8");
280373
});
281374

282375
ipcMain.handle("consume_pending_auth_callback", async () => {

0 commit comments

Comments
 (0)