mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-09-20 03:23:32 +08:00
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>
297 lines
16 KiB
TypeScript
297 lines
16 KiB
TypeScript
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; // 上次检查时间戳(ms);0=尚未检查
|
||
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' }),
|
||
};
|