termdock 1.4.169 → 1.4.170

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.
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, net, nativeImage, Notification, screen, session, shell, Tray, } from 'electron';
1
+ import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, net, nativeImage, Notification, powerMonitor, screen, session, shell, Tray, } from 'electron';
2
2
  import { execFile, spawn } from 'node:child_process';
3
3
  import crypto from 'node:crypto';
4
4
  import dgram from 'node:dgram';
@@ -16,7 +16,8 @@ import { isExternalLinkStagingUrl, isSafeExternalUrl } from './externalLinks.js'
16
16
  import { shouldThrottleDesktopRenderer } from './windowPolicy.js';
17
17
  import { checkConnectedServiceRuntime, getConnectedServiceRuntimeState, restartConnectedServiceRuntime, } from './connectedServiceRuntime.js';
18
18
  import { isOwnedDesktopRuntimeTarget } from './runtimeTarget.js';
19
- import { canOfferCertificateTrust, downloadCertificateAuthority, isCertificateTrustError, isLocalNetworkHostname, } from './certificateTrust.js';
19
+ import { serviceDocumentNeedsReload } from './serviceWindowRecovery.js';
20
+ import { CertificateTrustRequests, resolveServiceCertificateTrust, canOfferCertificateTrust, downloadCertificateAuthority, isCertificateTrustError, isLocalNetworkHostname, matchManagedLocalCertificate, } from './certificateTrust.js';
20
21
  const execFileAsync = promisify(execFile);
21
22
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
22
23
  const projectRoot = path.resolve(currentDir, '..');
@@ -27,12 +28,17 @@ const desktopRuntimeOwnerSocketPath = path.join(termdockDir, 'desktop-runtime-ow
27
28
  const DEFAULT_LOCAL_URL = 'http://localhost:9834';
28
29
  const PROTOCOL_VERSION = 1;
29
30
  const HEALTH_TIMEOUT_MS = 3_500;
31
+ const SERVICE_RECOVERY_PROBE_TIMEOUT_MS = 5_000;
30
32
  const START_TIMEOUT_MS = 90_000;
31
33
  const RESTORE_LOAD_TIMEOUT_MS = 15_000;
32
34
  const localServiceCertificatePath = path.join(termdockDir, 'certs', 'termdock-local.pem');
33
35
  const sessionTrustedCertificateTargets = new Set();
36
+ const certificateTrustRequests = new CertificateTrustRequests();
37
+ const sessionTrustedLeafByOrigin = new Map();
34
38
  const sessionTrustedCertificateAuthorities = new Map();
35
39
  let managedLocalCertificateFingerprint = null;
40
+ const serviceWindowRecoveryTimers = new WeakMap();
41
+ const serviceWindowRecoveryInFlight = new WeakSet();
36
42
  /** Delivered-but-unseen desktop notifications, mirrored into the Dock badge. */
37
43
  const activeNotifications = new Map();
38
44
  let unreadNotificationCount = 0;
@@ -443,7 +449,15 @@ function openExternalLink(url) {
443
449
  function certificateTrustKey(hostname, fingerprint) {
444
450
  return `${hostname.toLowerCase().replace(/^\[|\]$/g, '')}\0${fingerprint}`;
445
451
  }
446
- function installCertificateVerifyProcedure(targetSession = session.defaultSession) {
452
+ function readManagedLocalCertificateFingerprint() {
453
+ try {
454
+ return new crypto.X509Certificate(fs.readFileSync(localServiceCertificatePath)).fingerprint256;
455
+ }
456
+ catch {
457
+ return null;
458
+ }
459
+ }
460
+ function installCertificateVerifyProcedure(targetSession = session.defaultSession, serviceOrigin) {
447
461
  targetSession.setCertificateVerifyProc((request, callback) => {
448
462
  let isLocalTarget = false;
449
463
  try {
@@ -460,20 +474,36 @@ function installCertificateVerifyProcedure(targetSession = session.defaultSessio
460
474
  // Keep Chromium's default verification if the certificate cannot be parsed.
461
475
  }
462
476
  const explicitlyTrustedTarget = presentedFingerprint
463
- ? sessionTrustedCertificateTargets.has(certificateTrustKey(request.hostname, presentedFingerprint))
477
+ ? serviceOrigin
478
+ ? new URL(serviceOrigin).hostname.replace(/^\[|\]$/g, '').toLowerCase()
479
+ === request.hostname.replace(/^\[|\]$/g, '').toLowerCase()
480
+ && sessionTrustedLeafByOrigin.get(serviceOrigin) === presentedFingerprint
481
+ : sessionTrustedCertificateTargets.has(certificateTrustKey(request.hostname, presentedFingerprint))
464
482
  : false;
465
- const managedLocalCertificate = isLocalTarget
466
- && presentedFingerprint === managedLocalCertificateFingerprint;
467
- callback(explicitlyTrustedTarget || managedLocalCertificate ? 0 : -3);
483
+ const managedMatch = isLocalTarget
484
+ ? matchManagedLocalCertificate(presentedFingerprint, managedLocalCertificateFingerprint, readManagedLocalCertificateFingerprint)
485
+ : { matches: false, currentFingerprint: managedLocalCertificateFingerprint };
486
+ managedLocalCertificateFingerprint = managedMatch.currentFingerprint;
487
+ const managedLocalCertificate = managedMatch.matches;
488
+ if (explicitlyTrustedTarget || managedLocalCertificate) {
489
+ callback(0);
490
+ return;
491
+ }
492
+ if (serviceOrigin && canOfferCertificateTrust(serviceOrigin)
493
+ && isCertificateTrustError(request.verificationResult)) {
494
+ // Resolve trust before Chromium caches a rejection. This covers fetch,
495
+ // WebSocket and navigation requests, not only the initial Node probe.
496
+ void resolveServiceCertificateTrust(serviceOrigin, request.hostname, request.certificate.data, () => requestCertificateTrust(serviceOrigin)).then((trusted) => callback(trusted ? 0 : -3), (error) => {
497
+ console.warn(`[desktop-certificate] ${serviceOrigin}: ${networkErrorDetails(error)}`);
498
+ callback(-3);
499
+ });
500
+ return;
501
+ }
502
+ callback(-3);
468
503
  });
469
504
  }
470
505
  function configureLocalServiceCertificateTrust() {
471
- try {
472
- managedLocalCertificateFingerprint = new crypto.X509Certificate(fs.readFileSync(localServiceCertificatePath)).fingerprint256;
473
- }
474
- catch {
475
- // The certificate is created when the local service first enables HTTPS.
476
- }
506
+ managedLocalCertificateFingerprint = readManagedLocalCertificateFingerprint();
477
507
  installCertificateVerifyProcedure();
478
508
  }
479
509
  function defaultConfig() {
@@ -549,7 +579,19 @@ function trustedCertificateAuthorityFor(target) {
549
579
  const origin = new URL(target).origin;
550
580
  return readDesktopConfig().trustedCertificateAuthorities.find((entry) => entry.origin === origin);
551
581
  }
552
- async function requestCertificateTrust(target) {
582
+ function requestCertificateTrust(target) {
583
+ return certificateTrustRequests.request(new URL(target).origin, () => confirmCertificateTrust(target));
584
+ }
585
+ function rememberSessionCertificate(target, certificate) {
586
+ const url = new URL(target);
587
+ const previous = sessionTrustedLeafByOrigin.get(url.origin);
588
+ if (previous)
589
+ sessionTrustedCertificateTargets.delete(certificateTrustKey(url.hostname, previous));
590
+ sessionTrustedLeafByOrigin.set(url.origin, certificate.leafFingerprint256);
591
+ sessionTrustedCertificateAuthorities.set(url.origin, certificate.certificatePem);
592
+ sessionTrustedCertificateTargets.add(certificateTrustKey(url.hostname, certificate.leafFingerprint256));
593
+ }
594
+ async function confirmCertificateTrust(target) {
553
595
  let certificate;
554
596
  try {
555
597
  certificate = await downloadCertificateAuthority(target);
@@ -565,8 +607,7 @@ async function requestCertificateTrust(target) {
565
607
  }
566
608
  const existingTrust = trustedCertificateAuthorityFor(target);
567
609
  if (existingTrust?.fingerprint256 === certificate.fingerprint256) {
568
- sessionTrustedCertificateAuthorities.set(new URL(target).origin, certificate.certificatePem);
569
- sessionTrustedCertificateTargets.add(certificateTrustKey(new URL(target).hostname, certificate.leafFingerprint256));
610
+ rememberSessionCertificate(target, certificate);
570
611
  return certificate;
571
612
  }
572
613
  const confirmation = await showDesktopMessageBox({
@@ -596,8 +637,7 @@ async function requestCertificateTrust(target) {
596
637
  trustedAt: Date.now(),
597
638
  });
598
639
  writeDesktopConfig(config);
599
- sessionTrustedCertificateAuthorities.set(origin, certificate.certificatePem);
600
- sessionTrustedCertificateTargets.add(certificateTrustKey(new URL(target).hostname, certificate.leafFingerprint256));
640
+ rememberSessionCertificate(target, certificate);
601
641
  return certificate;
602
642
  }
603
643
  async function requestLocalNetworkPermissionRetry(target) {
@@ -834,6 +874,12 @@ async function probeServiceWithLocalNetworkPermission(rawUrl, options = {}) {
834
874
  // Do the first macOS HTTPS probe with Node TLS. Sending an untrusted
835
875
  // certificate through Chromium first permanently caches that rejection for
836
876
  // the process and prevents an immediate retry after the user approves it.
877
+ if (options.interactive !== false && canOfferCertificateTrust(normalizedUrl)
878
+ && trustedCertificateAuthorityFor(normalizedUrl)) {
879
+ const certificate = await requestCertificateTrust(normalizedUrl);
880
+ if (!certificate)
881
+ return { ok: false, url: normalizedUrl, error: 'HTTPS 证书尚未受信任' };
882
+ }
837
883
  const approvedAuthority = sessionTrustedCertificateAuthorities.get(new URL(normalizedUrl).origin);
838
884
  let probe = approvedAuthority
839
885
  ? await probeServiceWithCertificateAuthority(normalizedUrl, approvedAuthority)
@@ -1315,6 +1361,14 @@ async function installCli() {
1315
1361
  return snapshot();
1316
1362
  }
1317
1363
  async function connectWindow(rawUrl, options = {}) {
1364
+ if (options.focus !== false) {
1365
+ try {
1366
+ certificateTrustRequests.retry(new URL(normalizeServiceUrl(rawUrl)).origin);
1367
+ }
1368
+ catch {
1369
+ return await probeService(rawUrl);
1370
+ }
1371
+ }
1318
1372
  let probe = await probeServiceWithLocalNetworkPermission(rawUrl, {
1319
1373
  interactive: options.focus !== false,
1320
1374
  });
@@ -1344,6 +1398,9 @@ async function connectWindow(rawUrl, options = {}) {
1344
1398
  existing.lastConnectedAt = Date.now();
1345
1399
  writeDesktopConfig(config);
1346
1400
  }
1401
+ if (await serviceDocumentNeedsReload(existingWindow.webContents, key)) {
1402
+ await existingWindow.loadURL(probe.url);
1403
+ }
1347
1404
  if (options.focus !== false) {
1348
1405
  mainWindow?.hide();
1349
1406
  showAndFocusWindow(existingWindow);
@@ -1462,6 +1519,55 @@ function finishStartupProgress() {
1462
1519
  return;
1463
1520
  mainWindow.webContents.send('desktop:startup-progress', null);
1464
1521
  }
1522
+ function scheduleServiceWindowRecovery(window, reason, delayMs = 750) {
1523
+ if (window.isDestroyed() || !windowServiceOrigins.has(window))
1524
+ return;
1525
+ const previous = serviceWindowRecoveryTimers.get(window);
1526
+ if (previous)
1527
+ clearTimeout(previous);
1528
+ const timer = setTimeout(() => {
1529
+ serviceWindowRecoveryTimers.delete(window);
1530
+ void recoverServiceWindow(window, reason);
1531
+ }, delayMs);
1532
+ timer.unref();
1533
+ serviceWindowRecoveryTimers.set(window, timer);
1534
+ }
1535
+ async function recoverServiceWindow(window, reason) {
1536
+ if (window.isDestroyed() || serviceWindowRecoveryInFlight.has(window))
1537
+ return;
1538
+ const serviceOrigin = windowServiceOrigins.get(window);
1539
+ if (!serviceOrigin)
1540
+ return;
1541
+ serviceWindowRecoveryInFlight.add(window);
1542
+ try {
1543
+ let probeTimer = null;
1544
+ const probe = await Promise.race([
1545
+ probeServiceWithLocalNetworkPermission(serviceOrigin, { interactive: false }),
1546
+ new Promise((resolve) => {
1547
+ probeTimer = setTimeout(() => resolve({
1548
+ ok: false,
1549
+ url: serviceOrigin,
1550
+ error: '桌面恢复健康检查超时',
1551
+ }), SERVICE_RECOVERY_PROBE_TIMEOUT_MS);
1552
+ probeTimer.unref();
1553
+ }),
1554
+ ]);
1555
+ if (probeTimer)
1556
+ clearTimeout(probeTimer);
1557
+ if (!probe.ok || window.isDestroyed())
1558
+ return;
1559
+ if (!(await serviceDocumentNeedsReload(window.webContents, serviceOrigin)) || window.isDestroyed())
1560
+ return;
1561
+ console.warn(`[desktop-recovery] reloading ${serviceOrigin} after ${reason}`);
1562
+ await window.loadURL(probe.url);
1563
+ }
1564
+ catch (error) {
1565
+ console.warn(`[desktop-recovery] ${serviceOrigin} recovery after ${reason} failed: ${networkErrorDetails(error)}`);
1566
+ }
1567
+ finally {
1568
+ serviceWindowRecoveryInFlight.delete(window);
1569
+ }
1570
+ }
1465
1571
  function createDesktopWindow(options) {
1466
1572
  const serviceSession = options
1467
1573
  ? session.fromPartition(`persist:termdock-service-${crypto
@@ -1471,7 +1577,7 @@ function createDesktopWindow(options) {
1471
1577
  .slice(0, 24)}`)
1472
1578
  : null;
1473
1579
  if (serviceSession)
1474
- installCertificateVerifyProcedure(serviceSession);
1580
+ installCertificateVerifyProcedure(serviceSession, options?.serviceOrigin);
1475
1581
  const window = new BrowserWindow({
1476
1582
  title: options ? `Termdock — ${options.label}` : 'Termdock — 连接中心',
1477
1583
  show: false,
@@ -1499,6 +1605,15 @@ function createDesktopWindow(options) {
1499
1605
  });
1500
1606
  if (options)
1501
1607
  windowServiceOrigins.set(window, options.serviceOrigin);
1608
+ if (options) {
1609
+ window.webContents.on('certificate-error', (event, url, error, certificate, callback) => {
1610
+ if (new URL(url).origin !== options.serviceOrigin
1611
+ || !canOfferCertificateTrust(url) || !isCertificateTrustError(error))
1612
+ return;
1613
+ event.preventDefault();
1614
+ void resolveServiceCertificateTrust(options.serviceOrigin, new URL(url).hostname, certificate.data, () => requestCertificateTrust(options.serviceOrigin)).then(callback, () => callback(false));
1615
+ });
1616
+ }
1502
1617
  if (options) {
1503
1618
  window.webContents.on('page-title-updated', (event) => {
1504
1619
  event.preventDefault();
@@ -1610,6 +1725,14 @@ function createDesktopWindow(options) {
1610
1725
  }
1611
1726
  `);
1612
1727
  });
1728
+ window.webContents.on('did-fail-load', (_event, _errorCode, _errorDescription, _validatedURL, isMainFrame) => {
1729
+ if (isMainFrame)
1730
+ scheduleServiceWindowRecovery(window, 'main-frame-load-failure');
1731
+ });
1732
+ window.webContents.on('render-process-gone', (_event, details) => {
1733
+ scheduleServiceWindowRecovery(window, `renderer-${details.reason}`);
1734
+ });
1735
+ window.on('unresponsive', () => scheduleServiceWindowRecovery(window, 'unresponsive'));
1613
1736
  window.on('closed', () => {
1614
1737
  if (mainWindow === window)
1615
1738
  mainWindow = null;
@@ -1629,6 +1752,7 @@ function createDesktopWindow(options) {
1629
1752
  broadcastServiceActivity();
1630
1753
  // User is looking at the app — the Dock badge has served its purpose.
1631
1754
  clearUnreadNotifications();
1755
+ scheduleServiceWindowRecovery(window, 'window-focus');
1632
1756
  });
1633
1757
  window.on('close', (event) => {
1634
1758
  const serviceOrigin = windowServiceOrigins.get(window);
@@ -1663,7 +1787,13 @@ function installIpcHandlers() {
1663
1787
  return png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength);
1664
1788
  });
1665
1789
  ipcMain.handle('desktop:snapshot', () => snapshot());
1666
- ipcMain.handle('desktop:probe', (_event, url) => probeServiceWithLocalNetworkPermission(url));
1790
+ ipcMain.handle('desktop:probe', (_event, url) => {
1791
+ try {
1792
+ certificateTrustRequests.retry(new URL(normalizeServiceUrl(url)).origin);
1793
+ }
1794
+ catch { /* probe reports invalid URLs */ }
1795
+ return probeServiceWithLocalNetworkPermission(url);
1796
+ });
1667
1797
  ipcMain.handle('desktop:save-connection', async (_event, input) => {
1668
1798
  const url = normalizeServiceUrl(input.url);
1669
1799
  const config = readDesktopConfig();
@@ -2047,6 +2177,11 @@ app.whenReady().then(async () => {
2047
2177
  refreshDesktopStatusSurfaces();
2048
2178
  screen.on('display-removed', keepFloatingWidgetOnScreen);
2049
2179
  screen.on('display-metrics-changed', keepFloatingWidgetOnScreen);
2180
+ powerMonitor.on('resume', () => {
2181
+ for (const window of serviceWindows.values()) {
2182
+ scheduleServiceWindowRecovery(window, 'system-resume', 1_200);
2183
+ }
2184
+ });
2050
2185
  configureDesktopUpdater(showDesktopMessageBox);
2051
2186
  subscribeDesktopUpdateState((state) => {
2052
2187
  for (const window of BrowserWindow.getAllWindows()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.169",
3
+ "version": "1.4.170",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "termdock",
4
- "version": "1.4.169",
4
+ "version": "1.4.170",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,