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.
- package/.editorconfig +16 -0
- package/.env.example +23 -0
- package/.eslintrc.cjs +26 -0
- package/.gitignore +39 -0
- package/.nvmrc +1 -0
- package/.prettierignore +14 -0
- package/.prettierrc +10 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/bin/create-electron-vite-react-ts.js +129 -0
- package/dist/main/main.js +478 -0
- package/electron/dotenv.ts +83 -0
- package/electron/ipc.ts +29 -0
- package/electron/logger.ts +117 -0
- package/electron/main.ts +422 -0
- package/electron/preload.ts +28 -0
- package/electron.vite.config.ts +46 -0
- package/package.json +68 -0
- package/public/icons/vite.svg +1 -0
- package/public/logo/electron-vite.animate.svg +34 -0
- package/public/logo/electron-vite.svg +26 -0
- package/public/logo/react.svg +1 -0
- package/src/renderer/App.tsx +40 -0
- package/src/renderer/index.html +13 -0
- package/src/renderer/main.tsx +15 -0
- package/src/styles/App.css +42 -0
- package/src/styles/index.css +68 -0
- package/src/types/renderer.d.ts +12 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.electron.json +17 -0
- package/tsconfig.json +4 -0
|
@@ -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
|
+
}
|
package/electron/ipc.ts
ADDED
|
@@ -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 }
|