create-electron-vite-react-ts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ import { ipcMain, shell, app, BrowserWindow, Menu, screen } from "electron";
2
+ import { fileURLToPath } from "node:url";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import __cjs_mod__ from "node:module";
6
+ const __filename = import.meta.filename;
7
+ const __dirname = import.meta.dirname;
8
+ const require2 = __cjs_mod__.createRequire(import.meta.url);
9
+ function parseDotEnv(content) {
10
+ const out = {};
11
+ for (const line of content.split(/\r?\n/)) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith("#")) continue;
14
+ const eq = trimmed.indexOf("=");
15
+ if (eq === -1) continue;
16
+ const key = trimmed.slice(0, eq).trim();
17
+ let value = trimmed.slice(eq + 1).trim();
18
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
19
+ value = value.slice(1, -1);
20
+ }
21
+ out[key] = value;
22
+ }
23
+ return out;
24
+ }
25
+ function readDotEnvFile(appRoot) {
26
+ const envPath = path.join(appRoot, ".env");
27
+ if (!fs.existsSync(envPath)) return {};
28
+ try {
29
+ return parseDotEnv(fs.readFileSync(envPath, "utf-8"));
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+ function parseEnvBoolean(value, defaultValue) {
35
+ if (value === void 0 || value === "") return defaultValue;
36
+ const v = value.trim().toLowerCase();
37
+ if (v === "1" || v === "true" || v === "yes") return true;
38
+ if (v === "0" || v === "false" || v === "no") return false;
39
+ return defaultValue;
40
+ }
41
+ function pickString(...vals) {
42
+ for (const v of vals) {
43
+ if (v !== void 0 && v !== "") return v;
44
+ }
45
+ return void 0;
46
+ }
47
+ function pad2(value) {
48
+ return String(value).padStart(2, "0");
49
+ }
50
+ function pad3(value) {
51
+ return String(value).padStart(3, "0");
52
+ }
53
+ function toDateStamp(date) {
54
+ return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`;
55
+ }
56
+ function toTimeStamp(date) {
57
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`;
58
+ }
59
+ function resolveLoggerConfig(isDevelopment, isPackaged, appRoot) {
60
+ const dotEnv = readDotEnvFile(appRoot);
61
+ const enabledRaw = pickString(process.env.LOG_ENABLED, dotEnv.LOG_ENABLED);
62
+ const enabled = parseEnvBoolean(enabledRaw, true);
63
+ if (!enabled) {
64
+ return { enabled: false, file: false, console: false };
65
+ }
66
+ const fileRaw = pickString(process.env.LOG_FILE, dotEnv.LOG_FILE);
67
+ const consoleRaw = pickString(process.env.LOG_CONSOLE, dotEnv.LOG_CONSOLE);
68
+ return {
69
+ enabled: true,
70
+ file: parseEnvBoolean(fileRaw, !isPackaged),
71
+ console: parseEnvBoolean(consoleRaw, isDevelopment)
72
+ };
73
+ }
74
+ class AppLogger {
75
+ config;
76
+ isDevelopment;
77
+ logsDir;
78
+ logFile;
79
+ sessionId;
80
+ constructor(options) {
81
+ this.isDevelopment = options.isDevelopment;
82
+ this.sessionId = options.sessionId;
83
+ this.config = resolveLoggerConfig(options.isDevelopment, options.isPackaged, options.appRoot);
84
+ this.logsDir = path.join(options.appRoot, "logs");
85
+ this.logFile = path.join(this.logsDir, `system.${toDateStamp(/* @__PURE__ */ new Date())}.log`);
86
+ }
87
+ init() {
88
+ if (!this.config.enabled) return;
89
+ if (!this.config.file && !this.config.console) return;
90
+ if (this.config.file) {
91
+ fs.mkdirSync(this.logsDir, { recursive: true });
92
+ }
93
+ this.writeLine("SYSTEM", "=".repeat(112));
94
+ this.writeLine("SYSTEM", `Application Started | ${JSON.stringify({ devMode: this.isDevelopment, sessionId: this.sessionId })}`);
95
+ this.writeLine("SYSTEM", "=".repeat(112));
96
+ this.writeLine("LOGGER", "Logger ready");
97
+ }
98
+ info(level, message, meta = {}) {
99
+ const metaJson = JSON.stringify(meta);
100
+ if (!metaJson || metaJson === "{}") {
101
+ this.writeLine(level, message);
102
+ } else {
103
+ this.writeLine(level, `${message} | ${metaJson}`);
104
+ }
105
+ }
106
+ log(level, message) {
107
+ this.writeLine(level, message);
108
+ }
109
+ shutdown() {
110
+ if (!this.config.enabled) return;
111
+ if (!this.config.file && !this.config.console) return;
112
+ this.writeLine("INFO", "Logger shutting down");
113
+ this.writeLine("SYSTEM", "=".repeat(112));
114
+ this.writeLine("SYSTEM", `Application Ended | ${JSON.stringify({ sessionId: this.sessionId })}`);
115
+ this.writeLine("SYSTEM", "=".repeat(112) + "\n");
116
+ }
117
+ writeLine(level, body) {
118
+ if (!this.config.enabled) return;
119
+ if (!this.config.file && !this.config.console) return;
120
+ const line = `[${toTimeStamp(/* @__PURE__ */ new Date())}] [${level}] ${body}`;
121
+ if (this.config.file) {
122
+ fs.appendFileSync(this.logFile, `${line}
123
+ `, "utf-8");
124
+ }
125
+ if (this.config.console) {
126
+ console.log(line);
127
+ }
128
+ }
129
+ }
130
+ const IPC_CHANNELS = {
131
+ /** `invoke(url: string)` — allows only `http:` / `https:`, then calls `shell.openExternal`. */
132
+ OPEN_EXTERNAL: "app:open-external"
133
+ };
134
+ function isAllowedExternalUrl(url) {
135
+ try {
136
+ const u = new URL(url);
137
+ return u.protocol === "http:" || u.protocol === "https:";
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+ function registerIpcHandlers() {
143
+ ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event, raw) => {
144
+ if (typeof raw !== "string" || raw.length === 0) {
145
+ throw new Error("open-external: URL must be a non-empty string");
146
+ }
147
+ if (!isAllowedExternalUrl(raw)) {
148
+ throw new Error("open-external: only http(s) URLs are allowed");
149
+ }
150
+ await shell.openExternal(raw);
151
+ });
152
+ }
153
+ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
154
+ process.env.APP_ROOT = path.join(__dirname$1, "..", "..");
155
+ const SCREEN_CENTER = parseEnvBoolean(
156
+ pickString(process.env.SCREEN_CENTER, readDotEnvFile(process.env.APP_ROOT).SCREEN_CENTER),
157
+ false
158
+ );
159
+ const ELECTRON_MENU_ENABLED = parseEnvBoolean(
160
+ pickString(
161
+ process.env.ELECTRON_MENU_ENABLED,
162
+ readDotEnvFile(process.env.APP_ROOT).ELECTRON_MENU_ENABLED
163
+ ),
164
+ true
165
+ );
166
+ const VITE_DEV_SERVER_URL = process.env["ELECTRON_RENDERER_URL"] || process.env["VITE_DEV_SERVER_URL"];
167
+ const RUN_MODE = VITE_DEV_SERVER_URL ? "development" : "production";
168
+ const IS_DEVELOPMENT = RUN_MODE === "development";
169
+ const SESSION_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
170
+ const MAIN_DIST = path.join(process.env.APP_ROOT, "dist");
171
+ const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
172
+ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
173
+ let win;
174
+ const WINDOW_STATE_FILE = `window-state.${RUN_MODE}.json`;
175
+ const DEFAULT_WINDOW_SIZE = { width: 1200, height: 800 };
176
+ const logger = new AppLogger({
177
+ appRoot: process.env.APP_ROOT,
178
+ isDevelopment: IS_DEVELOPMENT,
179
+ isPackaged: app.isPackaged,
180
+ sessionId: SESSION_ID
181
+ });
182
+ function getWindowStatePath() {
183
+ return path.join(app.getPath("userData"), WINDOW_STATE_FILE);
184
+ }
185
+ function readWindowState() {
186
+ try {
187
+ const filePath = getWindowStatePath();
188
+ if (!fs.existsSync(filePath)) return { normalized: null, raw: null };
189
+ const raw = fs.readFileSync(filePath, "utf-8");
190
+ const parsed = JSON.parse(raw);
191
+ if (typeof parsed.width !== "number" || typeof parsed.height !== "number") {
192
+ return { normalized: null, raw: parsed };
193
+ }
194
+ return {
195
+ normalized: {
196
+ width: Math.max(400, Math.floor(parsed.width)),
197
+ height: Math.max(300, Math.floor(parsed.height)),
198
+ x: typeof parsed.x === "number" ? Math.floor(parsed.x) : void 0,
199
+ y: typeof parsed.y === "number" ? Math.floor(parsed.y) : void 0,
200
+ offsetX: typeof parsed.offsetX === "number" ? Math.floor(parsed.offsetX) : void 0,
201
+ offsetY: typeof parsed.offsetY === "number" ? Math.floor(parsed.offsetY) : void 0,
202
+ workAreaWidth: typeof parsed.workAreaWidth === "number" ? Math.floor(parsed.workAreaWidth) : void 0,
203
+ workAreaHeight: typeof parsed.workAreaHeight === "number" ? Math.floor(parsed.workAreaHeight) : void 0,
204
+ isMaximized: typeof parsed.isMaximized === "boolean" ? parsed.isMaximized : false,
205
+ displayId: typeof parsed.displayId === "number" ? parsed.displayId : void 0
206
+ },
207
+ raw: parsed
208
+ };
209
+ } catch {
210
+ return { normalized: null, raw: null };
211
+ }
212
+ }
213
+ function isVisibleOnAnyDisplay(bounds) {
214
+ return screen.getAllDisplays().some((display) => {
215
+ const area = display.workArea;
216
+ return bounds.x + bounds.width > area.x && bounds.x < area.x + area.width && bounds.y + bounds.height > area.y && bounds.y < area.y + area.height;
217
+ });
218
+ }
219
+ function getCenteredBoundsInDisplay(width, height, display) {
220
+ const area = display.workArea;
221
+ return {
222
+ x: Math.floor(area.x + (area.width - width) / 2),
223
+ y: Math.floor(area.y + (area.height - height) / 2)
224
+ };
225
+ }
226
+ function clamp(value, min, max) {
227
+ return Math.min(Math.max(value, min), max);
228
+ }
229
+ function normalizeStateToDisplay(state, display) {
230
+ const area = display.workArea;
231
+ const width = Math.max(state.width, 400);
232
+ const height = Math.max(state.height, 300);
233
+ const defaultCentered = getCenteredBoundsInDisplay(width, height, display);
234
+ const minX = area.x;
235
+ const maxX = area.x + area.width - width;
236
+ const minY = area.y;
237
+ const maxY = area.y + area.height - height;
238
+ const x = state.x === void 0 ? defaultCentered.x : width > area.width ? state.x : clamp(state.x, minX, maxX);
239
+ const y = state.y === void 0 ? defaultCentered.y : height > area.height ? state.y : clamp(state.y, minY, maxY);
240
+ const offsetX = x - area.x;
241
+ const offsetY = y - area.y;
242
+ return {
243
+ ...state,
244
+ width,
245
+ height,
246
+ x,
247
+ y,
248
+ offsetX,
249
+ offsetY,
250
+ workAreaWidth: area.width,
251
+ workAreaHeight: area.height,
252
+ displayId: display.id
253
+ };
254
+ }
255
+ function resolveDisplayForWindow(state) {
256
+ if (state.displayId !== void 0) {
257
+ const found = screen.getAllDisplays().find((d) => d.id === state.displayId);
258
+ if (found) return found;
259
+ }
260
+ return screen.getDisplayMatching({
261
+ x: state.x ?? 0,
262
+ y: state.y ?? 0,
263
+ width: state.width,
264
+ height: state.height
265
+ });
266
+ }
267
+ function applyScreenCenterIfNeeded(state) {
268
+ if (!SCREEN_CENTER) return state;
269
+ if (state.isMaximized) return state;
270
+ const w = state.width;
271
+ const h = state.height;
272
+ if (w <= 0 || h <= 0) return state;
273
+ const display = resolveDisplayForWindow(state);
274
+ const area = display.workArea;
275
+ if (w >= area.width || h >= area.height) return state;
276
+ const centered = getCenteredBoundsInDisplay(w, h, display);
277
+ return {
278
+ ...state,
279
+ x: centered.x,
280
+ y: centered.y,
281
+ offsetX: centered.x - area.x,
282
+ offsetY: centered.y - area.y,
283
+ workAreaWidth: area.width,
284
+ workAreaHeight: area.height,
285
+ displayId: display.id
286
+ };
287
+ }
288
+ function applyRelativePositionToDisplay(state, display) {
289
+ if (state.offsetX === void 0 || state.offsetY === void 0) {
290
+ return state;
291
+ }
292
+ const area = display.workArea;
293
+ return {
294
+ ...state,
295
+ x: area.x + state.offsetX,
296
+ y: area.y + state.offsetY
297
+ };
298
+ }
299
+ function getSafeWindowState() {
300
+ const savedState = readWindowState();
301
+ const saved = savedState.normalized;
302
+ const fallback = {
303
+ ...DEFAULT_WINDOW_SIZE,
304
+ ...saved
305
+ };
306
+ const primaryDisplay = screen.getPrimaryDisplay();
307
+ if (saved?.displayId !== void 0) {
308
+ const savedDisplay = screen.getAllDisplays().find((display) => display.id === saved.displayId);
309
+ if (!savedDisplay) {
310
+ const normalized2 = normalizeStateToDisplay(fallback, primaryDisplay);
311
+ return {
312
+ state: normalized2,
313
+ mode: "primary-fallback",
314
+ reason: "saved-display-missing",
315
+ requestedDisplayId: saved.displayId,
316
+ appliedDisplayId: primaryDisplay.id
317
+ };
318
+ }
319
+ const relativeAdjusted = applyRelativePositionToDisplay(fallback, savedDisplay);
320
+ const restoredRaw = {
321
+ ...relativeAdjusted,
322
+ displayId: savedDisplay.id
323
+ };
324
+ return {
325
+ state: restoredRaw,
326
+ mode: "restored",
327
+ reason: "restored-raw-same-display",
328
+ requestedDisplayId: saved.displayId,
329
+ appliedDisplayId: savedDisplay.id
330
+ };
331
+ }
332
+ if (saved?.x === void 0 || saved.y === void 0) {
333
+ const normalized2 = normalizeStateToDisplay(fallback, primaryDisplay);
334
+ return {
335
+ state: normalized2,
336
+ mode: "restored",
337
+ reason: "saved-position-missing",
338
+ requestedDisplayId: saved?.displayId,
339
+ appliedDisplayId: normalized2.displayId
340
+ };
341
+ }
342
+ const bounds = {
343
+ x: saved.x,
344
+ y: saved.y,
345
+ width: fallback.width,
346
+ height: fallback.height
347
+ };
348
+ if (!isVisibleOnAnyDisplay(bounds)) {
349
+ const normalized2 = normalizeStateToDisplay(fallback, primaryDisplay);
350
+ return {
351
+ state: normalized2,
352
+ mode: "primary-fallback",
353
+ reason: "offscreen-bounds",
354
+ requestedDisplayId: saved.displayId,
355
+ appliedDisplayId: primaryDisplay.id
356
+ };
357
+ }
358
+ const matchedDisplay = screen.getDisplayMatching(bounds);
359
+ const normalized = normalizeStateToDisplay(fallback, matchedDisplay);
360
+ const isChanged = normalized.width !== fallback.width || normalized.height !== fallback.height || normalized.x !== fallback.x || normalized.y !== fallback.y;
361
+ return {
362
+ state: normalized,
363
+ mode: "restored",
364
+ reason: isChanged ? "normalized-to-display" : "normal-restore",
365
+ requestedDisplayId: saved?.displayId,
366
+ appliedDisplayId: normalized.displayId
367
+ };
368
+ }
369
+ function saveWindowState(targetWindow) {
370
+ if (targetWindow.isDestroyed()) return null;
371
+ const isMaximized = targetWindow.isMaximized();
372
+ const bounds = isMaximized ? targetWindow.getNormalBounds() : targetWindow.getBounds();
373
+ const display = screen.getDisplayMatching(bounds);
374
+ const displayId = display.id;
375
+ const area = display.workArea;
376
+ const data = {
377
+ x: bounds.x,
378
+ y: bounds.y,
379
+ offsetX: bounds.x - area.x,
380
+ offsetY: bounds.y - area.y,
381
+ workAreaWidth: area.width,
382
+ workAreaHeight: area.height,
383
+ width: bounds.width,
384
+ height: bounds.height,
385
+ isMaximized,
386
+ displayId
387
+ };
388
+ fs.writeFileSync(getWindowStatePath(), JSON.stringify(data, null, 2), "utf-8");
389
+ return data;
390
+ }
391
+ function createWindow() {
392
+ logger.log("STARTUP", "Phase: Main window create");
393
+ const launchDecision = getSafeWindowState();
394
+ const beforeCenter = launchDecision.state;
395
+ const safeWindowState = applyScreenCenterIfNeeded(beforeCenter);
396
+ if (SCREEN_CENTER && !safeWindowState.isMaximized && (safeWindowState.x !== beforeCenter.x || safeWindowState.y !== beforeCenter.y)) {
397
+ logger.info("WINDOW", "SCREEN_CENTER: centered window on work area", {
398
+ displayId: safeWindowState.displayId,
399
+ before: `${beforeCenter.width}x${beforeCenter.height}@(${beforeCenter.x ?? "?"},${beforeCenter.y ?? "?"})`,
400
+ after: `${safeWindowState.width}x${safeWindowState.height}@(${safeWindowState.x ?? "?"},${safeWindowState.y ?? "?"})`
401
+ });
402
+ }
403
+ const restoredBounds = {
404
+ x: safeWindowState.x ?? 0,
405
+ y: safeWindowState.y ?? 0,
406
+ width: safeWindowState.width,
407
+ height: safeWindowState.height
408
+ };
409
+ win = new BrowserWindow({
410
+ show: false,
411
+ width: safeWindowState.width,
412
+ height: safeWindowState.height,
413
+ x: safeWindowState.x,
414
+ y: safeWindowState.y,
415
+ icon: path.join(process.env.VITE_PUBLIC, "electron-vite.svg"),
416
+ webPreferences: {
417
+ preload: path.join(__dirname$1, "..", "preload", "preload.js")
418
+ }
419
+ });
420
+ win.webContents.on("did-finish-load", () => {
421
+ win?.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
422
+ });
423
+ if (VITE_DEV_SERVER_URL) {
424
+ logger.info("WINDOW", "Loading URL", { url: VITE_DEV_SERVER_URL });
425
+ win.loadURL(VITE_DEV_SERVER_URL);
426
+ win.webContents.openDevTools({ mode: "right" });
427
+ } else {
428
+ logger.info("WINDOW", "Loading file", {
429
+ path: path.join(RENDERER_DIST, "index.html")
430
+ });
431
+ win.loadFile(path.join(RENDERER_DIST, "index.html"));
432
+ }
433
+ win.once("ready-to-show", () => {
434
+ if (safeWindowState.isMaximized) {
435
+ logger.info("WINDOW", "Apply ready-to-show maximize");
436
+ win?.maximize();
437
+ win?.show();
438
+ logger.info("WINDOW", "Window show after maximize");
439
+ return;
440
+ }
441
+ win?.setBounds(restoredBounds, false);
442
+ win?.show();
443
+ logger.info("WINDOW", "Window show after bounds applied");
444
+ });
445
+ win.on("close", () => {
446
+ saveWindowState(win);
447
+ logger.info("SHUTDOWN", "Window state saved");
448
+ });
449
+ }
450
+ app.on("window-all-closed", () => {
451
+ logger.info("STARTUP", "Phase: window-all-closed");
452
+ if (process.platform !== "darwin") {
453
+ logger.log("SHUTDOWN", "Application quitting");
454
+ logger.shutdown();
455
+ app.quit();
456
+ win = null;
457
+ }
458
+ });
459
+ app.on("activate", () => {
460
+ if (BrowserWindow.getAllWindows().length === 0) {
461
+ createWindow();
462
+ }
463
+ });
464
+ app.whenReady().then(() => {
465
+ logger.init();
466
+ if (!ELECTRON_MENU_ENABLED) {
467
+ Menu.setApplicationMenu(null);
468
+ logger.info("SYSTEM", "Application menu disabled (ELECTRON_MENU_ENABLED=false)");
469
+ }
470
+ logger.log("STARTUP", "Phase: IPC handlers register");
471
+ registerIpcHandlers();
472
+ createWindow();
473
+ });
474
+ export {
475
+ MAIN_DIST,
476
+ RENDERER_DIST,
477
+ VITE_DEV_SERVER_URL
478
+ };
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ declare global {
5
+ namespace NodeJS {
6
+ interface ProcessEnv {
7
+ NODE_ENV: 'development' | 'test' | 'production'
8
+ readonly VITE_DEV_SERVER_URL: string
9
+ APP_ROOT: string
10
+ VITE_PUBLIC: string
11
+ LOG_ENABLED?: string
12
+ LOG_FILE?: string
13
+ LOG_CONSOLE?: string
14
+ SCREEN_CENTER?: string
15
+ ELECTRON_MENU_ENABLED?: string
16
+ }
17
+
18
+ interface Process {
19
+ electronApp: import('node:child_process').ChildProcess
20
+ }
21
+ }
22
+
23
+ interface ImportMeta {
24
+ /** shims Vite */
25
+ env: Record<string, unknown>
26
+ }
27
+
28
+ interface ElectronLoggerConfig {
29
+ enabled: boolean
30
+ file: boolean
31
+ console: boolean
32
+ }
33
+
34
+ /** Used in Renderer process, expose in `preload.ts` */
35
+ interface Window {
36
+ ipcRenderer: import('electron').IpcRenderer
37
+ }
38
+ }
39
+
40
+ export function parseDotEnv(content: string): Record<string, string> {
41
+ const out: Record<string, string> = {}
42
+ for (const line of content.split(/\r?\n/)) {
43
+ const trimmed = line.trim()
44
+ if (!trimmed || trimmed.startsWith('#')) continue
45
+ const eq = trimmed.indexOf('=')
46
+ if (eq === -1) continue
47
+ const key = trimmed.slice(0, eq).trim()
48
+ let value = trimmed.slice(eq + 1).trim()
49
+ if (
50
+ (value.startsWith('"') && value.endsWith('"')) ||
51
+ (value.startsWith("'") && value.endsWith("'"))
52
+ ) {
53
+ value = value.slice(1, -1)
54
+ }
55
+ out[key] = value
56
+ }
57
+ return out
58
+ }
59
+
60
+ export function readDotEnvFile(appRoot: string): Record<string, string> {
61
+ const envPath = path.join(appRoot, '.env')
62
+ if (!fs.existsSync(envPath)) return {}
63
+ try {
64
+ return parseDotEnv(fs.readFileSync(envPath, 'utf-8'))
65
+ } catch {
66
+ return {}
67
+ }
68
+ }
69
+
70
+ export function parseEnvBoolean(value: string | undefined, defaultValue: boolean): boolean {
71
+ if (value === undefined || value === '') return defaultValue
72
+ const v = value.trim().toLowerCase()
73
+ if (v === '1' || v === 'true' || v === 'yes') return true
74
+ if (v === '0' || v === 'false' || v === 'no') return false
75
+ return defaultValue
76
+ }
77
+
78
+ export function pickString(...vals: (string | undefined)[]): string | undefined {
79
+ for (const v of vals) {
80
+ if (v !== undefined && v !== '') return v
81
+ }
82
+ return undefined
83
+ }
@@ -0,0 +1,29 @@
1
+ import { ipcMain, shell } from 'electron'
2
+
3
+ /** IPC channel names shared by renderer and preload (single source of truth). */
4
+ export const IPC_CHANNELS = {
5
+ /** `invoke(url: string)` — allows only `http:` / `https:`, then calls `shell.openExternal`. */
6
+ OPEN_EXTERNAL: 'app:open-external',
7
+ } as const
8
+
9
+ function isAllowedExternalUrl(url: string): boolean {
10
+ try {
11
+ const u = new URL(url)
12
+ return u.protocol === 'http:' || u.protocol === 'https:'
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
18
+ /** Registers IPC handlers in the main process (call once at startup). */
19
+ export function registerIpcHandlers(): void {
20
+ ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event, raw: unknown) => {
21
+ if (typeof raw !== 'string' || raw.length === 0) {
22
+ throw new Error('open-external: URL must be a non-empty string')
23
+ }
24
+ if (!isAllowedExternalUrl(raw)) {
25
+ throw new Error('open-external: only http(s) URLs are allowed')
26
+ }
27
+ await shell.openExternal(raw)
28
+ })
29
+ }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { parseEnvBoolean, pickString, readDotEnvFile } from './dotenv'
4
+
5
+ type LogLevel = 'SYSTEM' | 'LOGGER' | 'STARTUP' | 'WINDOW' | 'LOADING' | 'SHUTDOWN' | 'INFO' | 'WARN' | 'ERROR'
6
+
7
+ type LoggerOptions = {
8
+ appRoot: string
9
+ isDevelopment: boolean
10
+ isPackaged: boolean
11
+ sessionId: string
12
+ }
13
+
14
+ type LogMeta = Record<string, unknown>
15
+
16
+ function pad2(value: number) {
17
+ return String(value).padStart(2, '0')
18
+ }
19
+
20
+ function pad3(value: number) {
21
+ return String(value).padStart(3, '0')
22
+ }
23
+
24
+ function toDateStamp(date: Date) {
25
+ return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`
26
+ }
27
+
28
+ function toTimeStamp(date: Date) {
29
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`
30
+ }
31
+
32
+ function resolveLoggerConfig(isDevelopment: boolean, isPackaged: boolean, appRoot: string): ElectronLoggerConfig {
33
+ const dotEnv = readDotEnvFile(appRoot)
34
+
35
+ const enabledRaw = pickString(process.env.LOG_ENABLED, dotEnv.LOG_ENABLED)
36
+ const enabled = parseEnvBoolean(enabledRaw, true)
37
+ if (!enabled) {
38
+ return { enabled: false, file: false, console: false }
39
+ }
40
+
41
+ const fileRaw = pickString(process.env.LOG_FILE, dotEnv.LOG_FILE)
42
+ const consoleRaw = pickString(process.env.LOG_CONSOLE, dotEnv.LOG_CONSOLE)
43
+
44
+ return {
45
+ enabled: true,
46
+ file: parseEnvBoolean(fileRaw, !isPackaged),
47
+ console: parseEnvBoolean(consoleRaw, isDevelopment),
48
+ }
49
+ }
50
+
51
+ class AppLogger {
52
+ private readonly config: ElectronLoggerConfig
53
+ private readonly isDevelopment: boolean
54
+ private readonly logsDir: string
55
+ private readonly logFile: string
56
+ private readonly sessionId: string
57
+
58
+ constructor(options: LoggerOptions) {
59
+ this.isDevelopment = options.isDevelopment
60
+ this.sessionId = options.sessionId
61
+ this.config = resolveLoggerConfig(options.isDevelopment, options.isPackaged, options.appRoot)
62
+ this.logsDir = path.join(options.appRoot, 'logs')
63
+ this.logFile = path.join(this.logsDir, `system.${toDateStamp(new Date())}.log`)
64
+ }
65
+
66
+ init() {
67
+ if (!this.config.enabled) return
68
+ if (!this.config.file && !this.config.console) return
69
+
70
+ if (this.config.file) {
71
+ fs.mkdirSync(this.logsDir, { recursive: true })
72
+ }
73
+ this.writeLine('SYSTEM', '='.repeat(112))
74
+ this.writeLine('SYSTEM', `Application Started | ${JSON.stringify({ devMode: this.isDevelopment, sessionId: this.sessionId })}`)
75
+ this.writeLine('SYSTEM', '='.repeat(112))
76
+ this.writeLine('LOGGER', 'Logger ready')
77
+ }
78
+
79
+ info(level: LogLevel, message: string, meta: LogMeta = {}) {
80
+ const metaJson = JSON.stringify(meta)
81
+ if (!metaJson || metaJson === '{}') {
82
+ this.writeLine(level, message)
83
+ } else {
84
+ this.writeLine(level, `${message} | ${metaJson}`)
85
+ }
86
+ }
87
+
88
+ log(level: LogLevel, message: string) {
89
+ this.writeLine(level, message)
90
+ }
91
+
92
+ shutdown() {
93
+ if (!this.config.enabled) return
94
+ if (!this.config.file && !this.config.console) return
95
+
96
+ this.writeLine('INFO', 'Logger shutting down')
97
+ this.writeLine('SYSTEM', '='.repeat(112))
98
+ this.writeLine('SYSTEM', `Application Ended | ${JSON.stringify({ sessionId: this.sessionId })}`)
99
+ this.writeLine('SYSTEM', '='.repeat(112) + '\n')
100
+ }
101
+
102
+ private writeLine(level: LogLevel, body: string) {
103
+ if (!this.config.enabled) return
104
+ if (!this.config.file && !this.config.console) return
105
+
106
+ const line = `[${toTimeStamp(new Date())}] [${level}] ${body}`
107
+ if (this.config.file) {
108
+ fs.appendFileSync(this.logFile, `${line}\n`, 'utf-8')
109
+ }
110
+ if (this.config.console) {
111
+ console.log(line)
112
+ }
113
+ }
114
+ }
115
+
116
+ export { AppLogger, resolveLoggerConfig }
117
+ export type { LogLevel, LogMeta }