fix: 安装用户未就绪重试(#74) + 关窗清理音频桥(#82) + 输入条发送后回焦点(#81)

- #74 安装失败「unable to find user abc: no matching entries in passwd」:容器 init 未完成时
  abc 用户还没建好,docker 创建 exec 即报 400。加 execCreate 重试包装(对该错误重试 ~12s),
  超时给清晰中文提示,而非透传难懂的 docker 400。
- #82 开音频后关窗再进必须重启实例:React 清理在直接关窗时不一定执行,残留 audio socket.io
  (开麦时还占着麦克风)与下次新连接并存把实例顶到"需重启"。加 pagehide(非 bfcache)即断开音频桥。
- #81 输入条回车只落字、还得点发送:发送后把键盘焦点交回 VM iframe,用户再按一次回车即在微信里发出。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-23 21:15:04 +08:00
parent ba32cc90ae
commit c871a70b5a
2 changed files with 32 additions and 3 deletions

View File

@@ -403,10 +403,28 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
}
}
// 创建 exec 实例。容器 init 未完成时linuxserver 基镜像的 'abc' 用户可能还没建好docker 会以
// 400「unable to find user abc: no matching entries in passwd file」直接拒绝创建 exec见 issue #74
// 对这种"用户未就绪"错误短暂重试,给容器 init 一点时间;超时则抛清晰的中文错误,而非透传难懂的 docker 400。
async function execCreate(c: any, opts: any): Promise<any> {
let lastErr: any;
for (let i = 0; i < 8; i++) {
try {
return await c.exec(opts);
} catch (e: any) {
const msg = String(e?.message || e);
if (!/no matching entries in passwd|unable to find user/i.test(msg)) throw e;
lastErr = e;
await new Promise((r) => setTimeout(r, 1500));
}
}
throw new Error(`容器仍在初始化(桌面用户未就绪),请等待约十几秒后重试(${lastErr?.message || lastErr}`);
}
// 在实例容器内执行命令,返回 stdout若命令失败把 stderr 透出给调用方。
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const c = docker.getContainer(inst.containerName);
const exec = await c.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
const exec = await execCreate(c, { Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
const stream = await exec.start({ hijack: true, stdin: false });
return await new Promise<string>((resolve, reject) => {
let out = '';
@@ -437,7 +455,7 @@ export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'):
const c = docker.getContainer(inst.containerName);
const at = instanceAppType(inst);
const action = cmd === 'update' ? 'update' : 'install';
const exec = await c.exec({
const exec = await execCreate(c, {
Cmd: ['bash', '-c', `if [ -x /woc/app-ctl.sh ]; then /woc/app-ctl.sh ${at} ${action}; else /woc/wechat-ctl.sh ${action}; fi`],
AttachStdout: false,
AttachStderr: false,

View File

@@ -403,13 +403,21 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const isFocused = () => !document.hidden && document.hasFocus();
const sync = () => audio.setActive(isFocused());
sync(); // 初始:若当前已聚焦则立即开声
// 关窗/关标签页时彻底断开音频桥issue #82React effect 的清理在直接关闭窗口时不一定执行,
// 残留的 audio socket.io开了麦克风时还占着 getUserMedia会留在实例上下次再进与新连接并存
// 把实例顶到"需重启"。pagehide 在页面真正被丢弃(非进 bfcache时同步断开避免该残留。
const onPageHide = (e: PageTransitionEvent) => {
if (!e.persisted) audio.destroy();
};
document.addEventListener('visibilitychange', sync);
window.addEventListener('focus', sync);
window.addEventListener('blur', sync);
window.addEventListener('pagehide', onPageHide);
return () => {
document.removeEventListener('visibilitychange', sync);
window.removeEventListener('focus', sync);
window.removeEventListener('blur', sync);
window.removeEventListener('pagehide', onPageHide);
audio.destroy();
audioRef.current = null;
};
@@ -623,6 +631,9 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
try {
await api.typeInInstance(id, t);
setImeText('');
// 发送后把键盘焦点交回虚拟机issue #81字已落进微信输入框焦点回到桌面后用户再按一次回车
// 即可在微信里发出去,省掉用鼠标点「发送」。延迟一拍,等 React 清空输入框、避免被本框抢回焦点。
setTimeout(() => focusFrame(), 60);
} catch (e: any) {
toast(e?.message || '发送失败:请确认实例已「升级实例」(镜像含 xclip/xdotool', 'error');
} finally {
@@ -989,7 +1000,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
sendImeText();
}
}}
placeholder="中文输入这里 → 回车送进应用先点好应用的输入框。Shift+回车换行。"
placeholder="中文输入这里 → 回车送进应用(先点好应用的输入框),焦点会自动回到桌面,再按一次回车即发送。Shift+回车换行。"
rows={1}
/>
<button