loadtoagent 1.1.0 → 1.3.2

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 (86) hide show
  1. package/README.ko.md +14 -5
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +7 -0
  4. package/docs/PROVIDER-CONTRACTS.md +25 -1
  5. package/docs/UI-AUDIT-100.md +118 -0
  6. package/docs/UI-AUDIT-101-200.md +120 -0
  7. package/docs/UI-AUDIT-201-300.md +120 -0
  8. package/main.js +157 -38
  9. package/package.json +13 -4
  10. package/preload.js +13 -2
  11. package/renderer/app-agent-actions.js +125 -53
  12. package/renderer/app-bootstrap.js +54 -19
  13. package/renderer/app-dashboard.js +196 -55
  14. package/renderer/app-drawer-content.js +48 -44
  15. package/renderer/app-drawer-data.js +26 -13
  16. package/renderer/app-drawer.js +72 -54
  17. package/renderer/app-events-dialogs.js +146 -37
  18. package/renderer/app-events-filters.js +172 -13
  19. package/renderer/app-events-navigation.js +77 -30
  20. package/renderer/app-events-sessions.js +156 -9
  21. package/renderer/app-events.js +2 -1
  22. package/renderer/app-graph-model.js +20 -38
  23. package/renderer/app-graph-orchestration.js +15 -13
  24. package/renderer/app-graph-view.js +228 -113
  25. package/renderer/app-management.js +257 -0
  26. package/renderer/app-provider-visibility.js +124 -0
  27. package/renderer/app-quality.js +568 -0
  28. package/renderer/app-run-modal.js +104 -34
  29. package/renderer/app-runtime-overview.js +312 -0
  30. package/renderer/app-session-render.js +94 -37
  31. package/renderer/app-tmux-render.js +52 -44
  32. package/renderer/app.js +153 -75
  33. package/renderer/i18n-messages.js +867 -37
  34. package/renderer/i18n.js +104 -0
  35. package/renderer/index.html +154 -67
  36. package/renderer/shared.js +17 -0
  37. package/renderer/styles-agent-map.css +16 -10
  38. package/renderer/styles-cards.css +44 -18
  39. package/renderer/styles-collaboration.css +23 -23
  40. package/renderer/styles-components.css +122 -45
  41. package/renderer/styles-management.css +364 -0
  42. package/renderer/styles-onboarding.css +8 -8
  43. package/renderer/styles-overlays.css +13 -7
  44. package/renderer/styles-product.css +48 -40
  45. package/renderer/styles-quality.css +312 -0
  46. package/renderer/styles-readability.css +1040 -0
  47. package/renderer/styles-responsive-product.css +84 -12
  48. package/renderer/styles-responsive-runtime.css +25 -17
  49. package/renderer/styles-responsive-shell.css +44 -48
  50. package/renderer/styles-responsive-workflows.css +39 -5
  51. package/renderer/styles-run-composer.css +28 -23
  52. package/renderer/styles-runtime-overview.css +285 -0
  53. package/renderer/styles-settings.css +176 -16
  54. package/renderer/styles-terminal.css +374 -37
  55. package/renderer/styles-tmux.css +49 -49
  56. package/renderer/styles-workflow-map.css +362 -15
  57. package/renderer/styles-workflows.css +116 -49
  58. package/renderer/styles.css +34 -19
  59. package/renderer/terminal-agent.js +17 -13
  60. package/renderer/terminal-events.js +253 -37
  61. package/renderer/terminal-workbench.js +208 -89
  62. package/renderer/terminal.js +350 -35
  63. package/src/agentMonitor/claudeParser.js +96 -7
  64. package/src/agentMonitor/codexParser.js +59 -4
  65. package/src/agentMonitor/executionActivity.js +282 -0
  66. package/src/agentMonitor/genericParser.js +56 -13
  67. package/src/agentMonitor/hierarchy.js +17 -0
  68. package/src/agentMonitor/responseIntent.js +51 -0
  69. package/src/agentMonitor.js +21 -3
  70. package/src/agentRunner.js +66 -1
  71. package/src/attentionNotifier.js +14 -10
  72. package/src/automationMonitor.js +258 -0
  73. package/src/contracts.js +10 -1
  74. package/src/ipc/registerAgentIpc.js +9 -3
  75. package/src/ipc/registerAppIpc.js +4 -1
  76. package/src/ipc/registerTerminalIpc.js +12 -14
  77. package/src/macUpdateHelper.js +210 -0
  78. package/src/monitorWorker.js +65 -6
  79. package/src/platformPath.js +80 -0
  80. package/src/processMonitor.js +80 -22
  81. package/src/providerVisibilityStore.js +45 -0
  82. package/src/sessionIntelligence.js +261 -0
  83. package/src/terminalHost.js +453 -0
  84. package/src/terminalHostDaemon.js +61 -0
  85. package/src/terminalManager.js +218 -6
  86. package/src/updateInstaller.js +175 -0
@@ -11,6 +11,9 @@ const { runBestEffort } = require('./diagnostics');
11
11
  const MAX_SESSIONS = 24;
12
12
  const MAX_INPUT_CHARS = 128 * 1024;
13
13
  const MAX_REPLAY_CHARS = 2 * 1024 * 1024;
14
+ const MAX_STORE_BYTES = 64 * 1024 * 1024;
15
+ const STORE_VERSION = 1;
16
+ const PERSIST_DELAY_MS = 150;
14
17
  const TERMINAL_TYPES = new Set(['powershell', 'cmd', 'shell', 'wsl', 'tmux', 'agent']);
15
18
  const AGENT_PROVIDERS = Object.freeze({
16
19
  claude: { command: 'claude', label: 'Claude' },
@@ -205,6 +208,8 @@ function publicSession(session, includeReplay = false) {
205
208
  provider: session.options.provider,
206
209
  bridgeId: session.options.bridgeId,
207
210
  background: session.options.type === 'agent',
211
+ recoveredAfterHostRestart: Boolean(session.recoveredAfterHostRestart),
212
+ recoverySkippedReason: session.recoverySkippedReason || '',
208
213
  pid: session.pid,
209
214
  status: session.status,
210
215
  createdAt: session.createdAt,
@@ -218,6 +223,54 @@ function publicSession(session, includeReplay = false) {
218
223
  return value;
219
224
  }
220
225
 
226
+ function validTimestamp(value, fallback) {
227
+ const text = cleanText(value, 50);
228
+ return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : fallback;
229
+ }
230
+
231
+ function restoredOptions(value = {}, platform = process.platform) {
232
+ const fallbackType = platform === 'win32' ? 'powershell' : 'shell';
233
+ const type = TERMINAL_TYPES.has(value.type) ? value.type : fallbackType;
234
+ const provider = cleanText(value.provider, 30).toLowerCase();
235
+ if (type === 'agent' && !AGENT_PROVIDERS[provider]) return null;
236
+ return {
237
+ type,
238
+ cwd: cleanText(value.cwd, 2_000) || os.homedir(),
239
+ distro: cleanText(value.distro, 100),
240
+ tmuxSession: cleanText(value.tmuxSession, 100),
241
+ tmuxPane: cleanText(value.tmuxPane, 100),
242
+ provider,
243
+ args: Array.isArray(value.args) ? value.args.slice(0, 80).map(item => cleanText(item, 2_000)) : [],
244
+ bridgeId: cleanText(value.bridgeId, 100),
245
+ title: cleanText(value.title, 100),
246
+ cols: numericDimension(value.cols, 120, 20, 500),
247
+ rows: numericDimension(value.rows, 32, 5, 200),
248
+ };
249
+ }
250
+
251
+ function persistedSession(session) {
252
+ return {
253
+ id: session.id,
254
+ options: { ...session.options, cols: session.cols, rows: session.rows },
255
+ title: session.title,
256
+ shell: session.shell,
257
+ status: session.status,
258
+ createdAt: session.createdAt,
259
+ updatedAt: session.updatedAt,
260
+ exitCode: session.exitCode,
261
+ signal: session.signal,
262
+ replay: session.replay,
263
+ };
264
+ }
265
+
266
+ function hasSafeAgentResume(options = {}) {
267
+ if (options.type !== 'agent') return true;
268
+ const args = Array.isArray(options.args) ? options.args.map(value => String(value || '')) : [];
269
+ if (options.provider === 'codex') return args[0] === 'resume' && Boolean(args[1]);
270
+ const resumeIndex = args.indexOf('--resume');
271
+ return resumeIndex >= 0 && Boolean(args[resumeIndex + 1]);
272
+ }
273
+
221
274
  class TerminalManager extends EventEmitter {
222
275
  constructor(options = {}) {
223
276
  super();
@@ -225,7 +278,92 @@ class TerminalManager extends EventEmitter {
225
278
  this.killTree = options.killTree || killPtyTree;
226
279
  this.platform = options.platform || process.platform;
227
280
  this.agentProviders = options.agentProviders || AGENT_PROVIDERS;
281
+ this.fileSystem = options.fileSystem || fs;
282
+ this.storeFile = typeof options.storeFile === 'string' && options.storeFile.trim()
283
+ ? path.resolve(options.storeFile)
284
+ : '';
285
+ this.onPersistenceError = typeof options.onPersistenceError === 'function'
286
+ ? options.onPersistenceError
287
+ : () => {};
288
+ this.persistTimer = null;
228
289
  this.sessions = new Map();
290
+ this.loadPersistedSessions();
291
+ }
292
+
293
+ persistenceError(operation, error) {
294
+ runBestEffort(`terminal-persistence:${operation}`, () => this.onPersistenceError(operation, error));
295
+ }
296
+
297
+ loadPersistedSessions() {
298
+ if (!this.storeFile) return;
299
+ try {
300
+ const stat = this.fileSystem.statSync(this.storeFile);
301
+ if (!stat.isFile() || stat.size > MAX_STORE_BYTES) throw new Error('터미널 기록 파일의 크기가 허용 범위를 초과했습니다.');
302
+ const parsed = JSON.parse(this.fileSystem.readFileSync(this.storeFile, 'utf8'));
303
+ if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.sessions)) throw new Error('지원하지 않는 터미널 기록 형식입니다.');
304
+ for (const value of parsed.sessions.slice(0, MAX_SESSIONS)) {
305
+ const id = cleanText(value?.id, 200);
306
+ const options = restoredOptions(value?.options, this.platform);
307
+ if (!id || !options || this.sessions.has(id)) continue;
308
+ const now = new Date().toISOString();
309
+ const createdAt = validTimestamp(value.createdAt, now);
310
+ const updatedAt = validTimestamp(value.updatedAt, createdAt);
311
+ const status = value.status === 'failed' ? 'failed' : 'exited';
312
+ this.sessions.set(id, {
313
+ id,
314
+ options,
315
+ spec: null,
316
+ title: cleanText(value.title, 100) || options.title || options.tmuxSession || options.provider || options.type,
317
+ shell: cleanText(value.shell, 2_000),
318
+ pid: null,
319
+ status,
320
+ createdAt,
321
+ updatedAt,
322
+ exitCode: Number.isFinite(value.exitCode) ? value.exitCode : null,
323
+ signal: Number.isFinite(value.signal) ? value.signal : null,
324
+ cols: options.cols,
325
+ rows: options.rows,
326
+ replay: String(value.replay || '').slice(-MAX_REPLAY_CHARS),
327
+ process: null,
328
+ generation: 0,
329
+ recoveryPending: value.status === 'running' || value.status === 'starting',
330
+ recoveredAfterHostRestart: false,
331
+ recoverySkippedReason: '',
332
+ });
333
+ }
334
+ } catch (error) {
335
+ if (error?.code !== 'ENOENT') this.persistenceError('load', error);
336
+ }
337
+ }
338
+
339
+ schedulePersist() {
340
+ if (!this.storeFile || this.persistTimer) return;
341
+ this.persistTimer = setTimeout(() => {
342
+ this.persistTimer = null;
343
+ this.persistNow();
344
+ }, PERSIST_DELAY_MS);
345
+ if (typeof this.persistTimer.unref === 'function') this.persistTimer.unref();
346
+ }
347
+
348
+ persistNow() {
349
+ if (!this.storeFile) return;
350
+ if (this.persistTimer) {
351
+ clearTimeout(this.persistTimer);
352
+ this.persistTimer = null;
353
+ }
354
+ const temporary = `${this.storeFile}.${process.pid}.tmp`;
355
+ try {
356
+ this.fileSystem.mkdirSync(path.dirname(this.storeFile), { recursive: true });
357
+ const payload = {
358
+ version: STORE_VERSION,
359
+ sessions: [...this.sessions.values()].map(persistedSession),
360
+ };
361
+ this.fileSystem.writeFileSync(temporary, JSON.stringify(payload), 'utf8');
362
+ this.fileSystem.renameSync(temporary, this.storeFile);
363
+ } catch (error) {
364
+ runBestEffort('terminal-persistence-temp-cleanup', () => this.fileSystem.unlinkSync(temporary));
365
+ this.persistenceError('save', error);
366
+ }
229
367
  }
230
368
 
231
369
  pty() {
@@ -233,6 +371,35 @@ class TerminalManager extends EventEmitter {
233
371
  return this.ptyModule;
234
372
  }
235
373
 
374
+ recoverPersistedSessions() {
375
+ const recovered = [];
376
+ for (const session of this.sessions.values()) {
377
+ if (!session.recoveryPending) continue;
378
+ session.recoveryPending = false;
379
+ if (!hasSafeAgentResume(session.options)) {
380
+ session.status = 'exited';
381
+ session.pid = null;
382
+ session.recoveredAfterHostRestart = false;
383
+ session.recoverySkippedReason = 'unsafe-agent-restart';
384
+ const skippedMessage = '\r\n[LoadToAgent] 이 터미널은 재개할 기존 AI 세션 ID가 없어, 새 AI 대화를 만들 수 있어 자동 재개하지 않았습니다.\r\n';
385
+ session.replay = `${session.replay}${skippedMessage}`.slice(-MAX_REPLAY_CHARS);
386
+ continue;
387
+ }
388
+ session.recoveredAfterHostRestart = true;
389
+ session.recoverySkippedReason = '';
390
+ const message = '\r\n[LoadToAgent] 터미널 호스트 중단 뒤 새 프로세스로 복구했습니다. 이전 셸의 메모리 상태는 이어지지 않습니다.\r\n';
391
+ session.replay = `${session.replay}${message}`.slice(-MAX_REPLAY_CHARS);
392
+ try {
393
+ this.spawn(session);
394
+ } catch (_recoveryFailed) {
395
+ session.recoveredAfterHostRestart = false;
396
+ }
397
+ recovered.push(publicSession(session, true));
398
+ }
399
+ this.persistNow();
400
+ return recovered;
401
+ }
402
+
236
403
  create(rawOptions = {}) {
237
404
  if (this.sessions.size >= MAX_SESSIONS) throw new Error(`동시에 열 수 있는 터미널은 최대 ${MAX_SESSIONS}개입니다.`);
238
405
  const options = normalizeLaunchOptions(rawOptions, this.platform);
@@ -256,19 +423,30 @@ class TerminalManager extends EventEmitter {
256
423
  replay: '',
257
424
  process: null,
258
425
  generation: 0,
426
+ recoveryPending: false,
427
+ recoveredAfterHostRestart: false,
428
+ recoverySkippedReason: '',
259
429
  };
260
430
  this.sessions.set(id, session);
261
431
  try {
262
432
  this.spawn(session);
263
433
  } catch (error) {
264
- this.sessions.delete(id);
265
- this.emit('state', { change: 'removed', session: publicSession(session, false), sessions: this.list() });
434
+ // Keep failed launches visible until the user explicitly closes them.
435
+ // The failed session contains the startup error in replay and can be
436
+ // inspected, restarted, or removed from the session terminal.
437
+ this.persistNow();
266
438
  throw error;
267
439
  }
440
+ this.persistNow();
268
441
  return publicSession(session, true);
269
442
  }
270
443
 
271
444
  spawn(session) {
445
+ if (!session.spec) {
446
+ session.options = normalizeLaunchOptions(session.options, this.platform);
447
+ session.spec = launchSpec(session.options, this.platform, this.agentProviders);
448
+ session.shell = session.spec.file;
449
+ }
272
450
  const generation = ++session.generation;
273
451
  session.status = 'starting';
274
452
  session.exitCode = null;
@@ -296,20 +474,24 @@ class TerminalManager extends EventEmitter {
296
474
  session.replay = `${session.replay}${text}`.slice(-MAX_REPLAY_CHARS);
297
475
  session.updatedAt = new Date().toISOString();
298
476
  this.emit('data', { id: session.id, data: text });
477
+ this.schedulePersist();
299
478
  });
300
479
  processHandle.onExit(event => {
301
480
  processHandle.__loadtoagentExited = true;
302
481
  if (session.generation !== generation) return;
303
482
  session.process = null;
483
+ session.pid = null;
304
484
  session.status = 'exited';
305
485
  session.exitCode = Number.isFinite(event.exitCode) ? event.exitCode : null;
306
486
  session.signal = Number.isFinite(event.signal) ? event.signal : null;
307
487
  session.updatedAt = new Date().toISOString();
488
+ this.persistNow();
308
489
  this.emitState('updated', session);
309
490
  });
310
491
  this.emitState('updated', session);
311
492
  } catch (error) {
312
493
  session.process = null;
494
+ session.pid = null;
313
495
  session.status = 'failed';
314
496
  session.updatedAt = new Date().toISOString();
315
497
  const failureMessage = `\r\n[LoadToAgent] 터미널을 시작하지 못했습니다: ${error.message}\r\n`;
@@ -322,6 +504,7 @@ class TerminalManager extends EventEmitter {
322
504
 
323
505
  emitState(change, session) {
324
506
  this.emit('state', { change, session: session ? publicSession(session, false) : null, sessions: this.list() });
507
+ this.schedulePersist();
325
508
  }
326
509
 
327
510
  list() {
@@ -360,6 +543,7 @@ class TerminalManager extends EventEmitter {
360
543
  session.cols = numericDimension(cols, session.cols, 20, 500);
361
544
  session.rows = numericDimension(rows, session.rows, 5, 200);
362
545
  if (session.process && session.status === 'running') session.process.resize(session.cols, session.rows);
546
+ this.schedulePersist();
363
547
  return { ok: true, cols: session.cols, rows: session.rows };
364
548
  }
365
549
 
@@ -380,24 +564,31 @@ class TerminalManager extends EventEmitter {
380
564
  const session = this.required(id);
381
565
  if (session.process) {
382
566
  const handle = session.process;
567
+ const pid = session.pid;
383
568
  session.process = null;
384
569
  session.generation += 1;
385
- this.killTree(handle, session.pid);
570
+ this.killTree(handle, pid);
386
571
  }
572
+ session.pid = null;
387
573
  session.status = 'exited';
388
574
  session.updatedAt = new Date().toISOString();
389
575
  this.emitState('updated', session);
576
+ this.persistNow();
390
577
  return { ok: true };
391
578
  }
392
579
 
393
580
  restart(id) {
394
581
  const session = this.required(id);
582
+ session.recoveredAfterHostRestart = false;
583
+ session.recoverySkippedReason = '';
395
584
  if (session.process) {
396
585
  const handle = session.process;
586
+ const pid = session.pid;
397
587
  session.process = null;
398
588
  session.generation += 1;
399
- this.killTree(handle, session.pid);
589
+ this.killTree(handle, pid);
400
590
  }
591
+ session.pid = null;
401
592
  session.replay = '';
402
593
  this.spawn(session);
403
594
  return publicSession(session, true);
@@ -407,21 +598,42 @@ class TerminalManager extends EventEmitter {
407
598
  const session = this.required(id);
408
599
  if (session.process) {
409
600
  const handle = session.process;
601
+ const pid = session.pid;
410
602
  session.process = null;
411
603
  session.generation += 1;
412
- this.killTree(handle, session.pid);
604
+ this.killTree(handle, pid);
413
605
  }
606
+ session.pid = null;
414
607
  session.status = 'exited';
415
608
  session.updatedAt = new Date().toISOString();
416
609
  this.sessions.delete(session.id);
417
610
  this.emit('state', { change: 'removed', session: publicSession(session, false), sessions: this.list() });
611
+ this.persistNow();
418
612
  return { ok: true };
419
613
  }
420
614
 
421
- dispose() {
615
+ dispose({ preserveSessions = false } = {}) {
616
+ if (preserveSessions) {
617
+ const now = new Date().toISOString();
618
+ for (const session of this.sessions.values()) {
619
+ const shouldRecover = session.status === 'running' || session.status === 'starting';
620
+ if (session.process) {
621
+ const handle = session.process;
622
+ session.process = null;
623
+ session.generation += 1;
624
+ this.killTree(handle, session.pid);
625
+ }
626
+ if (shouldRecover) session.status = 'running';
627
+ session.pid = null;
628
+ session.updatedAt = now;
629
+ }
630
+ this.persistNow();
631
+ return;
632
+ }
422
633
  for (const id of [...this.sessions.keys()]) {
423
634
  runBestEffort(`terminal-dispose:${id}`, () => this.close(id));
424
635
  }
636
+ this.persistNow();
425
637
  }
426
638
  }
427
639
 
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn: spawnProcess } = require('child_process');
6
+
7
+ const MAC_UPDATE_HELPER_SOURCE = path.join(__dirname, 'macUpdateHelper.js');
8
+
9
+ const WINDOWS_UPDATE_HELPER = `param(
10
+ [Parameter(Mandatory = $true)][string]$InstallerPath,
11
+ [Parameter(Mandatory = $true)][int]$ParentPid,
12
+ [Parameter(Mandatory = $true)][string]$AppPath,
13
+ [Parameter(Mandatory = $true)][string]$LogPath
14
+ )
15
+
16
+ $ErrorActionPreference = 'Stop'
17
+ $exitCode = -1
18
+ try {
19
+ Wait-Process -Id $ParentPid -ErrorAction SilentlyContinue
20
+ $installer = Start-Process -FilePath $InstallerPath -ArgumentList '/S' -PassThru -Wait -WindowStyle Hidden
21
+ $exitCode = $installer.ExitCode
22
+ } catch {
23
+ $_ | Out-String | Set-Content -LiteralPath $LogPath -Encoding UTF8
24
+ } finally {
25
+ try { "exitCode=$exitCode" | Add-Content -LiteralPath $LogPath -Encoding UTF8 } catch {}
26
+ if ($exitCode -ne 0) {
27
+ try { "updateFailed=true" | Add-Content -LiteralPath $LogPath -Encoding UTF8 } catch {}
28
+ }
29
+ if (Test-Path -LiteralPath $AppPath) {
30
+ Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue
31
+ try { Start-Process -FilePath $AppPath } catch {}
32
+ }
33
+ Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
34
+ }
35
+ `;
36
+
37
+ function isWithinDirectory(file, directory) {
38
+ if (!file || !directory) return false;
39
+ const relative = path.relative(path.resolve(directory), path.resolve(file));
40
+ return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
41
+ }
42
+
43
+ function macAppBundlePath(executablePath) {
44
+ const normalized = path.posix.normalize(String(executablePath || '').replace(/\\/g, '/'));
45
+ const match = normalized.match(/^((?:\/|[A-Za-z]:\/).+?\.app)\/Contents\/MacOS\/[^/]+$/i);
46
+ return match ? match[1] : '';
47
+ }
48
+
49
+ function automaticInstallPlatform({ platform, installType, installerPath, downloadsDir, appPath }) {
50
+ if (installType !== 'desktop' || !isWithinDirectory(installerPath, downloadsDir)) return '';
51
+ const fileName = path.basename(installerPath);
52
+ if (platform === 'win32' && /^LoadToAgent-Setup-[0-9A-Za-z.-]+\.exe$/i.test(fileName)) return 'win32';
53
+ if (platform === 'darwin' && /^LoadToAgent-[0-9A-Za-z.-]+-(?:arm64|x64)\.dmg$/i.test(fileName)) {
54
+ const appBundle = macAppBundlePath(appPath);
55
+ if (appBundle && appBundle !== '/Volumes' && !appBundle.startsWith('/Volumes/')) return 'darwin';
56
+ }
57
+ return '';
58
+ }
59
+
60
+ function canInstallSilently(options) {
61
+ return Boolean(automaticInstallPlatform(options || {}));
62
+ }
63
+
64
+ function windowsPowerShell(environment = process.env) {
65
+ const systemRoot = String(environment.SystemRoot || environment.WINDIR || 'C:\\Windows');
66
+ return path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
67
+ }
68
+
69
+ function waitForProcessSpawn(child, timeoutMs = 5000) {
70
+ if (!child || typeof child.once !== 'function' || typeof child.unref !== 'function') {
71
+ return Promise.reject(new Error('업데이트 설치 프로세스를 시작하지 못했습니다.'));
72
+ }
73
+ return new Promise((resolve, reject) => {
74
+ let settled = false;
75
+ const finish = error => {
76
+ if (settled) return;
77
+ settled = true;
78
+ clearTimeout(timer);
79
+ if (error) reject(error);
80
+ else resolve();
81
+ };
82
+ const timer = setTimeout(() => finish(new Error('업데이트 설치 프로세스 시작 확인 시간이 초과되었습니다.')), timeoutMs);
83
+ child.once('spawn', () => finish());
84
+ child.once('error', error => finish(error));
85
+ });
86
+ }
87
+
88
+ async function launchDownloadedUpdate(options = {}) {
89
+ const installerPath = String(options.installerPath || '');
90
+ const downloadsDir = String(options.downloadsDir || '');
91
+ if (!installerPath || !fs.existsSync(installerPath)) throw new Error('내려받은 설치 파일을 찾지 못했습니다. 다시 다운로드해 주세요.');
92
+
93
+ const platform = String(options.platform || process.platform);
94
+ const automaticPlatform = automaticInstallPlatform({
95
+ platform,
96
+ installType: String(options.installType || ''),
97
+ installerPath,
98
+ downloadsDir,
99
+ appPath: String(options.appPath || ''),
100
+ });
101
+ if (!automaticPlatform) {
102
+ if (!options.shell || typeof options.shell.openPath !== 'function') throw new Error('설치 파일을 열 수 없습니다.');
103
+ const openError = await options.shell.openPath(installerPath);
104
+ if (openError) throw new Error(openError);
105
+ return { mode: 'manual' };
106
+ }
107
+
108
+ const appPath = String(options.appPath || '');
109
+ const parentPid = Number(options.parentPid);
110
+ if (!appPath || !fs.existsSync(appPath) || !Number.isSafeInteger(parentPid) || parentPid <= 0) {
111
+ throw new Error('업데이트 후 앱을 다시 시작할 정보를 준비하지 못했습니다.');
112
+ }
113
+
114
+ const spawn = options.spawn || spawnProcess;
115
+ if (automaticPlatform === 'darwin') {
116
+ const targetApp = macAppBundlePath(appPath);
117
+ if (!targetApp || !fs.existsSync(targetApp)) throw new Error('현재 설치된 macOS 앱을 찾지 못했습니다.');
118
+ const helperPath = path.join(downloadsDir, 'install-update-macos.js');
119
+ const logPath = path.join(downloadsDir, 'install-update.log');
120
+ const helperSource = await fs.promises.readFile(MAC_UPDATE_HELPER_SOURCE, 'utf8');
121
+ await fs.promises.writeFile(helperPath, helperSource, { encoding: 'utf8', mode: 0o700 });
122
+ const environment = { ...process.env, ...(options.environment || {}), ELECTRON_RUN_AS_NODE: '1' };
123
+ const child = spawn(appPath, [
124
+ helperPath,
125
+ '--dmg', installerPath,
126
+ '--target', targetApp,
127
+ '--parent-pid', String(parentPid),
128
+ '--log', logPath,
129
+ ], {
130
+ detached: true,
131
+ stdio: 'ignore',
132
+ env: environment,
133
+ });
134
+ await waitForProcessSpawn(child, Number(options.spawnTimeoutMs) || 5000);
135
+ if (!Number.isSafeInteger(child.pid) || child.pid <= 0) throw new Error('업데이트 설치 프로세스를 시작하지 못했습니다.');
136
+ child.unref();
137
+ return { mode: 'automatic', helperPath, logPath, targetApp };
138
+ }
139
+
140
+ const helperPath = path.join(downloadsDir, 'install-update.ps1');
141
+ const logPath = path.join(downloadsDir, 'install-update.log');
142
+ await fs.promises.writeFile(helperPath, WINDOWS_UPDATE_HELPER, { encoding: 'utf8', mode: 0o600 });
143
+ const child = spawn(windowsPowerShell(options.environment), [
144
+ '-NoLogo',
145
+ '-NoProfile',
146
+ '-NonInteractive',
147
+ '-WindowStyle', 'Hidden',
148
+ '-ExecutionPolicy', 'Bypass',
149
+ '-File', helperPath,
150
+ '-InstallerPath', installerPath,
151
+ '-ParentPid', String(parentPid),
152
+ '-AppPath', appPath,
153
+ '-LogPath', logPath,
154
+ ], {
155
+ detached: true,
156
+ windowsHide: true,
157
+ stdio: 'ignore',
158
+ });
159
+ await waitForProcessSpawn(child, Number(options.spawnTimeoutMs) || 5000);
160
+ if (!Number.isSafeInteger(child.pid) || child.pid <= 0) throw new Error('업데이트 설치 프로세스를 시작하지 못했습니다.');
161
+ child.unref();
162
+ return { mode: 'automatic', helperPath, logPath };
163
+ }
164
+
165
+ module.exports = {
166
+ MAC_UPDATE_HELPER_SOURCE,
167
+ WINDOWS_UPDATE_HELPER,
168
+ automaticInstallPlatform,
169
+ canInstallSilently,
170
+ isWithinDirectory,
171
+ launchDownloadedUpdate,
172
+ macAppBundlePath,
173
+ waitForProcessSpawn,
174
+ windowsPowerShell,
175
+ };