Files
WechatOnCloud/panel/web/src/api.ts
Gloridust 94b591e0b2 fix(critical): xsettingsd Xft/DPI 单位错误 —— v1.2.9~v1.3.1 微信公众号/附件秒关、浏览器黑屏的总根因
XSETTINGS 规范中 Xft/DPI 的单位是「DPI×1024」(96 DPI 应写 98304),#101 引入的 xsettingsd
配置误写成 96 → 所有 Chromium 内核应用把 DPI 读成 96/1024≈0.09 → 缩放因子≈0 → 显示变换
矩阵 [0 0 0 0;…] 不可逆(transform.cc NOTREACHED)→ GPU/viz 进程连崩 3 次 → 窗口/应用退出:
- 微信内嵌 CEF:点公众号/附件/小程序 → 窗口秒关/白屏(issue #111,日志与本地复现完全吻合)
- 系统 Chromium:整个浏览器崩溃循环 → 黑屏(v1.3.1 的 --force-device-scale-factor=1 只救了
  能加 flag 的 Chromium,是治症状;微信 CEF 无法加 flag,必须修 XSETTINGS 源头)
A/B 实证:Xft/DPI 96 → 裸 chromium 立崩(同款堆栈);改 98304 → 存活。两处写入点均已修
(docker/autostart 每次开机重写 + 面板 applyXsettingsFont 实时下发)。

配套修复本轮反馈的其余问题:
- 磁盘暴涨("1TB 没了"):实例容器注入 LogConfig 20m×2(默认 json-file 无上限,崩溃循环
  每 2s 刷错顶满磁盘);compose 面板同配;微信 crashinfo 崩溃转储 1 天过期清理;
  一键升级完成后清理悬空(<none>)旧镜像并记录释放空间。
- 一键升级"卡死":改异步——立即返回、后台顺序执行、前端轮询进度(n/总数·当前实例);
  镜像统一只拉一次(原每实例各拉一次,受限网络下每次都等停滞超时)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:31:24 +08:00

297 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export interface PanelUser {
id: string;
username: string;
role: 'admin' | 'sub';
disabled: boolean;
createdAt: string;
allowedInstances: string[]; // admin 为空数组(隐式全部)
mustChangePassword?: boolean; // 仍在用默认密码时为 true
}
export type WechatPhase = 'idle' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error';
export interface WechatStatus {
phase: WechatPhase;
percent: number; // -1 表示进度不确定
installed: boolean;
version: string;
message: string;
updatedAt: number;
}
export type RuntimeState = 'running' | 'stopped' | 'missing';
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
export const APP_LABELS: Record<AppType, string> = {
wechat: '微信',
telegram: 'Telegram',
chromium: 'Chromium',
custom: '自定义应用',
};
// 各应用的 UI 画像,供卡片/桌面页按类型显示正确文案(避免到处写死「微信」)。
// needsInstall: 是否需要运行时下载安装(微信/Telegram 是Chromium 已烤进镜像、即创建即就绪)。
// enterHint: 首次进入实例的提示。
// updateLabel: 「管理」菜单里的更新按钮文案needsInstall=false 时不显示)。
export interface AppProfile {
label: string;
needsInstall: boolean;
enterHint: string;
updateLabel: string;
}
export const APP_PROFILES: Record<AppType, AppProfile> = {
wechat: { label: '微信', needsInstall: true, enterHint: '首次进入请扫码登录微信', updateLabel: '更新微信' },
telegram: { label: 'Telegram', needsInstall: true, enterHint: '首次进入请登录 Telegram', updateLabel: '更新 Telegram' },
chromium: { label: 'Chromium', needsInstall: false, enterHint: '浏览器已就绪,直接使用即可', updateLabel: '' },
custom: { label: '自定义应用', needsInstall: true, enterHint: '', updateLabel: '更新' },
};
export const appProfile = (t?: AppType): AppProfile => APP_PROFILES[t ?? 'wechat'] ?? APP_PROFILES.wechat;
export interface PanelInstance {
id: string;
name: string;
appType?: AppType; // 缺省(老实例)= wechat
icon?: string; // 自定义图标data: 图片 / builtin:<key>;缺省按 appType 取默认图标
createdAt: string;
createdBy: string;
memSoftLimitMB?: number;
memHardLimitMB?: number;
}
export interface MemLimits {
soft: number | null;
hard: number | null;
defaultSoft: number;
defaultHard: number;
currentMB: number;
watchdogEnabled: boolean;
intervalSec: number;
}
export interface InstanceWithStatus extends PanelInstance {
runtime: RuntimeState;
wechat: WechatStatus;
}
export interface VolEntry {
name: string;
type: 'dir' | 'file' | 'link' | 'other';
size: number;
mtime: number; // epoch ms
}
export interface VersionInfo {
current: string; // 当前构建版本(如 v1.2.0 / dev-<sha>
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
hasUpdate: boolean; // 有可升级目标正式版latest>current开发版查到任一正式版
isDev: boolean; // 当前是开发版(非正式 vX.Y.Z
checkedAt: number; // 上次检查时间戳ms0=尚未检查
source: string | null; // 数据来源dockerhub / ghcr / dockerhub+ghcr
error: string | null; // 检查失败原因
}
// 原始二进制上传File 直传 application/octet-stream用于数据卷上传/解压/恢复
async function rawUpload(url: string, file: File): Promise<any> {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
return data;
}
async function req<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
// 仅在有 body 时声明 JSON content-type否则 Fastify 对「空 body + application/json」会报 400
const headers = opts.body ? { 'content-type': 'application/json', ...opts.headers } : opts.headers;
const res = await fetch(path, {
credentials: 'same-origin',
...opts,
headers,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// 会话过期:除登录/探测接口外,任意接口收到 401 都说明 cookie 失效,直接回登录页(避免页面卡在错误态)
const isAuthProbe = path.includes('/api/auth/login') || path.includes('/api/auth/me');
if (res.status === 401 && !isAuthProbe && location.pathname !== '/login') {
location.assign('/login');
}
throw new Error((data as any).error || `请求失败 (${res.status})`);
}
return data as T;
}
export const api = {
me: () => req<{ user: PanelUser }>('/api/auth/me'),
login: (username: string, password: string) =>
req<{ user: PanelUser }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => req('/api/auth/logout', { method: 'POST' }),
changePassword: (oldPassword: string, newPassword: string) =>
req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }),
// 版本与更新检测
getVersion: () => req<VersionInfo>('/api/version'),
checkUpdate: () => req<VersionInfo>('/api/admin/version/check', { method: 'POST' }),
// 一键更新面板自身:拉新镜像 + 派生 helper 容器重建 woc-panel带回滚。返回后面板会重启。
selfUpdatePanel: () => req<{ ok: boolean; target: string; message: string }>('/api/admin/version/self-update', { method: 'POST' }),
// 实例桌面深色(与面板主题统一的那个开关):读取当前态 + 设置(管理员,实时切换运行中实例)。
getDesktopTheme: () => req<{ dark: boolean }>('/api/desktop-theme'),
setDesktopTheme: (dark: boolean) =>
req<{ ok: boolean; dark: boolean; applied: number; failed: number }>('/api/admin/desktop-theme', {
method: 'POST',
body: JSON.stringify({ dark }),
}),
// 子账号
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
createUser: (username: string, password: string, allowedInstances: string[] = []) =>
req<{ user: PanelUser }>('/api/admin/users', {
method: 'POST',
body: JSON.stringify({ username, password, allowedInstances }),
}),
setDisabled: (id: string, disabled: boolean) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/disable`, { method: 'POST', body: JSON.stringify({ disabled }) }),
resetUser: (id: string, newPassword: string) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/reset`, { method: 'POST', body: JSON.stringify({ newPassword }) }),
renameUser: (id: string, username: string) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/rename`, { method: 'POST', body: JSON.stringify({ username }) }),
deleteUser: (id: string) => req(`/api/admin/users/${id}`, { method: 'DELETE' }),
setUserInstances: (id: string, instanceIds: string[]) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/instances`, { method: 'POST', body: JSON.stringify({ instanceIds }) }),
// 微信实例
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') =>
req<{ instance: PanelInstance }>('/api/admin/instances', {
method: 'POST',
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }),
}),
regenMachineId: (id: string) =>
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
getInstanceMemLimits: (id: string) =>
req<MemLimits>(`/api/admin/instances/${id}/mem-limits`),
setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/mem-limits`, {
method: 'PUT',
body: JSON.stringify({ soft, hard }),
}),
listOrphanVolumes: () =>
req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'),
deleteOrphanVolume: (name: string) =>
req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }),
listOrphanContainers: () =>
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
deleteOrphanContainer: (idOrName: string) =>
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
setInstanceIcon: (id: string, icon: string | null) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/icon`, { method: 'POST', body: JSON.stringify({ icon }) }),
renameInstance: (id: string, name: string) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
deleteInstance: (id: string, purge = false) =>
req(`/api/admin/instances/${id}${purge ? '?purge=1' : ''}`, { method: 'DELETE' }),
setInstanceUsers: (id: string, userIds: string[]) =>
req(`/api/admin/instances/${id}/users`, { method: 'POST', body: JSON.stringify({ userIds }) }),
instanceWechatStatus: (id: string) => req<{ status: WechatStatus }>(`/api/instances/${id}/wechat/status`),
// 卡死自愈VNC 多次重连仍连不上时,重启该实例恢复(限频;需对实例有访问权)。
healInstance: (id: string) => req<{ ok: boolean; restarted: boolean }>(`/api/instances/${id}/heal`, { method: 'POST' }),
// 客户端连接日志:把前端的 VNC 连接态/动作回传服务端,记进实例日志([client] 前缀便于排查。Fire-and-forget。
clientLog: (id: string, msg: string) => {
req(`/api/instances/${id}/clientlog`, { method: 'POST', body: JSON.stringify({ msg }) }).catch(() => {});
},
instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }),
instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }),
instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }),
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
// 实例镜像升级状态(哪些实例落后于最新镜像 + 一键升级进行中的进度)+ 发起一键升级(异步,立即返回,轮询进度)。
upgradeStatus: () =>
req<{
known: boolean;
outdatedCount: number;
outdatedIds: string[];
instances: { id: string; name: string; outdated: boolean }[];
upgradeAll: { running: boolean; total: number; done: number; failed: number; phase: string };
}>('/api/admin/instances/upgrade-status'),
upgradeAllInstances: () =>
req<{ ok: boolean; started: boolean; total?: number }>('/api/admin/instances/upgrade-all', { method: 'POST' }),
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
// 全局日志 / 诊断包(范围 24h/7d/30d/1y
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
panelLogUrl: (range: string) => `/api/admin/panel-log?range=${encodeURIComponent(range)}`,
// 文件中转
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
uploadFile: async (id: string, file: File) => {
const res = await fetch(`/api/instances/${id}/upload?name=${encodeURIComponent(file.name)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
return res.json();
},
downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`,
deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }),
// 数据卷管理(仅管理员)
volumeList: (id: string, path = '') =>
req<{ path: string; entries: VolEntry[] }>(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`),
volumeMkdir: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
volumeMove: (id: string, from: string, to: string) =>
req(`/api/admin/instances/${id}/volume/move`, { method: 'POST', body: JSON.stringify({ from, to }) }),
volumeDelete: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`, { method: 'DELETE' }),
volumeDownloadUrl: (id: string, path: string) =>
`/api/admin/instances/${id}/volume/download?path=${encodeURIComponent(path)}`,
volumeBackupUrl: (id: string) => `/api/admin/instances/${id}/volume/backup`,
volumeUpload: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/upload?path=${encodeURIComponent(path)}&name=${encodeURIComponent(file.name)}`, file),
volumeExtract: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/extract?path=${encodeURIComponent(path)}`, file),
volumeRestore: (id: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/restore`, file),
// 多端协作:操作控制权
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }),
// 桌面壁纸
listBackgrounds: (id: string) => req<{ backgrounds: string[] }>(`/api/admin/instances/${id}/backgrounds`),
uploadBackground: async (id: string, name: string, file: File) => {
const res = await fetch(`/api/admin/instances/${id}/backgrounds?name=${encodeURIComponent(name)}`, {
method: 'POST', credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' }, body: file,
});
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
return res.json();
},
applyBackground: (id: string, name: string) =>
req(`/api/admin/instances/${id}/backgrounds/${encodeURIComponent(name)}/apply`, { method: 'POST' }),
deleteBackground: (id: string, name: string) =>
req(`/api/admin/instances/${id}/backgrounds/${encodeURIComponent(name)}`, { method: 'DELETE' }),
getCurrentBackground: (id: string) => req<{ background: string }>(`/api/admin/instances/${id}/backgrounds/current`),
clearBackground: (id: string) => req(`/api/admin/instances/${id}/backgrounds/clear`, { method: 'POST' }),
// 字体管理
listFonts: (id: string) => req<{ fonts: string[] }>(`/api/admin/instances/${id}/fonts`),
uploadFont: async (id: string, name: string, file: File) => {
const res = await fetch(`/api/admin/instances/${id}/fonts?name=${encodeURIComponent(name)}`, {
method: 'POST', credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' }, body: file,
});
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
return res.json();
},
deleteFont: (id: string, name: string) =>
req(`/api/admin/instances/${id}/fonts/${encodeURIComponent(name)}`, { method: 'DELETE' }),
getCurrentFont: (id: string) => req<{ fontFile: string }>(`/api/admin/instances/${id}/fonts/current`),
applyFont: (id: string, name: string) =>
req(`/api/admin/instances/${id}/fonts/${encodeURIComponent(name)}/apply`, { method: 'POST' }),
resetFontDefault: (id: string) =>
req(`/api/admin/instances/${id}/fonts/default`, { method: 'POST' }),
};