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.
- package/README.ko.md +14 -5
- package/README.md +7 -0
- package/README.zh-CN.md +7 -0
- package/docs/PROVIDER-CONTRACTS.md +25 -1
- package/docs/UI-AUDIT-100.md +118 -0
- package/docs/UI-AUDIT-101-200.md +120 -0
- package/docs/UI-AUDIT-201-300.md +120 -0
- package/main.js +157 -38
- package/package.json +13 -4
- package/preload.js +13 -2
- package/renderer/app-agent-actions.js +125 -53
- package/renderer/app-bootstrap.js +54 -19
- package/renderer/app-dashboard.js +196 -55
- package/renderer/app-drawer-content.js +48 -44
- package/renderer/app-drawer-data.js +26 -13
- package/renderer/app-drawer.js +72 -54
- package/renderer/app-events-dialogs.js +146 -37
- package/renderer/app-events-filters.js +172 -13
- package/renderer/app-events-navigation.js +77 -30
- package/renderer/app-events-sessions.js +156 -9
- package/renderer/app-events.js +2 -1
- package/renderer/app-graph-model.js +20 -38
- package/renderer/app-graph-orchestration.js +15 -13
- package/renderer/app-graph-view.js +228 -113
- package/renderer/app-management.js +257 -0
- package/renderer/app-provider-visibility.js +124 -0
- package/renderer/app-quality.js +568 -0
- package/renderer/app-run-modal.js +104 -34
- package/renderer/app-runtime-overview.js +312 -0
- package/renderer/app-session-render.js +94 -37
- package/renderer/app-tmux-render.js +52 -44
- package/renderer/app.js +153 -75
- package/renderer/i18n-messages.js +867 -37
- package/renderer/i18n.js +104 -0
- package/renderer/index.html +154 -67
- package/renderer/shared.js +17 -0
- package/renderer/styles-agent-map.css +16 -10
- package/renderer/styles-cards.css +44 -18
- package/renderer/styles-collaboration.css +23 -23
- package/renderer/styles-components.css +122 -45
- package/renderer/styles-management.css +364 -0
- package/renderer/styles-onboarding.css +8 -8
- package/renderer/styles-overlays.css +13 -7
- package/renderer/styles-product.css +48 -40
- package/renderer/styles-quality.css +312 -0
- package/renderer/styles-readability.css +1040 -0
- package/renderer/styles-responsive-product.css +84 -12
- package/renderer/styles-responsive-runtime.css +25 -17
- package/renderer/styles-responsive-shell.css +44 -48
- package/renderer/styles-responsive-workflows.css +39 -5
- package/renderer/styles-run-composer.css +28 -23
- package/renderer/styles-runtime-overview.css +285 -0
- package/renderer/styles-settings.css +176 -16
- package/renderer/styles-terminal.css +374 -37
- package/renderer/styles-tmux.css +49 -49
- package/renderer/styles-workflow-map.css +362 -15
- package/renderer/styles-workflows.css +116 -49
- package/renderer/styles.css +34 -19
- package/renderer/terminal-agent.js +17 -13
- package/renderer/terminal-events.js +253 -37
- package/renderer/terminal-workbench.js +208 -89
- package/renderer/terminal.js +350 -35
- package/src/agentMonitor/claudeParser.js +96 -7
- package/src/agentMonitor/codexParser.js +59 -4
- package/src/agentMonitor/executionActivity.js +282 -0
- package/src/agentMonitor/genericParser.js +56 -13
- package/src/agentMonitor/hierarchy.js +17 -0
- package/src/agentMonitor/responseIntent.js +51 -0
- package/src/agentMonitor.js +21 -3
- package/src/agentRunner.js +66 -1
- package/src/attentionNotifier.js +14 -10
- package/src/automationMonitor.js +258 -0
- package/src/contracts.js +10 -1
- package/src/ipc/registerAgentIpc.js +9 -3
- package/src/ipc/registerAppIpc.js +4 -1
- package/src/ipc/registerTerminalIpc.js +12 -14
- package/src/macUpdateHelper.js +210 -0
- package/src/monitorWorker.js +65 -6
- package/src/platformPath.js +80 -0
- package/src/processMonitor.js +80 -22
- package/src/providerVisibilityStore.js +45 -0
- package/src/sessionIntelligence.js +261 -0
- package/src/terminalHost.js +453 -0
- package/src/terminalHostDaemon.js +61 -0
- package/src/terminalManager.js +218 -6
- package/src/updateInstaller.js +175 -0
package/src/terminalManager.js
CHANGED
|
@@ -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
|
-
|
|
265
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
};
|