@alickjoe/webterm 0.1.4
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.md +158 -0
- package/cli.js +100 -0
- package/main.js +210 -0
- package/package.json +37 -0
- package/postinstall.js +44 -0
- package/retry.html +61 -0
- package/tools/cdp-font-probe.js +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# @alickjoe/webterm
|
|
2
|
+
|
|
3
|
+
webterm 桌面客户端:用 Electron 把 webterm 网页包装成独立应用窗口(纯展示壳,不打包 exe/安装器)。
|
|
4
|
+
|
|
5
|
+
内核即 Chromium,页面加载、Cookie、SSE、终端渲染与浏览器访问完全一致;仅 Cookie 分区独立、无浏览器扩展。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @alickjoe/webterm
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Electron 运行时下载说明**:electron@44 官方包不再带 postinstall 自动下载,默认是首次运行 `webterm` 时才下载。本包已通过自带 postinstall 把下载**合并进 npm install 阶段**(失败不阻断安装,首次运行时会自动重试)。同版本二进制有用户级缓存(Windows `%LOCALAPPDATA%\electron\Cache`),升级重装只解压不重新联网下载。企业网络下载失败的修复见「Windows 企业网络」一节。
|
|
14
|
+
|
|
15
|
+
## 使用
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 默认加载内置的线上地址(部署者通过 config.json / 构建参数设定)
|
|
19
|
+
webterm
|
|
20
|
+
|
|
21
|
+
# 临时指向其它地址(优先级最高)
|
|
22
|
+
webterm http://127.0.0.1:3300/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 配置
|
|
26
|
+
|
|
27
|
+
目标地址优先级:**命令行参数 > 环境变量 `WEBTERM_URL` > `~/.webterm/config.json` > 内置默认值**。
|
|
28
|
+
|
|
29
|
+
修改默认地址,创建 `~/.webterm/config.json`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "url": "https://<your-webterm-host>/" }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
其它环境变量:
|
|
36
|
+
|
|
37
|
+
| 变量 | 说明 |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `WEBTERM_URL` | 覆盖目标地址 |
|
|
40
|
+
| `WEBTERM_INSECURE_TLS=1` | 跳过 TLS 证书校验(仅公司自签证书链特殊时使用,有安全风险) |
|
|
41
|
+
| `WEBTERM_REMOTE_DEBUGGING=9222` | 开启 CDP 远程调试端口(仅调试用,勿在不可信环境开启) |
|
|
42
|
+
|
|
43
|
+
窗口尺寸/位置自动记忆到 `~/.webterm/window.json`。
|
|
44
|
+
|
|
45
|
+
## 平台说明
|
|
46
|
+
|
|
47
|
+
### Windows(主要目标平台)
|
|
48
|
+
|
|
49
|
+
- 无需任何系统依赖,Electron 自包含。
|
|
50
|
+
- **页面 TLS**:Chromium 读 **Windows 系统证书库**,与 Chrome/Edge 同源——企业 TLS 解密网关重签的证书由组策略下发的公司 CA 自动受信,**无需额外配置**。判据:浏览器能正常打开 webterm,本客户端就能。
|
|
51
|
+
- 例外:非公司管控的个人电脑若无公司根 CA,会显示重试页,可临时用 `WEBTERM_INSECURE_TLS=1`。
|
|
52
|
+
|
|
53
|
+
#### Windows 企业网络:Electron 二进制下载失败(fetch failed)
|
|
54
|
+
|
|
55
|
+
Electron 二进制下载走的是 Node 内置 fetch(undici)——它**不读 Windows 系统证书库**,遇到企业网关重签的证书会报 `TypeError: fetch failed`(重跑 `webterm` 会自动重试)。
|
|
56
|
+
|
|
57
|
+
修复(PowerShell):
|
|
58
|
+
|
|
59
|
+
```powershell
|
|
60
|
+
# ① 导出公司根 CA 并转为 PEM(Windows 内置工具;多条结果时改用 foreach 逐条导出)
|
|
61
|
+
# 将 <公司CA关键字> 替换为公司根 CA 证书 Subject 里的可匹配关键字
|
|
62
|
+
$out = "$env:USERPROFILE\corp-root-ca.pem"
|
|
63
|
+
Remove-Item $out -ErrorAction SilentlyContinue
|
|
64
|
+
foreach ($c in (@(Get-ChildItem Cert:\LocalMachine\Root) +
|
|
65
|
+
@(Get-ChildItem Cert:\LocalMachine\CA) |
|
|
66
|
+
Where-Object { $_.Subject -match "<公司CA关键字>" })) {
|
|
67
|
+
"-----BEGIN CERTIFICATE-----" | Add-Content $out
|
|
68
|
+
[Convert]::ToBase64String($c.RawData, 'InsertLineBreaks') | Add-Content $out
|
|
69
|
+
"-----END CERTIFICATE-----" | Add-Content $out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# ② 让 Node 信任公司根 CA + 走镜像,重新运行 webterm
|
|
73
|
+
$env:NODE_EXTRA_CA_CERTS = "$out"
|
|
74
|
+
$env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/"
|
|
75
|
+
webterm
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
验证通过后建议持久化(`setx` 对新开终端生效):
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
setx NODE_EXTRA_CA_CERTS "%USERPROFILE%\corp-root-ca.pem"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Linux
|
|
85
|
+
|
|
86
|
+
- 需要安装 GTK/NSS 等系统运行库(缺失时 `webterm` 启动会自动检测并提示)。Debian/Ubuntu:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
sudo apt-get install -y libgtk-3-0 libnss3 libasound2 libgl1 \
|
|
90
|
+
libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0 libcups2 libgbm1 \
|
|
91
|
+
libpango-1.0-0 libcairo2 libxcomposite1 libxdamage1 libxfixes3 \
|
|
92
|
+
libxkbcommon0 libxrandr2 \
|
|
93
|
+
fonts-noto-cjk fonts-noto-color-emoji fonts-noto-core
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> 字体三件套提供中文(CJK)、emoji 图标(SFTP 界面的 📁/📄 等)和符号字符;缺失会显示方块。安装后需重启客户端。
|
|
97
|
+
|
|
98
|
+
- 企业 TLS 解密网关证书:Chromium 在 Linux 上读 NSS 数据库而非系统 CA bundle,需手动导入公司根 CA(前提:根 CA 已导入系统信任库):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
sudo apt-get install -y libnss3-tools
|
|
102
|
+
mkdir -p ~/.pki/nssdb
|
|
103
|
+
certutil -d sql:$HOME/.pki/nssdb -N --empty-password
|
|
104
|
+
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n corp-root-ca \
|
|
105
|
+
-i /path/to/corp-root-ca.crt
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
(根 CA 文件路径以实际环境为准;导入后无需 `WEBTERM_INSECURE_TLS`。)
|
|
109
|
+
|
|
110
|
+
Fedora/RHEL 系统库:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
sudo dnf install gtk3 nss libnss3-tools alsa-lib libglvnd atk at-spi2-atk cups-libs mesa-libgbm pango libXcomposite libXdamage libXfixes libxkbcommon libXrandr
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Linux 字体排障
|
|
117
|
+
|
|
118
|
+
中文/emoji 显示方块(tofu)且已安装 `fonts-noto-cjk fonts-noto-color-emoji` 时,可能是 Electron 内置 fontconfig 与系统缓存格式不匹配(日志可见 `cache files were generated by a newer version` 警告)。清掉用户缓存后重启客户端即可(启动时自动重建):
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
rm -rf ~/.cache/fontconfig
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
验证 Chromium 实际字体视野(需 `WEBTERM_REMOTE_DEBUGGING=9223` 启动):
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
node tools/cdp-font-probe.js "ws://127.0.0.1:9223/devtools/page/<id>"
|
|
128
|
+
# 或先 curl http://127.0.0.1:9223/json/list 拿到 webSocketDebuggerUrl
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 行为说明
|
|
132
|
+
|
|
133
|
+
- 单实例:重复执行 `webterm` 会聚焦已打开的窗口,不会开出第二个。
|
|
134
|
+
- 断网/VPN 未连时显示内置重试页,可一键重试。
|
|
135
|
+
- 页面里的外部链接(非 webterm 同源)会在系统默认浏览器打开。
|
|
136
|
+
- 关闭窗口即完全退出。
|
|
137
|
+
|
|
138
|
+
## 开发
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
cd desktop
|
|
142
|
+
npm install
|
|
143
|
+
npm start # electron .
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## 发布(GitHub Actions CI)
|
|
147
|
+
|
|
148
|
+
发布由 tag 触发的 CI 自动完成(`.github/workflows/publish.yml`):
|
|
149
|
+
|
|
150
|
+
1. **一次性配置**:npmjs.com → Access Tokens → Generate New Token(选择 **Granular Access Token**,权限 Publish、包范围限定 `@alickjoe/webterm`、设置过期时间)→ GitHub 仓库 **Settings → Secrets and variables → Actions** 添加 secret `NPM_TOKEN`。
|
|
151
|
+
2. **日常发布**:更新 `desktop/package.json` 的 `version` → 提交 → 打 tag 并推送:
|
|
152
|
+
```bash
|
|
153
|
+
git tag v0.1.4 && git push origin v0.1.4
|
|
154
|
+
```
|
|
155
|
+
CI 校验 tag 与 package.json 版本一致后自动 `npm publish --access public --provenance`。
|
|
156
|
+
3. **手动兜底**(本地):`cd desktop && npm publish --access public`(浏览器 OTP 授权)。
|
|
157
|
+
|
|
158
|
+
> bin 路径写法注意:npm 11 的 `npm pkg fix` 会把 bin 值的 `./` 前缀剥掉,且发布时 `./xxx` 形式会被 auto-correct 移除——保持不带 `./` 的文件名形式。
|
package/cli.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* webterm CLI 入口(bin: webterm)
|
|
6
|
+
*
|
|
7
|
+
* 职责:
|
|
8
|
+
* 1. 基本环境检查(Linux 无图形会话时给出友好报错);
|
|
9
|
+
* 2. 支持 `webterm <url>` 快速覆盖目标地址(通过 env 传给主进程);
|
|
10
|
+
* 3. 拉起 Electron 主进程(main.js)并转发退出码。
|
|
11
|
+
*
|
|
12
|
+
* 单实例聚焦逻辑在 Electron 主进程内(requestSingleInstanceLock)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawn, spawnSync } = require('child_process');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
function fail(message) {
|
|
19
|
+
console.error(`[webterm] ${message}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Linux 无图形会话时 Electron 无法开窗,提前给出可读的错误
|
|
24
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
25
|
+
fail('未检测到图形会话(DISPLAY/WAYLAND_DISPLAY 均为空),无法打开应用窗口。');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// require('electron') 在普通 Node 进程中返回 electron 可执行文件路径(字符串);
|
|
29
|
+
// 二进制缺失时会触发懒下载(走公司网/TLS 网关可能失败)
|
|
30
|
+
let electronPath;
|
|
31
|
+
try {
|
|
32
|
+
electronPath = require('electron');
|
|
33
|
+
} catch (err) {
|
|
34
|
+
fail(
|
|
35
|
+
'Electron 运行时缺失或下载失败(常见于公司网络)。\n' +
|
|
36
|
+
'修复方法(PowerShell):\n' +
|
|
37
|
+
' 1. 公司 TLS 解密网关会重签证书,需让 Node 信任公司根 CA:\n' +
|
|
38
|
+
' 导出根 CA 为 PEM 后:$env:NODE_EXTRA_CA_CERTS="<根CA.pem路径>"\n' +
|
|
39
|
+
' 2. 或改用镜像下载:$env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"\n' +
|
|
40
|
+
' 然后重新运行 webterm(会自动重试下载)。\n' +
|
|
41
|
+
'详细说明见 README「Windows 企业网络」一节。'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (typeof electronPath !== 'string') {
|
|
45
|
+
fail('electron 依赖未正确安装,请尝试在包目录重新运行 `npm install`。');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Linux:检测 Electron 运行时缺失的系统库,给出发行版安装提示
|
|
49
|
+
if (process.platform === 'linux') {
|
|
50
|
+
try {
|
|
51
|
+
const ldd = spawnSync('ldd', [electronPath], { encoding: 'utf8' });
|
|
52
|
+
if (ldd.status === 0 && ldd.stdout) {
|
|
53
|
+
const missing = [...new Set(
|
|
54
|
+
ldd.stdout.split('\n')
|
|
55
|
+
.filter((line) => line.includes('not found'))
|
|
56
|
+
.map((line) => line.trim().split(' ')[0])
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
)];
|
|
59
|
+
if (missing.length > 0) {
|
|
60
|
+
fail(
|
|
61
|
+
`Electron 缺少系统依赖库:\n ${missing.join('\n ')}\n` +
|
|
62
|
+
'Debian/Ubuntu 可执行:\n' +
|
|
63
|
+
' sudo apt-get install -y libgtk-3-0 libnss3 libasound2 libgl1 \\\n' +
|
|
64
|
+
' libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0 libcups2 libgbm1 \\\n' +
|
|
65
|
+
' libpango-1.0-0 libcairo2 libxcomposite1 libxdamage1 libxfixes3 \\\n' +
|
|
66
|
+
' libxkbcommon0 libxrandr2\n' +
|
|
67
|
+
'Fedora/RHEL 对应:sudo dnf install gtk3 nss alsa-lib atk at-spi2-atk cups-libs mesa-libgbm pango libXcomposite libXdamage libXfixes libxkbcommon libXrandr'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// ldd 不存在时跳过检测,交给 Electron 自身报错
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `webterm <url>`:位置参数快速覆盖目标地址
|
|
77
|
+
const urlArg = process.argv[2];
|
|
78
|
+
if (urlArg) {
|
|
79
|
+
process.env.WEBTERM_URL = urlArg;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const appDir = __dirname;
|
|
83
|
+
|
|
84
|
+
const child = spawn(electronPath, [appDir], {
|
|
85
|
+
stdio: 'inherit',
|
|
86
|
+
// 注意:Windows 上不要设 windowsHide:true —— CREATE_NO_WINDOW 会导致
|
|
87
|
+
// Electron 窗口永不显示(进程正常、日志正常但无窗口)。
|
|
88
|
+
// electron.exe 是 GUI 子系统程序,不需要隐藏控制台。
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
child.on('error', (err) => {
|
|
92
|
+
fail(`无法启动 Electron:${err.message}`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
child.on('exit', (code, signal) => {
|
|
96
|
+
if (signal) {
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
process.exit(code == null ? 0 : code);
|
|
100
|
+
});
|
package/main.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* webterm Electron 主进程(纯展示壳)
|
|
5
|
+
*
|
|
6
|
+
* 行为:
|
|
7
|
+
* - 加载目标 URL(默认线上地址,WEBTERM_URL / ~/.webterm/config.json 可覆盖)
|
|
8
|
+
* - 单实例锁:二次启动时聚焦已有窗口
|
|
9
|
+
* - 窗口尺寸/位置记忆到 ~/.webterm/window.json
|
|
10
|
+
* - 主框架加载失败时展示本地重试页(retry.html)
|
|
11
|
+
* - 外部链接 / 跨源导航交给系统默认浏览器
|
|
12
|
+
* - 安全:contextIsolation 开启,不向页面暴露任何 Node 能力
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { app, BrowserWindow, Menu, shell } = require('electron');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
|
|
20
|
+
// 默认地址以 Base64 模糊存储:避免公开 npm 包内明文出现内部域名
|
|
21
|
+
// (仅为防直接检索的混淆手段,非加密;可用 config.json / WEBTERM_URL 覆盖)
|
|
22
|
+
const DEFAULT_URL = Buffer.from('aHR0cHM6Ly93ZWJ0ZXJtLmFsaWNremhvdS5jb20v', 'base64').toString('utf8');
|
|
23
|
+
const DATA_DIR = path.join(os.homedir(), '.webterm');
|
|
24
|
+
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
25
|
+
const WINDOW_STATE_FILE = path.join(DATA_DIR, 'window.json');
|
|
26
|
+
|
|
27
|
+
// ---- TLS 逃生开关(默认严格校验)----
|
|
28
|
+
if (process.env.WEBTERM_INSECURE_TLS === '1') {
|
|
29
|
+
// 必须在 app ready 之前设置
|
|
30
|
+
app.commandLine.appendSwitch('ignore-certificate-errors');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---- 调试:CDP 远程调试端口(WEBTERM_REMOTE_DEBUGGING=9222)----
|
|
34
|
+
if (process.env.WEBTERM_REMOTE_DEBUGGING) {
|
|
35
|
+
app.commandLine.appendSwitch(
|
|
36
|
+
'remote-debugging-port',
|
|
37
|
+
process.env.WEBTERM_REMOTE_DEBUGGING
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- 配置加载 ----
|
|
42
|
+
function loadConfig() {
|
|
43
|
+
// 优先级:env > 配置文件 > 内置默认
|
|
44
|
+
const fromEnv = process.env.WEBTERM_URL;
|
|
45
|
+
if (fromEnv) {
|
|
46
|
+
return normalizeUrl(fromEnv, 'WEBTERM_URL');
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
50
|
+
const cfg = JSON.parse(raw);
|
|
51
|
+
if (cfg && typeof cfg.url === 'string' && cfg.url) {
|
|
52
|
+
return normalizeUrl(cfg.url, CONFIG_FILE);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// 配置文件不存在或解析失败都走默认
|
|
56
|
+
}
|
|
57
|
+
return normalizeUrl(DEFAULT_URL, 'default');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeUrl(url, source) {
|
|
61
|
+
try {
|
|
62
|
+
const u = new URL(url);
|
|
63
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
64
|
+
throw new Error(`unsupported protocol: ${u.protocol}`);
|
|
65
|
+
}
|
|
66
|
+
return { url: u.toString(), source };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.warn(`[webterm] 无效的目标地址(来源 ${source}):${url},回退默认地址`);
|
|
69
|
+
return { url: DEFAULT_URL, source: 'default' };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- 窗口状态记忆 ----
|
|
74
|
+
function loadWindowState() {
|
|
75
|
+
const fallback = { width: 1280, height: 800 };
|
|
76
|
+
try {
|
|
77
|
+
const raw = fs.readFileSync(WINDOW_STATE_FILE, 'utf8');
|
|
78
|
+
const state = JSON.parse(raw);
|
|
79
|
+
return {
|
|
80
|
+
width: Number(state.width) > 0 ? Number(state.width) : fallback.width,
|
|
81
|
+
height: Number(state.height) > 0 ? Number(state.height) : fallback.height,
|
|
82
|
+
x: Number.isFinite(state.x) ? state.x : undefined,
|
|
83
|
+
y: Number.isFinite(state.y) ? state.y : undefined,
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function saveWindowState(win) {
|
|
91
|
+
try {
|
|
92
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
93
|
+
const bounds = win.getNormalBounds(); // 非最大化时的位置尺寸
|
|
94
|
+
fs.writeFileSync(WINDOW_STATE_FILE, JSON.stringify(bounds, null, 2), 'utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
// 写失败不影响退出
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- 主流程 ----
|
|
101
|
+
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
|
102
|
+
if (!gotSingleInstanceLock) {
|
|
103
|
+
// 已有实例:直接退出,由已有实例聚焦窗口
|
|
104
|
+
app.quit();
|
|
105
|
+
} else {
|
|
106
|
+
let mainWindow = null;
|
|
107
|
+
let target = loadConfig();
|
|
108
|
+
|
|
109
|
+
app.on('second-instance', () => {
|
|
110
|
+
if (mainWindow) {
|
|
111
|
+
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
112
|
+
if (!mainWindow.isVisible()) mainWindow.show();
|
|
113
|
+
mainWindow.focus();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function createWindow() {
|
|
118
|
+
const state = loadWindowState();
|
|
119
|
+
|
|
120
|
+
mainWindow = new BrowserWindow({
|
|
121
|
+
width: state.width,
|
|
122
|
+
height: state.height,
|
|
123
|
+
x: state.x,
|
|
124
|
+
y: state.y,
|
|
125
|
+
minWidth: 640,
|
|
126
|
+
minHeight: 480,
|
|
127
|
+
title: 'webterm',
|
|
128
|
+
autoHideMenuBar: true,
|
|
129
|
+
backgroundColor: '#1e1e1e',
|
|
130
|
+
webPreferences: {
|
|
131
|
+
contextIsolation: true,
|
|
132
|
+
nodeIntegration: false,
|
|
133
|
+
sandbox: true,
|
|
134
|
+
spellcheck: false,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
Menu.setApplicationMenu(null);
|
|
139
|
+
|
|
140
|
+
// 主框架加载失败 → 本地重试页(可点 Retry 重新加载目标地址)
|
|
141
|
+
mainWindow.webContents.on('did-fail-load', (event, code, desc, url, isMainFrame) => {
|
|
142
|
+
if (!isMainFrame) return;
|
|
143
|
+
if (code === -3) return; // ERR_ABORTED:通常由重定向/手动取消引起,忽略
|
|
144
|
+
console.error(`[webterm] 页面加载失败:${code} ${desc || ''}`);
|
|
145
|
+
mainWindow
|
|
146
|
+
.loadFile(path.join(__dirname, 'retry.html'), {
|
|
147
|
+
query: { url: target.url, code: String(code), desc: desc || '' },
|
|
148
|
+
})
|
|
149
|
+
.catch(() => {});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
mainWindow.webContents.on('did-finish-load', () => {
|
|
153
|
+
console.log('[webterm] 页面加载完成');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// 页面内的跨源导航 → 系统浏览器;同源导航放行
|
|
157
|
+
const appOrigin = safeOrigin(target.url);
|
|
158
|
+
mainWindow.webContents.on('will-navigate', (event, url) => {
|
|
159
|
+
if (safeOrigin(url) !== appOrigin) {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
shell.openExternal(url);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// window.open / target=_blank → 系统浏览器,不在壳内开新窗口
|
|
166
|
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
167
|
+
shell.openExternal(url);
|
|
168
|
+
return { action: 'deny' };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 关闭时保存窗口状态
|
|
172
|
+
mainWindow.on('close', () => {
|
|
173
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
174
|
+
saveWindowState(mainWindow);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
mainWindow.on('closed', () => {
|
|
179
|
+
mainWindow = null;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
mainWindow.loadURL(target.url).catch(() => {
|
|
183
|
+
// did-fail-load 会处理,这里吞掉 promise 拒绝
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function safeOrigin(url) {
|
|
188
|
+
try {
|
|
189
|
+
return new URL(url).origin;
|
|
190
|
+
} catch {
|
|
191
|
+
return '';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
app.whenReady().then(() => {
|
|
196
|
+
createWindow();
|
|
197
|
+
console.log(`[webterm] 目标地址:${target.url}(来源:${target.source})`);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.on('window-all-closed', () => {
|
|
201
|
+
app.quit();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.on('activate', () => {
|
|
205
|
+
// macOS:dock 点击时若无窗口则重建
|
|
206
|
+
if (BrowserWindow.getAllWindows().length === 0) {
|
|
207
|
+
createWindow();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alickjoe/webterm",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "webterm 桌面客户端:用 Electron 应用窗口包装 webterm 网页(不打包 exe)",
|
|
5
|
+
"bin": {
|
|
6
|
+
"webterm": "cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "main.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.js",
|
|
11
|
+
"main.js",
|
|
12
|
+
"postinstall.js",
|
|
13
|
+
"retry.html",
|
|
14
|
+
"README.md",
|
|
15
|
+
"tools"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postinstall": "node postinstall.js",
|
|
19
|
+
"start": "electron ."
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"webterm",
|
|
26
|
+
"ssh",
|
|
27
|
+
"terminal",
|
|
28
|
+
"sftp",
|
|
29
|
+
"electron",
|
|
30
|
+
"desktop"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"electron": "^44.4.3"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"private": false
|
|
37
|
+
}
|
package/postinstall.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @alickjoe/webterm postinstall:把 Electron 运行时下载合并进 npm install 阶段。
|
|
6
|
+
*
|
|
7
|
+
* 背景:electron@44 官方包不再带 postinstall 自动下载(scripts 为空),
|
|
8
|
+
* 默认体验是"装完之后第一次运行才下载"。这里在 install 阶段 require('electron')
|
|
9
|
+
* 主动触发一次下载(electron 的 index.js 会在二进制缺失时阻塞式下载)。
|
|
10
|
+
*
|
|
11
|
+
* 设计原则:
|
|
12
|
+
* - 下载失败【不阻断】npm install:懒下载仍会在首次运行 webterm 时兜底;
|
|
13
|
+
* - 同版本二进制有 @electron/get 用户级缓存(Windows %LOCALAPPDATA%\electron\Cache、
|
|
14
|
+
* Linux/macOS ~/.electron 或 ~/.cache),升级重装时仅解压不重新联网下载。
|
|
15
|
+
*
|
|
16
|
+
* 企业网络失败时的两个关键环境变量(详见 README):
|
|
17
|
+
* - NODE_EXTRA_CA_CERTS 让 Node 的 fetch 信任公司 TLS 解密网关重签的证书
|
|
18
|
+
* - ELECTRON_MIRROR 换下载镜像,如 https://npmmirror.com/mirrors/electron/
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
|
|
23
|
+
let binaryPath = null;
|
|
24
|
+
try {
|
|
25
|
+
// 普通 Node 进程中 require('electron') 返回二进制路径字符串;
|
|
26
|
+
// 若二进制缺失会触发 electron 内置的阻塞式下载,失败则抛错。
|
|
27
|
+
const p = require('electron');
|
|
28
|
+
if (typeof p === 'string' && fs.existsSync(p)) {
|
|
29
|
+
binaryPath = p;
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// 落到下面的警告分支
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (binaryPath) {
|
|
36
|
+
console.log('[webterm] Electron 运行时就绪 ✓');
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.warn('[webterm] Electron 运行时未就绪:本次安装没有下载成功。');
|
|
41
|
+
console.warn('[webterm] 不影响安装完成——首次运行 webterm 时会自动重试下载。');
|
|
42
|
+
console.warn('[webterm] 公司网络下载失败时,请在运行前设置(详见 README「Windows 企业网络」):');
|
|
43
|
+
console.warn('[webterm] NODE_EXTRA_CA_CERTS=<公司根CA.pem> ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/');
|
|
44
|
+
process.exit(0);
|
package/retry.html
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>webterm - 无法连接</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color-scheme: dark; }
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
height: 100vh;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
15
|
+
background: #1e1e1e;
|
|
16
|
+
color: #d4d4d4;
|
|
17
|
+
}
|
|
18
|
+
.box { max-width: 560px; padding: 32px; text-align: center; }
|
|
19
|
+
h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
|
|
20
|
+
.desc { color: #9ca3af; font-size: 13px; margin: 0 0 20px; word-break: break-all; }
|
|
21
|
+
.target { font-size: 13px; color: #6b7280; margin: 0 0 24px; word-break: break-all; }
|
|
22
|
+
#retry {
|
|
23
|
+
display: inline-block;
|
|
24
|
+
padding: 8px 28px;
|
|
25
|
+
border-radius: 6px;
|
|
26
|
+
background: #2563eb;
|
|
27
|
+
color: #fff;
|
|
28
|
+
text-decoration: none;
|
|
29
|
+
font-size: 14px;
|
|
30
|
+
}
|
|
31
|
+
#retry:hover { background: #1d4ed8; }
|
|
32
|
+
.hint { margin-top: 20px; font-size: 12px; color: #6b7280; line-height: 1.7; }
|
|
33
|
+
code { background: #2d2d2d; padding: 1px 6px; border-radius: 4px; }
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div class="box">
|
|
38
|
+
<h1>无法连接到 webterm</h1>
|
|
39
|
+
<p class="desc" id="desc"></p>
|
|
40
|
+
<p class="target">目标地址:<span id="target"></span></p>
|
|
41
|
+
<a id="retry" href="#">重试</a>
|
|
42
|
+
<p class="hint">
|
|
43
|
+
请检查网络与 VPN 连接。<br />
|
|
44
|
+
更换地址:修改 <code>~/.webterm/config.json</code>,或用环境变量
|
|
45
|
+
<code>WEBTERM_URL</code>,或执行 <code>webterm <url></code>。
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
<script>
|
|
49
|
+
var p = new URLSearchParams(location.search);
|
|
50
|
+
var url = p.get('url') || '';
|
|
51
|
+
document.getElementById('desc').textContent = p.get('desc') ? '错误:' + p.get('code') + ' ' + p.get('desc') : '';
|
|
52
|
+
if (url) {
|
|
53
|
+
document.getElementById('target').textContent = url;
|
|
54
|
+
document.getElementById('retry').href = url;
|
|
55
|
+
} else {
|
|
56
|
+
document.getElementById('retry').style.display = 'none';
|
|
57
|
+
document.getElementById('target').textContent = '未配置目标地址(WEBTERM_URL 环境变量或 ~/.webterm/config.json)';
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CDP 字体探测:通过 remote-debugging-port 询问页面渲染进程
|
|
6
|
+
* document.fonts.check 是否认为 CJK / emoji 字体可用。
|
|
7
|
+
* 用法: node cdp-font-probe.js <ws-url>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const wsUrl = process.argv[2];
|
|
11
|
+
if (!wsUrl) {
|
|
12
|
+
console.error('usage: node cdp-font-probe.js <ws-url>');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ws = new WebSocket(wsUrl);
|
|
17
|
+
let id = 0;
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
|
|
20
|
+
function send(method, params) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const msgId = ++id;
|
|
23
|
+
pending.set(msgId, resolve);
|
|
24
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ws.onopen = async () => {
|
|
29
|
+
const probes = [
|
|
30
|
+
['zh-general', 'sans-serif', '中文测试'],
|
|
31
|
+
['zh-explicit', '"Noto Sans CJK SC"', '中'],
|
|
32
|
+
['emoji-dir', '"Noto Color Emoji"', '📁'],
|
|
33
|
+
['emoji-file', '"Noto Color Emoji"', '📄'],
|
|
34
|
+
['latin', 'sans-serif', 'abc'],
|
|
35
|
+
];
|
|
36
|
+
for (const [name, font, text] of probes) {
|
|
37
|
+
const expr = `document.fonts.check('16px ${font}', ${JSON.stringify(text)})`;
|
|
38
|
+
const res = await send('Runtime.evaluate', { expression: expr, returnByValue: true });
|
|
39
|
+
console.log(`${name.padEnd(12)} ${font.padEnd(22)} "${text}" -> ${res.result?.result?.value ?? JSON.stringify(res.result)}`);
|
|
40
|
+
}
|
|
41
|
+
ws.close();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
ws.onerror = (e) => {
|
|
46
|
+
console.error('ws error', e.message || e);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
ws.onmessage = (ev) => {
|
|
51
|
+
const msg = JSON.parse(ev.data);
|
|
52
|
+
if (msg.id && pending.has(msg.id)) {
|
|
53
|
+
pending.get(msg.id)(msg.result);
|
|
54
|
+
pending.delete(msg.id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
setTimeout(() => { console.error('timeout'); process.exit(1); }, 10000);
|