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.
- package/.desktop-dist/main.js +155 -20
- package/package.json +1 -1
- package/runtime-manifest.json +1 -1
package/.desktop-dist/main.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
?
|
|
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
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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