mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-08-30 18:21:27 -07:00
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>
This commit is contained in:
@@ -56,4 +56,12 @@ services:
|
||||
ports:
|
||||
- "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口
|
||||
|
||||
# 日志硬上限:docker 默认 json-file 不限大小,长期运行/异常刷错会无限吃磁盘。
|
||||
# (实例容器的同类上限由面板创建时注入,无需在此配置。)
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "2"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -145,12 +145,16 @@ if command -v xsettingsd >/dev/null 2>&1; then
|
||||
family=$(cat "$FONT_FAMILY_FILE")
|
||||
[ -n "$family" ] && XSETTINGS_FONT="$family"
|
||||
fi
|
||||
# ⚠️ XSETTINGS 规范里 Xft/DPI 的单位是「DPI × 1024」:96 DPI 必须写 98304。
|
||||
# 若误写 96,所有 Chromium 内核应用(系统 Chromium、微信内嵌 CEF)会把 DPI 读成 96/1024≈0.09
|
||||
# → 缩放因子≈0 → 显示变换矩阵不可逆(transform.cc NOTREACHED) → GPU 进程连崩 → 窗口秒关/黑屏。
|
||||
# 这曾是 v1.2.9~v1.3.1 微信打不开公众号/附件、浏览器黑屏的总根因(issue #111)。
|
||||
cat > "${XSETTINGS_CONF}" << EOF
|
||||
Xft/Antialias 1
|
||||
Xft/Hinting 1
|
||||
Xft/HintStyle "hintslight"
|
||||
Xft/RGBA "rgb"
|
||||
Xft/DPI 96
|
||||
Xft/DPI 98304
|
||||
Gtk/FontName "${XSETTINGS_FONT} 10"
|
||||
EOF
|
||||
xsettingsd --config="${XSETTINGS_CONF}" 2>/dev/null &
|
||||
@@ -167,6 +171,15 @@ while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 3.5) 清理微信崩溃转储(crashinfo)。
|
||||
# 微信内嵌浏览器崩溃时 crashpad 会往 /config/.xwechat/crashinfo 里堆 .dmp 转储与 lock/meta 文件;
|
||||
# 崩溃风暴(如 DPI bug 期间每次点公众号都崩)会让它无限膨胀吃满磁盘,且启动时反复扫描刷日志。
|
||||
# 转储仅用于事后诊断,保留 1 天足够;过期即删,防磁盘被吃。
|
||||
if [ -d /config/.xwechat/crashinfo ]; then
|
||||
find /config/.xwechat/crashinfo -type f -mtime +1 -delete 2>/dev/null || true
|
||||
find /config/.xwechat/crashinfo -mindepth 1 -type d -empty -delete 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 3) 常驻拉起应用
|
||||
while true; do
|
||||
if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then
|
||||
|
||||
@@ -223,6 +223,9 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
SecurityOpt: ['seccomp=unconfined'],
|
||||
ShmSize: SHM_SIZE,
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
// 日志硬上限:docker 默认 json-file 无大小限制,应用崩溃循环(每 2s 刷错误)会把宿主磁盘
|
||||
// 无限吃掉(群晖用户反馈"一下子 1TB 没了"的元凶之一)。每实例封顶 20MB×2。
|
||||
LogConfig: { Type: 'json-file', Config: { 'max-size': '20m', 'max-file': '2' } },
|
||||
};
|
||||
if (INSTANCE_MEM > 0) {
|
||||
hostConfig.Memory = INSTANCE_MEM;
|
||||
@@ -293,15 +296,31 @@ export async function ensureRunning(inst: Instance): Promise<void> {
|
||||
|
||||
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
|
||||
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
|
||||
export async function upgradeInstance(inst: Instance): Promise<void> {
|
||||
// skipPull:批量升级时由调用方先统一拉取一次,避免 N 个实例拉 N 次(受限网络下每次
|
||||
// 都要等到拉取停滞超时,表现为"一键升级卡死")。
|
||||
export async function upgradeInstance(inst: Instance, opts?: { skipPull?: boolean }): Promise<void> {
|
||||
if (!opts?.skipPull) {
|
||||
try {
|
||||
await pullImage();
|
||||
} catch (e: any) {
|
||||
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
|
||||
}
|
||||
}
|
||||
await runInstance(inst);
|
||||
}
|
||||
|
||||
// 清理悬空(dangling)镜像:升级后旧实例镜像失去 tag 变成 <none>,长期堆积吃磁盘
|
||||
// (每层 1-2GB,多次升级后可观)。只删无 tag 且无容器引用的镜像,安全。best-effort。
|
||||
export async function pruneDanglingImages(): Promise<void> {
|
||||
try {
|
||||
const res: any = await docker.pruneImages({ filters: { dangling: ['true'] } as any });
|
||||
const freed = Number(res?.SpaceReclaimed || 0);
|
||||
if (freed > 0) appendPanelLog('INFO', `已清理悬空镜像,释放 ${(freed / 1024 / 1024 / 1024).toFixed(2)} GB`);
|
||||
} catch (e: any) {
|
||||
console.warn('[docker] 清理悬空镜像失败(忽略):', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成
|
||||
// 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。
|
||||
// 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。
|
||||
@@ -1214,12 +1233,15 @@ export async function applyFont(inst: Instance, fontFile: string): Promise<void>
|
||||
|
||||
async function applyXsettingsFont(inst: Instance, family: string): Promise<void> {
|
||||
const conf = '/config/.xsettingsd';
|
||||
// ⚠️ XSETTINGS 规范里 Xft/DPI 单位是「DPI × 1024」:96 DPI 必须写 98304。误写 96 会让所有
|
||||
// Chromium 内核应用(系统 Chromium / 微信内嵌 CEF)把缩放因子算成≈0 → 变换矩阵不可逆 →
|
||||
// GPU 进程连崩 → 窗口秒关/黑屏(v1.2.9~v1.3.1 的总根因,issue #111)。
|
||||
const lines = [
|
||||
'Xft/Antialias 1',
|
||||
'Xft/Hinting 1',
|
||||
'Xft/HintStyle "hintslight"',
|
||||
'Xft/RGBA "rgb"',
|
||||
'Xft/DPI 96',
|
||||
'Xft/DPI 98304',
|
||||
`Gtk/FontName "${family} 10"`,
|
||||
];
|
||||
await execCapture(inst, ['sh', '-c', `printf '%s\\n' ${lines.map(l => `'${l}'`).join(' ')} > ${conf}`]);
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
upgradeInstance,
|
||||
latestInstanceImageId,
|
||||
instanceOutdated,
|
||||
pullImage,
|
||||
pruneDanglingImages,
|
||||
removeInstance as removeInstanceContainer,
|
||||
instanceRuntime,
|
||||
triggerWechat,
|
||||
@@ -663,30 +665,55 @@ app.get('/api/admin/instances/upgrade-status', async (req, reply) => {
|
||||
list.map(async (inst) => ({ id: inst.id, name: inst.name, outdated: await instanceOutdated(inst, latestId) })),
|
||||
);
|
||||
const outdated = results.filter((r) => r.outdated);
|
||||
return { known: !!latestId, outdatedCount: outdated.length, outdatedIds: outdated.map((r) => r.id), instances: results };
|
||||
return {
|
||||
known: !!latestId,
|
||||
outdatedCount: outdated.length,
|
||||
outdatedIds: outdated.map((r) => r.id),
|
||||
instances: results,
|
||||
upgradeAll: upgradeAllState, // 一键升级进行中的进度(running=false 表示空闲/已完成)
|
||||
};
|
||||
});
|
||||
|
||||
// 一键升级全部"镜像落后"的实例(顺序执行,逐个 best-effort;个别失败不影响其余)。
|
||||
// 一键升级全部"镜像落后"的实例。
|
||||
// 异步化:拉镜像 + 逐个重建可能耗时数分钟到更久(受限网络下拉取要等到停滞超时),同步等待会让
|
||||
// 前端请求悬死、代理超时——用户反馈"一键升级一直卡死"。改为:立即返回,后台顺序执行,
|
||||
// 前端轮询 upgrade-status 里的 upgradeAll 进度。镜像只拉一次(不再每实例各拉一次)。
|
||||
let upgradeAllState = { running: false, total: 0, done: 0, failed: 0, phase: '' };
|
||||
app.post('/api/admin/instances/upgrade-all', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
if (upgradeAllState.running) return reply.code(409).send({ error: '一键升级正在进行中,请等待完成' });
|
||||
const latestId = await latestInstanceImageId();
|
||||
if (!latestId) return reply.code(409).send({ error: '本地尚无实例镜像,无法判断是否需要升级(请先联网拉取)' });
|
||||
const list = listInstances();
|
||||
let upgraded = 0;
|
||||
let failed = 0;
|
||||
for (const inst of list) {
|
||||
if (!(await instanceOutdated(inst, latestId))) continue;
|
||||
const outdated: typeof list = [];
|
||||
for (const inst of list) if (await instanceOutdated(inst, latestId)) outdated.push(inst);
|
||||
if (!outdated.length) return { ok: true, started: false, upgraded: 0, failed: 0 };
|
||||
|
||||
upgradeAllState = { running: true, total: outdated.length, done: 0, failed: 0, phase: '拉取最新镜像…' };
|
||||
void (async () => {
|
||||
// 统一拉取一次(失败不阻断:用本地已有镜像重建)
|
||||
try {
|
||||
await pullImage();
|
||||
} catch (e: any) {
|
||||
appendPanelLog('WARN', `一键升级:拉取镜像失败(${e?.message || e}),改用本地镜像重建`);
|
||||
}
|
||||
for (const inst of outdated) {
|
||||
upgradeAllState.phase = `升级「${inst.name}」…`;
|
||||
try {
|
||||
appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`);
|
||||
await upgradeInstance(inst);
|
||||
upgraded++;
|
||||
await upgradeInstance(inst, { skipPull: true });
|
||||
} catch (e: any) {
|
||||
failed++;
|
||||
upgradeAllState.failed++;
|
||||
appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
}
|
||||
upgradeAllState.done++;
|
||||
}
|
||||
appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgraded}、失败 ${failed}`);
|
||||
return { ok: true, upgraded, failed };
|
||||
appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgradeAllState.done - upgradeAllState.failed}、失败 ${upgradeAllState.failed}`);
|
||||
// 升级后旧镜像变悬空(<none>),顺手清理防磁盘堆积
|
||||
await pruneDanglingImages();
|
||||
upgradeAllState = { ...upgradeAllState, running: false, phase: '' };
|
||||
})();
|
||||
return { ok: true, started: true, total: outdated.length };
|
||||
});
|
||||
|
||||
// 实例侧:设置该实例可被哪些账户访问
|
||||
|
||||
@@ -202,13 +202,17 @@ export const api = {
|
||||
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 }[] }>(
|
||||
'/api/admin/instances/upgrade-status',
|
||||
),
|
||||
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; upgraded: number; failed: number }>('/api/admin/instances/upgrade-all', { method: 'POST' }),
|
||||
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)}`,
|
||||
|
||||
@@ -270,6 +270,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||
const [upg, setUpg] = useState<{ outdatedCount: number; outdatedIds: string[] } | null>(null); // 镜像落后的实例
|
||||
const [upgradingAll, setUpgradingAll] = useState(false);
|
||||
const [upgProgress, setUpgProgress] = useState(''); // 一键升级进度文案("2/5 · 升级「xxx」…")
|
||||
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
|
||||
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
|
||||
// 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。
|
||||
@@ -412,25 +413,51 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
}
|
||||
};
|
||||
|
||||
// 一键升级全部"镜像落后"的实例(逐个拉新镜像重建,可能几分钟)。
|
||||
// 一键升级全部"镜像落后"的实例。后端异步执行(拉镜像+逐个重建可能数分钟),
|
||||
// 这里发起后轮询 upgrade-status 里的进度,避免单个请求悬死(旧版同步等待被反馈"一直卡死")。
|
||||
const upgradeAll = async () => {
|
||||
const n = upg?.outdatedCount || 0;
|
||||
const ok = await confirm({
|
||||
title: `升级全部 ${n} 个可升级实例?`,
|
||||
body: '将逐个拉取最新实例镜像并重建(数据保留),每个约十几秒到几分钟;期间这些实例会短暂重连。',
|
||||
body: '后台逐个拉取最新实例镜像并重建(数据保留);期间这些实例会短暂重连,可离开本页。',
|
||||
confirmText: '全部升级',
|
||||
});
|
||||
if (!ok) return;
|
||||
setUpgradingAll(true);
|
||||
toast('正在逐个升级实例,请勿离开…', 'info');
|
||||
try {
|
||||
const r = await api.upgradeAllInstances();
|
||||
toast(`升级完成:成功 ${r.upgraded}${r.failed ? `、失败 ${r.failed}` : ''}`, r.failed ? 'error' : 'ok');
|
||||
if (!r.started) {
|
||||
toast('所有实例已是最新镜像', 'ok');
|
||||
setUpgradingAll(false);
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
toast(`已开始升级 ${r.total} 个实例(后台进行)…`, 'info');
|
||||
// 轮询进度直到完成(3s 一次;异常网络下最多轮 30 分钟兜底退出)
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise((res) => setTimeout(res, 3000));
|
||||
try {
|
||||
const s = await api.upgradeStatus();
|
||||
const p = s.upgradeAll;
|
||||
if (p.running) {
|
||||
setUpgProgress(`${p.done}/${p.total}${p.phase ? ` · ${p.phase}` : ''}`);
|
||||
continue;
|
||||
}
|
||||
toast(
|
||||
`升级完成:成功 ${p.total - p.failed}${p.failed ? `、失败 ${p.failed}(看面板日志)` : ''}`,
|
||||
p.failed ? 'error' : 'ok',
|
||||
);
|
||||
break;
|
||||
} catch {
|
||||
/* 面板短暂不可达(不影响后台任务),继续轮询 */
|
||||
}
|
||||
}
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '升级失败', 'error');
|
||||
} finally {
|
||||
setUpgradingAll(false);
|
||||
setUpgProgress('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -485,7 +512,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
<span className="muted small">(更新面板不会自动升级实例,二者是不同镜像)</span>
|
||||
</span>
|
||||
<button className="btn btn-primary s-btn" disabled={upgradingAll} onClick={upgradeAll}>
|
||||
{upgradingAll ? '升级中…请稍候' : '一键升级全部实例'}
|
||||
{upgradingAll ? `升级中 ${upgProgress || '…'}` : '一键升级全部实例'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user