Skip to content

Commit b50c4d1

Browse files
committed
feat(desktop): add Electron wrapper for Linux (and cross-platform)
WebKitGTK (Tauri's Linux webview) has broken flexbox scroll, variable fonts, backdrop filters, and compositing. Electron bundles its own Chromium, eliminating all of these issues at the cost of a larger installer (~150MB vs ~15MB). This commit adds a minimal Electron main process that: 1. Spawns the same sidecar (API server + OpenCode) via Node.js child_process, using the same binary discovery logic as Tauri. 2. Exposes an IPC bridge via contextBridge that mimics the Tauri invoke/listen APIs, so the existing Next.js frontend works unmodified (with runtime detection). 3. Handles deep-link auth callbacks (openlinear://callback), window controls (close/minimize/maximize), file picker, external URL open, and store persistence — all via Electron's native APIs. 4. Uses electron-builder to produce AppImage and .deb packages for Linux, plus dmg/zip for macOS and nsis for Windows. Frontend patches: - client.ts: detect Electron runtime and use electronAPI for sidecar - auth.ts: desktop login flow works via Electron shell.openExternal - use-auth.tsx: auth:callback listener works via Electron IPC - utils.ts: openExternal uses Electron API when available - sidebar.tsx: window controls use Electron IPC - database-settings.tsx: store read/write via Electron IPC - opencode-setup-dialog.tsx: check_opencode + platform via Electron - projects/page.tsx: pickFolder via Electron dialog - next.config.js: BUILD_FOR_ELECTRON triggers static export Build commands: pnpm build:electron # all platforms pnpm build:electron:linux # AppImage + deb The existing Tauri desktop app is preserved and unaffected. Electron is an additional target.
1 parent a946010 commit b50c4d1

21 files changed

Lines changed: 3061 additions & 81 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"appId": "com.openlinear.app",
3+
"productName": "OpenLinear",
4+
"directories": {
5+
"output": "release",
6+
"buildResources": "build"
7+
},
8+
"files": [
9+
"dist/**/*",
10+
{
11+
"from": "../desktop-ui/out",
12+
"to": "frontend"
13+
}
14+
],
15+
"extraResources": [
16+
{
17+
"from": "../desktop/src-tauri/binaries",
18+
"to": "binaries"
19+
}
20+
],
21+
"linux": {
22+
"target": ["AppImage", "deb"],
23+
"category": "Development",
24+
"maintainer": "OpenLinear",
25+
"vendor": "OpenLinear",
26+
"synopsis": "AI-powered project management",
27+
"description": "OpenLinear - AI-powered project management that actually writes the code."
28+
},
29+
"mac": {
30+
"target": ["dmg", "zip"],
31+
"category": "public.app-category.developer-tools"
32+
},
33+
"win": {
34+
"target": ["nsis", "portable"]
35+
},
36+
"asar": true,
37+
"asarUnpack": [
38+
"**/binaries/**/*"
39+
]
40+
}

apps/desktop-electron/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@openlinear/desktop-electron",
3+
"version": "0.1.0",
4+
"private": true,
5+
"main": "dist/main.js",
6+
"scripts": {
7+
"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+
"typecheck": "tsc --noEmit"
14+
},
15+
"dependencies": {
16+
"electron": "^35.0.0"
17+
},
18+
"devDependencies": {
19+
"electron-builder": "^26.0.0",
20+
"typescript": "^5.9.3",
21+
"@types/node": "^20.19.32"
22+
}
23+
}

apps/desktop-electron/src/main.ts

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import {
2+
app,
3+
BrowserWindow,
4+
ipcMain,
5+
shell,
6+
dialog,
7+
protocol,
8+
} from "electron";
9+
import * as path from "node:path";
10+
import {
11+
launchSidecar,
12+
shutdownSidecar,
13+
getSidecarPort,
14+
SidecarReady,
15+
SidecarOutput,
16+
SidecarExit,
17+
} from "./sidecar";
18+
19+
const isDev = process.argv.includes("--dev");
20+
const gotTheLock = app.requestSingleInstanceLock();
21+
22+
let mainWindow: BrowserWindow | null = null;
23+
let pendingAuthCallback: {
24+
success: boolean;
25+
token?: string;
26+
error?: string;
27+
} | null = null;
28+
29+
function getFrontendPath(): string {
30+
if (isDev) {
31+
return "http://127.0.0.1:3000";
32+
}
33+
return path.join(__dirname, "../frontend/index.html");
34+
}
35+
36+
function createWindow(): BrowserWindow {
37+
const win = new BrowserWindow({
38+
width: 1200,
39+
height: 800,
40+
minWidth: 800,
41+
minHeight: 500,
42+
center: true,
43+
frame: false,
44+
titleBarStyle: "hidden",
45+
webPreferences: {
46+
preload: path.join(__dirname, "preload.js"),
47+
contextIsolation: true,
48+
nodeIntegration: false,
49+
sandbox: true,
50+
allowRunningInsecureContent: false,
51+
experimentalFeatures: false,
52+
},
53+
});
54+
55+
const frontendPath = getFrontendPath();
56+
if (frontendPath.startsWith("http")) {
57+
win.loadURL(frontendPath);
58+
} else {
59+
win.loadFile(frontendPath);
60+
}
61+
62+
return win;
63+
}
64+
65+
function emitToWindow(channel: string, payload: unknown): void {
66+
if (mainWindow && !mainWindow.isDestroyed()) {
67+
mainWindow.webContents.send(channel, payload);
68+
}
69+
}
70+
71+
function focusMainWindow(): void {
72+
if (!mainWindow || mainWindow.isDestroyed()) {
73+
mainWindow = createWindow();
74+
}
75+
if (mainWindow.isMinimized()) mainWindow.restore();
76+
mainWindow.show();
77+
mainWindow.focus();
78+
}
79+
80+
function handleCallbackUrl(urlStr: string): void {
81+
const url = new URL(urlStr);
82+
if (url.hostname !== "callback" || (url.pathname !== "" && url.pathname !== "/")) {
83+
return;
84+
}
85+
86+
const error = url.searchParams.get("error");
87+
const token = url.searchParams.get("token");
88+
89+
if (error) {
90+
pendingAuthCallback = { success: false, error };
91+
emitToWindow("auth:callback", pendingAuthCallback);
92+
} else if (token) {
93+
pendingAuthCallback = { success: true, token };
94+
emitToWindow("auth:callback", pendingAuthCallback);
95+
} else {
96+
pendingAuthCallback = {
97+
success: false,
98+
error: "Missing 'token' parameter in callback URL",
99+
};
100+
emitToWindow("auth:callback", pendingAuthCallback);
101+
}
102+
103+
focusMainWindow();
104+
}
105+
106+
if (!gotTheLock) {
107+
app.quit();
108+
} else {
109+
app.on("second-instance", (_event, argv) => {
110+
const url = argv.find((arg) => arg.startsWith("openlinear://"));
111+
if (url) {
112+
handleCallbackUrl(url);
113+
}
114+
focusMainWindow();
115+
});
116+
}
117+
118+
app.whenReady().then(async () => {
119+
if (process.platform === "linux") {
120+
app.setAsDefaultProtocolClient("openlinear");
121+
}
122+
123+
mainWindow = createWindow();
124+
125+
try {
126+
const port = await launchSidecar(emitToWindow);
127+
console.log(`[Sidecar] Started on port ${port}`);
128+
} catch (err) {
129+
console.error("[Sidecar] Failed to launch:", err);
130+
}
131+
132+
const url = process.argv.find((arg) => arg.startsWith("openlinear://"));
133+
if (url) {
134+
handleCallbackUrl(url);
135+
}
136+
137+
app.on("activate", () => {
138+
if (BrowserWindow.getAllWindows().length === 0) {
139+
mainWindow = createWindow();
140+
}
141+
});
142+
});
143+
144+
app.on("window-all-closed", () => {
145+
if (process.platform !== "darwin") {
146+
shutdownSidecar();
147+
app.quit();
148+
}
149+
});
150+
151+
app.on("before-quit", () => {
152+
shutdownSidecar();
153+
});
154+
155+
app.on("open-url", (_event, url) => {
156+
if (url.startsWith("openlinear://")) {
157+
handleCallbackUrl(url);
158+
}
159+
});
160+
161+
ipcMain.handle("start_api_server", async () => {
162+
const port = getSidecarPort();
163+
if (port) return port;
164+
return launchSidecar(emitToWindow);
165+
});
166+
167+
ipcMain.handle("stop_api_server", async () => {
168+
shutdownSidecar();
169+
});
170+
171+
ipcMain.handle("get_api_server_port", async () => {
172+
return getSidecarPort();
173+
});
174+
175+
ipcMain.handle("check_opencode", async () => {
176+
const { execFileSync } = await import("node:child_process");
177+
const whichModule = await import("which");
178+
const whichSync = whichModule.sync || (whichModule.default as { sync: (cmd: string) => string }).sync;
179+
180+
const bundledPath = path.join(
181+
process.resourcesPath ?? path.resolve(__dirname, "../../desktop/src-tauri/binaries"),
182+
`opencode-${process.platform === "darwin" ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : process.platform === "linux" ? "x86_64-unknown-linux-gnu" : "x86_64-pc-windows-msvc"}`
183+
);
184+
185+
const candidates = [bundledPath];
186+
try {
187+
const systemPath = whichSync("opencode");
188+
candidates.push(systemPath);
189+
} catch {
190+
}
191+
192+
for (const candidate of candidates) {
193+
try {
194+
const output = execFileSync(candidate, ["--version"], {
195+
encoding: "utf-8",
196+
timeout: 5000,
197+
});
198+
return {
199+
found: true,
200+
version: output.trim() || null,
201+
path: candidate,
202+
};
203+
} catch {
204+
}
205+
}
206+
207+
return { found: false, version: null, path: null };
208+
});
209+
210+
ipcMain.handle("pick-folder", async () => {
211+
const result = await dialog.showOpenDialog({
212+
properties: ["openDirectory"],
213+
});
214+
return result.canceled ? null : result.filePaths[0] ?? null;
215+
});
216+
217+
ipcMain.handle("open-external", async (_event, url: string) => {
218+
await shell.openExternal(url);
219+
});
220+
221+
ipcMain.handle("get-platform", () => {
222+
if (process.platform === "darwin") return "macos";
223+
if (process.platform === "linux") return "linux";
224+
if (process.platform === "win32") return "windows";
225+
return "unknown";
226+
});
227+
228+
ipcMain.handle("get-arch", () => {
229+
if (process.arch === "arm64") return "aarch64";
230+
if (process.arch === "x64") return "x86_64";
231+
return "unknown";
232+
});
233+
234+
const storeCache = new Map<string, Map<string, unknown>>();
235+
236+
ipcMain.handle("store-load", async (_event, filename: string) => {
237+
const fs = await import("node:fs/promises");
238+
const storePath = path.join(app.getPath("userData"), filename);
239+
try {
240+
const data = await fs.readFile(storePath, "utf-8");
241+
const parsed = JSON.parse(data) as Record<string, unknown>;
242+
storeCache.set(filename, new Map(Object.entries(parsed)));
243+
} catch {
244+
storeCache.set(filename, new Map());
245+
}
246+
});
247+
248+
ipcMain.handle("store-get", async (_event, filename: string, key: string) => {
249+
const store = storeCache.get(filename);
250+
if (!store) {
251+
await ipcMain.emit("store-load", undefined as never, filename);
252+
return storeCache.get(filename)?.get(key);
253+
}
254+
return store.get(key);
255+
});
256+
257+
ipcMain.handle(
258+
"store-set",
259+
async (_event, filename: string, key: string, value: unknown) => {
260+
let store = storeCache.get(filename);
261+
if (!store) {
262+
await ipcMain.emit("store-load", undefined as never, filename);
263+
store = storeCache.get(filename);
264+
if (!store) {
265+
store = new Map();
266+
storeCache.set(filename, store);
267+
}
268+
}
269+
store.set(key, value);
270+
}
271+
);
272+
273+
ipcMain.handle("store-save", async (_event, filename: string) => {
274+
const fs = await import("node:fs/promises");
275+
const store = storeCache.get(filename);
276+
if (!store) return;
277+
const storePath = path.join(app.getPath("userData"), filename);
278+
const data = Object.fromEntries(store.entries());
279+
await fs.writeFile(storePath, JSON.stringify(data, null, 2), "utf-8");
280+
});
281+
282+
ipcMain.handle("consume_pending_auth_callback", async () => {
283+
const result = pendingAuthCallback;
284+
pendingAuthCallback = null;
285+
return result;
286+
});
287+
288+
ipcMain.handle("window-close", async () => {
289+
mainWindow?.close();
290+
});
291+
292+
ipcMain.handle("window-minimize", async () => {
293+
mainWindow?.minimize();
294+
});
295+
296+
ipcMain.handle("window-maximize", async () => {
297+
if (!mainWindow) return;
298+
if (mainWindow.isMaximized()) {
299+
mainWindow.unmaximize();
300+
} else {
301+
mainWindow.maximize();
302+
}
303+
});
304+
305+
ipcMain.handle("window-is-fullscreen", async () => {
306+
return mainWindow?.isFullScreen() ?? false;
307+
});
308+
309+
ipcMain.handle("window-set-fullscreen", async (_event, fullscreen: boolean) => {
310+
mainWindow?.setFullScreen(fullscreen);
311+
});
312+
313+
ipcMain.handle("window-toggle-maximize", async () => {
314+
if (!mainWindow) return;
315+
if (mainWindow.isMaximized()) {
316+
mainWindow.unmaximize();
317+
} else {
318+
mainWindow.maximize();
319+
}
320+
});

0 commit comments

Comments
 (0)