mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
3 Commits
+8
-4
@@ -42,15 +42,19 @@ WOC_HTTP_PORT=36080
|
||||
#
|
||||
# 单域名: PANEL_ALLOWED_HOSTS=woc.example.com
|
||||
# 多域名: PANEL_ALLOWED_HOSTS=woc.example.com,woc.lan,wechat.mynas.cn
|
||||
# 通配子域: PANEL_ALLOWED_HOSTS=*.example.com (匹配 a.example.com,不含裸 example.com)
|
||||
# 含 IPv6 字面量:PANEL_ALLOWED_HOSTS=[2001:db8::1]
|
||||
#
|
||||
# 排错:若反代部署后浏览器看到 400 "Host header not allowed",多半是反代未透传
|
||||
# 客户端 Host(nginx 默认透传;Caddy v2 默认透传;某些环境会改写)—— 要么修反代
|
||||
# 配置让 Host 头透传,要么把反代后端看到的内部域名加进本白名单。
|
||||
# 改完务必用 **docker compose up -d** 让容器带新环境重建;只 `docker restart` **不会**加载新值!
|
||||
#
|
||||
# 排错:若反代/Cloudflare 部署后仍看到 400 "Host header not allowed",错误响应里会回显面板实际
|
||||
# 收到的 host / forwardedHost —— 把那个值(或其通配)加进本白名单即可。面板也会自动接受
|
||||
# X-Forwarded-Host(可信反代设置),故多数反代无需额外配置。
|
||||
PANEL_ALLOWED_HOSTS=
|
||||
|
||||
# ── 音频 / 麦克风 / 摄像头 ───────────────────────────────────
|
||||
# 音频(听):开箱即用,进入桌面后点 KasmVNC 左侧工具条的扬声器开启。
|
||||
# 音频(听):**自动开启**,进入桌面、点一下画面(浏览器自动播放策略要求一次手势)即可出声,
|
||||
# 无需手动找工具条。仅当前聚焦的实例出声,切走/切到别的实例会自动断开,避免多端串音。
|
||||
# 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板
|
||||
# (或 localhost)。生产环境务必给面板套 HTTPS(反代/证书),否则浏览器会禁用麦克风与摄像头。
|
||||
#
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/issues"><img src="https://img.shields.io/github/issues/Gloridust/WechatOnCloud?style=flat-square" alt="issues" /></a>
|
||||
<img src="https://img.shields.io/badge/arch-amd64%20%7C%20arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="arch" />
|
||||
<img src="https://img.shields.io/badge/PWA-ready-5A0FC8?style=flat-square" alt="pwa" />
|
||||
<a href="https://x.com/gloridust1024"><img src="https://img.shields.io/badge/Twitter-@gloridust1024-1DA1F2?style=flat-square&logo=x&logoColor=white" alt="twitter" /></a>
|
||||
<a href="https://t.me/WechatOnCloud"><img src="https://img.shields.io/badge/Telegram-WechatOnCloud-26A5E4?style=flat-square&logo=telegram&logoColor=white" alt="telegram" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -35,6 +37,8 @@
|
||||
|
||||
**一句话原理**:每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个微信会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
|
||||
|
||||
交流群: [@WechatOnCloud](https://t.me/WechatOnCloud)
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
@@ -177,6 +181,11 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
- [ ] 掉登录时 web 端二维码重扫入口
|
||||
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
|
||||
|
||||
## 交流与关注
|
||||
|
||||
- 🐦 Twitter / X:[@gloridust1024](https://x.com/gloridust1024) — 更新与动态
|
||||
- ✈️ Telegram:[@WechatOnCloud](https://t.me/WechatOnCloud) — 交流群 / 问题反馈
|
||||
|
||||
## 致谢
|
||||
|
||||
创意启发自懒猫微服(原 Deepin 团队做的硬件产品),推荐有经济实力、追求稳定运营的朋友尝试!
|
||||
|
||||
+2
-1
@@ -17,9 +17,10 @@
|
||||
| **唯一且持久的 machine-id** | 每个实例首启生成专属 machine-id,存入数据卷,重启/升级/重建都不变。解决"全网实例共用镜像里烤死的同一个 machine-id"这一最致命信号。 | 恒开 |
|
||||
| **真实的 hostname** | 内部主机名伪装成"个人电脑"样式(如 `lenovo-pc-372`),不再是 `woc-wx-<hex>` 这种容器/服务器特征。每实例不同、稳定不变。 | 恒开 |
|
||||
| **移除 `/.dockerenv`** | 删掉 Docker 注入的容器标记文件。 | 恒开 |
|
||||
| **真实网卡 MAC** | 用常见网卡厂商 OUI(Intel/Realtek 等)+ 由实例 id 稳定派生的后三段,替代容器默认带"本地管理位"的 MAC(`02/26/ee…` 开头 = 明显非真实硬件)。每实例不同、稳定不变。 | 恒开 |
|
||||
| **os-release 伪装成 deepin** | `/etc/os-release` 显示为 deepin 23(微信官方支持的发行版;Deepin 基于 Debian,与本镜像用户态一致,不自相矛盾)。 | `WOC_SPOOF_OS`,默认 1,设 0 恢复 Debian |
|
||||
|
||||
实现位置:`docker/woc-identity.sh`(启动钩子 `/custom-cont-init.d/00-woc-identity`,root 身份、在微信启动前执行)+ `panel/server/src/docker.ts`(hostname / 开关透传)。
|
||||
实现位置:`docker/woc-identity.sh`(启动钩子 `/custom-cont-init.d/00-woc-identity`,root 身份、在微信启动前执行)+ `panel/server/src/docker.ts`(hostname / MAC / 开关透传,建容器时设置)。
|
||||
|
||||
## 手动「重置设备 ID」
|
||||
|
||||
|
||||
@@ -7,3 +7,8 @@ mkdir -p /config/.config/openbox
|
||||
cp /defaults/autostart /config/.config/openbox/autostart
|
||||
chmod +x /config/.config/openbox/autostart
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /config/.config/openbox/autostart 2>/dev/null || true
|
||||
|
||||
# 文件中转目录(桌面):确保归 abc(PUID) 所有。否则曾被 root 建成 755 root:root 时,微信(abc 身份)
|
||||
# 另存到此处会"保存失败",面板上传也写不进。每次启动强制纠正属主,兼容历史遗留的 root 属主。
|
||||
mkdir -p /config/Desktop
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /config/Desktop 2>/dev/null || true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { hostname } from 'node:os';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import Docker from 'dockerode';
|
||||
import type { Instance } from './store.js';
|
||||
|
||||
@@ -38,6 +39,20 @@ function realisticHostname(id: string): string {
|
||||
return `${w}-pc-${n}`;
|
||||
}
|
||||
|
||||
// 给实例容器派生一个"像真实有线网卡"的 MAC:常见网卡厂商 OUI 前缀 + 由 id 稳定派生的后三段。
|
||||
// 容器默认 MAC 带"本地管理位"(第一字节第 2 位为 1,如 02/26/ee 开头),是"非真实硬件"的明显特征;
|
||||
// 这里用全局管理、单播的真实厂商 OUI,更像一台插了网卡的真机。同一实例每次重建得到相同 MAC。
|
||||
function realisticMac(id: string): string {
|
||||
// 常见消费级网卡厂商 OUI(全局管理 + 单播,首字节低两位为 0)
|
||||
const ouis = ['001b21', '8c1645', '00e04c', '0021cc', '3c970e', '001422', 'b827eb'];
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h * 131 + id.charCodeAt(i)) >>> 0;
|
||||
const oui = ouis[h % ouis.length];
|
||||
const hex = (n: number) => (n & 0xff).toString(16).padStart(2, '0');
|
||||
const tail = hex(h >>> 3) + hex(h >>> 11) + hex(h >>> 19);
|
||||
return (oui + tail).match(/.{2}/g)!.join(':');
|
||||
}
|
||||
|
||||
const docker = new Docker(); // 默认连 /var/run/docker.sock
|
||||
|
||||
// 面板自身所在的 docker 网络名;新实例都 attach 到它,便于按容器名互访。
|
||||
@@ -141,7 +156,9 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN
|
||||
console.log(`[docker] 实例 ${inst.id} 挂载摄像头设备: ${vids.join(', ')}`);
|
||||
}
|
||||
const container = await docker.createContainer({
|
||||
// 伪装成真实有线网卡 MAC(厂商 OUI),替代容器默认的本地管理位 MAC。
|
||||
const mac = realisticMac(inst.id);
|
||||
const createOpts: Docker.ContainerCreateOptions = {
|
||||
name: inst.containerName,
|
||||
Image: WECHAT_IMAGE,
|
||||
// 内部 hostname 伪装成"个人电脑"名(不再用 woc-wx-<hex>,那是容器/服务器特征)。
|
||||
@@ -150,7 +167,14 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
Env: envList(inst),
|
||||
ExposedPorts: { '3000/tcp': {} },
|
||||
HostConfig: hostConfig,
|
||||
});
|
||||
};
|
||||
// 自定义网络时,MAC 须写到对应 endpoint 上(新版 docker 弃用顶层 MacAddress);默认网络则用顶层。
|
||||
if (net) {
|
||||
createOpts.NetworkingConfig = { EndpointsConfig: { [net]: { MacAddress: mac } as any } };
|
||||
} else {
|
||||
(createOpts as any).MacAddress = mac;
|
||||
}
|
||||
const container = await docker.createContainer(createOpts);
|
||||
try {
|
||||
await container.start();
|
||||
} catch (e) {
|
||||
@@ -307,6 +331,42 @@ export async function instanceMemoryMB(inst: Instance): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
// 响应性健康探测:实测发现容器跑久了会出现 I/O / 服务 stall —— 进程没死、面板显示"在线",
|
||||
// 但读不出 VNC 客户端静态文件(nginx 报 upstream timed out),浏览器永远卡在"正在连接桌面"。
|
||||
// 这里带注入鉴权请求真正会卡的那条路径(/vnc/index.html,经 nginx→kclient 静态serve),
|
||||
// 超时即判不健康。无鉴权时 nginx 直接 401(很快),故必须注入鉴权让请求真正打到 kclient 静态层。
|
||||
export async function instanceHttpHealthy(inst: Instance, timeoutMs = 8000): Promise<boolean> {
|
||||
const auth = 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64');
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const done = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(ok);
|
||||
};
|
||||
const req = http.get(
|
||||
{
|
||||
host: inst.containerName,
|
||||
port: 3000,
|
||||
path: '/vnc/index.html',
|
||||
headers: { authorization: auth },
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
// 拿到响应头即说明 nginx+kclient 静态serve 活着(健康时为 200)。读掉 body 释放连接。
|
||||
const ok = !!res.statusCode && res.statusCode < 500;
|
||||
res.resume();
|
||||
done(ok);
|
||||
},
|
||||
);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
done(false); // 超时 = stall,判不健康
|
||||
});
|
||||
req.on('error', () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
|
||||
try {
|
||||
const info = await docker.getContainer(inst.containerName).inspect();
|
||||
@@ -463,10 +523,26 @@ export async function downloadFromInstance(inst: Instance, name: string): Promis
|
||||
stream.on('error', reject);
|
||||
});
|
||||
const tar = Buffer.concat(chunks);
|
||||
if (tar.length < 512) return Buffer.alloc(0);
|
||||
const sizeStr = tar.toString('ascii', 124, 135).replace(/\0/g, '').trim();
|
||||
const size = parseInt(sizeStr, 8) || 0;
|
||||
return tar.subarray(512, 512 + size);
|
||||
// 解析 tar,定位真正的普通文件块。Docker(Go archive/tar) 在 mtime 含纳秒精度等情况下会先写一个
|
||||
// PAX 扩展头块(typeflag 'x'),旧代码误把它当文件头、读到的是扩展记录长度 → 返回错误长度的数据
|
||||
// ("大小不对")。这里跳过 PAX/全局('x'/'g')与 GNU 长名('L'/'K')等扩展头,找到普通文件('0'/NUL)再取内容。
|
||||
let off = 0;
|
||||
while (off + 512 <= tar.length) {
|
||||
const header = tar.subarray(off, off + 512);
|
||||
let allZero = true;
|
||||
for (let i = 0; i < 512; i++) if (header[i] !== 0) { allZero = false; break; }
|
||||
if (allZero) break; // 归档结束(全零块)
|
||||
const sizeStr = header.toString('ascii', 124, 136).replace(/[^0-7]/g, '');
|
||||
const size = sizeStr ? parseInt(sizeStr, 8) : 0;
|
||||
const typeflag = header[156]; // '0'(0x30) 或 NUL(0) = 普通文件
|
||||
const dataStart = off + 512;
|
||||
if (typeflag === 0x30 || typeflag === 0) {
|
||||
return tar.subarray(dataStart, dataStart + size);
|
||||
}
|
||||
// 扩展头/目录等:跳过其数据块(向上对齐 512)后继续
|
||||
off = dataStart + size + ((512 - (size % 512)) % 512);
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
// 拉取实例容器日志(末尾 N 行),供前端"查看/导出日志"排错。
|
||||
|
||||
@@ -69,6 +69,31 @@ export function isAllowedHost(host: string, allowlist: string[]): boolean {
|
||||
if (!host) return false;
|
||||
if (isLoopbackHost(host)) return true;
|
||||
if (isPrivateIpv4(host)) return true;
|
||||
if (allowlist.includes(host)) return true;
|
||||
for (const entry of allowlist) {
|
||||
if (entry === host) return true;
|
||||
// 通配子域:*.example.com 匹配任意子域(a.example.com),但不匹配裸 example.com。
|
||||
if (entry.startsWith('*.')) {
|
||||
const suffix = entry.slice(1); // ".example.com"
|
||||
if (host.length > suffix.length && host.endsWith(suffix)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 反代/CDN(Cloudflare、nginx、Caddy 等)部署时,真实对外域名可能在 X-Forwarded-Host 里,
|
||||
// 而 Host 被改写成内部地址。综合判定:Host 或 X-Forwarded-Host 任一在白名单即放行。
|
||||
// 安全性:DNS-rebinding 攻击者直连面板时,浏览器 fetch 无法设置 X-Forwarded-Host(禁止首部),
|
||||
// 故该首部只会由可信反代设置,不会被攻击者利用。
|
||||
export function isRequestHostAllowed(
|
||||
hostHeader: string | undefined,
|
||||
forwardedHostHeader: string | string[] | undefined,
|
||||
allowlist: string[],
|
||||
): boolean {
|
||||
if (isAllowedHost(parseHost(hostHeader), allowlist)) return true;
|
||||
let xfh = Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader;
|
||||
if (xfh) {
|
||||
xfh = xfh.split(',')[0]; // 多级代理链取第一个(最初的客户端 Host)
|
||||
if (isAllowedHost(parseHost(xfh), allowlist)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+58
-40
@@ -53,10 +53,11 @@ import {
|
||||
listOrphanContainers,
|
||||
removeContainerById,
|
||||
instanceMemoryMB,
|
||||
instanceHttpHealthy,
|
||||
regenInstanceMachineId,
|
||||
} from './docker.js';
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
import { parseHost, parseAllowedHosts, isAllowedHost } from './host-guard.js';
|
||||
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -82,14 +83,14 @@ const app = Fastify({ logger: true, trustProxy: true });
|
||||
// RFC1918 LAN address nor in PANEL_ALLOWED_HOSTS. Runs before every route so
|
||||
// /api/*, /desktop/* and static-file responses are all covered.
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const host = parseHost(req.headers.host);
|
||||
if (!isAllowedHost(host, ALLOWED_HOSTS)) {
|
||||
// 把被拒的 host 一起回显,反代调试时可一眼看出"反代后端实际传过来的 Host 是什么"
|
||||
// —— 决定是去白名单加这个 host,还是修反代让它透传客户端 Host。不泄露敏感信息。
|
||||
if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) {
|
||||
// 把被拒的 Host / X-Forwarded-Host 一起回显,反代调试时可一眼看出"后端实际收到的是什么"
|
||||
// —— 决定是去白名单加这个 host,还是修反代让它透传 Host。不泄露敏感信息。
|
||||
reply.code(400).send({
|
||||
error: 'Host header not allowed',
|
||||
host: host || null,
|
||||
hint: '反代部署请把对外域名加入 PANEL_ALLOWED_HOSTS(.env,逗号分隔多个域名),或修反代让 Host 头透传',
|
||||
host: parseHost(req.headers.host) || null,
|
||||
forwardedHost: req.headers['x-forwarded-host'] || null,
|
||||
hint: '反代部署请把对外域名加入 PANEL_ALLOWED_HOSTS(.env 逗号分隔,支持 *.example.com),改完用 docker compose up -d 重建容器(不是 restart)使其生效',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -757,7 +758,7 @@ await app.ready();
|
||||
app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
|
||||
// DNS-rebinding gate for WebSocket upgrades (Fastify's onRequest hook does
|
||||
// not run on raw upgrades). KasmVNC proxying goes through this path.
|
||||
if (!isAllowedHost(parseHost(req.headers.host), ALLOWED_HOSTS)) {
|
||||
if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -825,45 +826,62 @@ function hasActiveSession(id: string): boolean {
|
||||
|
||||
if (WATCHDOG_ENABLED) {
|
||||
const recovering = new Set<string>(); // 防重入:自愈期间跳过本实例
|
||||
const healthFails = new Map<string, number>(); // id → 连续无响应次数
|
||||
const HEALTH_FAIL_LIMIT = 2; // 连续 N 次无响应才重启,避免误杀刚启动/瞬时抖动
|
||||
|
||||
const recover = async (inst: Instance, reason: string, detail: string) => {
|
||||
recovering.add(inst.id);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
healthFails.delete(inst.id);
|
||||
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`);
|
||||
} catch (e: any) {
|
||||
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`);
|
||||
} finally {
|
||||
recovering.delete(inst.id);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
for (const pub of listInstances()) {
|
||||
const inst = findInstance(pub.id);
|
||||
if (!inst || recovering.has(inst.id)) continue;
|
||||
try {
|
||||
if ((await instanceRuntime(inst)) !== 'running') continue;
|
||||
const mb = await instanceMemoryMB(inst);
|
||||
if (mb === 0) continue;
|
||||
const { soft, hard } = effectiveLimits(inst);
|
||||
const active = hasActiveSession(inst.id);
|
||||
let reason: 'hard' | 'soft' | null = null;
|
||||
if (hard > 0 && mb >= hard) reason = 'hard';
|
||||
else if (soft > 0 && mb >= soft && !active) reason = 'soft';
|
||||
if (!reason) {
|
||||
if (soft > 0 && mb >= soft && active) {
|
||||
app.log.info(
|
||||
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用(holder=${controlHolders.get(inst.id)?.username}),延后`,
|
||||
);
|
||||
}
|
||||
if ((await instanceRuntime(inst)) !== 'running') {
|
||||
healthFails.delete(inst.id);
|
||||
continue;
|
||||
}
|
||||
recovering.add(inst.id);
|
||||
if (reason === 'hard') {
|
||||
app.log.warn(
|
||||
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active})`,
|
||||
);
|
||||
} else {
|
||||
app.log.warn(
|
||||
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`,
|
||||
);
|
||||
// 1) 内存阈值自愈(既有):hard 强制 / soft 仅在无人会话时
|
||||
const mb = await instanceMemoryMB(inst);
|
||||
if (mb > 0) {
|
||||
const { soft, hard } = effectiveLimits(inst);
|
||||
const active = hasActiveSession(inst.id);
|
||||
if (hard > 0 && mb >= hard) {
|
||||
await recover(inst, 'hard', `mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active})`);
|
||||
continue;
|
||||
}
|
||||
if (soft > 0 && mb >= soft && !active) {
|
||||
await recover(inst, 'soft', `mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`);
|
||||
continue;
|
||||
}
|
||||
if (soft > 0 && mb >= soft && active) {
|
||||
app.log.info(`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用,延后`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`);
|
||||
} catch (e: any) {
|
||||
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`);
|
||||
} finally {
|
||||
recovering.delete(inst.id);
|
||||
// 2) 响应性自愈(新):探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启
|
||||
// 应对"进程没死、显示在线,但 I/O/服务 stall 读不出 VNC 文件、永远卡在正在连接桌面"。
|
||||
const healthy = await instanceHttpHealthy(inst);
|
||||
if (healthy) {
|
||||
healthFails.delete(inst.id);
|
||||
continue;
|
||||
}
|
||||
const fails = (healthFails.get(inst.id) || 0) + 1;
|
||||
healthFails.set(inst.id, fails);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT})`);
|
||||
if (fails >= HEALTH_FAIL_LIMIT) {
|
||||
await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`);
|
||||
@@ -872,7 +890,7 @@ if (WATCHDOG_ENABLED) {
|
||||
};
|
||||
setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref();
|
||||
console.log(
|
||||
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s`,
|
||||
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · 含响应性探测`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,19 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
// PWA 更新即时生效:vite-plugin-pwa 用 autoUpdate + skipWaiting + clientsClaim,新版本会立即接管,
|
||||
// 但当前页仍显示已加载的旧资源,需再刷一次才生效——这正是"改了却还看旧界面"的根源。这里监听 SW 接管,
|
||||
// 在"本来已有 SW 在控制"(即一次更新,而非首次安装)时自动重载一次,让更新一刷即生效。
|
||||
if ('serviceWorker' in navigator) {
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
let reloaded = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloaded || !hadController) return;
|
||||
reloaded = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
+137
-92
@@ -4,6 +4,7 @@ import { api } from '../api';
|
||||
import { useUI } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
import { useInstances } from '../AppShell';
|
||||
import { VncAudio } from '../vncAudio';
|
||||
|
||||
// KasmVNC noVNC 页面;反代按实例隔离:/desktop/<id>/* → 对应容器,注入凭据。
|
||||
function desktopUrl(id: string) {
|
||||
@@ -39,11 +40,16 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const [frameLoaded, setFrameLoaded] = useState(false);
|
||||
const [loadStuck, setLoadStuck] = useState(false); // iframe 久未加载出来(疑似实例无响应)
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [files, setFiles] = useState<TFile[]>([]);
|
||||
const [showClip, setShowClip] = useState(false);
|
||||
const [clipText, setClipText] = useState('');
|
||||
// 中文输入条:面板里的真实 textarea(原生客户端输入法 100% 可用),回车经 xclip+xdotool 粘进微信。
|
||||
const [imeBar, setImeBar] = useState(true); // 默认开(直接在 VNC 里打中文不稳,给一个可靠通道)
|
||||
const [imeText, setImeText] = useState('');
|
||||
const [imeSending, setImeSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
|
||||
@@ -52,7 +58,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
const lastBeat = useRef(0);
|
||||
const lastImeError = useRef(0);
|
||||
const audioRef = useRef<VncAudio | null>(null);
|
||||
|
||||
const inst = instances.find((i) => i.id === id);
|
||||
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
|
||||
@@ -65,13 +71,24 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
// 切换实例时重置内嵌态
|
||||
useEffect(() => {
|
||||
setFrameLoaded(false);
|
||||
setLoadStuck(false);
|
||||
setShowFiles(false);
|
||||
setFiles([]);
|
||||
setShowClip(false);
|
||||
setClipText('');
|
||||
setImeText('');
|
||||
setProbing(true);
|
||||
}, [id]);
|
||||
|
||||
// 桌面久未加载出来 → 判为"无响应",把无限转圈换成可操作的重试/重启,不让用户干等。
|
||||
// (实测容器跑久了会 I/O/服务 stall,进程没死、显示在线,但读不出 VNC 文件而永远连接中。)
|
||||
useEffect(() => {
|
||||
setLoadStuck(false);
|
||||
if (!showVnc || frameLoaded) return;
|
||||
const t = window.setTimeout(() => setLoadStuck(true), 12000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [showVnc, frameLoaded, id, vncNonce]);
|
||||
|
||||
// 探测态收敛:找到实例即结束;否则给共享列表一点刷新时间(AppShell 已在导航时拉取),超时仍无则判定不存在。
|
||||
useEffect(() => {
|
||||
if (inst) {
|
||||
@@ -186,6 +203,42 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
};
|
||||
}, [showVnc, id, frameLoaded]);
|
||||
|
||||
// 每次进入/重连桌面前,强制把 KasmVNC 的 enable_ime 设为【关】。
|
||||
// 原因:开启 IME 模式后,noVNC 用隐藏 textarea + 合成事件还原中文,需要前端拦截/差分,环环相扣极脆,
|
||||
// 实测会"中文混数字时丢最后两个汉字+首个数字"等损坏。中文输入改由底部「中文输入条」可靠承担
|
||||
// (面板真实 textarea 原生输入法 → xclip+xdotool 粘贴),故 VNC 直接打字回归纯 keysym:英文/数字/
|
||||
// 标点都正常、不再损坏;中文直接打不进(请用输入条)。iframe 同源共享 localStorage,加载前设好即生效。
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem('enable_ime', 'false');
|
||||
} catch {
|
||||
/* 隐私模式等禁用 localStorage:忽略 */
|
||||
}
|
||||
}, [id, vncNonce]);
|
||||
|
||||
// 音频/麦克风桥接:实例就绪即自动连接 kclient 的音频流(扬声器恒开,无需手动找工具条);
|
||||
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
|
||||
useEffect(() => {
|
||||
if (!showVnc || !id) return;
|
||||
const audio = new VncAudio(id);
|
||||
audioRef.current = audio;
|
||||
audio.connect();
|
||||
const isFocused = () => !document.hidden && document.hasFocus();
|
||||
const sync = () => audio.setActive(isFocused());
|
||||
sync(); // 初始:若当前已聚焦则立即开声
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
window.addEventListener('focus', sync);
|
||||
window.addEventListener('blur', sync);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
window.removeEventListener('focus', sync);
|
||||
window.removeEventListener('blur', sync);
|
||||
audio.destroy();
|
||||
audioRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showVnc, id]);
|
||||
|
||||
if (!id) {
|
||||
nav('/', { replace: true });
|
||||
return null;
|
||||
@@ -270,92 +323,6 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
};
|
||||
|
||||
// 中文 IME 输入修复:绕过 VNC XKB keysym 容量限制(~21 个 CJK 字符后 keymap 满,输入全废)。
|
||||
// 根因:KasmVNC 的 Perl 补丁在 compositionend 发 CJK keysym,但紧随其后的 _handleInput
|
||||
// diff 逻辑会发 Backspace 清拼音,把刚发的字也删了。必须在捕获阶段拦截,阻止 Perl 补丁执行,
|
||||
// 手动重置内部状态(防止 _handleInput 发 Backspace),然后通过 API 用 xdotool 粘贴文字。
|
||||
const patchVncIme = () => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!doc || doc.getElementById('woc-ime-patch')) return;
|
||||
const ta = doc.getElementById('noVNC_keyboardinput') as HTMLTextAreaElement | null;
|
||||
if (!ta) return;
|
||||
const win = frameRef.current?.contentWindow as any;
|
||||
let imeComposing = false;
|
||||
let swallowInputUntil = 0;
|
||||
const keyboard = () => {
|
||||
const cv = doc.querySelector('canvas') as any;
|
||||
return win?.UI?.rfb?.keyboard || cv?._rfb?.keyboard || null;
|
||||
};
|
||||
const installKeyboardGuard = () => {
|
||||
const kb = keyboard() as any;
|
||||
if (!kb || kb._wocImeGuard || typeof kb._sendKeyEvent !== 'function') return;
|
||||
const original = kb._sendKeyEvent.bind(kb);
|
||||
kb._wocImeGuard = true;
|
||||
if (typeof kb._wocImeSuppressUnicode !== 'boolean') kb._wocImeSuppressUnicode = false;
|
||||
kb._sendKeyEvent = (keysym: number, ...args: any[]) => {
|
||||
if (kb._wocImeSuppressUnicode && typeof keysym === 'number' && keysym >= 0x01000000) return;
|
||||
return original(keysym, ...args);
|
||||
};
|
||||
};
|
||||
const syncKeyboardInput = (value: string) => {
|
||||
try {
|
||||
installKeyboardGuard();
|
||||
const kb = keyboard();
|
||||
if (kb) {
|
||||
kb._imeInProgress = false;
|
||||
kb._imeHold = false;
|
||||
kb._lastKeyboardInput = value;
|
||||
if (kb._rfbKeyQueue) kb._rfbKeyQueue.length = 0;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
const swallowNoVncInput = (e: Event) => {
|
||||
if (!imeComposing && Date.now() > swallowInputUntil) return;
|
||||
e.stopImmediatePropagation();
|
||||
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
|
||||
};
|
||||
ta.addEventListener('compositionstart', (e) => {
|
||||
imeComposing = true;
|
||||
const kb = keyboard() as any;
|
||||
if (kb) kb._wocImeSuppressUnicode = true;
|
||||
e.stopImmediatePropagation();
|
||||
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
|
||||
}, true);
|
||||
ta.addEventListener('beforeinput', swallowNoVncInput, true);
|
||||
ta.addEventListener('input', swallowNoVncInput, true);
|
||||
ta.addEventListener('compositionend', (e) => {
|
||||
const text = (e as CompositionEvent).data;
|
||||
if (!text || !id) return;
|
||||
imeComposing = false;
|
||||
swallowInputUntil = Date.now() + 300;
|
||||
e.stopImmediatePropagation(); // 阻止 KasmVNC 原生 IME 路径再发一遍 keysym
|
||||
const kb = keyboard() as any;
|
||||
if (kb) kb._wocImeSuppressUnicode = true;
|
||||
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
|
||||
window.setTimeout(() => {
|
||||
ta.value = '';
|
||||
syncKeyboardInput('');
|
||||
const kb = keyboard() as any;
|
||||
if (kb) kb._wocImeSuppressUnicode = false;
|
||||
}, 0);
|
||||
// 通过面板 API → xdotool 在容器内粘贴,完全绕过 VNC keysym
|
||||
api.typeInInstance(id, text).catch((err) => {
|
||||
const now = Date.now();
|
||||
if (now - lastImeError.current > 3000) {
|
||||
lastImeError.current = now;
|
||||
toast(err?.message || '中文输入失败,请确认实例镜像包含 xclip/xdotool', 'error');
|
||||
}
|
||||
});
|
||||
}, true); // capture:先于 Perl 补丁的 bubble handler
|
||||
const mark = doc.createElement('meta');
|
||||
mark.id = 'woc-ime-patch';
|
||||
(doc.head || doc.documentElement).appendChild(mark);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
|
||||
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
|
||||
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
|
||||
@@ -386,6 +353,22 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
};
|
||||
|
||||
// 中文输入条发送:把本框文本经 xclip+xdotool 直接粘进微信当前聚焦的输入框(绕开 VNC IME)。
|
||||
// 在面板的真实 textarea 里用原生输入法打字,100% 可靠,不依赖 VNC 的 enable_ime / 合成事件。
|
||||
const sendImeText = async () => {
|
||||
const t = imeText;
|
||||
if (!t.trim() || !id) return;
|
||||
setImeSending(true);
|
||||
try {
|
||||
await api.typeInInstance(id, t);
|
||||
setImeText('');
|
||||
} catch (e: any) {
|
||||
toast(e?.message || '发送失败:请确认实例已「升级实例」(镜像含 xclip/xdotool)', 'error');
|
||||
} finally {
|
||||
setImeSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 读取容器(微信侧)当前剪贴板内容到本框,便于把容器内复制的文字带回本地
|
||||
const pullClipboardFromRemote = () => {
|
||||
try {
|
||||
@@ -465,6 +448,13 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
>
|
||||
文件
|
||||
</button>
|
||||
<button
|
||||
className={'ws-action' + (imeBar ? ' on' : '')}
|
||||
title="底部中文输入条:用本机输入法打中文,回车送进微信(最可靠)"
|
||||
onClick={() => setImeBar((v) => !v)}
|
||||
>
|
||||
中文输入
|
||||
</button>
|
||||
<button
|
||||
className="ws-action"
|
||||
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
|
||||
@@ -548,7 +538,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="iv-stage">
|
||||
<div className="iv-stage iv-stage--vnc">
|
||||
<div className="iv-canvas">
|
||||
<iframe
|
||||
key={`${id}:${vncNonce}`}
|
||||
ref={frameRef}
|
||||
@@ -559,25 +550,53 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
onLoad={() => {
|
||||
setFrameLoaded(true);
|
||||
setTimeout(() => {
|
||||
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
|
||||
focusFrame(); // 加载完把键盘焦点交给 VNC
|
||||
injectVncStyle(); // 让原生控制条在深色背景下可见
|
||||
patchVncIme(); // 修复中文 IME 吞字(绕过 VNC XKB keysym 限制)
|
||||
// 注意:不再调用 patchVncIme —— enable_ime 已关,直接打字走纯 keysym(英文/数字正常);
|
||||
// 中文由底部「中文输入条」承担。那套合成拦截既脆弱又会损坏混合输入,已弃用。
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!frameLoaded && (
|
||||
{!frameLoaded && !loadStuck && (
|
||||
<div className="iv-loading">
|
||||
<div className="spinner" />
|
||||
<div className="iv-loading-text">正在连接桌面…</div>
|
||||
<div className="iv-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;音频/剪贴板等在画面左侧边缘的工具条里</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;声音自动开启,点一下画面即可出声</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="iv-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!frameLoaded && loadStuck && (
|
||||
<div className="iv-loading">
|
||||
<div className="iv-loading-text">桌面无响应</div>
|
||||
<div className="iv-loading-sub">连接超时。可能是实例临时卡住,先「重新连接」;若仍无效请「重启实例」。</div>
|
||||
<div className="iv-stuck-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setLoadStuck(false);
|
||||
setFrameLoaded(false);
|
||||
setVncNonce((n) => n + 1); // 强制 iframe 重挂、重新请求
|
||||
}}
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="btn" onClick={restartInstance}>
|
||||
重启实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="iv-loading-sub" style={{ marginTop: 8 }}>
|
||||
管理员也可稍候,面板会自动检测无响应实例并重启自愈。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dragging && (
|
||||
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="drop-card">
|
||||
@@ -669,6 +688,32 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{imeBar && (
|
||||
<div className="iv-imebar">
|
||||
<textarea
|
||||
className="iv-imebar-input"
|
||||
value={imeText}
|
||||
onChange={(e) => setImeText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendImeText();
|
||||
}
|
||||
}}
|
||||
placeholder="中文输入这里 → 回车送进微信(先点好微信的输入框)。Shift+回车换行。"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary iv-imebar-send"
|
||||
disabled={imeSending || !imeText.trim()}
|
||||
onClick={sendImeText}
|
||||
>
|
||||
{imeSending ? '发送中' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -748,6 +748,11 @@ button {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.iv-stuck-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.files-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
@@ -1427,6 +1432,10 @@ button {
|
||||
.ws-action:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.ws-action.on {
|
||||
background: var(--wx-green, #07c160);
|
||||
color: #fff;
|
||||
}
|
||||
.ws-page .content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1510,6 +1519,18 @@ button {
|
||||
justify-content: center;
|
||||
background: var(--base);
|
||||
}
|
||||
/* VNC 分支:纵向 flex —— 画面区(.iv-canvas, flex:1) 在上,中文输入条在下,互不重叠。
|
||||
输入条占用布局空间 → iframe 区域变小 → resize=remote 让远端桌面同步缩小,微信画面完整可见、不被遮。 */
|
||||
.iv-stage--vnc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.iv-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
}
|
||||
.iv-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1518,6 +1539,43 @@ button {
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
.iv-imebar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
/* 与顶部 nav 栏同款牛奶布艺浅色主题 + 顶部细折痕分隔 */
|
||||
background: var(--base);
|
||||
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.07);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.iv-imebar-input {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
max-height: 38px;
|
||||
resize: none;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.3;
|
||||
/* 牛奶布艺凹槽输入框:与「剪贴板」文本框一致 */
|
||||
background: var(--trough);
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.16);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.iv-imebar-input:focus {
|
||||
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
|
||||
}
|
||||
.iv-imebar-send {
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.iv-loading,
|
||||
.iv-drop {
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// VNC 音频/麦克风桥接(扬声器 + 麦克风)。
|
||||
//
|
||||
// 背景:linuxserver KasmVNC 的音频不在我们内嵌的原生 noVNC 客户端里,而在它外层的 kclient
|
||||
// (容器内 nginx :3000 / → kclient :6900)通过 socket.io(路径 audio/socket.io)提供:
|
||||
// - 扬声器:服务端把 PulseAudio sink 的 PCM 通过 'audio' 事件推下来,前端用 Web Audio 播放;
|
||||
// - 麦克风:前端采集 Int16 通过 'micdata' 事件上传,服务端灌进 PulseAudio。
|
||||
// 我们没有内嵌 kclient(会破坏对原生客户端的 IME / 剪贴板 / 控制条定制),故在面板父页面直接
|
||||
// 复刻它的音频客户端,连到经面板反代的 /desktop/<id>/audio/socket.io。这样还能精确控制:
|
||||
// - 「强制开启」:实例就绪即自动连接、首个用户手势后开始播放(浏览器自动播放策略所限);
|
||||
// - 「焦点不在该实例时断开」:标签页隐藏 / 失焦 / 离开页面时关闭,避免多实例多端互相串音。
|
||||
//
|
||||
// 麦克风需要「安全上下文」(HTTPS 或 localhost) 才有 getUserMedia;局域网 http 下浏览器禁用,
|
||||
// 此时自动跳过麦克风、只保留扬声器。
|
||||
|
||||
// kclient 服务端用的 socket.io 版本未知,为避免协议不匹配,动态加载它自带的 socket.io.js
|
||||
// (经反代取 /desktop/<id>/audio/socket.io/socket.io.js),用全局 io,而非打包我们自己的版本。
|
||||
function loadIo(id: string): Promise<any> {
|
||||
const w = window as any;
|
||||
if (w.io) return Promise.resolve(w.io);
|
||||
const existing = document.getElementById('woc-socketio') as HTMLScriptElement | null;
|
||||
if (existing && (existing as any)._wocPromise) return (existing as any)._wocPromise;
|
||||
const p = new Promise<any>((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.id = 'woc-socketio';
|
||||
s.src = `/desktop/${encodeURIComponent(id)}/audio/socket.io/socket.io.js`;
|
||||
s.onload = () => ((window as any).io ? resolve((window as any).io) : reject(new Error('io 未就绪')));
|
||||
s.onerror = () => reject(new Error('加载 socket.io 失败'));
|
||||
document.head.appendChild(s);
|
||||
(s as any)._wocPromise = p;
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
// PCM 播放器:忠实复刻 kclient 的解码/调度(Int16 立体声 @ 44100 → Web Audio),
|
||||
// 这套参数与服务端音频格式匹配,改动易出杂音,故照搬。
|
||||
class PcmPlayer {
|
||||
audioCtx: AudioContext | null = null;
|
||||
private gain: GainNode | null = null;
|
||||
private startTime = 0;
|
||||
private buffer: Float32Array = new Float32Array(0);
|
||||
private playing = false;
|
||||
private lock = false;
|
||||
private resetTimer: number | undefined;
|
||||
|
||||
init() {
|
||||
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
this.audioCtx = new Ctx({ sampleRate: 44100 });
|
||||
this.audioCtx!.resume().catch(() => {});
|
||||
this.gain = this.audioCtx!.createGain();
|
||||
this.gain.gain.value = 1;
|
||||
this.gain.connect(this.audioCtx!.destination);
|
||||
this.startTime = this.audioCtx!.currentTime;
|
||||
// 与 kclient 一致:100ms 内无新数据则清空缓冲,避免拖尾/堆积
|
||||
this.resetTimer = window.setInterval(() => {
|
||||
if (this.playing) {
|
||||
if (!this.lock) {
|
||||
this.buffer = new Float32Array(0);
|
||||
this.playing = false;
|
||||
}
|
||||
this.lock = false;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
feed(data: ArrayBuffer) {
|
||||
if (!this.audioCtx) return;
|
||||
this.lock = true;
|
||||
const i16 = new Int16Array(data);
|
||||
const f32 = Float32Array.from(i16, (x) => x / 32767);
|
||||
const merged = new Float32Array(this.buffer.length + f32.length);
|
||||
merged.set(this.buffer);
|
||||
merged.set(f32, this.buffer.length);
|
||||
this.buffer = merged;
|
||||
const frames = this.buffer.length / 2; // 立体声
|
||||
const duration = frames / 44100 / 2; // 与 kclient 的 buffAudio.duration/2 等价
|
||||
if (duration > 0.05 || this.playing) {
|
||||
this.playing = true;
|
||||
const buffAudio = this.audioCtx.createBuffer(2, this.buffer.length, 44100);
|
||||
const left = buffAudio.getChannelData(0);
|
||||
const right = buffAudio.getChannelData(1);
|
||||
let bc = 0;
|
||||
let off = 1;
|
||||
for (let i = 0; i < frames; i++) {
|
||||
left[i] = this.buffer[bc];
|
||||
bc += 2;
|
||||
right[i] = this.buffer[off];
|
||||
off += 2;
|
||||
}
|
||||
this.buffer = new Float32Array(0);
|
||||
if (this.startTime < this.audioCtx.currentTime) this.startTime = this.audioCtx.currentTime;
|
||||
const src = this.audioCtx.createBufferSource();
|
||||
src.buffer = buffAudio;
|
||||
src.connect(this.gain!);
|
||||
src.start(this.startTime);
|
||||
this.startTime += buffAudio.duration / 2;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.resetTimer) window.clearInterval(this.resetTimer);
|
||||
this.resetTimer = undefined;
|
||||
this.buffer = new Float32Array(0);
|
||||
this.playing = false;
|
||||
try {
|
||||
this.audioCtx?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.audioCtx = null;
|
||||
this.gain = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class VncAudio {
|
||||
private id: string;
|
||||
private socket: any = null;
|
||||
private player: PcmPlayer | null = null;
|
||||
private active = false; // 当前实例是否处于"焦点中"(应出声)
|
||||
private opened = false; // 是否已对服务端 emit('open')
|
||||
private micStream: MediaStream | null = null;
|
||||
private micCtx: AudioContext | null = null;
|
||||
private micNode: ScriptProcessorNode | null = null;
|
||||
private micSource: MediaStreamAudioSourceNode | null = null;
|
||||
private gestureBound = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
// 建立 socket 连接(不自动出声,由 setActive 控制)。
|
||||
async connect() {
|
||||
if (this.socket || this.destroyed) return;
|
||||
const io = await loadIo(this.id);
|
||||
if (this.destroyed) return;
|
||||
this.socket = io(window.location.origin, {
|
||||
path: `/desktop/${this.id}/audio/socket.io`,
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
reconnection: true,
|
||||
});
|
||||
this.socket.on('audio', (data: ArrayBuffer) => {
|
||||
if (this.active && this.player) this.player.feed(data);
|
||||
});
|
||||
this.socket.on('connect', () => {
|
||||
if (this.active) this.open();
|
||||
});
|
||||
}
|
||||
|
||||
// 焦点变化时调用:true=本实例获得焦点(出声+收音),false=失焦(断开设备)。
|
||||
setActive(on: boolean) {
|
||||
if (this.destroyed) return;
|
||||
this.active = on;
|
||||
if (on) {
|
||||
this.open();
|
||||
this.startMic();
|
||||
} else {
|
||||
this.close();
|
||||
this.stopMic();
|
||||
}
|
||||
}
|
||||
|
||||
private open() {
|
||||
if (!this.socket || !this.socket.connected) return;
|
||||
if (!this.opened) {
|
||||
this.socket.emit('open', '');
|
||||
this.opened = true;
|
||||
}
|
||||
if (!this.player) {
|
||||
this.player = new PcmPlayer();
|
||||
this.player.init();
|
||||
}
|
||||
this.ensureResumeOnGesture();
|
||||
}
|
||||
|
||||
private close() {
|
||||
if (this.socket && this.opened) {
|
||||
try {
|
||||
this.socket.emit('close', '');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this.opened = false;
|
||||
this.player?.destroy();
|
||||
this.player = null;
|
||||
}
|
||||
|
||||
// 浏览器自动播放策略:AudioContext 常被挂起,需用户手势恢复。绑定一次性手势监听,
|
||||
// 用户点进画面/按键时自动 resume,实现"无需手动点工具条即可出声"。
|
||||
private ensureResumeOnGesture() {
|
||||
const ctx = this.player?.audioCtx;
|
||||
if (!ctx) return;
|
||||
if (ctx.state !== 'suspended' || this.gestureBound) return;
|
||||
this.gestureBound = true;
|
||||
const resume = () => {
|
||||
this.player?.audioCtx?.resume().catch(() => {});
|
||||
window.removeEventListener('pointerdown', resume, true);
|
||||
window.removeEventListener('keydown', resume, true);
|
||||
this.gestureBound = false;
|
||||
};
|
||||
window.addEventListener('pointerdown', resume, true);
|
||||
window.addEventListener('keydown', resume, true);
|
||||
}
|
||||
|
||||
private async startMic() {
|
||||
// 麦克风需安全上下文(HTTPS / localhost);http 局域网下静默跳过,只保留扬声器。
|
||||
if (this.micCtx || !this.socket) return;
|
||||
const md = navigator.mediaDevices;
|
||||
if (!window.isSecureContext || !md || !md.getUserMedia) return;
|
||||
try {
|
||||
const stream = await md.getUserMedia({ audio: true });
|
||||
if (this.destroyed || !this.active) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
this.micStream = stream;
|
||||
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
this.micCtx = new Ctx();
|
||||
this.micSource = this.micCtx!.createMediaStreamSource(stream);
|
||||
this.micNode = this.micCtx!.createScriptProcessor(512, 1, 1);
|
||||
this.micSource.connect(this.micNode);
|
||||
this.micNode.connect(this.micCtx!.destination);
|
||||
this.micNode.onaudioprocess = (e) => {
|
||||
if (!this.active || !this.socket) return;
|
||||
const input = e.inputBuffer.getChannelData(0);
|
||||
// 简单能量门限:近乎静音不上传,省带宽(替代 kclient 的 JSON.size 启发式)
|
||||
let peak = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const a = input[i] < 0 ? -input[i] : input[i];
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
if (peak < 0.01) return;
|
||||
const i16 = Int16Array.from(input, (x) => Math.max(-32768, Math.min(32767, x * 32767)));
|
||||
this.socket.emit('micdata', i16.buffer);
|
||||
};
|
||||
} catch {
|
||||
this.stopMic();
|
||||
}
|
||||
}
|
||||
|
||||
private stopMic() {
|
||||
try {
|
||||
if (this.micNode) this.micNode.onaudioprocess = null as any;
|
||||
this.micNode?.disconnect();
|
||||
this.micSource?.disconnect();
|
||||
this.micStream?.getTracks().forEach((t) => t.stop());
|
||||
this.micCtx?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.micNode = null;
|
||||
this.micSource = null;
|
||||
this.micStream = null;
|
||||
this.micCtx = null;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.close();
|
||||
this.stopMic();
|
||||
try {
|
||||
this.socket?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user