feat(panel): 桌面壁纸与字体管理

- 上传/应用/清除壁纸,缩略图预览
- 上传/安装/删除字体,fc-cache 刷新
- fontconfig 别名配置 + xsettingsd 实时生效
- 移除 hook-fontconfig,改为重启提示
- 切换 tsinghua 安全更新源
- 安装 xwallpaper/imagemagick/xsettingsd 依赖
This commit is contained in:
d1y
2026-06-29 23:51:07 +08:00
parent 5bb94d88e1
commit c9ef07eb56
7 changed files with 915 additions and 8 deletions

View File

@@ -15,7 +15,7 @@ RUN set -eux; \
'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware' \
'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware' \
'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware' \
'deb https://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware' \
'deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware' \
> /etc/apt/sources.list
RUN set -eux; \
@@ -27,7 +27,8 @@ RUN set -eux; \
libnss3 libgbm1 libasound2 libxss1 \
xz-utils \
chromium \
xdotool xclip; \
xdotool xclip \
xwallpaper imagemagick xsettingsd; \
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
locale-gen; \
apt-get clean; \
@@ -62,6 +63,11 @@ RUN set -eux; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*
# 预建 fontconfig 目录 + 用户字体目录 symlink持久化到 /config/.fonts
RUN mkdir -p /home/abc/.config/fontconfig /config/.fonts /home/abc/.local/share && \
ln -sfn /config/.fonts /home/abc/.local/share/fonts && \
chown -R 1000:1000 /home/abc/.config /home/abc/.local
ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \
LIBGL_ALWAYS_SOFTWARE=1

View File

@@ -47,7 +47,107 @@ if [ "$APP_TYPE" = "custom" ] && [ -z "${APP_LAUNCH:-}" ]; then
exit 0
fi
# 1) 等待应用安装就绪(首次需在面板点「下载并安装」
# 1) 应用壁纸(若用户设置了自定义背景图
WALLPAPER_FILE="/config/.wallpaper"
WALLPAPER_DIR="/config/backgrounds"
if [ -f "$WALLPAPER_FILE" ]; then
wp=$(cat "$WALLPAPER_FILE")
if [ -n "$wp" ] && [ -f "$WALLPAPER_DIR/$wp" ]; then
export DISPLAY="${DISPLAY:-:1}"
xwallpaper --zoom "$WALLPAPER_DIR/$wp" 2>/dev/null && \
echo "[autostart] 壁纸已应用: $wp"
fi
fi
# 2) 应用自定义字体(若用户设置了)
# 从持久卷恢复系统级 fontconfig 配置(跨越容器重建)
if [ -f /config/.woc-fc-local.conf ]; then
cp /config/.woc-fc-local.conf /etc/fonts/local.conf
echo "[autostart] 系统字体配置已恢复"
fi
# 确保 ~/.local/share/fonts → /config/.fonts symlink 存在(旧容器升级兼容)
mkdir -p /config/.fonts 2>/dev/null || true
if [ ! -L /home/abc/.local/share/fonts ] && [ ! -d /home/abc/.local/share/fonts ]; then
mkdir -p /home/abc/.local/share 2>/dev/null || true
ln -sfn /config/.fonts /home/abc/.local/share/fonts 2>/dev/null || true
fi
FONT_CONF="/home/abc/.config/fontconfig/fonts.conf"
FONT_FAMILY_FILE="/config/.woc-font-family"
if [ -f "$FONT_FAMILY_FILE" ]; then
family=$(cat "$FONT_FAMILY_FILE")
if [ -n "$family" ]; then
mkdir -p "$(dirname "$FONT_CONF")" 2>/dev/null || true
cat > "$FONT_CONF" << EOF
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<!-- generic families -->
<alias>
<family>sans-serif</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>serif</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>monospace</family>
<prefer><family>${family}</family></prefer>
</alias>
<!-- system CJK fonts that WeChat/CEF may request -->
<alias>
<family>WenQuanYi Micro Hei</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>WenQuanYi Zen Hei</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>Noto Sans CJK SC</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>Noto Sans CJK</family>
<prefer><family>${family}</family></prefer>
</alias>
<!-- force user font for any zh text regardless of requested family -->
<match target="pattern">
<test name="lang" compare="contains"><string>zh</string></test>
<edit name="family" mode="prepend" binding="strong"><string>${family}</string></edit>
</match>
</fontconfig>
EOF
echo "[autostart] 自定义字体已恢复: ${family}"
fc-cache -f 2>/dev/null || true
fi
elif [ -f "$FONT_CONF" ]; then
echo "[autostart] 自定义字体配置已就绪"
fc-cache -f 2>/dev/null || true
fi
# 3) 启动 xsettingsd让 GTK/Qt 应用实时响应字体/DPI 等 XSETTINGS 变更,无需重启)
XSETTINGS_CONF="/home/abc/.xsettingsd"
if command -v xsettingsd >/dev/null 2>&1; then
XSETTINGS_FONT="WenQuanYi Micro Hei"
# 若用户保存了字体偏好,优先使用
if [ -f "$FONT_FAMILY_FILE" ]; then
family=$(cat "$FONT_FAMILY_FILE")
[ -n "$family" ] && XSETTINGS_FONT="$family"
fi
cat > "${XSETTINGS_CONF}" << EOF
Xft/Antialias 1
Xft/Hinting 1
Xft/HintStyle "hintslight"
Xft/RGBA "rgb"
Xft/DPI 96
Gtk/FontName "${XSETTINGS_FONT} 10"
EOF
xsettingsd --config="${XSETTINGS_CONF}" 2>/dev/null &
echo "[autostart] xsettingsd 已启动"
fi
# 4) 等待应用安装就绪(首次需在面板点「下载并安装」)
notified=0
while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
if [ "${notified}" -eq 0 ]; then

View File

@@ -212,6 +212,8 @@ export async function runInstance(inst: Instance): Promise<void> {
try {
await container.start();
appendInstanceLog(inst.id, '容器已启动');
// 容器重建后恢复持久化的字体配置 / xsettingsd
restoreFontFromVolume(inst).catch(() => {});
} catch (e) {
// 启动失败但容器已被创建出来Created 状态),不清理的话会成为"幽灵容器"——
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
@@ -433,9 +435,9 @@ async function execCreate(c: any, opts: any): Promise<any> {
}
// 在实例容器内执行命令,返回 stdout若命令失败把 stderr 透出给调用方。
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
async function execCapture(inst: Instance, cmd: string[], user = 'abc'): Promise<string> {
const c = docker.getContainer(inst.containerName);
const exec = await execCreate(c, { Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
const exec = await execCreate(c, { Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: user });
const stream = await exec.start({ hijack: true, stdin: false });
return await new Promise<string>((resolve, reject) => {
let out = '';
@@ -939,6 +941,202 @@ export async function volRestoreArchive(inst: Instance, archive: Buffer): Promis
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: '/' });
}
// ---------- 桌面壁纸 ----------
const BG_DIR = '/config/backgrounds';
const WP_FILE = '/config/.wallpaper';
export async function listBackgrounds(inst: Instance): Promise<string[]> {
try {
const out = await execCapture(inst, ['sh', '-c', `ls -1 ${BG_DIR} 2>/dev/null || true`]);
return out.split('\n').filter(Boolean);
} catch { return []; }
}
export async function getBackgroundImage(inst: Instance, name: string, thumb = false): Promise<Buffer> {
if (!safeName(name)) throw new Error('文件名不合法');
let path = `${BG_DIR}/${name}`;
if (thumb) {
const tmp = `/tmp/.woc-thumb-${name}`;
try {
await execCapture(inst, ['sh', '-c', `convert '${BG_DIR}/${name}' -resize 400x '${tmp}'`], 'root');
path = tmp;
} catch {
// convert 不可用或失败,回退原始图
}
}
const stream = (await docker.getContainer(inst.containerName).getArchive({ path })) as NodeJS.ReadableStream;
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
stream.on('data', (d: Buffer) => chunks.push(d));
stream.on('end', () => resolve());
stream.on('error', reject);
});
const buf = extractSingleFileFromTar(Buffer.concat(chunks));
if (thumb) {
// 清理临时缩略图
execCapture(inst, ['rm', '-f', `/tmp/.woc-thumb-${name}`], 'root').catch(() => {});
}
return buf;
}
export async function uploadBackground(inst: Instance, name: string, content: Buffer): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
await execCapture(inst, ['mkdir', '-p', BG_DIR]);
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: BG_DIR });
}
export async function applyBackground(inst: Instance, name: string): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
await execCapture(inst, ['sh', '-c', `DISPLAY=:1 xwallpaper --zoom '${BG_DIR}/${name}' 2>/dev/null`]);
await execCapture(inst, ['sh', '-c', `echo '${name}' > '${WP_FILE}'`]);
}
export async function deleteBackground(inst: Instance, name: string): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
await execCapture(inst, ['rm', '-f', `${BG_DIR}/${name}`]);
await execCapture(inst, ['sh', '-c', `if [ -f '${WP_FILE}' ] && [ "$(cat '${WP_FILE}')" = '${name}' ]; then rm -f '${WP_FILE}'; fi`]);
}
export async function getCurrentBackground(inst: Instance): Promise<string> {
try {
const out = await execCapture(inst, ['sh', '-c', `cat ${WP_FILE} 2>/dev/null || true`]);
return out.trim();
} catch { return ''; }
}
export async function clearBackground(inst: Instance): Promise<void> {
await execCapture(inst, ['sh', '-c', 'DISPLAY=:1 xsetroot -solid black 2>/dev/null']);
await execCapture(inst, ['rm', '-f', WP_FILE]);
}
// ---------- 字体管理 ----------
const FONT_DIR = '/config/.fonts';
export async function listFonts(inst: Instance): Promise<string[]> {
try {
const out = await execCapture(inst, ['sh', '-c', `ls -1 ${FONT_DIR} 2>/dev/null || true`]);
return out.split('\n').filter(Boolean);
} catch { return []; }
}
export async function uploadFont(inst: Instance, name: string, content: Buffer): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
await execCapture(inst, ['mkdir', '-p', FONT_DIR]);
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: FONT_DIR });
await execCapture(inst, ['fc-cache', '-f'], 'root');
}
export async function deleteFont(inst: Instance, name: string): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
await execCapture(inst, ['rm', '-f', `${FONT_DIR}/${name}`]);
await execCapture(inst, ['fc-cache', '-f'], 'root');
}
const FONT_SEL_FILE = '/config/.woc-font';
// 将字体的 fontconfig family name 设为用户首选fallback 仍用文泉驿等系统字体)。
// 设空字符串或 "default" 则清除偏好,回退系统默认。
export async function applyFont(inst: Instance, fontFile: string): Promise<void> {
if (fontFile && !safeName(fontFile)) throw new Error('文件名不合法');
if (fontFile && fontFile !== 'default') {
// 用 fc-scan 读取字体实际 family name取第一个
const out = await execCapture(inst, ['sh', '-c', `fc-scan --format='%{family[0]}' '${FONT_DIR}/${fontFile}' 2>/dev/null`]);
const family = out.trim();
if (!family) throw new Error('未能识别该字体的 family name');
// 写 fontconfig 系统级配置(/etc/fonts/local.conf 保证被读取,用户级可能被 XDG 路径问题跳过)
const xml = `<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<!-- generic families -->
<alias>
<family>sans-serif</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>serif</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>monospace</family>
<prefer><family>${family}</family></prefer>
</alias>
<!-- system CJK fonts that WeChat/CEF may request -->
<alias>
<family>WenQuanYi Micro Hei</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>WenQuanYi Zen Hei</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>Noto Sans CJK SC</family>
<prefer><family>${family}</family></prefer>
</alias>
<alias>
<family>Noto Sans CJK</family>
<prefer><family>${family}</family></prefer>
</alias>
<!-- force user font for any zh text regardless of requested family -->
<match target="pattern">
<test name="lang" compare="contains"><string>zh</string></test>
<edit name="family" mode="prepend" binding="strong"><string>${family}</string></edit>
</match>
</fontconfig>`;
await execCapture(inst, ['bash', '-c', `cat > /etc/fonts/local.conf << 'CONF'\n${xml}\nCONF`], 'root');
execCapture(inst, ['bash', '-c', `cat > /config/.woc-fc-local.conf << 'CONF'\n${xml}\nCONF`], 'root').catch(() => {});
await execCapture(inst, ['fc-cache', '-f'], 'root');
await execCapture(inst, ['sh', '-c', `echo '${fontFile}' > ${FONT_SEL_FILE}`]);
await execCapture(inst, ['bash', '-c', `cat > ${FONT_SEL_FILE}-family << 'FAMILYEOF'\n${family}\nFAMILYEOF`]);
// 更新 xsettingsd 配置 → GTK/Qt 应用实时响应
applyXsettingsFont(inst, family).catch(() => {});
} else {
// 清除偏好,回退默认(文泉驿等系统字体)
await execCapture(inst, ['rm', '-f', '/etc/fonts/local.conf', '/config/.woc-fc-local.conf'], 'root');
await execCapture(inst, ['rm', '-f', '/home/abc/.config/fontconfig/fonts.conf']);
await execCapture(inst, ['fc-cache', '-f'], 'root');
await execCapture(inst, ['rm', '-f', FONT_SEL_FILE, `${FONT_SEL_FILE}-family`]);
applyXsettingsFont(inst, 'WenQuanYi Micro Hei').catch(() => {});
}
}
async function applyXsettingsFont(inst: Instance, family: string): Promise<void> {
const conf = '/home/abc/.xsettingsd';
const lines = [
'Xft/Antialias 1',
'Xft/Hinting 1',
'Xft/HintStyle "hintslight"',
'Xft/RGBA "rgb"',
'Xft/DPI 96',
`Gtk/FontName "${family} 10"`,
];
await execCapture(inst, ['sh', '-c', `printf '%s\\n' ${lines.map(l => `'${l}'`).join(' ')} > ${conf}`]);
await execCapture(inst, ['sh', '-c', 'pkill -HUP xsettingsd 2>/dev/null || xsettingsd --config=/home/abc/.xsettingsd 2>/dev/null &']);
}
// 返回当前选中的字体文件名,空字符串表示默认
export async function getAppliedFont(inst: Instance): Promise<string> {
try {
const out = await execCapture(inst, ['sh', '-c', `cat ${FONT_SEL_FILE} 2>/dev/null || true`]);
return out.trim();
} catch { return ''; }
}
export async function getFontFamily(inst: Instance, fontFile: string): Promise<string> {
try {
const out = await execCapture(inst, ['sh', '-c', `fc-scan '${FONT_DIR}/${fontFile}' 2>/dev/null | grep 'family:' | head -1 | cut -d'"' -f2`]);
return out.trim();
} catch { return ''; }
}
// 容器重建后从挂载卷恢复字体配置 & xsettingsd不依赖 autostart 镜像版本)
async function restoreFontFromVolume(inst: Instance): Promise<void> {
await execCapture(inst, ['bash', '-c', 'if [ -f /config/.woc-fc-local.conf ]; then cp /config/.woc-fc-local.conf /etc/fonts/local.conf && fc-cache -f; fi'], 'root').catch(() => {});
const famOut = await execCapture(inst, ['sh', '-c', 'cat /config/.woc-font-family 2>/dev/null || true']).catch(() => '');
const family = famOut.trim();
if (family) applyXsettingsFont(inst, family).catch(() => {});
}
// 实例容器名(供反代构造 target
export function instanceTarget(inst: Instance): string {
return `http://${inst.containerName}:3000`;

View File

@@ -72,6 +72,19 @@ import {
volDownloadFile,
volBackupStream,
volRestoreArchive,
listBackgrounds,
uploadBackground,
applyBackground,
deleteBackground,
getBackgroundImage,
getCurrentBackground,
clearBackground,
listFonts,
uploadFont,
deleteFont,
applyFont,
getAppliedFont,
getFontFamily,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
@@ -1008,6 +1021,147 @@ app.post('/api/admin/instances/:id/wechat/update', async (req, reply) => {
return triggerInstanceWechat((req.params as any).id, 'update', reply);
});
// ---------- 桌面壁纸管理 ----------
const bgHandler = (id: string, reply: FastifyReply) => {
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
return inst;
};
app.get('/api/admin/instances/:id/backgrounds', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { backgrounds: await listBackgrounds(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '列出壁纸失败' }); }
});
app.post('/api/admin/instances/:id/backgrounds', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
const name = String((req.query as any)?.name || '').trim();
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件' });
try {
await uploadBackground(inst, name, body);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); }
});
app.get('/api/admin/instances/:id/backgrounds/current', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { background: await getCurrentBackground(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前壁纸失败' }); }
});
app.get('/api/admin/instances/:id/backgrounds/:name/image', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
const buf = await getBackgroundImage(inst, (req.params as any).name, true);
const name = (req.params as any).name as string;
const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : 'png';
const mime: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' };
reply.header('Content-Type', mime[ext] || 'application/octet-stream');
reply.header('Cache-Control', 'public, max-age=86400');
return reply.send(buf);
} catch (e: any) { return reply.code(404).send({ error: '图片不存在' }); }
});
app.post('/api/admin/instances/:id/backgrounds/:name/apply', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await applyBackground(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '应用壁纸失败' }); }
});
app.post('/api/admin/instances/:id/backgrounds/clear', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await clearBackground(inst);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '清除壁纸失败' }); }
});
app.delete('/api/admin/instances/:id/backgrounds/:name', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await deleteBackground(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); }
});
// ---------- 字体管理 ----------
app.get('/api/admin/instances/:id/fonts', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { fonts: await listFonts(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '列出字体失败' }); }
});
app.post('/api/admin/instances/:id/fonts', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
const name = String((req.query as any)?.name || '').trim();
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件' });
try {
await uploadFont(inst, name, body);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); }
});
app.delete('/api/admin/instances/:id/fonts/:name', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await deleteFont(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); }
});
app.get('/api/admin/instances/:id/fonts/current', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { fontFile: await getAppliedFont(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前字体失败' }); }
});
app.post('/api/admin/instances/:id/fonts/:name/apply', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await applyFont(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '应用字体失败' }); }
});
app.post('/api/admin/instances/:id/fonts/default', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await applyFont(inst, 'default');
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '重置字体失败' }); }
});
// ---------- 反向代理到内网 KasmVNC按实例注入 Basic auth会话 + 权限把守) ----------
// 单个 proxy 实例target 与凭据逐请求指定:凭据暂存在 req 上proxyReq 时注入。
const proxy = httpProxy.createProxyServer({ changeOrigin: true, ws: true });

View File

@@ -246,4 +246,39 @@ export const api = {
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' }),
};

View File

@@ -212,6 +212,16 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const [uploading, setUploading] = useState(false);
const [starting, setStarting] = useState(false);
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [settingsTab, setSettingsTab] = useState<'bg' | 'font'>('bg');
const [bgList, setBgList] = useState<string[]>([]);
const [fontList, setFontList] = useState<string[]>([]);
const [bgUploading, setBgUploading] = useState(false);
const [fontUploading, setFontUploading] = useState(false);
const [currentBg, setCurrentBg] = useState('');
const [currentFont, setCurrentFont] = useState('');
const bgInput = useRef<HTMLInputElement>(null);
const fontInput = useRef<HTMLInputElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
@@ -578,6 +588,100 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
// ---------- 桌面壁纸 ----------
const refreshBgList = async () => {
if (!id) return;
try { const r = await api.listBackgrounds(id); setBgList(r.backgrounds); } catch { /* ignore */ }
try { const r = await api.getCurrentBackground(id); setCurrentBg(r.background); } catch { /* ignore */ }
};
const uploadBg = async () => {
const input = bgInput.current;
if (!input?.files?.length) return;
setBgUploading(true);
for (const f of Array.from(input.files)) {
try {
await api.uploadBackground(id, f.name, f);
toast(`壁纸「${f.name}」已上传`, 'ok');
} catch (e: any) { toast(e.message || '上传失败', 'error'); }
}
setBgUploading(false);
input.value = '';
refreshBgList();
};
const applyBg = async (name: string) => {
try {
await api.applyBackground(id, name);
setCurrentBg(name);
toast('壁纸已应用', 'ok');
} catch (e: any) { toast(e.message || '应用失败', 'error'); }
};
const clearBg = async () => {
try {
await api.clearBackground(id);
setCurrentBg('');
toast('已恢复默认黑屏', 'ok');
} catch (e: any) { toast(e.message || '清除失败', 'error'); }
};
const deleteBg = async (name: string) => {
if (!(await confirm({ title: `删除壁纸「${name}」?`, danger: true, confirmText: '删除' }))) return;
try {
await api.deleteBackground(id, name);
toast('已删除', 'ok');
refreshBgList();
} catch (e: any) { toast(e.message || '删除失败', 'error'); }
};
// ---------- 字体管理 ----------
const refreshFontList = async () => {
if (!id) return;
try { const r = await api.listFonts(id); setFontList(r.fonts); } catch { /* ignore */ }
try { const r = await api.getCurrentFont(id); setCurrentFont(r.fontFile); } catch { /* ignore */ }
};
const uploadFont = async () => {
const input = fontInput.current;
if (!input?.files?.length) return;
setFontUploading(true);
for (const f of Array.from(input.files)) {
try {
await api.uploadFont(id, f.name, f);
toast(`字体「${f.name}」已安装`, 'ok');
} catch (e: any) { toast(e.message || '上传失败', 'error'); }
}
setFontUploading(false);
input.value = '';
refreshFontList();
};
const deleteFont = async (name: string) => {
if (!(await confirm({ title: `删除字体「${name}」?`, danger: true, confirmText: '删除' }))) return;
try {
await api.deleteFont(id, name);
toast('已删除,字体缓存已刷新', 'ok');
refreshFontList();
} catch (e: any) { toast(e.message || '删除失败', 'error'); }
};
const applyUserFont = async (name: string) => {
try {
await api.applyFont(id, name);
setCurrentFont(name);
toast('字体已应用,重启微信后完全生效', 'ok');
} catch (e: any) { toast(e.message || '应用失败', 'error'); }
};
const resetFontDefault = async () => {
try {
await api.resetFontDefault(id);
setCurrentFont('');
toast('已恢复系统默认字体(文泉驿),重启微信后生效', 'ok');
} catch (e: any) { toast(e.message || '重置失败', 'error'); }
};
// 同源 iframe把键盘焦点交给 VNC帮助宿主机输入法把合成的字送进去
const focusFrame = () => {
try {
@@ -779,9 +883,14 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</button>
)}
{isAdmin && (
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
</button>
<>
<button className={'ws-action' + (showSettings ? ' on' : '')} title="桌面设置(壁纸/字体)" onClick={() => { setShowSettings((v) => !v); if (!showSettings) { refreshBgList(); refreshFontList(); } }}>
</button>
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
</button>
</>
)}
</>
)}
@@ -1005,6 +1114,76 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</div>
</div>
)}
{showSettings && (
<div className="iv-files">
<div className="files-head">
<span></span>
<button className="btn-text" onClick={() => setShowSettings(false)}></button>
</div>
<div className="settings-tabs">
<button className={'settings-tab' + (settingsTab === 'bg' ? ' on' : '')} onClick={() => setSettingsTab('bg')}></button>
<button className={'settings-tab' + (settingsTab === 'font' ? ' on' : '')} onClick={() => setSettingsTab('font')}></button>
</div>
{settingsTab === 'bg' && (
<div className="settings-panel">
<input ref={bgInput} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={uploadBg} />
<button className="btn btn-primary files-upload" disabled={bgUploading} onClick={() => bgInput.current?.click()}>
{bgUploading ? '上传中…' : ' 上传壁纸'}
</button>
{currentBg && (
<button className="btn-text" style={{ alignSelf: 'flex-start', color: 'var(--danger)', fontSize: 12 }} onClick={clearBg}>
</button>
)}
<div className="files-hint"> JPG / PNG </div>
{bgList.length === 0 && <div className="muted small" style={{ padding: '10px 2px' }}></div>}
<div className="bg-grid">
{bgList.map((name) => (
<div key={name} className={'bg-card' + (currentBg === name ? ' active' : '')} onClick={() => applyBg(name)} title="单击应用">
<div className="bg-thumb-wrap">
<img className="bg-thumb" src={`/api/admin/instances/${id}/backgrounds/${encodeURIComponent(name)}/image`} alt={name} loading="lazy" />
{currentBg !== name && <div className="bg-hint"></div>}
{currentBg === name && <span className="bg-active-badge"> 使</span>}
<button className="bg-del" title="删除" onClick={(e) => { e.stopPropagation(); deleteBg(name); }}></button>
</div>
<span className="bg-name">{name}</span>
</div>
))}
</div>
</div>
)}
{settingsTab === 'font' && (
<div className="settings-panel">
<input ref={fontInput} type="file" accept=".ttf,.otf,.ttc" multiple style={{ display: 'none' }} onChange={uploadFont} />
<button className="btn btn-primary files-upload" disabled={fontUploading} onClick={() => fontInput.current?.click()}>
{fontUploading ? '上传中…' : ' 上传字体'}
</button>
{currentFont && (
<button className="btn-text" style={{ alignSelf: 'flex-start', color: 'var(--danger)', fontSize: 12 }} onClick={resetFontDefault}>
驿
</button>
)}
<div className="files-hint"> TTF / OTF / TTC </div>
{fontList.length === 0 && <div className="muted small" style={{ padding: '10px 2px' }}></div>}
<div className="font-grid">
{fontList.map((name) => (
<div key={name} className={'font-card' + (currentFont === name ? ' active' : '')} onClick={() => applyUserFont(name)} title="点击应用">
{currentFont === name && <span className="font-badge"> 使</span>}
<button className="font-del" title="删除" onClick={(e) => { e.stopPropagation(); deleteFont(name); }}></button>
<div className="font-preview">
<span className="font-preview-text">Aa</span>
</div>
<span className="font-name">{name}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{inputMode === 'forward' && (

View File

@@ -875,6 +875,13 @@ button {
.files-item:last-child {
box-shadow: none;
}
.files-item.sel {
background: rgba(var(--green-rgb) / 0.08);
border-radius: var(--r-small);
box-shadow: var(--crease-accent);
padding: 0 4px;
margin: 0 -4px;
}
.files-dl {
flex: 1;
min-width: 0;
@@ -2273,3 +2280,231 @@ button {
.icon-pick:active {
transform: scale(0.96);
}
/* 桌面设置面板 — 壁纸 / 字体 */
.settings-tabs {
display: flex;
gap: 0;
margin: 8px 0;
border-radius: var(--r-small);
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--text) 30%, transparent);
background: var(--trough);
}
.settings-tab {
flex: 1;
padding: 8px 0;
text-align: center;
font-size: 13px;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.settings-tab.on {
background: var(--surface);
color: var(--text);
font-weight: 600;
box-shadow: var(--crease);
}
.settings-tab + .settings-tab {
border-left: 1px solid color-mix(in srgb, var(--text) 15%, transparent);
}
.settings-panel {
display: flex;
flex-direction: column;
gap: 6px;
}
/* 壁纸缩略图网格 */
.bg-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.bg-card {
background: var(--surface);
border-radius: var(--r-small);
box-shadow: var(--crease);
overflow: hidden;
position: relative;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.12s;
}
.bg-card:hover {
transform: translateY(-1px);
box-shadow: var(--crease), 0 4px 12px rgba(var(--shadow) / 0.15);
}
.bg-card:active {
transform: scale(0.98);
}
.bg-card.active {
box-shadow: 0 0 0 2px var(--wx-green), var(--crease);
}
.bg-thumb-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
overflow: hidden;
background: var(--trough);
border-bottom: 1px solid rgba(var(--shadow) / 0.06);
}
.bg-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.bg-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0 0 0 / 0.45);
color: #fff;
font-size: 13px;
font-weight: 600;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.bg-card:not(.active):hover .bg-hint {
opacity: 1;
}
.bg-active-badge {
position: absolute;
top: 6px;
right: 6px;
background: var(--wx-green);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
line-height: 1.4;
pointer-events: none;
z-index: 1;
}
.bg-del {
position: absolute;
top: 4px;
left: 4px;
z-index: 1;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(0 0 0 / 0.5);
color: #fff;
font-size: 11px;
line-height: 22px;
text-align: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
}
.bg-card:hover .bg-del {
opacity: 1;
}
.bg-del:hover {
background: var(--danger);
}
.bg-name {
display: block;
font-size: 11px;
color: var(--muted);
padding: 5px 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 字体卡片网格 */
.font-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.font-card {
position: relative;
background: var(--surface);
border-radius: var(--r-small);
box-shadow: var(--crease);
overflow: hidden;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.12s;
}
.font-card:hover {
transform: translateY(-1px);
box-shadow: var(--crease), 0 4px 12px rgba(var(--shadow) / 0.15);
}
.font-card:active {
transform: scale(0.98);
}
.font-card.active {
box-shadow: 0 0 0 2px var(--wx-green), var(--crease);
}
.font-badge {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
background: var(--wx-green);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
line-height: 1.4;
pointer-events: none;
}
.font-del {
position: absolute;
top: 4px;
left: 4px;
z-index: 1;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(0 0 0 / 0.5);
color: #fff;
font-size: 11px;
line-height: 22px;
text-align: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
}
.font-card:hover .font-del {
opacity: 1;
}
.font-del:hover {
background: var(--danger);
}
.font-preview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 72px;
background: var(--trough);
border-bottom: 1px solid rgba(var(--shadow) / 0.06);
}
.font-preview-text {
font-size: 32px;
font-weight: 700;
color: var(--text);
line-height: 1;
}
.font-name {
display: block;
font-size: 11px;
color: var(--muted);
padding: 5px 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}