termdock 1.4.34 → 1.4.35
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 +800 -0
- package/README.md +13 -0
- package/dist/client/assets/{abnfDiagram-VRR7QNED-C0dQSsoH.js → abnfDiagram-VRR7QNED-DJ6Yq6GP.js} +1 -1
- package/dist/client/assets/{arc-DYA1ov03.js → arc-kBJ9sW7T.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-BLFQJMml.js → architectureDiagram-ZJ3FMSHR-Cb93VIke.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-yG6-Tbj-.js → blockDiagram-677ZJIJ3-Ti9R15O5.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-CvQTDVga.js → c4Diagram-LMCZKHZV-Bmig4PBm.js} +1 -1
- package/dist/client/assets/channel-BoXSn7FH.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-Bf4YGJ3F.js → chunk-2Q5K7J3B-07zEMyec.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-B-kMdGHl.js → chunk-32BRIVSS-BIk-_oIf.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-Ce8Cqwk_.js → chunk-5VM5RSS4-BzhNbnWD.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BPBbbSD3.js → chunk-EX3LRPZG-t9bReK7N.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-BXIFurSn.js → chunk-JWPE2WC7-CLSDgcjE.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-CYWJVxLV.js → chunk-MOJQB5TN-B5T4ivkz.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-S9f9wct-.js → chunk-RYQCIY6F-BS3eFWQI.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-BV7bx9fJ.js → chunk-V7JOEXUC-CJF3jzYE.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-rMA-UjjY.js → chunk-VR4S4FIN-DU-SHKYW.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-Ckst7G5N.js → chunk-XXDRQBXY-CobNAbcK.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-C26JutgZ.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-C26JutgZ.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-CrbATiXx.js → cose-bilkent-JH36ORCC-B79BNIyG.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-BZ-R3La7.js → cynefin-VYW2F7L2-CXICklHM.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BtIXk4X7.js → cynefinDiagram-TSTJHNR4-DyTkCIbt.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-DURezupR.js → dagre-VKFMJZFB-DcVOWzSf.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-CsoGSzC5.js → diagram-FQU43EPY-DZYk_nYw.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-QuB2r0Vg.js → diagram-G47NLZAW-DVA6NeKc.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-BhZeAlmq.js → diagram-NH7WQ7WH-uKF70Mhc.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-BT1Mmddm.js → diagram-OA4YK3LP-CkN9kgIx.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-SHNc7tUN.js → diagram-WEI45ONY-DpxBX964.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-CJYipYZ-.js → ebnfDiagram-CCIWWBDH-CE2I50iL.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-CkZOpI-I.js → erDiagram-Q63AITRT-B9YVjL3f.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-BY81IcpE.js → flowDiagram-23GEKE2U-DdJSODkn.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-Db20bqVZ.js → ganttDiagram-NO4QXBWP-BAJm8N7S.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-kW_pwwxZ.js → gitGraphDiagram-IHSO6WYX-BidRfnwi.js} +1 -1
- package/dist/client/assets/index-BpANqDLk.css +1 -0
- package/dist/client/assets/index-DHJJPt2f.js +428 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-BgA-e5yO.js → infoDiagram-FWYZ7A6U-BeNR07Sb.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BXm4cs64.js → ishikawaDiagram-FXEZZL3T-DNDoTEWX.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BKLJTXOT.js → journeyDiagram-5HDEW3XC-ChVhQ3fS.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-BqG2vBDj.js → kanban-definition-HUTT4EX6-BbXFe0q5.js} +1 -1
- package/dist/client/assets/{linear-Cd3N-C-u.js → linear-E-rJq47Y.js} +1 -1
- package/dist/client/assets/{mermaid.core-CTF-egug.js → mermaid.core-DjsjqJ8C.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-DtcrNWwo.js → mindmap-definition-LN4V7U3C-VFDIOJEA.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-ClbJdO1u.js → pegDiagram-2B236MQR-CHG4KUqq.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DM0_Ei5k.js → pieDiagram-ENE6RG2P-B5TjQCWy.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-j5oZ1Yp6.js → quadrantDiagram-ABIIQ3AL-DVUvdogx.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DLWubqvc.js → railroadDiagram-RFXS5EU6-CoIP0_iy.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-Uj3DMCA0.js → requirementDiagram-TGXJPOKE-Bvjo3ha6.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CKEuv1WM.js → sankeyDiagram-HTMAVEWB-pxTZHAYO.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-DrO9nw6k.js → sequenceDiagram-DBY2YBRQ-DAI2QZkb.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-B1YO3j08.js → sizeCapture-X5ZJPWSS-C6GqdPZu.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-BWlXpdah.js → stateDiagram-2N3HPSRC-BFWLhcdy.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BE3-8lFK.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-wQX0-mMC.js → swimlanes-5IMT3BWC-BHbh9wxz.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-BXs0aUO4.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-Del9sQNG.js → timeline-definition-FHXFAJF6-Cy-efeIk.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-BPiPBIT9.js → vennDiagram-L72KCM5P-Dyt_IAiy.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-CMk3mt0q.js → wardleyDiagram-EHGQE667-D7vEeOqf.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-B-8ypIu6.js → xychartDiagram-FW5EYKEG-CyUEG84c.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw-notifications.js +78 -0
- package/dist/client/sw.js +1 -1
- package/dist/server/cli.js +3 -3
- package/dist/server/entry.js +17 -0
- package/dist/server/notifications/pushService.js +136 -0
- package/dist/server/routes/notifications.js +75 -0
- package/dist/server/routes/terminal.js +10 -3
- package/dist/server/utils/settings.js +9 -0
- package/dist/server/utils/version.js +33 -0
- package/package.json +12 -1
- package/scripts/prepare-desktop-runtime.mjs +200 -0
- package/scripts/prepare-desktop-tokens.mjs +35 -0
- package/dist/client/assets/channel-Ba_VIQg_.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-BuY9UlR6.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BuY9UlR6.js +0 -1
- package/dist/client/assets/index-V98UNkyI.css +0 -1
- package/dist/client/assets/index-o3mSVtfJ.js +0 -422
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Cyw-2qEK.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DGZQ-K3m.js +0 -8
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import { app, BrowserWindow, dialog, ipcMain, Menu, net, Notification, shell, } from 'electron';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { checkForDesktopUpdates, configureDesktopUpdater } from './updater.js';
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const projectRoot = path.resolve(currentDir, '..');
|
|
13
|
+
const termdockDir = path.join(os.homedir(), '.termdock');
|
|
14
|
+
const desktopConfigPath = path.join(termdockDir, 'desktop.json');
|
|
15
|
+
const serverStatePath = path.join(termdockDir, 'server.json');
|
|
16
|
+
const DEFAULT_LOCAL_URL = 'http://localhost:9834';
|
|
17
|
+
const PROTOCOL_VERSION = 1;
|
|
18
|
+
const HEALTH_TIMEOUT_MS = 3_500;
|
|
19
|
+
const START_TIMEOUT_MS = 90_000;
|
|
20
|
+
let mainWindow = null;
|
|
21
|
+
let activeServiceOrigin = null;
|
|
22
|
+
let isQuitting = false;
|
|
23
|
+
function showDesktopMessageBox(options) {
|
|
24
|
+
return mainWindow
|
|
25
|
+
? dialog.showMessageBox(mainWindow, options)
|
|
26
|
+
: dialog.showMessageBox(options);
|
|
27
|
+
}
|
|
28
|
+
function defaultConfig() {
|
|
29
|
+
return { version: 1, connections: [], lastConnectionUrl: null };
|
|
30
|
+
}
|
|
31
|
+
function normalizeServiceUrl(raw) {
|
|
32
|
+
const value = raw.trim();
|
|
33
|
+
if (!value)
|
|
34
|
+
throw new Error('请输入 Termdock 服务地址');
|
|
35
|
+
const parsed = new URL(value.includes('://') ? value : `http://${value}`);
|
|
36
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
37
|
+
throw new Error('服务地址只支持 http:// 或 https://');
|
|
38
|
+
}
|
|
39
|
+
parsed.username = '';
|
|
40
|
+
parsed.password = '';
|
|
41
|
+
parsed.hash = '';
|
|
42
|
+
parsed.search = '';
|
|
43
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
|
44
|
+
return parsed.toString().replace(/\/$/, '');
|
|
45
|
+
}
|
|
46
|
+
function isLocalNetworkTarget(url) {
|
|
47
|
+
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
48
|
+
if (hostname === 'localhost'
|
|
49
|
+
|| hostname.endsWith('.local')
|
|
50
|
+
|| !hostname.includes('.')) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (/^10\./.test(hostname) || /^192\.168\./.test(hostname) || /^169\.254\./.test(hostname)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const ipv4 = hostname.match(/^172\.(\d{1,3})\./);
|
|
57
|
+
if (ipv4) {
|
|
58
|
+
const secondOctet = Number(ipv4[1]);
|
|
59
|
+
if (secondOctet >= 16 && secondOctet <= 31)
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return hostname === '::1'
|
|
63
|
+
|| hostname.startsWith('fe80:')
|
|
64
|
+
|| hostname.startsWith('fc')
|
|
65
|
+
|| hostname.startsWith('fd');
|
|
66
|
+
}
|
|
67
|
+
function networkErrorDetails(error) {
|
|
68
|
+
if (!(error instanceof Error))
|
|
69
|
+
return String(error);
|
|
70
|
+
const cause = error.cause;
|
|
71
|
+
return cause ? `${error.message} ${networkErrorDetails(cause)}` : error.message;
|
|
72
|
+
}
|
|
73
|
+
function looksLikeLocalNetworkPermissionError(error) {
|
|
74
|
+
return /ERR_ADDRESS_UNREACHABLE|ERR_NETWORK_ACCESS_DENIED/i.test(networkErrorDetails(error));
|
|
75
|
+
}
|
|
76
|
+
async function requestLocalNetworkPermissionRetry(target) {
|
|
77
|
+
const result = await showDesktopMessageBox({
|
|
78
|
+
type: 'warning',
|
|
79
|
+
title: '需要本地网络权限',
|
|
80
|
+
message: 'macOS 阻止了 Termdock 访问局域网服务',
|
|
81
|
+
detail: `目标:${target}\n\n请在系统提示中选择“允许”。如果之前选择过“不允许”,请前往“系统设置 → 隐私与安全性 → 本地网络”开启 Termdock。`,
|
|
82
|
+
buttons: ['已允许,重试', '打开系统设置', '取消'],
|
|
83
|
+
defaultId: 0,
|
|
84
|
+
cancelId: 2,
|
|
85
|
+
});
|
|
86
|
+
if (result.response === 1) {
|
|
87
|
+
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork');
|
|
88
|
+
}
|
|
89
|
+
return result.response === 0;
|
|
90
|
+
}
|
|
91
|
+
function readDesktopConfig() {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(fs.readFileSync(desktopConfigPath, 'utf8'));
|
|
94
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.connections))
|
|
95
|
+
return defaultConfig();
|
|
96
|
+
return {
|
|
97
|
+
version: 1,
|
|
98
|
+
connections: parsed.connections.filter((entry) => Boolean(entry)
|
|
99
|
+
&& typeof entry.id === 'string'
|
|
100
|
+
&& typeof entry.label === 'string'
|
|
101
|
+
&& typeof entry.url === 'string'),
|
|
102
|
+
lastConnectionUrl: typeof parsed.lastConnectionUrl === 'string' ? parsed.lastConnectionUrl : null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return defaultConfig();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function writeDesktopConfig(config) {
|
|
110
|
+
fs.mkdirSync(termdockDir, { recursive: true, mode: 0o700 });
|
|
111
|
+
const temporaryPath = `${desktopConfigPath}.${process.pid}.tmp`;
|
|
112
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
113
|
+
fs.renameSync(temporaryPath, desktopConfigPath);
|
|
114
|
+
}
|
|
115
|
+
function readServerState() {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(fs.readFileSync(serverStatePath, 'utf8'));
|
|
118
|
+
if (typeof parsed.pid !== 'number'
|
|
119
|
+
|| !Number.isInteger(parsed.pid)
|
|
120
|
+
|| typeof parsed.host !== 'string'
|
|
121
|
+
|| typeof parsed.port !== 'number') {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function isProcessRunning(pid) {
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, 0);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function stateUrl(state) {
|
|
140
|
+
if (state.localUrl)
|
|
141
|
+
return normalizeServiceUrl(state.localUrl);
|
|
142
|
+
const scheme = state.scheme ?? 'http';
|
|
143
|
+
const host = state.host === '0.0.0.0' ? 'localhost' : state.host;
|
|
144
|
+
return `${scheme}://${host}:${state.port}`;
|
|
145
|
+
}
|
|
146
|
+
async function fetchWithTimeout(url) {
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
149
|
+
try {
|
|
150
|
+
// Electron's network stack follows the macOS Keychain trust store. Node's
|
|
151
|
+
// global fetch does not, so a healthy local service using Termdock's
|
|
152
|
+
// mkcert certificate could otherwise be reported as a failed start.
|
|
153
|
+
return await net.fetch(url, {
|
|
154
|
+
signal: controller.signal,
|
|
155
|
+
redirect: 'error',
|
|
156
|
+
headers: { Accept: 'application/json' },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function probeService(rawUrl) {
|
|
164
|
+
let url;
|
|
165
|
+
try {
|
|
166
|
+
url = normalizeServiceUrl(rawUrl);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return { ok: false, url: rawUrl, error: error instanceof Error ? error.message : String(error) };
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const healthResponse = await fetchWithTimeout(`${url}/health`);
|
|
173
|
+
if (!healthResponse.ok) {
|
|
174
|
+
return { ok: false, url, error: `健康检查返回 HTTP ${healthResponse.status}` };
|
|
175
|
+
}
|
|
176
|
+
const health = await healthResponse.json();
|
|
177
|
+
if (health.status !== 'ok') {
|
|
178
|
+
return { ok: false, url, error: '目标地址不是可识别的 Termdock 服务' };
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const metadataResponse = await fetchWithTimeout(`${url}/api/meta`);
|
|
182
|
+
if (metadataResponse.ok) {
|
|
183
|
+
const metadata = await metadataResponse.json();
|
|
184
|
+
if (metadata.product && metadata.product !== 'termdock') {
|
|
185
|
+
return { ok: false, url, error: '目标服务的产品标识不是 Termdock' };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
url,
|
|
190
|
+
version: typeof metadata.version === 'string' ? metadata.version : undefined,
|
|
191
|
+
protocolVersion: typeof metadata.protocolVersion === 'number'
|
|
192
|
+
? metadata.protocolVersion
|
|
193
|
+
: undefined,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Older Termdock releases do not expose /api/meta. A valid health
|
|
199
|
+
// response is enough to allow a backwards-compatible connection.
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, url };
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
const message = error instanceof Error && error.name === 'AbortError'
|
|
205
|
+
? '连接超时'
|
|
206
|
+
: (error instanceof Error ? error.message : String(error));
|
|
207
|
+
return { ok: false, url, error: message };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function getLocalServiceStatus() {
|
|
211
|
+
const state = readServerState();
|
|
212
|
+
if (!state || !isProcessRunning(state.pid)) {
|
|
213
|
+
return { running: false, state, probe: null };
|
|
214
|
+
}
|
|
215
|
+
const probe = await probeService(stateUrl(state));
|
|
216
|
+
return { running: probe.ok, state, probe };
|
|
217
|
+
}
|
|
218
|
+
function runtimePaths() {
|
|
219
|
+
if (app.isPackaged) {
|
|
220
|
+
return {
|
|
221
|
+
node: path.join(process.resourcesPath, 'runtime', 'bin', 'node'),
|
|
222
|
+
cli: path.join(process.resourcesPath, 'server', 'dist', 'server', 'cli.js'),
|
|
223
|
+
launcher: path.join(process.resourcesPath, 'cli', 'td'),
|
|
224
|
+
toolchainBin: path.join(process.resourcesPath, 'toolchain', 'bin'),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
node: process.env.TERMDOCK_NODE_BIN || 'node',
|
|
229
|
+
cli: path.join(projectRoot, 'dist', 'server', 'cli.js'),
|
|
230
|
+
launcher: path.join(projectRoot, 'desktop', 'cli', 'td'),
|
|
231
|
+
toolchainBin: path.join(projectRoot, '.desktop-runtime', 'toolchain', 'bin'),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async function executableVersion(executable) {
|
|
235
|
+
try {
|
|
236
|
+
const { stdout } = await execFileAsync(executable, ['--version'], {
|
|
237
|
+
timeout: 5_000,
|
|
238
|
+
maxBuffer: 128 * 1024,
|
|
239
|
+
});
|
|
240
|
+
return stdout.trim().split(/\s+/).at(-1) || null;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function discoverCliInstallations() {
|
|
247
|
+
const candidates = new Set();
|
|
248
|
+
try {
|
|
249
|
+
const { stdout } = await execFileAsync('/bin/zsh', ['-lic', 'whence -pa td termdock'], {
|
|
250
|
+
timeout: 5_000,
|
|
251
|
+
maxBuffer: 128 * 1024,
|
|
252
|
+
});
|
|
253
|
+
for (const line of stdout.split('\n')) {
|
|
254
|
+
const candidate = line.trim();
|
|
255
|
+
if (candidate.startsWith('/'))
|
|
256
|
+
candidates.add(candidate);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// A clean machine may not have any CLI entry yet.
|
|
261
|
+
}
|
|
262
|
+
for (const candidate of ['/usr/local/bin/td', '/opt/homebrew/bin/td']) {
|
|
263
|
+
if (fs.existsSync(candidate))
|
|
264
|
+
candidates.add(candidate);
|
|
265
|
+
}
|
|
266
|
+
const bundledLauncher = runtimePaths().launcher;
|
|
267
|
+
return Promise.all([...candidates].map(async (candidate) => {
|
|
268
|
+
let resolved = candidate;
|
|
269
|
+
try {
|
|
270
|
+
resolved = fs.realpathSync(candidate);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Keep the visible path for a broken symlink so the repair UI can show it.
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
path: candidate,
|
|
277
|
+
version: await executableVersion(candidate),
|
|
278
|
+
bundled: resolved === bundledLauncher,
|
|
279
|
+
};
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
async function bundledCliVersion() {
|
|
283
|
+
const runtime = runtimePaths();
|
|
284
|
+
try {
|
|
285
|
+
const { stdout } = await execFileAsync(runtime.node, [runtime.cli, '--version'], {
|
|
286
|
+
timeout: 5_000,
|
|
287
|
+
maxBuffer: 128 * 1024,
|
|
288
|
+
env: desktopRuntimeEnv(),
|
|
289
|
+
});
|
|
290
|
+
return stdout.trim() || app.getVersion();
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return app.getVersion();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function snapshot() {
|
|
297
|
+
const config = readDesktopConfig();
|
|
298
|
+
const [localService, cliInstallations, cliVersion] = await Promise.all([
|
|
299
|
+
getLocalServiceStatus(),
|
|
300
|
+
discoverCliInstallations(),
|
|
301
|
+
bundledCliVersion(),
|
|
302
|
+
]);
|
|
303
|
+
return {
|
|
304
|
+
appVersion: app.getVersion(),
|
|
305
|
+
packaged: app.isPackaged,
|
|
306
|
+
bundledCliVersion: cliVersion,
|
|
307
|
+
cliInstallations,
|
|
308
|
+
localService,
|
|
309
|
+
connections: config.connections,
|
|
310
|
+
lastConnectionUrl: config.lastConnectionUrl,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function desktopRuntimeEnv() {
|
|
314
|
+
const runtime = runtimePaths();
|
|
315
|
+
const currentPath = process.env.PATH ?? '/usr/bin:/bin:/usr/sbin:/sbin';
|
|
316
|
+
return {
|
|
317
|
+
...process.env,
|
|
318
|
+
PATH: [runtime.toolchainBin, '/opt/homebrew/bin', '/usr/local/bin', currentPath]
|
|
319
|
+
.filter(Boolean)
|
|
320
|
+
.join(path.delimiter),
|
|
321
|
+
TERMDOCK_DESKTOP: '1',
|
|
322
|
+
TERMDOCK_BUNDLED_RUNTIME: app.isPackaged ? '1' : '0',
|
|
323
|
+
TERMDOCK_VERSION: app.getVersion(),
|
|
324
|
+
TMUX_BIN: fs.existsSync(path.join(runtime.toolchainBin, 'tmux'))
|
|
325
|
+
? path.join(runtime.toolchainBin, 'tmux')
|
|
326
|
+
: process.env.TMUX_BIN,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async function waitForLocalService() {
|
|
330
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
331
|
+
let lastProbe = { ok: false, url: DEFAULT_LOCAL_URL, error: '服务尚未启动' };
|
|
332
|
+
while (Date.now() < deadline) {
|
|
333
|
+
const state = readServerState();
|
|
334
|
+
const url = state ? stateUrl(state) : DEFAULT_LOCAL_URL;
|
|
335
|
+
lastProbe = await probeService(url);
|
|
336
|
+
if (lastProbe.ok)
|
|
337
|
+
return lastProbe;
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
339
|
+
}
|
|
340
|
+
return lastProbe;
|
|
341
|
+
}
|
|
342
|
+
async function confirmAndStopExisting(status) {
|
|
343
|
+
if (!status.state)
|
|
344
|
+
return true;
|
|
345
|
+
const version = status.probe?.version ? `版本:${status.probe.version}\n` : '';
|
|
346
|
+
const result = await showDesktopMessageBox({
|
|
347
|
+
type: 'warning',
|
|
348
|
+
title: '接管本机 Termdock 服务',
|
|
349
|
+
message: '检测到正在运行的 Termdock 服务',
|
|
350
|
+
detail: `${version}地址:${stateUrl(status.state)}\nPID:${status.state.pid}\n\n停止后将由桌面版使用同一个 ~/.termdock 重新启动。tmux 会话不会被删除。`,
|
|
351
|
+
buttons: ['连接现有服务', '停止并由桌面版接管', '取消'],
|
|
352
|
+
defaultId: 0,
|
|
353
|
+
cancelId: 2,
|
|
354
|
+
});
|
|
355
|
+
if (result.response === 0) {
|
|
356
|
+
await connectWindow(stateUrl(status.state));
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
if (result.response !== 1)
|
|
360
|
+
return false;
|
|
361
|
+
if (!isProcessRunning(status.state.pid))
|
|
362
|
+
return true;
|
|
363
|
+
process.kill(status.state.pid, 'SIGTERM');
|
|
364
|
+
const deadline = Date.now() + 8_000;
|
|
365
|
+
while (Date.now() < deadline && isProcessRunning(status.state.pid)) {
|
|
366
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
367
|
+
}
|
|
368
|
+
if (isProcessRunning(status.state.pid)) {
|
|
369
|
+
throw new Error(`旧服务 PID ${status.state.pid} 未能在超时时间内退出`);
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const latest = readServerState();
|
|
373
|
+
if (latest?.pid === status.state.pid)
|
|
374
|
+
fs.rmSync(serverStatePath, { force: true });
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// The exiting CLI normally removes the state file itself.
|
|
378
|
+
}
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
async function startLocalService() {
|
|
382
|
+
const existing = await getLocalServiceStatus();
|
|
383
|
+
if (existing.running) {
|
|
384
|
+
const shouldStart = await confirmAndStopExisting(existing);
|
|
385
|
+
if (!shouldStart) {
|
|
386
|
+
return existing.probe ?? { ok: true, url: stateUrl(existing.state) };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const runtime = runtimePaths();
|
|
390
|
+
if (!fs.existsSync(runtime.cli)) {
|
|
391
|
+
throw new Error('未找到 Termdock 服务端构建,请先运行 npm run build');
|
|
392
|
+
}
|
|
393
|
+
if (app.isPackaged && !fs.existsSync(runtime.node)) {
|
|
394
|
+
throw new Error('安装包缺少内嵌 Node Runtime');
|
|
395
|
+
}
|
|
396
|
+
fs.mkdirSync(termdockDir, { recursive: true, mode: 0o700 });
|
|
397
|
+
const logPath = path.join(termdockDir, 'server.log');
|
|
398
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
399
|
+
const child = spawn(runtime.node, [
|
|
400
|
+
runtime.cli,
|
|
401
|
+
'--foreground',
|
|
402
|
+
'--host',
|
|
403
|
+
'0.0.0.0',
|
|
404
|
+
'--port',
|
|
405
|
+
'9834',
|
|
406
|
+
], {
|
|
407
|
+
detached: true,
|
|
408
|
+
stdio: ['ignore', logFd, logFd],
|
|
409
|
+
env: desktopRuntimeEnv(),
|
|
410
|
+
});
|
|
411
|
+
fs.closeSync(logFd);
|
|
412
|
+
child.unref();
|
|
413
|
+
const probe = await waitForLocalService();
|
|
414
|
+
if (!probe.ok) {
|
|
415
|
+
throw new Error(`桌面版服务启动失败:${probe.error ?? '未知错误'}。日志:${logPath}`);
|
|
416
|
+
}
|
|
417
|
+
await connectWindow(probe.url);
|
|
418
|
+
return probe;
|
|
419
|
+
}
|
|
420
|
+
function shellQuote(value) {
|
|
421
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
422
|
+
}
|
|
423
|
+
function appleScriptQuote(value) {
|
|
424
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
425
|
+
}
|
|
426
|
+
async function installCli() {
|
|
427
|
+
if (!app.isPackaged) {
|
|
428
|
+
throw new Error('CLI 一键安装只在打包后的 Termdock.app 中可用');
|
|
429
|
+
}
|
|
430
|
+
const runtime = runtimePaths();
|
|
431
|
+
if (!fs.existsSync(runtime.launcher)) {
|
|
432
|
+
throw new Error('安装包中缺少 CLI 启动器');
|
|
433
|
+
}
|
|
434
|
+
const current = await discoverCliInstallations();
|
|
435
|
+
const detailLines = current.length > 0
|
|
436
|
+
? current.map((entry) => `${entry.path}(${entry.version ?? '版本未知'})`)
|
|
437
|
+
: ['未检测到已有 td/termdock 命令'];
|
|
438
|
+
const confirmation = await showDesktopMessageBox({
|
|
439
|
+
type: 'question',
|
|
440
|
+
title: '安装 Termdock CLI',
|
|
441
|
+
message: '将桌面版内嵌 CLI 安装为 td 和 termdock',
|
|
442
|
+
detail: `${detailLines.join('\n')}\n\n目标目录:/usr/local/bin\n已有同名入口只会在你确认后替换。`,
|
|
443
|
+
buttons: ['安装', '取消'],
|
|
444
|
+
defaultId: 0,
|
|
445
|
+
cancelId: 1,
|
|
446
|
+
});
|
|
447
|
+
if (confirmation.response !== 0)
|
|
448
|
+
return snapshot();
|
|
449
|
+
const command = [
|
|
450
|
+
'mkdir -p /usr/local/bin',
|
|
451
|
+
`ln -sfn ${shellQuote(runtime.launcher)} /usr/local/bin/td`,
|
|
452
|
+
`ln -sfn ${shellQuote(runtime.launcher)} /usr/local/bin/termdock`,
|
|
453
|
+
].join(' && ');
|
|
454
|
+
await execFileAsync('/usr/bin/osascript', [
|
|
455
|
+
'-e',
|
|
456
|
+
`do shell script "${appleScriptQuote(command)}" with administrator privileges`,
|
|
457
|
+
], { timeout: 120_000, maxBuffer: 256 * 1024 });
|
|
458
|
+
return snapshot();
|
|
459
|
+
}
|
|
460
|
+
async function connectWindow(rawUrl) {
|
|
461
|
+
let probe = await probeService(rawUrl);
|
|
462
|
+
if (!probe.ok) {
|
|
463
|
+
let parsedProbeUrl = null;
|
|
464
|
+
try {
|
|
465
|
+
parsedProbeUrl = new URL(probe.url);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// The probe already contains the normalized URL validation error.
|
|
469
|
+
}
|
|
470
|
+
if (parsedProbeUrl
|
|
471
|
+
&& isLocalNetworkTarget(parsedProbeUrl)
|
|
472
|
+
&& looksLikeLocalNetworkPermissionError(probe.error)) {
|
|
473
|
+
const shouldRetry = await requestLocalNetworkPermissionRetry(probe.url);
|
|
474
|
+
if (shouldRetry) {
|
|
475
|
+
probe = await probeService(rawUrl);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
return { ...probe, error: 'Termdock 尚未获得 macOS 本地网络权限' };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (!probe.ok)
|
|
483
|
+
return probe;
|
|
484
|
+
const parsed = new URL(probe.url);
|
|
485
|
+
while (mainWindow) {
|
|
486
|
+
activeServiceOrigin = parsed.origin;
|
|
487
|
+
try {
|
|
488
|
+
await mainWindow.loadURL(probe.url);
|
|
489
|
+
const config = readDesktopConfig();
|
|
490
|
+
config.lastConnectionUrl = probe.url;
|
|
491
|
+
const existing = config.connections.find((entry) => entry.url === probe.url);
|
|
492
|
+
if (existing)
|
|
493
|
+
existing.lastConnectedAt = Date.now();
|
|
494
|
+
writeDesktopConfig(config);
|
|
495
|
+
return probe;
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
activeServiceOrigin = null;
|
|
499
|
+
await showConnectionCenter();
|
|
500
|
+
const message = networkErrorDetails(error);
|
|
501
|
+
if (isLocalNetworkTarget(parsed) && looksLikeLocalNetworkPermissionError(error)) {
|
|
502
|
+
if (await requestLocalNetworkPermissionRetry(probe.url))
|
|
503
|
+
continue;
|
|
504
|
+
return { ...probe, ok: false, error: 'Termdock 尚未获得 macOS 本地网络权限' };
|
|
505
|
+
}
|
|
506
|
+
await showDesktopMessageBox({
|
|
507
|
+
type: 'error',
|
|
508
|
+
title: '连接 Termdock 服务失败',
|
|
509
|
+
message: `无法打开 ${probe.url}`,
|
|
510
|
+
detail: message,
|
|
511
|
+
});
|
|
512
|
+
return { ...probe, ok: false, error: message };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { ...probe, ok: false, error: 'Termdock 窗口已关闭' };
|
|
516
|
+
}
|
|
517
|
+
async function showConnectionCenter() {
|
|
518
|
+
activeServiceOrigin = null;
|
|
519
|
+
const rendererPath = app.isPackaged
|
|
520
|
+
? path.join(process.resourcesPath, 'renderer', 'index.html')
|
|
521
|
+
: path.join(projectRoot, 'desktop', 'renderer', 'index.html');
|
|
522
|
+
await mainWindow?.loadFile(rendererPath);
|
|
523
|
+
mainWindow?.show();
|
|
524
|
+
mainWindow?.focus();
|
|
525
|
+
}
|
|
526
|
+
function createMainWindow() {
|
|
527
|
+
const window = new BrowserWindow({
|
|
528
|
+
title: 'Termdock',
|
|
529
|
+
width: 1280,
|
|
530
|
+
height: 820,
|
|
531
|
+
minWidth: 760,
|
|
532
|
+
minHeight: 520,
|
|
533
|
+
backgroundColor: 'rgb(28, 27, 26)',
|
|
534
|
+
titleBarStyle: 'hiddenInset',
|
|
535
|
+
trafficLightPosition: { x: 16, y: 16 },
|
|
536
|
+
webPreferences: {
|
|
537
|
+
preload: path.join(currentDir, 'preload.cjs'),
|
|
538
|
+
nodeIntegration: false,
|
|
539
|
+
contextIsolation: true,
|
|
540
|
+
sandbox: true,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
544
|
+
void shell.openExternal(url);
|
|
545
|
+
return { action: 'deny' };
|
|
546
|
+
});
|
|
547
|
+
window.webContents.on('will-navigate', (event, url) => {
|
|
548
|
+
if (!activeServiceOrigin)
|
|
549
|
+
return;
|
|
550
|
+
try {
|
|
551
|
+
if (new URL(url).origin === activeServiceOrigin)
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
// Block malformed navigations.
|
|
556
|
+
}
|
|
557
|
+
event.preventDefault();
|
|
558
|
+
void shell.openExternal(url);
|
|
559
|
+
});
|
|
560
|
+
window.webContents.on('did-finish-load', () => {
|
|
561
|
+
if (!activeServiceOrigin)
|
|
562
|
+
return;
|
|
563
|
+
void window.webContents.insertCSS(`
|
|
564
|
+
html[data-termdock-desktop='true'] {
|
|
565
|
+
--safe-top-inset: max(env(safe-area-inset-top, 0px), 38px) !important;
|
|
566
|
+
}
|
|
567
|
+
`);
|
|
568
|
+
});
|
|
569
|
+
window.on('closed', () => {
|
|
570
|
+
if (mainWindow === window)
|
|
571
|
+
mainWindow = null;
|
|
572
|
+
});
|
|
573
|
+
window.on('close', (event) => {
|
|
574
|
+
if (process.platform === 'darwin' && !isQuitting) {
|
|
575
|
+
event.preventDefault();
|
|
576
|
+
window.hide();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
return window;
|
|
580
|
+
}
|
|
581
|
+
function installIpcHandlers() {
|
|
582
|
+
ipcMain.handle('desktop:snapshot', () => snapshot());
|
|
583
|
+
ipcMain.handle('desktop:probe', (_event, url) => probeService(url));
|
|
584
|
+
ipcMain.handle('desktop:save-connection', async (_event, input) => {
|
|
585
|
+
const url = normalizeServiceUrl(input.url);
|
|
586
|
+
const config = readDesktopConfig();
|
|
587
|
+
const existing = config.connections.find((entry) => entry.url === url);
|
|
588
|
+
if (existing) {
|
|
589
|
+
existing.label = input.label.trim() || new URL(url).host;
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
config.connections.push({
|
|
593
|
+
id: crypto.randomUUID(),
|
|
594
|
+
label: input.label.trim() || new URL(url).host,
|
|
595
|
+
url,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
writeDesktopConfig(config);
|
|
599
|
+
return snapshot();
|
|
600
|
+
});
|
|
601
|
+
ipcMain.handle('desktop:remove-connection', async (_event, id) => {
|
|
602
|
+
const config = readDesktopConfig();
|
|
603
|
+
config.connections = config.connections.filter((entry) => entry.id !== id);
|
|
604
|
+
writeDesktopConfig(config);
|
|
605
|
+
return snapshot();
|
|
606
|
+
});
|
|
607
|
+
ipcMain.handle('desktop:connect', (_event, url) => connectWindow(url));
|
|
608
|
+
ipcMain.handle('desktop:start-local', () => startLocalService());
|
|
609
|
+
ipcMain.handle('desktop:install-cli', () => installCli());
|
|
610
|
+
ipcMain.handle('desktop:show-connection-center', () => showConnectionCenter());
|
|
611
|
+
ipcMain.handle('desktop:reveal-data-directory', async () => {
|
|
612
|
+
fs.mkdirSync(termdockDir, { recursive: true, mode: 0o700 });
|
|
613
|
+
const error = await shell.openPath(termdockDir);
|
|
614
|
+
if (error)
|
|
615
|
+
throw new Error(error);
|
|
616
|
+
});
|
|
617
|
+
ipcMain.handle('desktop:show-notification', (_event, payload) => {
|
|
618
|
+
if (!Notification.isSupported() || typeof payload?.title !== 'string')
|
|
619
|
+
return false;
|
|
620
|
+
const notification = new Notification({
|
|
621
|
+
title: payload.title.slice(0, 160),
|
|
622
|
+
body: typeof payload.body === 'string' ? payload.body.slice(0, 1000) : undefined,
|
|
623
|
+
silent: payload.silent === true,
|
|
624
|
+
});
|
|
625
|
+
notification.on('click', () => {
|
|
626
|
+
mainWindow?.show();
|
|
627
|
+
mainWindow?.focus();
|
|
628
|
+
if (typeof payload.sessionId === 'string') {
|
|
629
|
+
mainWindow?.webContents.send('desktop:focus-session', payload.sessionId);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
notification.show();
|
|
633
|
+
return true;
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
function installMenu() {
|
|
637
|
+
const sendCommand = (command) => {
|
|
638
|
+
mainWindow?.webContents.send('desktop:command', command);
|
|
639
|
+
};
|
|
640
|
+
const menu = Menu.buildFromTemplate([
|
|
641
|
+
{
|
|
642
|
+
label: 'Termdock',
|
|
643
|
+
submenu: [
|
|
644
|
+
{ role: 'about' },
|
|
645
|
+
{ type: 'separator' },
|
|
646
|
+
{
|
|
647
|
+
label: '设置…',
|
|
648
|
+
accelerator: 'CmdOrCtrl+,',
|
|
649
|
+
click: () => mainWindow?.webContents.send('desktop:open-settings'),
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
label: '连接中心',
|
|
653
|
+
accelerator: 'CmdOrCtrl+Shift+O',
|
|
654
|
+
click: () => void showConnectionCenter(),
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
label: '安装或修复 CLI…',
|
|
658
|
+
click: () => void installCli().catch((error) => {
|
|
659
|
+
void showDesktopMessageBox({
|
|
660
|
+
type: 'error',
|
|
661
|
+
title: 'CLI 安装失败',
|
|
662
|
+
message: error instanceof Error ? error.message : String(error),
|
|
663
|
+
});
|
|
664
|
+
}),
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
label: '检查更新…',
|
|
668
|
+
click: () => void checkForDesktopUpdates(true),
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
label: '打开 Termdock 数据目录',
|
|
672
|
+
click: () => {
|
|
673
|
+
fs.mkdirSync(termdockDir, { recursive: true, mode: 0o700 });
|
|
674
|
+
void shell.openPath(termdockDir);
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{ type: 'separator' },
|
|
678
|
+
{ role: 'hide' },
|
|
679
|
+
{ role: 'hideOthers' },
|
|
680
|
+
{ role: 'unhide' },
|
|
681
|
+
{ type: 'separator' },
|
|
682
|
+
{ role: 'quit' },
|
|
683
|
+
],
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
label: '文件',
|
|
687
|
+
submenu: [
|
|
688
|
+
{
|
|
689
|
+
label: '新建会话',
|
|
690
|
+
accelerator: 'CmdOrCtrl+T',
|
|
691
|
+
click: () => sendCommand('new-session'),
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
label: '关闭当前会话',
|
|
695
|
+
accelerator: 'CmdOrCtrl+W',
|
|
696
|
+
click: () => sendCommand('close-session'),
|
|
697
|
+
},
|
|
698
|
+
{ type: 'separator' },
|
|
699
|
+
{
|
|
700
|
+
label: '上一个会话',
|
|
701
|
+
accelerator: 'CmdOrCtrl+Shift+[',
|
|
702
|
+
click: () => sendCommand('previous-session'),
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
label: '下一个会话',
|
|
706
|
+
accelerator: 'CmdOrCtrl+Shift+]',
|
|
707
|
+
click: () => sendCommand('next-session'),
|
|
708
|
+
},
|
|
709
|
+
{ type: 'separator' },
|
|
710
|
+
{
|
|
711
|
+
label: '上一个待处理',
|
|
712
|
+
accelerator: 'CmdOrCtrl+Alt+[',
|
|
713
|
+
click: () => sendCommand('previous-attention'),
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
label: '下一个待处理',
|
|
717
|
+
accelerator: 'CmdOrCtrl+Alt+]',
|
|
718
|
+
click: () => sendCommand('next-attention'),
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
label: '编辑',
|
|
724
|
+
submenu: [
|
|
725
|
+
{ role: 'undo' },
|
|
726
|
+
{ role: 'redo' },
|
|
727
|
+
{ type: 'separator' },
|
|
728
|
+
{ role: 'cut' },
|
|
729
|
+
{ role: 'copy' },
|
|
730
|
+
{ role: 'paste' },
|
|
731
|
+
{ role: 'selectAll' },
|
|
732
|
+
],
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
label: '显示',
|
|
736
|
+
submenu: [
|
|
737
|
+
{
|
|
738
|
+
label: '切换会话侧栏',
|
|
739
|
+
accelerator: 'CmdOrCtrl+B',
|
|
740
|
+
click: () => sendCommand('toggle-left-sidebar'),
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
label: '切换文件侧栏',
|
|
744
|
+
accelerator: 'CmdOrCtrl+Shift+B',
|
|
745
|
+
click: () => sendCommand('toggle-right-sidebar'),
|
|
746
|
+
},
|
|
747
|
+
{ type: 'separator' },
|
|
748
|
+
{ role: 'reload' },
|
|
749
|
+
{ role: 'forceReload' },
|
|
750
|
+
{ type: 'separator' },
|
|
751
|
+
{ role: 'resetZoom' },
|
|
752
|
+
{ role: 'zoomIn' },
|
|
753
|
+
{ role: 'zoomOut' },
|
|
754
|
+
{ type: 'separator' },
|
|
755
|
+
{ role: 'togglefullscreen' },
|
|
756
|
+
],
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
label: '窗口',
|
|
760
|
+
submenu: [
|
|
761
|
+
{ role: 'minimize' },
|
|
762
|
+
{ role: 'zoom' },
|
|
763
|
+
{ role: 'front' },
|
|
764
|
+
],
|
|
765
|
+
},
|
|
766
|
+
]);
|
|
767
|
+
Menu.setApplicationMenu(menu);
|
|
768
|
+
}
|
|
769
|
+
app.whenReady().then(async () => {
|
|
770
|
+
installIpcHandlers();
|
|
771
|
+
installMenu();
|
|
772
|
+
mainWindow = createMainWindow();
|
|
773
|
+
configureDesktopUpdater(showDesktopMessageBox);
|
|
774
|
+
await showConnectionCenter();
|
|
775
|
+
const lastConnectionUrl = readDesktopConfig().lastConnectionUrl;
|
|
776
|
+
if (lastConnectionUrl) {
|
|
777
|
+
const probe = await connectWindow(lastConnectionUrl);
|
|
778
|
+
if (!probe.ok && mainWindow?.webContents.getURL().startsWith('file:') === false) {
|
|
779
|
+
await showConnectionCenter();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
app.on('activate', () => {
|
|
783
|
+
if (!mainWindow) {
|
|
784
|
+
mainWindow = createMainWindow();
|
|
785
|
+
void showConnectionCenter();
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}).catch((error) => {
|
|
789
|
+
void dialog.showErrorBox('Termdock 启动失败', error instanceof Error ? error.message : String(error));
|
|
790
|
+
app.quit();
|
|
791
|
+
});
|
|
792
|
+
app.on('window-all-closed', () => {
|
|
793
|
+
// Keep the macOS application lifecycle conventional: closing the last
|
|
794
|
+
// window keeps the app available in the Dock, while the detached Termdock
|
|
795
|
+
// service continues independently.
|
|
796
|
+
});
|
|
797
|
+
app.on('before-quit', () => {
|
|
798
|
+
isQuitting = true;
|
|
799
|
+
});
|
|
800
|
+
export { PROTOCOL_VERSION };
|