Skip to content

Commit a1d037b

Browse files
committed
chore: node process managemeent in renderer process
1 parent 301a8e7 commit a1d037b

3 files changed

Lines changed: 92 additions & 140 deletions

File tree

src-electron/main.js

Lines changed: 84 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -5,115 +5,12 @@ const readline = require('readline');
55
const fs = require('fs');
66
const fsp = require('fs/promises');
77
const os = require('os');
8+
const { identifier: APP_IDENTIFIER } = require('./package.json');
89

910
let mainWindow;
10-
let nodeProcess;
11-
let nodeWSPort = null;
12-
let isNodeTerminated = false;
13-
14-
// Promise that resolves when node server port is available (similar to Tauri's serverPortPromise)
15-
let nodePortResolve;
16-
const nodePortPromise = new Promise((resolve) => { nodePortResolve = resolve; });
17-
18-
const NODE_COMMANDS = {
19-
TERMINATE: "terminate",
20-
PING: "ping",
21-
GET_PORT: "getPort",
22-
HEART_BEAT: "heartBeat"
23-
};
24-
25-
let commandId = 0;
26-
const pendingCommands = {};
27-
28-
function execNode(commandCode) {
29-
return new Promise((resolve, reject) => {
30-
if (!nodeProcess || isNodeTerminated) {
31-
reject(new Error('Node process not running'));
32-
return;
33-
}
34-
const newCommandID = commandId++;
35-
const cmd = JSON.stringify({ commandCode, commandId: newCommandID }) + "\n";
36-
nodeProcess.stdin.write(cmd);
37-
pendingCommands[newCommandID] = { resolve, reject };
38-
});
39-
}
40-
41-
function startNodeServer() {
42-
return new Promise((resolve, reject) => {
43-
const nodeSrcPath = path.join(__dirname, '..', 'src-tauri', 'node-src', 'index.js');
44-
45-
console.log('Starting Node server from:', nodeSrcPath);
46-
47-
nodeProcess = spawn('node', [nodeSrcPath], {
48-
stdio: ['pipe', 'pipe', 'pipe']
49-
});
50-
51-
const rl = readline.createInterface({
52-
input: nodeProcess.stdout,
53-
crlfDelay: Infinity
54-
});
55-
56-
rl.on('line', (line) => {
57-
if (line && line.trim().startsWith("{")) {
58-
try {
59-
const jsonMsg = JSON.parse(line);
60-
if (pendingCommands[jsonMsg.commandId]) {
61-
pendingCommands[jsonMsg.commandId].resolve(jsonMsg.message);
62-
delete pendingCommands[jsonMsg.commandId];
63-
}
64-
} catch (e) {
65-
console.log('Node:', line);
66-
}
67-
} else if (line) {
68-
console.log('Node:', line);
69-
}
70-
});
71-
72-
nodeProcess.stderr.on('data', (data) => {
73-
console.error('Node Error:', data.toString());
74-
});
75-
76-
nodeProcess.on('close', (code, signal) => {
77-
isNodeTerminated = true;
78-
console.log(`Node process exited with code ${code} and signal ${signal}`);
79-
});
80-
81-
nodeProcess.on('error', (err) => {
82-
console.error('Failed to start Node process:', err);
83-
reject(err);
84-
});
85-
86-
// Node-src's GET_PORT command waits for serverPortPromise internally,
87-
// so no timeout needed - it will respond once the server is ready
88-
execNode(NODE_COMMANDS.GET_PORT)
89-
.then((result) => {
90-
nodeWSPort = result.port;
91-
nodePortResolve(nodeWSPort);
92-
console.log('Node WebSocket server running on port:', nodeWSPort);
93-
resolve(nodeWSPort);
94-
})
95-
.catch((err) => {
96-
reject(err);
97-
});
98-
});
99-
}
100-
101-
// Heartbeat to keep Node server alive
102-
let heartbeatInterval;
103-
function startHeartbeat() {
104-
heartbeatInterval = setInterval(() => {
105-
if (!isNodeTerminated) {
106-
execNode(NODE_COMMANDS.HEART_BEAT).catch(() => {});
107-
}
108-
}, 10000);
109-
}
110-
111-
function stopHeartbeat() {
112-
if (heartbeatInterval) {
113-
clearInterval(heartbeatInterval);
114-
heartbeatInterval = null;
115-
}
116-
}
11+
let processInstanceId = 0;
12+
// Map of instanceId -> { process, terminated }
13+
const spawnedProcesses = new Map();
11714

11815
async function createWindow() {
11916
mainWindow = new BrowserWindow({
@@ -139,9 +36,59 @@ async function createWindow() {
13936
}
14037

14138
// IPC handlers
142-
ipcMain.handle('get-node-ws-port', async () => {
143-
// Wait for node server to be ready before returning port
144-
return await nodePortPromise;
39+
40+
// Spawn a child process and forward stdio to the calling renderer.
41+
// Returns an instanceId so the renderer can target the correct process.
42+
ipcMain.handle('spawn-process', async (event, command, args) => {
43+
const instanceId = ++processInstanceId;
44+
const sender = event.sender;
45+
console.log(`Spawning: ${command} ${args.join(' ')} (instance ${instanceId})`);
46+
47+
const childProcess = spawn(command, args, {
48+
stdio: ['pipe', 'pipe', 'pipe']
49+
});
50+
51+
const instance = { process: childProcess, terminated: false };
52+
spawnedProcesses.set(instanceId, instance);
53+
54+
const rl = readline.createInterface({
55+
input: childProcess.stdout,
56+
crlfDelay: Infinity
57+
});
58+
59+
rl.on('line', (line) => {
60+
if (!sender.isDestroyed()) {
61+
sender.send('process-stdout', instanceId, line);
62+
}
63+
});
64+
65+
childProcess.stderr.on('data', (data) => {
66+
if (!sender.isDestroyed()) {
67+
sender.send('process-stderr', instanceId, data.toString());
68+
}
69+
});
70+
71+
childProcess.on('close', (code, signal) => {
72+
instance.terminated = true;
73+
console.log(`Process (instance ${instanceId}) exited with code ${code} and signal ${signal}`);
74+
if (!sender.isDestroyed()) {
75+
sender.send('process-close', instanceId, { code, signal });
76+
}
77+
});
78+
79+
childProcess.on('error', (err) => {
80+
console.error(`Failed to start process (instance ${instanceId}):`, err);
81+
});
82+
83+
return instanceId;
84+
});
85+
86+
// Write data to a specific spawned process stdin
87+
ipcMain.handle('write-to-process', (event, instanceId, data) => {
88+
const instance = spawnedProcesses.get(instanceId);
89+
if (instance && !instance.terminated) {
90+
instance.process.stdin.write(data);
91+
}
14592
});
14693

14794
ipcMain.handle('quit-app', (event, exitCode) => {
@@ -153,17 +100,30 @@ ipcMain.on('console-log', (event, message) => {
153100
console.log('Renderer:', message);
154101
});
155102

103+
// App path (repo root when running from source)
104+
ipcMain.handle('get-app-path', () => {
105+
return app.getAppPath();
106+
});
107+
156108
// Directory APIs
157109
ipcMain.handle('get-documents-dir', () => {
158110
return path.join(os.homedir(), 'Documents');
159111
});
160112

161113
ipcMain.handle('get-app-data-dir', () => {
162-
// Returns app-specific data directory similar to Tauri's appLocalDataDir
163-
// Linux: ~/.local/share/<app-name>/
164-
// macOS: ~/Library/Application Support/<app-name>/
165-
// Windows: %APPDATA%/<app-name>/
166-
return app.getPath('userData');
114+
// Match Tauri's appLocalDataDir which uses the bundle identifier "fs.phcode"
115+
// Linux: ~/.local/share/fs.phcode/
116+
// macOS: ~/Library/Application Support/fs.phcode/
117+
// Windows: %LOCALAPPDATA%/fs.phcode/
118+
const home = os.homedir();
119+
switch (process.platform) {
120+
case 'darwin':
121+
return path.join(home, 'Library', 'Application Support', APP_IDENTIFIER);
122+
case 'win32':
123+
return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), APP_IDENTIFIER);
124+
default:
125+
return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), APP_IDENTIFIER);
126+
}
167127
});
168128

169129
// Dialogs
@@ -228,37 +188,23 @@ function waitForTrue(fn, timeout) {
228188
async function gracefulShutdown(exitCode = 0) {
229189
console.log('Initiating graceful shutdown...');
230190

231-
stopHeartbeat();
232-
233-
if (!isNodeTerminated && nodeProcess) {
234-
// Send terminate command (don't await - node exits without responding)
235-
try {
236-
const cmd = JSON.stringify({ commandCode: NODE_COMMANDS.TERMINATE, commandId: -1 }) + "\n";
237-
nodeProcess.stdin.write(cmd);
238-
} catch (e) {
239-
// Process may already be terminated
240-
}
241-
242-
// Wait for node process to terminate (like Tauri does)
243-
await waitForTrue(() => isNodeTerminated, 1000);
191+
for (const [, instance] of spawnedProcesses) {
192+
if (!instance.terminated) {
193+
try {
194+
instance.process.kill();
195+
} catch (e) {
196+
// Process may already be terminated
197+
}
244198

245-
if (!isNodeTerminated) {
246-
nodeProcess.kill();
199+
await waitForTrue(() => instance.terminated, 1000);
247200
}
248201
}
249202

250203
app.exit(exitCode);
251204
}
252205

253206
app.whenReady().then(async () => {
254-
try {
255-
await startNodeServer();
256-
startHeartbeat();
257-
await createWindow();
258-
} catch (err) {
259-
console.error('Failed to start:', err);
260-
app.exit(1);
261-
}
207+
await createWindow();
262208
});
263209

264210
app.on('window-all-closed', () => {

src-electron/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"name": "phoenix-fs-electron-shell",
3+
"identifier": "fs.phcode",
34
"version": "1.0.0",
45
"description": "Electron development shell for phoenix-fs testing",
56
"main": "main.js",

src-electron/preload.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
const { contextBridge, ipcRenderer } = require('electron');
22

33
contextBridge.exposeInMainWorld('electronAPI', {
4-
// Get the WebSocket port for the Node.js file system server
5-
getNodeWSPort: () => ipcRenderer.invoke('get-node-ws-port'),
4+
// Process lifecycle
5+
spawnProcess: (command, args) => ipcRenderer.invoke('spawn-process', command, args),
6+
writeToProcess: (instanceId, data) => ipcRenderer.invoke('write-to-process', instanceId, data),
7+
onProcessStdout: (callback) => ipcRenderer.on('process-stdout', (_event, instanceId, line) => callback(instanceId, line)),
8+
onProcessStderr: (callback) => ipcRenderer.on('process-stderr', (_event, instanceId, line) => callback(instanceId, line)),
9+
onProcessClose: (callback) => ipcRenderer.on('process-close', (_event, instanceId, data) => callback(instanceId, data)),
610

711
// Quit the app with an exit code (for CI)
812
quitApp: (exitCode) => ipcRenderer.invoke('quit-app', exitCode),
@@ -14,6 +18,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
1418
isElectron: true,
1519

1620
// File system APIs
21+
getAppPath: () => ipcRenderer.invoke('get-app-path'),
1722
getDocumentsDir: () => ipcRenderer.invoke('get-documents-dir'),
1823
getAppDataDir: () => ipcRenderer.invoke('get-app-data-dir'),
1924
showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options),

0 commit comments

Comments
 (0)