Skip to content

Commit 566cad7

Browse files
committed
Use pure node process to handle whisper to bypass electron cage
1 parent 382b77f commit 566cad7

16 files changed

Lines changed: 498 additions & 102 deletions

apps/desktop/forge.config.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const EXTERNAL_DEPENDENCIES = [
3838
"@libsql/win32-x64-msvc",
3939
"libsql",
4040
"onnxruntime-node",
41+
"workerpool",
4142
// Add any other native modules you need here
4243
];
4344

@@ -53,14 +54,12 @@ const config: ForgeConfig = {
5354
console.log(`Copying Node.js binary for ${platform}-${arch}...`);
5455
const nodeBinarySource = join(
5556
projectRoot,
56-
"resources",
5757
"node-binaries",
5858
`${platform}-${arch}`,
5959
platform === "win32" ? "node.exe" : "node",
6060
);
6161
const nodeBinaryDest = join(
6262
projectRoot,
63-
"resources",
6463
"node-binaries",
6564
`${platform}-${arch}`,
6665
);
@@ -273,7 +272,8 @@ const config: ForgeConfig = {
273272
},
274273
packagerConfig: {
275274
asar: {
276-
unpack: "{*.node,*.dylib,*.so,*.dll,*.metal,**/whisper.cpp/**}",
275+
unpack:
276+
"{*.node,*.dylib,*.so,*.dll,*.metal,**/whisper.cpp/**,**/.vite/build/whisper-worker-fork.js,**/node_modules/smart-whisper/**,**/node_modules/jest-worker/**}",
277277
},
278278
name: "Amical",
279279
executableName: "Amical",
@@ -282,7 +282,8 @@ const config: ForgeConfig = {
282282
extraResource: [
283283
"../../packages/native-helpers/swift-helper/bin",
284284
"./src/db/migrations",
285-
"./resources",
285+
"./node-binaries",
286+
"./models",
286287
"./src/assets",
287288
],
288289
extendInfo: {
File renamed without changes.
File renamed without changes.
File renamed without changes.

apps/desktop/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@amical/desktop",
3-
"version": "0.0.5-test-publish",
3+
"version": "0.0.6",
44
"description": "Amical Desktop app",
55
"main": ".vite/build/main.js",
66
"productName": "Amical",
@@ -150,6 +150,7 @@
150150
"update-electron-app": "^3.1.1",
151151
"uuid": "^11.1.0",
152152
"vaul": "^1.1.2",
153+
"workerpool": "^9.3.3",
153154
"zod": "^3.25.24"
154155
}
155156
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env node
2+
3+
const https = require('https');
4+
const fs = require('fs');
5+
const path = require('path');
6+
const { execSync } = require('child_process');
7+
const { createWriteStream, mkdirSync, chmodSync } = fs;
8+
9+
// Node.js version to download
10+
const NODE_VERSION = '24.4.0';
11+
12+
// Platform configurations
13+
const PLATFORMS = [
14+
{
15+
platform: 'darwin',
16+
arch: 'arm64',
17+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-arm64.tar.gz`,
18+
binary: 'bin/node'
19+
},
20+
{
21+
platform: 'darwin',
22+
arch: 'x64',
23+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-x64.tar.gz`,
24+
binary: 'bin/node'
25+
},
26+
{
27+
platform: 'win32',
28+
arch: 'x64',
29+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-win-x64.zip`,
30+
binary: 'node.exe'
31+
},
32+
{
33+
platform: 'linux',
34+
arch: 'x64',
35+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz`,
36+
binary: 'bin/node'
37+
}
38+
];
39+
40+
// Base directory for binaries
41+
const RESOURCES_DIR = path.join(__dirname, '..', 'node-binaries');
42+
43+
async function downloadFile(url, dest) {
44+
return new Promise((resolve, reject) => {
45+
const file = createWriteStream(dest);
46+
47+
https.get(url, (response) => {
48+
if (response.statusCode === 302 || response.statusCode === 301) {
49+
// Handle redirect
50+
https.get(response.headers.location, (redirectResponse) => {
51+
redirectResponse.pipe(file);
52+
file.on('finish', () => {
53+
file.close(resolve);
54+
});
55+
}).on('error', reject);
56+
} else {
57+
response.pipe(file);
58+
file.on('finish', () => {
59+
file.close(resolve);
60+
});
61+
}
62+
}).on('error', reject);
63+
});
64+
}
65+
66+
async function extractArchive(archivePath, platform) {
67+
const tempDir = path.join(path.dirname(archivePath), 'temp');
68+
mkdirSync(tempDir, { recursive: true });
69+
70+
if (platform === 'win32') {
71+
// Use unzip command (available on macOS) to extract zip files
72+
execSync(`unzip -q "${archivePath}" -d "${tempDir}"`, { stdio: 'inherit' });
73+
} else {
74+
// Use tar for Unix-like systems
75+
execSync(`tar -xzf "${archivePath}" -C "${tempDir}"`, { stdio: 'inherit' });
76+
}
77+
78+
return tempDir;
79+
}
80+
81+
async function downloadNodeBinary(config) {
82+
const { platform, arch, url, binary } = config;
83+
const platformDir = path.join(RESOURCES_DIR, `${platform}-${arch}`);
84+
const binaryPath = path.join(platformDir, platform === 'win32' ? 'node.exe' : 'node');
85+
86+
// Skip if already exists
87+
if (fs.existsSync(binaryPath)) {
88+
console.log(`✓ ${platform}-${arch} binary already exists`);
89+
return;
90+
}
91+
92+
console.log(`Downloading Node.js for ${platform}-${arch}...`);
93+
94+
// Create directory
95+
mkdirSync(platformDir, { recursive: true });
96+
97+
// Download archive
98+
const archiveExt = platform === 'win32' ? '.zip' : '.tar.gz';
99+
const archivePath = path.join(platformDir, `node-v${NODE_VERSION}${archiveExt}`);
100+
101+
try {
102+
await downloadFile(url, archivePath);
103+
console.log(`Downloaded archive for ${platform}-${arch}`);
104+
105+
// Extract archive
106+
const tempDir = await extractArchive(archivePath, platform);
107+
108+
// Find the node binary in extracted files
109+
// Windows uses different directory naming convention (win instead of win32)
110+
const extractedDirName = platform === 'win32'
111+
? `node-v${NODE_VERSION}-win-${arch}`
112+
: `node-v${NODE_VERSION}-${platform}-${arch}`;
113+
const extractedBinaryPath = path.join(tempDir, extractedDirName, binary);
114+
115+
// Copy binary to final location
116+
fs.copyFileSync(extractedBinaryPath, binaryPath);
117+
118+
// Make executable on Unix-like systems
119+
if (platform !== 'win32') {
120+
chmodSync(binaryPath, '755');
121+
}
122+
123+
// Clean up
124+
fs.rmSync(tempDir, { recursive: true, force: true });
125+
fs.unlinkSync(archivePath);
126+
127+
console.log(`✓ Successfully installed ${platform}-${arch} binary`);
128+
} catch (error) {
129+
console.error(`✗ Failed to download ${platform}-${arch}:`, error.message);
130+
// Clean up on failure
131+
if (fs.existsSync(archivePath)) {
132+
fs.unlinkSync(archivePath);
133+
}
134+
}
135+
}
136+
137+
async function main() {
138+
console.log(`Downloading Node.js v${NODE_VERSION} binaries for all platforms...\n`);
139+
140+
// Create base directory
141+
mkdirSync(RESOURCES_DIR, { recursive: true });
142+
143+
// Download binaries for all platforms
144+
for (const platform of PLATFORMS) {
145+
await downloadNodeBinary(platform);
146+
}
147+
148+
console.log('\nDone! Node.js binaries downloaded to:', RESOURCES_DIR);
149+
}
150+
151+
// Run if called directly
152+
if (require.main === module) {
153+
main().catch(console.error);
154+
}
155+
156+
module.exports = { downloadNodeBinary, PLATFORMS, NODE_VERSION };

apps/desktop/scripts/download-node-binaries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const PLATFORMS: PlatformConfig[] = [
4949
},
5050
];
5151

52-
const RESOURCES_DIR = path.join(__dirname, "..", "resources", "node-binaries");
52+
const RESOURCES_DIR = path.join(__dirname, "..", "node-binaries");
5353

5454
// Parse command line arguments
5555
const args = process.argv.slice(2);

apps/desktop/src/hooks/useRecording.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,7 @@ export const useRecording = (): UseRecordingOutput => {
6262
);
6363

6464
// Manage audio capture when recording is active
65-
const isActive =
66-
recordingStatus.state === "recording" ||
67-
recordingStatus.state === "starting";
65+
const isActive = recordingStatus.state === "recording";
6866

6967
const { voiceDetected } = useAudioCapture({
7068
onAudioChunk: handleAudioChunk,

apps/desktop/src/main/managers/recording-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export class RecordingManager extends EventEmitter {
195195
try {
196196
const swiftBridge = this.serviceManager.getService("swiftIOBridge");
197197
if (swiftBridge) {
198-
//await swiftBridge.call("muteSystemAudio", {});
198+
await swiftBridge.call("muteSystemAudio", {});
199199
}
200200
} catch (error) {
201201
logger.main.warn("Swift bridge not available for audio muting");
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { fork, ChildProcess } from "child_process";
2+
import { app } from "electron";
3+
import * as path from "path";
4+
import { logger } from "../../../main/logger";
5+
6+
interface WorkerMessage {
7+
id: number;
8+
method: string;
9+
args: any[];
10+
}
11+
12+
interface WorkerResponse {
13+
id: number;
14+
result?: any;
15+
error?: string;
16+
}
17+
18+
export class SimpleForkWrapper {
19+
private worker: ChildProcess | null = null;
20+
private messageId = 0;
21+
private pendingCalls = new Map<
22+
number,
23+
{
24+
resolve: (value: any) => void;
25+
reject: (error: any) => void;
26+
}
27+
>();
28+
29+
constructor(
30+
private workerPath: string,
31+
private nodeBinaryPath: string,
32+
) {}
33+
34+
async initialize(): Promise<void> {
35+
if (this.worker) return;
36+
37+
logger.transcription.info(`Starting worker process: ${this.workerPath}`);
38+
39+
// When packaged, we need to extract the worker to a temp file
40+
// because fork needs an actual file path, not an asar path
41+
let actualWorkerPath = this.workerPath;
42+
43+
// Set up environment for the worker
44+
const workerEnv: any = {
45+
...process.env,
46+
ELECTRON_RUN_AS_NODE: "1",
47+
GGML_METAL_PATH_RESOURCES: process.env.GGML_METAL_PATH_RESOURCES,
48+
NODE_OPTIONS: "--max-old-space-size=8192",
49+
};
50+
51+
if (app.isPackaged && this.workerPath.includes(".asar")) {
52+
// For packaged app, use the unpacked worker
53+
actualWorkerPath = this.workerPath.replace(
54+
"app.asar",
55+
"app.asar.unpacked",
56+
);
57+
workerEnv.APP_ASAR_PATH = path.join(process.resourcesPath, "app.asar");
58+
logger.transcription.info(`Using unpacked worker: ${actualWorkerPath}`);
59+
}
60+
61+
this.worker = fork(actualWorkerPath, [], {
62+
execPath: this.nodeBinaryPath,
63+
env: workerEnv,
64+
silent: false,
65+
cwd: app.isPackaged ? process.resourcesPath : process.cwd(),
66+
});
67+
68+
this.worker.on("message", (message: WorkerResponse) => {
69+
if (message.id !== undefined && this.pendingCalls.has(message.id)) {
70+
const { resolve, reject } = this.pendingCalls.get(message.id)!;
71+
this.pendingCalls.delete(message.id);
72+
73+
if (message.error) {
74+
reject(new Error(message.error));
75+
} else {
76+
resolve(message.result);
77+
}
78+
}
79+
});
80+
81+
this.worker.on("error", (error) => {
82+
logger.transcription.error("Worker process error:", error);
83+
this.rejectAllPending(error);
84+
});
85+
86+
this.worker.on("exit", (code, signal) => {
87+
logger.transcription.info(
88+
`Worker process exited: code=${code}, signal=${signal}`,
89+
);
90+
this.worker = null;
91+
this.rejectAllPending(new Error(`Worker exited with code ${code}`));
92+
});
93+
}
94+
95+
private rejectAllPending(error: Error): void {
96+
for (const { reject } of this.pendingCalls.values()) {
97+
reject(error);
98+
}
99+
this.pendingCalls.clear();
100+
}
101+
102+
async exec<T>(method: string, args: any[]): Promise<T> {
103+
if (!this.worker) {
104+
await this.initialize();
105+
}
106+
107+
return new Promise((resolve, reject) => {
108+
const id = this.messageId++;
109+
this.pendingCalls.set(id, { resolve, reject });
110+
111+
// Convert Float32Array to regular array for IPC
112+
const serializedArgs = args.map((arg) => {
113+
if (arg instanceof Float32Array) {
114+
return {
115+
__type: "Float32Array",
116+
data: Array.from(arg),
117+
};
118+
}
119+
return arg;
120+
});
121+
122+
this.worker!.send({
123+
id,
124+
method,
125+
args: serializedArgs,
126+
} as WorkerMessage);
127+
});
128+
}
129+
130+
async terminate(): Promise<void> {
131+
if (this.worker) {
132+
this.worker.kill();
133+
this.worker = null;
134+
this.pendingCalls.clear();
135+
}
136+
}
137+
}

0 commit comments

Comments
 (0)