-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.js
More file actions
398 lines (351 loc) · 13.7 KB
/
Copy pathmain.js
File metadata and controls
398 lines (351 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
// Electron main process for LogNotes.
//
// Responsibilities:
// - spawn the Python sidecar and supervise its lifecycle
// - read the `PORT <n>` handshake line from the sidecar's stdout
// - hand that port to the renderer so it can open the WebSocket
// - show the window only once the sidecar is up
//
// The renderer never spawns processes or talks to the sidecar directly; it
// goes through the preload bridge and IPC defined here.
const { app, BrowserWindow, ipcMain, screen, session, Tray, Menu, nativeImage } = require('electron');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const readline = require('readline');
// Windows ties taskbar grouping + pinned-shortcut identity to the AppUserModelID,
// which must match the installer's appId. Without it, a running window shows the
// generic Electron icon and a pinned shortcut can't activate the live instance.
const APP_USER_MODEL_ID = 'com.lognotes.app';
if (process.platform === 'win32') {
app.setAppUserModelId(APP_USER_MODEL_ID);
}
// Single-instance: a second launch (e.g. clicking the pinned shortcut while the
// app is already running, possibly hidden to tray) must focus the existing
// window, not spin up another Electron + sidecar.
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
}
let mainWindow = null;
let overlayWindow = null;
let tray = null;
let isQuitting = false;
let sidecarProc = null;
let sidecarPort = null;
const OVERLAY_W = 132;
const OVERLAY_H = 38;
const OVERLAY_MARGIN = 12;
// ----------------------------------------------------------------------------
// Sidecar resolution
// ----------------------------------------------------------------------------
// Packaged: the bundled sidecar.exe under resourcesPath. Dev: the sidecar run
// from source via the project venv.
function resolveSidecarCommand() {
const projectRoot = path.resolve(__dirname, '..');
if (app.isPackaged) {
// electron-builder bundles dist/LogNotes/ as an extraResource under
// resources/sidecar/ (see electron/package.json build.extraResources).
const exe = path.join(process.resourcesPath, 'sidecar', 'LogNotes.exe');
return { command: exe, args: [], cwd: path.dirname(exe) };
}
// Dev: prefer the venv python so torch/whisper resolve.
const venvPy = process.platform === 'win32'
? path.join(projectRoot, 'venv', 'Scripts', 'python.exe')
: path.join(projectRoot, 'venv', 'bin', 'python');
const python = fs.existsSync(venvPy) ? venvPy : 'python';
return {
command: python,
args: [path.join(projectRoot, 'sidecar.py')],
cwd: projectRoot,
};
}
function startSidecar() {
return new Promise((resolve, reject) => {
const { command, args, cwd } = resolveSidecarCommand();
console.log(`[main] spawning sidecar: ${command} ${args.join(' ')}`);
sidecarProc = spawn(command, args, {
cwd,
// LOGNOTES_PARENT_PID lets the sidecar self-terminate if we are hard-killed
// (Task Manager / OS / crash), where no JS exit handler runs to stop it.
env: { ...process.env, LOGNOTES_SIDECAR_PORT: '0', LOGNOTES_PARENT_PID: String(process.pid) },
});
let settled = false;
// Parse stdout line-by-line for the PORT handshake.
const rl = readline.createInterface({ input: sidecarProc.stdout });
rl.on('line', (line) => {
const text = line.trim();
if (text.startsWith('PORT ')) {
sidecarPort = parseInt(text.slice(5), 10);
console.log(`[main] sidecar announced port ${sidecarPort}`);
if (!settled) {
settled = true;
resolve(sidecarPort);
}
} else if (text) {
console.log(`[sidecar] ${text}`);
}
});
sidecarProc.stderr.on('data', (d) => {
process.stderr.write(`[sidecar:err] ${d}`);
});
sidecarProc.on('error', (err) => {
if (!settled) {
settled = true;
reject(err);
}
});
sidecarProc.on('exit', (code) => {
console.log(`[main] sidecar exited with code ${code}`);
sidecarProc = null;
if (!settled) {
settled = true;
reject(new Error(`sidecar exited before handshake (code ${code})`));
}
// If the sidecar dies while running, tear the app down — the UI is
// useless without it.
if (mainWindow) {
mainWindow.webContents.send('sidecar-down');
}
});
// Safety net: don't hang forever if the handshake never arrives.
setTimeout(() => {
if (!settled) {
settled = true;
reject(new Error('timed out waiting for sidecar handshake'));
}
}, 60000);
});
}
// Kill the sidecar reliably, including any child processes. On Windows a frozen
// PyInstaller exe can spawn helper children and `proc.kill()` only signals the
// top process, leaving orphans that hold their old WebSocket port and confuse
// the next launch's handshake (manifesting as a "Disconnected" UI). Use
// taskkill /T to tear down the whole tree. Called from every exit path so a
// hide-to-tray-then-quit, a crash, or a hard process exit can't orphan it.
function stopSidecar() {
if (!sidecarProc) return;
const pid = sidecarProc.pid;
sidecarProc = null;
if (pid == null) return;
if (process.platform === 'win32') {
try {
// /T kills the process tree, /F forces it. spawnSync so it completes
// before the app fully exits (an async kill can be cut short).
require('child_process').spawnSync('taskkill', ['/PID', String(pid), '/T', '/F']);
} catch {
// Fall through to a best-effort signal below.
}
}
try {
process.kill(pid);
} catch {
// Already gone (e.g. taskkill handled it) — nothing to do.
}
}
// ----------------------------------------------------------------------------
// Window
// ----------------------------------------------------------------------------
function createWindow() {
const iconPath = appIconPath();
mainWindow = new BrowserWindow({
width: 500,
height: 750,
resizable: false,
show: false, // shown after the renderer signals it has connected
icon: iconPath || undefined,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
// Closing the window quits the whole app (window, overlay, and the Python
// sidecar). Users who want LogNotes to keep running in the background minimize
// instead — minimizing never fires 'close'. Setting isQuitting lets the close
// proceed and drives before-quit/will-quit, which tear down the sidecar.
mainWindow.on('close', () => {
isQuitting = true;
app.quit();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// ----------------------------------------------------------------------------
// Overlay (floating status pill)
// ----------------------------------------------------------------------------
function overlayPosition(corner) {
const wa = screen.getPrimaryDisplay().workArea; // excludes taskbar
const right = wa.x + wa.width - OVERLAY_W - OVERLAY_MARGIN;
const bottom = wa.y + wa.height - OVERLAY_H - OVERLAY_MARGIN;
const left = wa.x + OVERLAY_MARGIN;
const top = wa.y + OVERLAY_MARGIN;
switch (corner) {
case 'top-left': return { x: left, y: top };
case 'top-right': return { x: right, y: top };
case 'bottom-left': return { x: left, y: bottom };
default: return { x: right, y: bottom }; // bottom-right
}
}
function createOverlay(corner = 'bottom-right') {
const { x, y } = overlayPosition(corner);
overlayWindow = new BrowserWindow({
width: OVERLAY_W,
height: OVERLAY_H,
x, y,
frame: false,
transparent: true,
resizable: false,
movable: true,
skipTaskbar: true,
alwaysOnTop: true,
focusable: false,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
overlayWindow.setAlwaysOnTop(true, 'screen-saver');
overlayWindow.loadFile(path.join(__dirname, 'renderer', 'overlay.html'));
// Show the overlay as soon as its content has loaded (it displays
// "Starting..." until its own sidecar connection is up). showInactive() keeps
// it from stealing focus from whatever the user is typing into. Showing on its
// own load rather than the main window's handshake means it appears reliably
// even if the main window is slow. did-finish-load is used (not ready-to-show)
// because transparent frameless windows can be unreliable with ready-to-show
// on Windows.
const showOverlay = () => { if (overlayWindow) overlayWindow.showInactive(); };
overlayWindow.webContents.once('did-finish-load', showOverlay);
overlayWindow.on('closed', () => { overlayWindow = null; });
}
// Overlay asks to move itself to the next corner (right-click cycle); we persist
// via the same setConfig path the renderer uses, but positioning is local.
ipcMain.on('overlay-set-corner', (_e, corner) => {
if (!overlayWindow) return;
const { x, y } = overlayPosition(corner);
overlayWindow.setPosition(x, y);
});
// ----------------------------------------------------------------------------
// Tray
// ----------------------------------------------------------------------------
function appIconPath() {
const projectRoot = path.resolve(__dirname, '..');
const candidates = app.isPackaged
? [
path.join(process.resourcesPath, 'assets', 'logo.ico'),
path.join(process.resourcesPath, 'assets', 'logo.png'),
]
: [
path.join(projectRoot, 'src', 'ui', 'assets', 'logo.ico'),
path.join(projectRoot, 'src', 'ui', 'assets', 'logo.png'),
];
return candidates.find((p) => fs.existsSync(p)) || null;
}
function showMainWindow() {
if (!mainWindow) {
createWindow();
return;
}
mainWindow.show();
mainWindow.focus();
}
function createTray() {
const iconPath = appIconPath();
// nativeImage tolerates a missing file by returning an empty image; Tray
// still works (shows a blank icon) so a missing asset doesn't crash startup.
let icon = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
// The source logo is 256x256; Windows trays expect ~16px. Resize so the icon
// actually renders instead of showing blank.
if (!icon.isEmpty()) {
icon = icon.resize({ width: 16, height: 16 });
}
tray = new Tray(icon);
tray.setToolTip('LogNotes');
const menu = Menu.buildFromTemplate([
{ label: 'Show', click: showMainWindow },
{ type: 'separator' },
{
label: 'Quit',
click: () => {
isQuitting = true;
app.quit();
},
},
]);
tray.setContextMenu(menu);
tray.on('click', showMainWindow);
}
// Renderer asks for the sidecar port once it is ready to connect.
ipcMain.handle('get-sidecar-port', () => sidecarPort);
// Renderer signals it has connected. The window is already shown early for
// responsiveness; this is a no-op safety net to ensure it's visible. The overlay
// shows itself on its own 'ready-to-show' (see createOverlay).
ipcMain.on('renderer-ready', () => {
if (mainWindow && !mainWindow.isVisible()) mainWindow.show();
});
// ----------------------------------------------------------------------------
// Lifecycle
// ----------------------------------------------------------------------------
// Security: deny every web permission except the clipboard, which the Activity
// "Copy" button uses. This app is a local control panel — it has no legitimate
// use for geolocation, camera, web-microphone, notifications, etc. Denying them
// stops Chromium from ever prompting the user (e.g. the "use your location"
// prompt) and shrinks the attack surface.
const ALLOWED_PERMISSIONS = new Set(['clipboard-read', 'clipboard-sanitized-write']);
function lockDownPermissions() {
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
cb(ALLOWED_PERMISSIONS.has(permission));
});
session.defaultSession.setPermissionCheckHandler((_wc, permission) => {
return ALLOWED_PERMISSIONS.has(permission);
});
}
// A second launch attempt: surface the existing window instead of starting anew.
app.on('second-instance', () => {
showMainWindow();
});
if (gotSingleInstanceLock) {
app.whenReady().then(async () => {
lockDownPermissions();
// Create + show the window first so the user gets immediate feedback. The
// first frozen-build launch can take many seconds to start the sidecar +
// load models; without an early window the app looks broken and users click
// again. The renderer shows "Connecting to sidecar..." until the handshake.
createWindow();
mainWindow.show();
createOverlay();
createTray();
try {
await startSidecar();
} catch (err) {
console.error(`[main] failed to start sidecar: ${err.message}`);
// Window already shown; renderer reflects the error/connecting state.
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
else showMainWindow();
});
});
}
// With hide-to-tray, the main window hides rather than closes, so this normally
// won't fire. Guard it: only quit if there is no tray to live in.
app.on('window-all-closed', () => {
if (!tray) {
stopSidecar();
if (process.platform !== 'darwin') app.quit();
}
});
app.on('before-quit', () => {
isQuitting = true;
stopSidecar();
});
// Belt-and-suspenders: `will-quit` fires after `before-quit` on the normal quit
// path, but also on paths a missed `before-quit` wouldn't cover. `process.exit`
// is the last-resort hook for a hard teardown so the sidecar is never orphaned
// (orphans hold their old port and break the next launch's handshake).
app.on('will-quit', stopSidecar);
process.on('exit', stopSidecar);