Skip to content

Commit 1362910

Browse files
committed
feat: electron migration in linux scaffolding
1 parent 275fed2 commit 1362910

8 files changed

Lines changed: 1234 additions & 2 deletions

File tree

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
"_ci-release:prod": "npm run _ci-env-warn && npm run _ci-cloneAndBuildPhoenix && cd phoenix && npm run release:prod && cd .. && npm run _ci-createDistReleaseConfig && npm run _ci_make_src-node",
3434
"_ci-update-phcode-build": "node src-build/update-phcode-build.js",
3535
"_watch_src-node": "chokidar '../phoenix/src-node/**/*' --ignore '../phoenix/src-node/node_modules/**/*' -c 'npm run _make_src-node_debug_dev'",
36-
"serve": "npm run _servePhoenix && npm run _make_src-node && tauri dev",
36+
"serve": "node src-build/serveForPlatform.js",
37+
"_serveTauri": "npm run _servePhoenix && npm run _make_src-node && tauri dev",
38+
"_serveElectron": "npm run _servePhoenix && npm run _make_src-node && ./src-electron/node_modules/.bin/electron src-electron/main.js",
3739
"postinstall": "node ./src-build/downloadNodeBinary.js",
3840
"cleanNodeBinary": "node src-build/cleanNodeBinary.js",
3941
"installNodeArmDarwin": "node ./src-build/downloadNodeBinary.js '{\"platform\":\"darwin\",\"arch\":\"arm64\"}'",
@@ -56,4 +58,4 @@
5658
"branch": "tauri",
5759
"commit": "23987dad9240266fd297cbb99202178a3f68a6b2"
5860
}
59-
}
61+
}

src-build/serveForPlatform.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {getPlatformDetails} from "./utils.js";
2+
import {execa} from "execa";
3+
4+
const {platform} = getPlatformDetails();
5+
6+
// Linux uses Electron, Windows/Mac use Tauri
7+
const serveScript = (platform === "linux") ? "_serveElectron" : "_serveTauri";
8+
9+
console.log(`Platform: ${platform}, running: npm run ${serveScript}`);
10+
11+
await execa("npm", ["run", serveScript], {stdio: "inherit"});

src-electron/main-app-ipc.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
const { app, ipcMain } = require('electron');
2+
const { spawn } = require('child_process');
3+
const readline = require('readline');
4+
const { productName } = require('./package.json');
5+
6+
let processInstanceId = 0;
7+
// Map of instanceId -> { process, terminated }
8+
const spawnedProcesses = new Map();
9+
10+
function waitForTrue(fn, timeout) {
11+
return new Promise((resolve) => {
12+
const startTime = Date.now();
13+
function check() {
14+
if (fn()) {
15+
resolve(true);
16+
} else if (Date.now() - startTime > timeout) {
17+
resolve(false);
18+
} else {
19+
setTimeout(check, 50);
20+
}
21+
}
22+
check();
23+
});
24+
}
25+
26+
async function terminateAllProcesses() {
27+
for (const [, instance] of spawnedProcesses) {
28+
if (!instance.terminated) {
29+
try {
30+
instance.process.kill();
31+
} catch (e) {
32+
// Process may already be terminated
33+
}
34+
35+
await waitForTrue(() => instance.terminated, 1000);
36+
}
37+
}
38+
}
39+
40+
function registerAppIpcHandlers() {
41+
// Spawn a child process and forward stdio to the calling renderer.
42+
// Returns an instanceId so the renderer can target the correct process.
43+
ipcMain.handle('spawn-process', async (event, command, args) => {
44+
const instanceId = ++processInstanceId;
45+
const sender = event.sender;
46+
console.log(`Spawning: ${command} ${args.join(' ')} (instance ${instanceId})`);
47+
48+
const childProcess = spawn(command, args, {
49+
stdio: ['pipe', 'pipe', 'pipe']
50+
});
51+
52+
const instance = { process: childProcess, terminated: false };
53+
spawnedProcesses.set(instanceId, instance);
54+
55+
const rl = readline.createInterface({
56+
input: childProcess.stdout,
57+
crlfDelay: Infinity
58+
});
59+
60+
rl.on('line', (line) => {
61+
if (!sender.isDestroyed()) {
62+
sender.send('process-stdout', instanceId, line);
63+
}
64+
});
65+
66+
childProcess.stderr.on('data', (data) => {
67+
if (!sender.isDestroyed()) {
68+
sender.send('process-stderr', instanceId, data.toString());
69+
}
70+
});
71+
72+
childProcess.on('close', (code, signal) => {
73+
instance.terminated = true;
74+
console.log(`Process (instance ${instanceId}) exited with code ${code} and signal ${signal}`);
75+
if (!sender.isDestroyed()) {
76+
sender.send('process-close', instanceId, { code, signal });
77+
}
78+
});
79+
80+
childProcess.on('error', (err) => {
81+
console.error(`Failed to start process (instance ${instanceId}):`, err);
82+
});
83+
84+
return instanceId;
85+
});
86+
87+
// Write data to a specific spawned process stdin
88+
ipcMain.handle('write-to-process', (event, instanceId, data) => {
89+
const instance = spawnedProcesses.get(instanceId);
90+
if (instance && !instance.terminated) {
91+
instance.process.stdin.write(data);
92+
}
93+
});
94+
95+
ipcMain.handle('quit-app', (event, exitCode) => {
96+
console.log('Quit requested with exit code:', exitCode);
97+
// This will be handled by the main module's gracefulShutdown
98+
app.emit('quit-requested', exitCode);
99+
});
100+
101+
ipcMain.on('console-log', (event, message) => {
102+
console.log('Renderer:', message);
103+
});
104+
105+
// CLI args (mirrors Tauri's cli.getMatches for --quit-when-done / -q)
106+
ipcMain.handle('get-cli-args', () => {
107+
return process.argv;
108+
});
109+
110+
// App path (repo root when running from source)
111+
ipcMain.handle('get-app-path', () => {
112+
return app.getAppPath();
113+
});
114+
115+
// App name from package.json
116+
ipcMain.handle('get-app-name', () => {
117+
return productName;
118+
});
119+
}
120+
121+
module.exports = {
122+
registerAppIpcHandlers,
123+
terminateAllProcesses
124+
};

src-electron/main-fs-ipc.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
const { ipcMain, dialog, BrowserWindow } = require('electron');
2+
const path = require('path');
3+
const fsp = require('fs/promises');
4+
const os = require('os');
5+
const { identifier: APP_IDENTIFIER } = require('./package.json');
6+
7+
// Electron IPC only preserves Error.message when errors cross the IPC boundary (see
8+
// https://github.com/electron/electron/issues/24427). To preserve error.code for FS
9+
// operations, we catch errors and return them as plain objects {error: {code, message}}.
10+
// The preload layer unwraps these back into proper Error objects.
11+
function fsResult(promise) {
12+
return promise.catch(err => {
13+
return { __fsError: true, code: err.code, message: err.message };
14+
});
15+
}
16+
17+
/**
18+
* Returns the app's local data directory path with trailing separator.
19+
* Matches Tauri's appLocalDataDir which uses the bundle identifier.
20+
* - Linux: ~/.local/share/{APP_IDENTIFIER}/
21+
* - macOS: ~/Library/Application Support/{APP_IDENTIFIER}/
22+
* - Windows: %LOCALAPPDATA%/{APP_IDENTIFIER}/
23+
*/
24+
function getAppDataDir() {
25+
const home = os.homedir();
26+
let appDataDir;
27+
switch (process.platform) {
28+
case 'darwin':
29+
appDataDir = path.join(home, 'Library', 'Application Support', APP_IDENTIFIER);
30+
break;
31+
case 'win32':
32+
appDataDir = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), APP_IDENTIFIER);
33+
break;
34+
default:
35+
appDataDir = path.join(process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), APP_IDENTIFIER);
36+
}
37+
return appDataDir + path.sep;
38+
}
39+
40+
function registerFsIpcHandlers() {
41+
// Directory APIs
42+
ipcMain.handle('get-documents-dir', () => {
43+
// Match Tauri's documentDir which ends with a trailing slash
44+
return path.join(os.homedir(), 'Documents') + path.sep;
45+
});
46+
47+
ipcMain.handle('get-home-dir', () => {
48+
// Match Tauri's homeDir which ends with a trailing slash
49+
const home = os.homedir();
50+
return home.endsWith(path.sep) ? home : home + path.sep;
51+
});
52+
53+
ipcMain.handle('get-temp-dir', () => {
54+
return os.tmpdir();
55+
});
56+
57+
ipcMain.handle('get-app-data-dir', () => getAppDataDir());
58+
59+
// Get Windows drive letters (returns null on non-Windows platforms)
60+
ipcMain.handle('get-windows-drives', async () => {
61+
if (process.platform !== 'win32') {
62+
return null;
63+
}
64+
// On Windows, check which drive letters exist by testing A-Z
65+
const drives = [];
66+
for (let i = 65; i <= 90; i++) { // A-Z
67+
const letter = String.fromCharCode(i);
68+
const drivePath = `${letter}:\\`;
69+
try {
70+
await fsp.access(drivePath);
71+
drives.push(letter);
72+
} catch {
73+
// Drive doesn't exist
74+
}
75+
}
76+
return drives.length > 0 ? drives : null;
77+
});
78+
79+
// Dialogs
80+
ipcMain.handle('show-open-dialog', async (event, options) => {
81+
const win = BrowserWindow.fromWebContents(event.sender);
82+
const result = await dialog.showOpenDialog(win, options);
83+
return result.filePaths;
84+
});
85+
86+
ipcMain.handle('show-save-dialog', async (event, options) => {
87+
const win = BrowserWindow.fromWebContents(event.sender);
88+
const result = await dialog.showSaveDialog(win, options);
89+
return result.filePath;
90+
});
91+
92+
// FS operations
93+
ipcMain.handle('fs-readdir', async (event, dirPath) => {
94+
return fsResult(
95+
fsp.readdir(dirPath, { withFileTypes: true })
96+
.then(entries => entries.map(e => ({ name: e.name, isDirectory: e.isDirectory() })))
97+
);
98+
});
99+
100+
ipcMain.handle('fs-stat', async (event, filePath) => {
101+
return fsResult(
102+
fsp.stat(filePath).then(stats => ({
103+
isFile: stats.isFile(),
104+
isDirectory: stats.isDirectory(),
105+
isSymbolicLink: stats.isSymbolicLink(),
106+
size: stats.size,
107+
mode: stats.mode,
108+
ctimeMs: stats.ctimeMs,
109+
atimeMs: stats.atimeMs,
110+
mtimeMs: stats.mtimeMs,
111+
nlink: stats.nlink,
112+
dev: stats.dev
113+
}))
114+
);
115+
});
116+
117+
ipcMain.handle('fs-mkdir', (event, dirPath, options) => fsResult(fsp.mkdir(dirPath, options)));
118+
ipcMain.handle('fs-unlink', (event, filePath) => fsResult(fsp.unlink(filePath)));
119+
ipcMain.handle('fs-rmdir', (event, dirPath, options) => fsResult(fsp.rm(dirPath, options)));
120+
ipcMain.handle('fs-rename', (event, oldPath, newPath) => fsResult(fsp.rename(oldPath, newPath)));
121+
ipcMain.handle('fs-read-file', (event, filePath) => fsResult(fsp.readFile(filePath)));
122+
ipcMain.handle('fs-write-file', (event, filePath, data) => fsResult(fsp.writeFile(filePath, Buffer.from(data))));
123+
}
124+
125+
module.exports = {
126+
registerFsIpcHandlers,
127+
getAppDataDir
128+
};

src-electron/main.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const { app, BrowserWindow, protocol } = require('electron');
2+
const path = require('path');
3+
4+
const { registerAppIpcHandlers, terminateAllProcesses } = require('./main-app-ipc');
5+
const { registerFsIpcHandlers, getAppDataDir } = require('./main-fs-ipc');
6+
7+
let mainWindow;
8+
9+
async function createWindow() {
10+
mainWindow = new BrowserWindow({
11+
width: 1200,
12+
height: 800,
13+
webPreferences: {
14+
preload: path.join(__dirname, 'preload.js'),
15+
contextIsolation: true,
16+
nodeIntegration: false
17+
},
18+
icon: path.join(__dirname, '..', 'src-tauri', 'icons', 'icon.png')
19+
});
20+
21+
// Load the test page from the http-server
22+
mainWindow.loadURL('http://localhost:8000/src/');
23+
24+
// Open DevTools for debugging
25+
mainWindow.webContents.openDevTools();
26+
27+
mainWindow.on('closed', () => {
28+
mainWindow = null;
29+
});
30+
}
31+
32+
async function gracefulShutdown(exitCode = 0) {
33+
console.log('Initiating graceful shutdown...');
34+
await terminateAllProcesses();
35+
app.exit(exitCode);
36+
}
37+
38+
// Register all IPC handlers
39+
registerAppIpcHandlers();
40+
registerFsIpcHandlers();
41+
42+
// Handle quit request from renderer
43+
app.on('quit-requested', (exitCode) => {
44+
gracefulShutdown(exitCode);
45+
});
46+
47+
app.whenReady().then(async () => {
48+
// Register asset:// protocol for serving local files from appLocalData/assets/
49+
const appDataDir = getAppDataDir();
50+
const assetsDir = path.join(appDataDir, 'assets');
51+
52+
protocol.registerFileProtocol('asset', (request, callback) => {
53+
try {
54+
const url = new URL(request.url);
55+
// Decode the path from URL encoding
56+
const requestedPath = decodeURIComponent(url.pathname.substring(1)); // Remove leading /
57+
const normalizedRequested = path.normalize(requestedPath);
58+
const normalizedAssetsDir = path.normalize(assetsDir);
59+
60+
// Security: Ensure path is under assets directory (prevent directory traversal)
61+
if (!normalizedRequested.startsWith(normalizedAssetsDir)) {
62+
console.error('Asset access denied - path not under assets dir:', requestedPath);
63+
callback({ error: -10 }); // net::ERR_ACCESS_DENIED
64+
return;
65+
}
66+
67+
callback({ path: normalizedRequested });
68+
} catch (err) {
69+
console.error('Asset protocol error:', err);
70+
callback({ error: -2 }); // net::ERR_FAILED
71+
}
72+
});
73+
74+
await createWindow();
75+
});
76+
77+
app.on('window-all-closed', () => {
78+
gracefulShutdown(0);
79+
});
80+
81+
app.on('activate', () => {
82+
if (BrowserWindow.getAllWindows().length === 0) {
83+
createWindow();
84+
}
85+
});
86+
87+
// Handle process termination signals
88+
process.on('SIGINT', () => gracefulShutdown(0));
89+
process.on('SIGTERM', () => gracefulShutdown(0));

0 commit comments

Comments
 (0)