@pipelab/asset-electron 1.0.0-beta.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.
Files changed (64) hide show
  1. package/LICENSE +110 -0
  2. package/package.json +18 -0
  3. package/template/config.cjs +45 -0
  4. package/template/forge.config.cjs +70 -0
  5. package/template/package.json +62 -0
  6. package/template/pipelab-plugin.cjs +160 -0
  7. package/template/src/custom-main.js +1 -0
  8. package/template/src/handlers/dialog/folder.js +25 -0
  9. package/template/src/handlers/dialog/open.js +25 -0
  10. package/template/src/handlers/dialog/save.js +24 -0
  11. package/template/src/handlers/discord/set-activity.js +68 -0
  12. package/template/src/handlers/fs/copy.js +29 -0
  13. package/template/src/handlers/fs/delete.js +23 -0
  14. package/template/src/handlers/fs/exist.js +37 -0
  15. package/template/src/handlers/fs/file-size.js +24 -0
  16. package/template/src/handlers/fs/folder-create.js +25 -0
  17. package/template/src/handlers/fs/list.js +61 -0
  18. package/template/src/handlers/fs/move.js +30 -0
  19. package/template/src/handlers/fs/read-binary.js +24 -0
  20. package/template/src/handlers/fs/read.js +42 -0
  21. package/template/src/handlers/fs/write-base64.js +32 -0
  22. package/template/src/handlers/fs/write.js +30 -0
  23. package/template/src/handlers/general/engine.js +17 -0
  24. package/template/src/handlers/general/exit.js +20 -0
  25. package/template/src/handlers/general/infos.js +23 -0
  26. package/template/src/handlers/general/open-in-explorer.js +21 -0
  27. package/template/src/handlers/general/open.js +21 -0
  28. package/template/src/handlers/general/run.js +27 -0
  29. package/template/src/handlers/steam/deleteItem.js +26 -0
  30. package/template/src/handlers/steam/download.js +26 -0
  31. package/template/src/handlers/steam/downloadInfo.js +26 -0
  32. package/template/src/handlers/steam/downloadScore.js +28 -0
  33. package/template/src/handlers/steam/getItem.js +28 -0
  34. package/template/src/handlers/steam/getItems.js +28 -0
  35. package/template/src/handlers/steam/installInfo.js +26 -0
  36. package/template/src/handlers/steam/raw.js +42 -0
  37. package/template/src/handlers/steam/saveScreenshot.js +66 -0
  38. package/template/src/handlers/steam/state.js +26 -0
  39. package/template/src/handlers/steam/subscribe.js +26 -0
  40. package/template/src/handlers/steam/unsubscribe.js +26 -0
  41. package/template/src/handlers/steam/updateItem.js +26 -0
  42. package/template/src/handlers/steam/uploadScore.js +27 -0
  43. package/template/src/handlers/steam/utils.js +78 -0
  44. package/template/src/handlers/user/folder.js +71 -0
  45. package/template/src/handlers/window/maximize.js +20 -0
  46. package/template/src/handlers/window/minimize.js +20 -0
  47. package/template/src/handlers/window/request-attention.js +20 -0
  48. package/template/src/handlers/window/restore.js +20 -0
  49. package/template/src/handlers/window/set-always-on-top.js +20 -0
  50. package/template/src/handlers/window/set-fullscreen.js +26 -0
  51. package/template/src/handlers/window/set-height.js +21 -0
  52. package/template/src/handlers/window/set-ignore-mouse-events.js +36 -0
  53. package/template/src/handlers/window/set-maximum-size.js +20 -0
  54. package/template/src/handlers/window/set-minimum-size.js +20 -0
  55. package/template/src/handlers/window/set-resizable.js +20 -0
  56. package/template/src/handlers/window/set-title.js +20 -0
  57. package/template/src/handlers/window/set-width.js +21 -0
  58. package/template/src/handlers/window/set-x.js +21 -0
  59. package/template/src/handlers/window/set-y.js +21 -0
  60. package/template/src/handlers/window/show-dev-tools.js +24 -0
  61. package/template/src/handlers/window/unmaximize.js +20 -0
  62. package/template/src/index.js +958 -0
  63. package/template/src/preload.js +27 -0
  64. package/template/src/utils.js +18 -0
@@ -0,0 +1,958 @@
1
+ // @ts-check
2
+
3
+ import { app, BrowserWindow, ipcMain, safeStorage, session, shell } from "electron";
4
+ import { dirname, join } from "node:path";
5
+ import path from "node:path";
6
+ import { createReadStream } from "node:fs";
7
+ import { stat } from "node:fs/promises";
8
+ import mime from "mime-types";
9
+ import serve from "serve-handler";
10
+ import { createServer } from "http";
11
+ import { WebSocketServer, WebSocket } from "ws";
12
+ import "./custom-main.js";
13
+ import mri from "mri";
14
+ import config from "../config.cjs";
15
+ import steamworks from "@pipelab/steamworks.js";
16
+ import DiscordRPC from "discord-rpc";
17
+
18
+ // user
19
+ import userFolder from "./handlers/user/folder.js";
20
+
21
+ // fs
22
+ import fsWrite from "./handlers/fs/write.js";
23
+ import fsWriteBase64 from "./handlers/fs/write-base64.js";
24
+ import fsRead from "./handlers/fs/read.js";
25
+ import fsReadBinary from "./handlers/fs/read-binary.js";
26
+ import fsFolderCreate from "./handlers/fs/folder-create.js";
27
+ import fsList from "./handlers/fs/list.js";
28
+ import fsMove from "./handlers/fs/move.js";
29
+ import fsDelete from "./handlers/fs/delete.js";
30
+ import fsCopy from "./handlers/fs/copy.js";
31
+ import fsFileSize from "./handlers/fs/file-size.js";
32
+ import fsExist from "./handlers/fs/exist.js";
33
+
34
+ // window
35
+ import windowMaximize from "./handlers/window/maximize.js";
36
+ import windowMinimize from "./handlers/window/minimize.js";
37
+ import windowRequestAttention from "./handlers/window/request-attention.js";
38
+ import windowRestore from "./handlers/window/restore.js";
39
+ import windowSetAlwaysOnTop from "./handlers/window/set-always-on-top.js";
40
+ import windowSetHeight from "./handlers/window/set-height.js";
41
+ import windowSetWidth from "./handlers/window/set-width.js";
42
+ import windowSetMinimumSize from "./handlers/window/set-minimum-size.js";
43
+ import windowSetMaximumSize from "./handlers/window/set-maximum-size.js";
44
+ import windowSetResizable from "./handlers/window/set-resizable.js";
45
+ import windowSetTitle from "./handlers/window/set-title.js";
46
+ import windowSetX from "./handlers/window/set-x.js";
47
+ import windowSetY from "./handlers/window/set-y.js";
48
+ import windowShowDevTools from "./handlers/window/show-dev-tools.js";
49
+ import windowUnmaximize from "./handlers/window/unmaximize.js";
50
+ import windowSetFullscreen from "./handlers/window/set-fullscreen.js";
51
+ import windowSetIgnoreMouseEvents from "./handlers/window/set-ignore-mouse-events.js";
52
+
53
+ // dialog
54
+ import dialogFolder from "./handlers/dialog/folder.js";
55
+ import dialogOpen from "./handlers/dialog/open.js";
56
+ import dialogSave from "./handlers/dialog/save.js";
57
+
58
+ // general
59
+ import engine from "./handlers/general/engine.js";
60
+ import run from "./handlers/general/run.js";
61
+ import open from "./handlers/general/open.js";
62
+ import showInExplorer from "./handlers/general/open-in-explorer.js";
63
+ import exit from "./handlers/general/exit.js";
64
+
65
+ // steam raw
66
+ import steamRaw from "./handlers/steam/raw.js";
67
+ import steamUploadScore from "./handlers/steam/uploadScore.js";
68
+ import steamDownloadScore from "./handlers/steam/downloadScore.js";
69
+ import steamUpdateItem from "./handlers/steam/updateItem.js";
70
+ import steamGetItem from "./handlers/steam/getItem.js";
71
+ import steamGetItems from "./handlers/steam/getItems.js";
72
+ import steamSubscribe from "./handlers/steam/subscribe.js";
73
+ import steamUnsubscribe from "./handlers/steam/unsubscribe.js";
74
+ import steamState from "./handlers/steam/state.js";
75
+ import steamInstallInfo from "./handlers/steam/installInfo.js";
76
+ import steamDownloadInfo from "./handlers/steam/downloadInfo.js";
77
+ import steamDownload from "./handlers/steam/download.js";
78
+ import steamDeleteItem from "./handlers/steam/deleteItem.js";
79
+ import steamSaveScreenshot from "./handlers/steam/saveScreenshot.js";
80
+
81
+ // discord set activity
82
+ import discordSetActivity from "./handlers/discord/set-activity.js";
83
+
84
+ import { getAppName } from "./utils.js";
85
+
86
+ import infos from "./handlers/general/infos.js";
87
+
88
+ import semver from "semver";
89
+ import { protocol } from "electron/main";
90
+
91
+ /**
92
+ * Assert switch is exhaustive
93
+ * @param {never} _x
94
+ * @returns {never}
95
+ */
96
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
97
+ function assertUnreachable(_x) {
98
+ throw new Error("Didn't expect to get here");
99
+ }
100
+
101
+ safeStorage.setUsePlainTextEncryption(true);
102
+
103
+ protocol.registerSchemesAsPrivileged([
104
+ {
105
+ scheme: "app",
106
+ privileges: {
107
+ standard: true, // Permet de résoudre les origines correctement (corrige l'erreur 'null')
108
+ secure: true, // Traité comme une origine sécurisée (nécessaire pour Service Workers/Web Workers)
109
+ supportFetchAPI: true, // Active l'API Fetch pour ce protocole
110
+ corsEnabled: true, // Autorise les requêtes CORS si nécessaire
111
+ allowServiceWorkers: true, // Souvent requis par Construct 3
112
+ },
113
+ },
114
+ ]);
115
+
116
+ function createErrorWindow(errorMessage) {
117
+ const errorWindow = new BrowserWindow({
118
+ width: 600,
119
+ height: 250,
120
+ title: "Game Startup Error",
121
+ center: true,
122
+ resizable: false,
123
+ webPreferences: {
124
+ preload: join(metaDirname, "preload.js"),
125
+ },
126
+ });
127
+
128
+ const htmlContent = `
129
+ <!DOCTYPE html>
130
+ <html>
131
+ <head>
132
+ <title>Error</title>
133
+ <style>
134
+ /* Basic reset and modern styling */
135
+ html, body {
136
+ margin: 0;
137
+ padding: 0;
138
+ height: 100%;
139
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
140
+ background-color: #2c2f33; /* Dark background */
141
+ color: #ffffff; /* White text */
142
+ overflow: hidden;
143
+ }
144
+ /* Flexbox container to center content */
145
+ body {
146
+ display: flex;
147
+ flex-direction: column;
148
+ justify-content: center; /* Center horizontally */
149
+ align-items: center; /* Center vertically */
150
+ text-align: center;
151
+ padding: 1rem;
152
+ gap: 1.5rem;
153
+ }
154
+ h1 {
155
+ font-weight: 400; /* Lighter font weight */
156
+ font-size: 1.2rem;
157
+ line-height: 1.5;
158
+ margin: 0;
159
+ }
160
+ .button-container {
161
+ margin-top: 0.5rem;
162
+ }
163
+ button {
164
+ background-color: #404448; /* Slightly lighter dark background */
165
+ color: #ffffff;
166
+ border: 1px solid #55585c;
167
+ border-radius: 6px;
168
+ padding: 0.75rem 1.5rem;
169
+ font-family: inherit;
170
+ font-size: 0.9rem;
171
+ font-weight: 500;
172
+ cursor: pointer;
173
+ transition: background-color 0.2s ease, border-color 0.2s ease;
174
+ }
175
+ button:hover {
176
+ background-color: #4a4e52;
177
+ border-color: #60646a;
178
+ }
179
+ button:active {
180
+ background-color: #363a3e;
181
+ transform: translateY(1px);
182
+ }
183
+ button:focus {
184
+ outline: none;
185
+ box-shadow: 0 0 0 2px rgba(85, 88, 92, 0.5);
186
+ }
187
+ </style>
188
+ </head>
189
+ <body>
190
+ <h1>${errorMessage}</h1>
191
+ <div class="button-container">
192
+ <button onclick="exitApplication()">Close</button>
193
+ </div>
194
+ <script>
195
+ function exitApplication() {
196
+ window.electronAPI.exit()
197
+ }
198
+ </script>
199
+ </body>
200
+ </html>
201
+ `;
202
+
203
+ // Encode the HTML and load it as a data URL
204
+ const encodedHtml = encodeURIComponent(htmlContent);
205
+ errorWindow.loadURL(`data:text/html,${encodedHtml}`);
206
+ return errorWindow;
207
+ }
208
+
209
+ process.on("uncaughtException", async (error) => {
210
+ console.error("Uncaught Exception:", error, error.code);
211
+
212
+ if (error.code === "EADDRINUSE") {
213
+ const errorWindow = await createErrorWindow(
214
+ "Unable to start the game.<br>Is another instance already running?",
215
+ );
216
+ errorWindow.show();
217
+ errorWindow.on("closed", () => {
218
+ app.quit();
219
+ });
220
+ }
221
+ });
222
+ process.on("unhandledRejection", (reason) => {
223
+ console.error("Unhandled Promise Rejection:", reason);
224
+ });
225
+
226
+ console.log("process.argv", process.argv);
227
+ const argv = process.argv;
228
+
229
+ /**
230
+ * @typedef {Object} Args
231
+ *
232
+ * @property {string} url
233
+ * @property {boolean} no-window
234
+ */
235
+
236
+ /** @type {mri.Argv<Args>} */
237
+ const cliArgs = mri(argv, {
238
+ alias: {
239
+ u: "url",
240
+ },
241
+ });
242
+
243
+ /** @type {undefined | Object} */
244
+ let rpc;
245
+ if (config.enableDiscordSupport) {
246
+ DiscordRPC.register(config.discordAppId);
247
+ rpc = new DiscordRPC.Client({ transport: "ipc" });
248
+ console.log("rpc", rpc);
249
+ }
250
+
251
+ //region commandLine Flags
252
+ if (config.enableInProcessGPU) {
253
+ app.commandLine.appendSwitch("in-process-gpu");
254
+ }
255
+ if (config.enableDisableRendererBackgrounding) {
256
+ app.commandLine.appendSwitch("disable-renderer-backgrounding");
257
+ }
258
+ if (config.forceHighPerformanceGpu) {
259
+ app.commandLine.appendSwitch("force-high-performance-gpu");
260
+ }
261
+
262
+ // Fix steam remote play together
263
+ app.commandLine.appendSwitch("disable-features", "AudioServiceOutOfProcess");
264
+
265
+ app.commandLine.appendSwitch("force-high-performance-gpu");
266
+ app.commandLine.appendSwitch("disable-gpu-sandbox");
267
+ //endregion
268
+
269
+ const hasElectronVersion = config.electronVersion !== undefined && config.electronVersion !== "";
270
+ const isCJSOnly =
271
+ hasElectronVersion && semver.lt(semver.coerce(config.electronVersion) || "0.0.0", "28.0.0");
272
+
273
+ //region Steam
274
+
275
+ /** @type {Omit<import('@pipelab/steamworks.js').Client, "init" | "runCallbacks">} */
276
+ let client;
277
+ console.log("config.enableSteamSupport", config.enableSteamSupport);
278
+ if (config.enableSteamSupport) {
279
+ app.commandLine.appendSwitch("in-process-gpu");
280
+ //app.commandLine.appendSwitch('disable-direct-composition')
281
+ app.commandLine.appendSwitch("no-sandbox");
282
+
283
+ // const isNecessary = steamworks.restartAppIfNecessary(config.steamGameId)
284
+ // console.log('isNecessary', isNecessary)
285
+
286
+ // if (isNecessary) {
287
+
288
+ // }
289
+
290
+ try {
291
+ client = steamworks.init(config.steamGameId);
292
+ console.log(client.localplayer.getName());
293
+ client.callback.register(client.callback.SteamCallback.GameOverlayActivated, (data) => {
294
+ /**
295
+ * @type {import('@pipelab/core').MakeInputOutput<import('@pipelab/core').FullscreenState, 'input'>}
296
+ */
297
+ const order = {
298
+ url: "/steam/overlay-activated",
299
+ body: data,
300
+ };
301
+ broadcastMessage(JSON.stringify(order));
302
+ });
303
+ } catch (e) {
304
+ console.error("e", e);
305
+ }
306
+ }
307
+ //endregion
308
+
309
+ const userData = app.getPath("userData");
310
+ const userDataDirname = dirname(userData);
311
+ const appNameFolder = getAppName(config);
312
+ const sessionDataPath = join(userDataDirname, `cache_${appNameFolder}`);
313
+ console.log("sessionDataPath", sessionDataPath);
314
+ app.setPath("userData", sessionDataPath);
315
+
316
+ /**
317
+ * @type {Set<import('ws').WebSocket>}
318
+ */
319
+ const clients = new Set();
320
+
321
+ /**
322
+ * @type {import('http').Server | null}
323
+ */
324
+ let httpServer = null;
325
+
326
+ /**
327
+ * @type {import('ws').WebSocketServer | null}
328
+ */
329
+ let wss = null;
330
+
331
+ /**
332
+ * @type {boolean}
333
+ */
334
+ let isQuitting = false;
335
+
336
+ /**
337
+ * @type {Promise<void> | null}
338
+ */
339
+ let cleanupPromise = null;
340
+
341
+ /**
342
+ * @param {string} message
343
+ */
344
+ const broadcastMessage = (message) => {
345
+ for (const client of clients) {
346
+ if (client.readyState === WebSocket.OPEN) {
347
+ client.send(message);
348
+ }
349
+ }
350
+ };
351
+
352
+ const metaDirname = isCJSOnly ? __dirname : import.meta.dirname;
353
+ const dir = app.isPackaged ? join(metaDirname, "./app") : "./src/app";
354
+
355
+ /**
356
+ * @param {BrowserWindow} mainWindow
357
+ * @returns {Promise<number>}
358
+ */
359
+ const createAppServer = (mainWindow, serveStatic = true) => {
360
+ // eslint-disable-next-line no-async-promise-executor
361
+ return new Promise(async (resolve, reject) => {
362
+ httpServer = createServer();
363
+ const server = httpServer;
364
+
365
+ if (serveStatic) {
366
+ server.on("request", (req, res) => {
367
+ return serve(req, res, {
368
+ headers: [
369
+ {
370
+ source: "**/*",
371
+ headers: [
372
+ {
373
+ key: "Cache-Control",
374
+ value: "no-cache",
375
+ },
376
+ {
377
+ key: "Access-Control-Allow-Origin",
378
+ value: "*",
379
+ },
380
+ ],
381
+ },
382
+ ],
383
+ public: dir,
384
+ });
385
+ });
386
+ } else {
387
+ server.on("request", (req, res) => {
388
+ res.setHeader("Access-Control-Allow-Origin", "*"); // or 'https://preview.construct.net'
389
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
390
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
391
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
392
+ if (req.method === "OPTIONS") {
393
+ res.writeHead(204);
394
+ res.end();
395
+ return;
396
+ }
397
+ res.writeHead(200, { "Content-Type": "text/plain" });
398
+ res.end("Pipelab Electron App Server\n");
399
+ });
400
+ }
401
+
402
+ try {
403
+ wss = new WebSocketServer({ server });
404
+ wss.on("connection", function connection(ws) {
405
+ clients.add(ws);
406
+
407
+ ws.on("error", console.error);
408
+
409
+ ws.on("message", async (data) => {
410
+ //region Message handlers
411
+ /** @type {import('@pipelab/core').Message} */
412
+ const json = JSON.parse(data.toString());
413
+ console.log("received:", json);
414
+
415
+ try {
416
+ switch (json.url) {
417
+ case "/paths":
418
+ await userFolder(json, ws, config);
419
+ break;
420
+
421
+ case "/fs/file/write":
422
+ await fsWrite(json, ws);
423
+ break;
424
+
425
+ case "/fs/file/write-base64":
426
+ await fsWriteBase64(json, ws);
427
+ break;
428
+
429
+ case "/fs/file/read":
430
+ await fsRead(json, ws);
431
+ break;
432
+
433
+ case "/fs/file/read/binary":
434
+ await fsReadBinary(json, ws);
435
+ break;
436
+
437
+ case "/fs/folder/create":
438
+ await fsFolderCreate(json, ws);
439
+ break;
440
+
441
+ case "/window/maximize":
442
+ await windowMaximize(json, ws, mainWindow);
443
+ break;
444
+
445
+ case "/window/minimize":
446
+ await windowMinimize(json, ws, mainWindow);
447
+ break;
448
+ case "/window/request-attention":
449
+ await windowRequestAttention(json, ws, mainWindow);
450
+ break;
451
+ case "/window/restore":
452
+ await windowRestore(json, ws, mainWindow);
453
+ break;
454
+ case "/dialog/folder":
455
+ await dialogFolder(json, ws);
456
+ break;
457
+ case "/dialog/open":
458
+ await dialogOpen(json, ws);
459
+ break;
460
+ case "/dialog/save":
461
+ await dialogSave(json, ws);
462
+ break;
463
+ case "/window/set-always-on-top":
464
+ await windowSetAlwaysOnTop(json, ws, mainWindow);
465
+ break;
466
+ case "/window/set-height":
467
+ await windowSetHeight(json, ws, mainWindow);
468
+ break;
469
+ case "/window/set-maximum-size":
470
+ await windowSetMaximumSize(json, ws, mainWindow);
471
+ break;
472
+ case "/window/set-minimum-size":
473
+ await windowSetMinimumSize(json, ws, mainWindow);
474
+ break;
475
+ case "/window/set-resizable":
476
+ await windowSetResizable(json, ws, mainWindow);
477
+ break;
478
+ case "/window/set-title":
479
+ await windowSetTitle(json, ws, mainWindow);
480
+ break;
481
+ case "/window/set-width":
482
+ await windowSetWidth(json, ws, mainWindow);
483
+ break;
484
+ case "/window/set-x":
485
+ await windowSetX(json, ws, mainWindow);
486
+ break;
487
+ case "/window/set-y":
488
+ await windowSetY(json, ws, mainWindow);
489
+ break;
490
+ case "/window/show-dev-tools":
491
+ await windowShowDevTools(json, ws, mainWindow);
492
+ break;
493
+ case "/window/unmaximize":
494
+ await windowUnmaximize(json, ws, mainWindow);
495
+ break;
496
+ case "/window/set-fullscreen":
497
+ await windowSetFullscreen(json, ws, mainWindow);
498
+ break;
499
+ case "/window/set-ignore-mouse-events":
500
+ await windowSetIgnoreMouseEvents(json, ws, mainWindow);
501
+ break;
502
+ case "/engine":
503
+ await engine(json, ws);
504
+ break;
505
+ case "/open":
506
+ await open(json, ws);
507
+ break;
508
+ case "/show-in-explorer":
509
+ await showInExplorer(json, ws);
510
+ break;
511
+ case "/run":
512
+ await run(json, ws);
513
+ break;
514
+ case "/fs/copy":
515
+ await fsCopy(json, ws);
516
+ break;
517
+ case "/fs/delete":
518
+ await fsDelete(json, ws);
519
+ break;
520
+ case "/fs/exist":
521
+ await fsExist(json, ws);
522
+ break;
523
+ case "/fs/list":
524
+ await fsList(json, ws);
525
+ break;
526
+ case "/fs/file/size":
527
+ await fsFileSize(json, ws);
528
+ break;
529
+ case "/fs/move":
530
+ await fsMove(json, ws);
531
+ break;
532
+ case "/steam/raw":
533
+ await steamRaw(json, ws, client);
534
+ break;
535
+ case "/steam/leaderboard/upload-score":
536
+ await steamUploadScore(json, ws, client);
537
+ break;
538
+ case "/steam/leaderboard/download-score":
539
+ await steamDownloadScore(json, ws, client);
540
+ break;
541
+ case "/steam/workshop/update-item":
542
+ await steamUpdateItem(json, ws, client);
543
+ break;
544
+ case "/steam/workshop/get-item":
545
+ await steamGetItem(json, ws, client);
546
+ break;
547
+ case "/steam/workshop/get-items":
548
+ await steamGetItems(json, ws, client);
549
+ break;
550
+ case "/steam/workshop/subscribe":
551
+ await steamSubscribe(json, ws, client);
552
+ break;
553
+ case "/steam/workshop/unsubscribe":
554
+ await steamUnsubscribe(json, ws, client);
555
+ break;
556
+ case "/steam/workshop/state":
557
+ await steamState(json, ws, client);
558
+ break;
559
+ case "/steam/workshop/install-info":
560
+ await steamInstallInfo(json, ws, client);
561
+ break;
562
+ case "/steam/workshop/download-info":
563
+ await steamDownloadInfo(json, ws, client);
564
+ break;
565
+ case "/steam/workshop/download":
566
+ await steamDownload(json, ws, client);
567
+ break;
568
+ case "/steam/workshop/delete-item":
569
+ await steamDeleteItem(json, ws, client);
570
+ break;
571
+ case "/steam/screenshots/save":
572
+ await steamSaveScreenshot(json, ws, client);
573
+ break;
574
+ case "/discord/set-activity":
575
+ await discordSetActivity(json, ws, mainWindow, rpc);
576
+ break;
577
+ case "/exit":
578
+ await exit(json, ws);
579
+ break;
580
+ case "/window/fullscreen-state":
581
+ // sent the other way around
582
+ break;
583
+ case "/infos":
584
+ await infos(json, ws);
585
+ break;
586
+
587
+ default:
588
+ console.log("unsupported", data);
589
+ assertUnreachable(json);
590
+ break;
591
+ }
592
+ } catch (e) {
593
+ console.error("e", e);
594
+ ws.send(
595
+ JSON.stringify({
596
+ url: json.url,
597
+ correlationId: json.correlationId,
598
+ body: {
599
+ success: false,
600
+ error: e.message,
601
+ },
602
+ }),
603
+ );
604
+ }
605
+ });
606
+
607
+ ws.on("close", () => {
608
+ clients.delete(ws);
609
+ });
610
+ });
611
+ } catch (e) {
612
+ console.error("Unable to create websocket server", e);
613
+ return reject(e);
614
+ }
615
+
616
+ server.listen(31753, "127.0.0.1", () => {
617
+ const adress = server.address();
618
+ if (adress && typeof adress !== "string") {
619
+ return resolve(adress.port);
620
+ }
621
+ return reject("Unable to bind server: adress is not an object");
622
+ });
623
+ });
624
+ };
625
+
626
+ const createWindow = async () => {
627
+ const argUrl = cliArgs.url;
628
+
629
+ // If there is a window
630
+ const mainWindow = new BrowserWindow({
631
+ width: config.width,
632
+ height: config.height,
633
+ fullscreen: config.fullscreen,
634
+ fullscreenable: true,
635
+ frame: config.frame,
636
+ transparent: config.transparent,
637
+ alwaysOnTop: config.alwaysOnTop,
638
+ backgroundColor: config.backgroundColor,
639
+ icon: config.icon,
640
+ webPreferences: {
641
+ preload: join(metaDirname, "preload.js"),
642
+ },
643
+ show: true,
644
+ });
645
+
646
+ if (!mainWindow) {
647
+ throw new Error("Unable to create window");
648
+ }
649
+
650
+ if (!config.toolbar) {
651
+ mainWindow.setMenu(null);
652
+ }
653
+
654
+ if (config.openDevtoolsOnStart) {
655
+ mainWindow.webContents.openDevTools();
656
+ }
657
+
658
+ if (config.clearServiceWorkerOnBoot) {
659
+ // It's better to export apps without service worker support
660
+ // only use when needed
661
+ try {
662
+ // Clear service workers to prevent old versions of the app
663
+ await session.defaultSession.clearStorageData({
664
+ storages: ["serviceworkers"],
665
+ });
666
+ } catch (e) {
667
+ console.error("Error clearing service workers", e);
668
+ }
669
+ }
670
+
671
+ // preview
672
+ if (argUrl) {
673
+ console.log("argUrl", argUrl);
674
+ await createAppServer(mainWindow, false);
675
+
676
+ await mainWindow?.loadURL(argUrl);
677
+ console.log("URL loaded");
678
+ }
679
+ // prod
680
+ else {
681
+ const isCustomProtocol = config.serverMode === "customProtocol";
682
+ const port = await createAppServer(mainWindow, isCustomProtocol === false);
683
+ if (isCustomProtocol) {
684
+ await mainWindow?.loadURL("app://game/index.html");
685
+ console.log("URL loaded (static)", "app://game/index.html");
686
+ } else {
687
+ const url = `http://127.0.0.1:${port}`;
688
+ await mainWindow?.loadURL(url);
689
+ console.log("URL loaded", url);
690
+ }
691
+ }
692
+
693
+ mainWindow.on("enter-full-screen", () => {
694
+ /**
695
+ * @type {import('@pipelab/core').MakeInputOutput<import('@pipelab/core').FullscreenState, 'input'>}
696
+ */
697
+ const order = {
698
+ url: "/window/fullscreen-state",
699
+ body: {
700
+ state: "fullscreen",
701
+ },
702
+ };
703
+ broadcastMessage(JSON.stringify(order));
704
+ });
705
+
706
+ mainWindow.on("leave-full-screen", () => {
707
+ /**
708
+ * @type {import('@pipelab/core').MakeInputOutput<import('@pipelab/core').FullscreenState, 'input'>}
709
+ */
710
+ const order = {
711
+ url: "/window/fullscreen-state",
712
+ body: {
713
+ state: "normal",
714
+ },
715
+ };
716
+ broadcastMessage(JSON.stringify(order));
717
+ });
718
+
719
+ mainWindow.webContents?.setWindowOpenHandler(({ url }) => {
720
+ // Open the URL in the default browser
721
+ shell.openExternal(url);
722
+ return { action: "deny" }; // Prevent Electron from creating a new window
723
+ });
724
+
725
+ return mainWindow;
726
+ };
727
+
728
+ /**
729
+ * Cleanup all resources before quitting
730
+ */
731
+ const cleanup = async () => {
732
+ if (cleanupPromise) {
733
+ return cleanupPromise;
734
+ }
735
+
736
+ cleanupPromise = (async () => {
737
+ console.log("Cleaning up resources...");
738
+
739
+ for (const client of clients) {
740
+ try {
741
+ client.close();
742
+ } catch (e) {
743
+ console.error("Error closing WebSocket client:", e);
744
+ }
745
+ }
746
+ clients.clear();
747
+
748
+ if (wss) {
749
+ try {
750
+ wss.close();
751
+ } catch (e) {
752
+ console.error("Error closing WebSocket server:", e);
753
+ }
754
+ wss = null;
755
+ }
756
+
757
+ if (rpc) {
758
+ try {
759
+ await rpc.clearActivity();
760
+ } catch (e) {
761
+ console.error("Error clearing Discord RPC activity:", e);
762
+ }
763
+
764
+ try {
765
+ rpc.destroy();
766
+ } catch (e) {
767
+ console.error("Error destroying Discord RPC client:", e);
768
+ }
769
+
770
+ rpc = undefined;
771
+ }
772
+
773
+ try {
774
+ protocol.unhandle("app");
775
+ } catch (e) {
776
+ console.error("Error unregistering app protocol:", e);
777
+ }
778
+
779
+ if (httpServer) {
780
+ const server = httpServer;
781
+ httpServer = null;
782
+
783
+ await new Promise((resolve) => {
784
+ let isSettled = false;
785
+ const done = () => {
786
+ if (!isSettled) {
787
+ isSettled = true;
788
+ resolve();
789
+ }
790
+ };
791
+
792
+ server.close(() => {
793
+ console.log("HTTP server closed");
794
+ done();
795
+ });
796
+
797
+ setTimeout(done, 500);
798
+ });
799
+ }
800
+ })().finally(() => {
801
+ cleanupPromise = null;
802
+ });
803
+
804
+ return cleanupPromise;
805
+ };
806
+
807
+ const registerHandlers = async () => {
808
+ ipcMain.on("exit", async (event, code) => {
809
+ console.log("exit", code);
810
+ isQuitting = true;
811
+ await cleanup();
812
+ app.exit(code);
813
+ });
814
+ };
815
+
816
+ const rpcLogin = async () => {
817
+ return new Promise((resolve, reject) => {
818
+ rpc.on("ready", () => {
819
+ console.log("rpc ready");
820
+ return resolve(rpc);
821
+ });
822
+
823
+ rpc.login({ clientId: config.discordAppId }).catch((e) => {
824
+ return reject(e);
825
+ });
826
+ });
827
+ };
828
+
829
+ function registerCustomProtocol() {
830
+ const GAME_DIR = path.join(metaDirname, "app");
831
+
832
+ protocol.handle("app", async (request) => {
833
+ let internalPath = request.url.replace("app://game/", "");
834
+ internalPath = internalPath.split("?")[0];
835
+ internalPath = decodeURIComponent(internalPath);
836
+
837
+ if (internalPath === "" || internalPath.endsWith("/")) {
838
+ internalPath = "index.html";
839
+ }
840
+
841
+ const finalPath = path.join(GAME_DIR, internalPath);
842
+
843
+ try {
844
+ const stats = await stat(finalPath);
845
+ const mimeType = mime.lookup(finalPath) || "application/octet-stream";
846
+
847
+ const headers = new Headers();
848
+ headers.set("Content-Type", mimeType);
849
+ headers.set("Access-Control-Allow-Origin", "*");
850
+ headers.set("Accept-Ranges", "bytes");
851
+
852
+ const rangeHeader = request.headers.get("Range");
853
+
854
+ if (rangeHeader) {
855
+ const parts = rangeHeader.replace(/bytes=/, "").split("-");
856
+ const start = parseInt(parts[0], 10);
857
+ const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
858
+ const chunkSize = end - start + 1;
859
+
860
+ const stream = createReadStream(finalPath, { start, end });
861
+
862
+ headers.set("Content-Range", `bytes ${start}-${end}/${stats.size}`);
863
+ headers.set("Content-Length", String(chunkSize));
864
+
865
+ // @ts-expect-error streams are accepted
866
+ return new Response(stream, {
867
+ status: 206,
868
+ statusText: "Partial Content",
869
+ headers: headers,
870
+ });
871
+ }
872
+ // no range / streaming
873
+ else {
874
+ headers.set("Content-Length", String(stats.size));
875
+ const stream = createReadStream(finalPath);
876
+
877
+ // @ts-expect-error streams are accepted
878
+ return new Response(stream, {
879
+ status: 200,
880
+ headers: headers,
881
+ });
882
+ }
883
+ } catch (error) {
884
+ console.error(`[App Protocol] Error serving ${finalPath}:`, error);
885
+ return new Response("Not Found", { status: 404 });
886
+ }
887
+ });
888
+ }
889
+
890
+ app.whenReady().then(async () => {
891
+ await registerHandlers();
892
+
893
+ if (config.enableSteamSupport) {
894
+ console.log("Enabling steam overlay support");
895
+ try {
896
+ steamworks.electronEnableSteamOverlay();
897
+ } catch (e) {
898
+ console.error("e", e);
899
+ }
900
+ }
901
+
902
+ registerCustomProtocol();
903
+
904
+ const mainWindow = await createWindow();
905
+
906
+ mainWindow.show();
907
+
908
+ if (config.enableDiscordSupport && rpc) {
909
+ try {
910
+ await rpcLogin();
911
+ } catch (e) {
912
+ console.error("e", e);
913
+ }
914
+ }
915
+
916
+ app.on("activate", async () => {
917
+ if (BrowserWindow.getAllWindows().length === 0) {
918
+ await createWindow();
919
+ }
920
+ });
921
+ });
922
+
923
+ app.on("before-quit", (event) => {
924
+ if (!isQuitting) {
925
+ event.preventDefault();
926
+ isQuitting = true;
927
+ cleanup()
928
+ .catch((error) => {
929
+ console.error("Error while quitting application:", error);
930
+ })
931
+ .finally(() => {
932
+ app.quit();
933
+ });
934
+ }
935
+ });
936
+
937
+ app.on("will-quit", () => {
938
+ for (const client of clients) {
939
+ try {
940
+ client.terminate();
941
+ } catch (e) {}
942
+ }
943
+ clients.clear();
944
+ });
945
+
946
+ app.on("window-all-closed", () => {
947
+ // On macOS, apps typically stay open until explicitly quit
948
+ // But for games, we usually want to quit when the window is closed
949
+ if (process.platform !== "darwin" || isQuitting) {
950
+ app.quit();
951
+ } else {
952
+ // On macOS, trigger the quit process which will run cleanup
953
+ isQuitting = true;
954
+ cleanup().then(() => {
955
+ app.quit();
956
+ });
957
+ }
958
+ });