diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 5024f32..e86bce8 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -213,6 +213,10 @@ const progress = ref(0); let rafHandle: number | null = null; let lastFrameAt = 0; +/** 形变兜底定时器:rAF 被系统限流时用它 snap 到目标几何(见 startFrameLoop) */ +let morphFallbackTimer: number | null = null; +/** 兜底等待时长:正常形变约 300~500ms,超出这个时间说明帧被限流了 */ +const MORPH_FALLBACK_MS = 1200; function syncTargets(snap = false): void { const size = targetSize(); @@ -237,6 +241,24 @@ function syncTargets(snap = false): void { function startFrameLoop(): void { if (rafHandle !== null) return; lastFrameAt = performance.now(); + // 形变兜底:某些情况下(Windows 把置顶透明小窗判定为"被遮挡/后台"、GPU 进程刚重启等) + // requestAnimationFrame 会被 Chromium 压到 1 帧/秒 —— 实测打包版细缝态出现过 1.1fps。 + // 那种情况下"点击后形变"看起来就是**没反应**(用户反馈的"点不动")。因此这里挂一个定时器, + // 到点若还没收敛就直接 snap 到目标几何:宁可少一段动画,也不能让点击看上去无效。 + if (morphFallbackTimer !== null) window.clearTimeout(morphFallbackTimer); + morphFallbackTimer = window.setTimeout(() => { + morphFallbackTimer = null; + if (rafHandle === null) return; // 已经收敛 + if (rafHandle !== null) window.cancelAnimationFrame(rafHandle); + rafHandle = null; + const size = targetSize(); + springs.width.snap(size.width); + springs.height.snap(size.height); + springs.radius.snap(targetRadius(size)); + morph.value = { width: size.width, height: size.height, radius: springs.radius.value }; + progress.value = mode.value === 'expanded' ? 1 : 0; + refreshInteractive(); + }, MORPH_FALLBACK_MS); const frame = (now: number): void => { const delta = now - lastFrameAt; lastFrameAt = now; @@ -270,6 +292,10 @@ function startFrameLoop(): void { springs.radius.snap(targetRadius(size)); morph.value = { width: size.width, height: size.height, radius: springs.radius.value }; progress.value = mode.value === 'expanded' ? 1 : 0; + if (morphFallbackTimer !== null) { + window.clearTimeout(morphFallbackTimer); + morphFallbackTimer = null; + } refreshInteractive(); rafHandle = null; }; @@ -457,6 +483,10 @@ function applyAppearance(next: IslandAppearance | null | undefined): void { onMounted(async () => { window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY)); + // 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口" + // 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。 + bridge?.alive?.(); + window.setInterval(() => bridge?.alive?.(), 5000); bridge?.onState((next) => { state.value = next; syncTargets(); diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index cfe806c..65d715c 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -143,6 +143,12 @@ class IslandController { private explicitShowId: string | null = null; /** 预览示例岛的自动收尾定时器 */ private previewTimer: ReturnType | null = null; + /** 渲染进程最近一次心跳/存活信号(用于发现"卡死的幽灵窗口") */ + private lastAliveAt = 0; + /** 正在重建窗口(避免并发重建) */ + private recovering = false; + /** 最近的窗口重建时刻(限流用) */ + private recoverAt: number[] = []; private state: IslandState = { mode: 'hidden', @@ -252,6 +258,25 @@ class IslandController { }); this.win = win; + this.lastAliveAt = Date.now(); + + /** + * 渲染进程没了/卡了时的自愈:隐藏死窗口(避免留下"幽灵胶囊")并重建窗口。 + * 三种触发:进程崩溃、系统判定无响应、心跳超时(见 checkRendererAlive)。 + */ + win.webContents.on('render-process-gone', (_event, details) => { + logger.error( + `灵动岛渲染进程已退出(reason=${details.reason} exitCode=${details.exitCode}),开始重建窗口`, + ); + this.recoverWindow('渲染进程退出'); + }); + win.webContents.on('unresponsive', () => { + logger.warn('灵动岛渲染进程无响应(等待心跳恢复,超时将重建窗口)'); + }); + win.webContents.on('responsive', () => { + logger.info('灵动岛渲染进程已恢复响应'); + this.lastAliveAt = Date.now(); + }); // 开发/冒烟时把灵动岛渲染进程的日志转发到主进程,便于排查"窗口空白"之类问题 win.webContents.on('console-message', (...args) => { @@ -315,6 +340,37 @@ class IslandController { return this.isImmediate(notification) || notification.kind === 'call'; } + /** + * 同一条消息**可能经由两条广播链路到达**:服务端 `createCall` 既发 `notification:new` + * (班级房间)又发 `call:new`(学生/班级 user 房间),两条 DTO 的 id 相同、 + * 只有"是否叫人"的语义不同。后到的那条绝不能把已有的"叫人"降级成普通通知 —— + * 否则胶囊显示成"新消息"、上课时段还会被当成普通通知隐藏(用户实测截图: + * 叫人胶囊写着"新消息 / 系统管理员 · 点击查看")。 + * + * 合并规则:类型取更特殊的(call 优先),优先级取更高的(URGENT > HIGH > NORMAL)。 + */ + private mergeNotification( + existing: IslandNotification | null, + incoming: IslandNotification, + ): IslandNotification { + if (!existing || existing.id !== incoming.id) return incoming; + const kind = existing.kind === 'call' || incoming.kind === 'call' ? 'call' : incoming.kind; + const rank: Record = { + LOW: 0, + NORMAL: 1, + HIGH: 2, + URGENT: 3, + }; + const priority = rank[incoming.priority] >= rank[existing.priority] ? incoming.priority : existing.priority; + return { ...incoming, kind, priority }; + } + + /** 找到同 id 的已知消息(正在展示的或队列里的) */ + private findKnownNotification(id: string): IslandNotification | null { + if (this.state.active?.id === id) return this.state.active; + return this.state.queued.find((item) => item.id === id) ?? null; + } + /** * 收到一条消息。 * - 紧急(含紧急叫人):立刻展开(无视上课时段) @@ -322,7 +378,9 @@ class IslandController { * - 上课中:普通通知进队列并保持隐藏;**叫人**仍以胶囊形式可见(可点开、不自动展开) * - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开 */ - pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void { + pushNotification(raw: IslandNotification, context: IslandPushContext = {}): void { + // 同一 id 的第二条链路(通知/叫人双播):合并而不是覆盖,保住"叫人/紧急"语义 + const notification = this.mergeNotification(this.findKnownNotification(raw.id), raw); const inClass = context.inClass ?? this.state.inClass; this.state.inClass = inClass; if (context.currentPeriodEnd !== undefined) @@ -769,10 +827,14 @@ class IslandController { return; } - if (!this.win.isVisible()) { - // 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度 - this.cancelFade(); + // 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0 + // 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。 + this.cancelFade(); + if (this.win.getOpacity() !== this.appearance.opacity) { this.win.setOpacity(this.appearance.opacity); + } + if (!this.win.isVisible()) { + // 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 this.win.showInactive(); } // 命中一律按"真实光标是否落在岛上"决定:**绝不**整块固定包围盒吃鼠标, @@ -1011,9 +1073,43 @@ class IslandController { return { x: point.x - bounds.x, y: point.y - bounds.y }; } + /** + * 岛体矩形:优先用渲染进程上报的真实几何;渲染进程还没上报时用**主进程按当前形态算出的 + * 目标矩形**兜底 —— 否则会出现"岛明明可见、窗口却一直不接收鼠标(点了没反应)"这种死局。 + */ + private effectiveHitRect(): { x: number; y: number; width: number; height: number } | null { + return this.hitRect ?? this.expectedHitRect(); + } + + /** 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底 */ + private expectedHitRect(): { x: number; y: number; width: number; height: number } { + let size = SIZES.pill; + if (this.state.mode === 'expanded') { + size = + this.state.active?.kind === 'call' + ? CALL_SIZE + : this.state.active?.priority === 'URGENT' + ? URGENT_SIZE + : SIZES.expanded; + } else if (this.state.mode === 'hidden' && this.appearance.idleSliver) { + size = SLIVER_SIZE; + } + const pad = ISLAND_SHADOW_PAD; + const position = this.appearance.position; + const width = Math.round(BBOX_SIZE.width); + const height = Math.round(BBOX_SIZE.height); + const x = position.endsWith('left') + ? pad + : position.endsWith('right') + ? width - pad - size.width + : Math.round((width - size.width) / 2); + const y = position.startsWith('bottom') ? height - pad - size.height : pad; + return { x, y, width: size.width, height: size.height }; + } + /** 本地坐标是否落在岛体矩形内(含 2px 容差) */ private isInsideHitRect(local: { x: number; y: number } | null): boolean { - const rect = this.hitRect; + const rect = this.effectiveHitRect(); if (!local || !rect) return false; const margin = 2; return ( @@ -1056,7 +1152,10 @@ class IslandController { if (this.hitTimer) return; // 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**, // 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。 - this.hitTimer = setInterval(() => this.syncHitFromCursor(), 60); + this.hitTimer = setInterval(() => { + this.syncHitFromCursor(); + this.checkRendererAlive(); + }, 60); // 定时器不应拖住进程退出 this.hitTimer.unref?.(); } @@ -1067,6 +1166,79 @@ class IslandController { this.hitTimer = null; } + /** 渲染进程心跳(渲染进程每隔几秒上报一次) */ + noteRendererAlive(): void { + this.lastAliveAt = Date.now(); + } + + /** + * 心跳检查:岛可见(非隐藏态)却长时间收不到渲染进程心跳 → 判定渲染进程卡死, + * 隐藏死窗口并重建。这是"幽灵胶囊(看着在、点不动)"的兜底自愈。 + */ + private checkRendererAlive(): void { + if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return; + if (this.state.mode === 'hidden' && !this.appearance.idleSliver) return; + if (Date.now() - this.lastAliveAt <= RENDERER_ALIVE_TIMEOUT_MS) return; + logger.error( + `灵动岛渲染进程超过 ${Math.round(RENDERER_ALIVE_TIMEOUT_MS / 1000)}s 没有心跳(疑似卡死),重建窗口`, + ); + this.lastAliveAt = Date.now(); // 先重置,避免 60ms 轮询里重复触发 + this.recoverWindow('心跳超时'); + } + + /** + * 重建灵动岛窗口(渲染进程崩溃/卡死后的自愈)。 + * + * 关键:先把**窗口隐藏**,否则 Windows 会保留最后一帧 —— 用户看到"岛还挂在屏幕上, + * 但怎么点都没反应"。随后销毁旧窗口并重新 init(状态与外观会重新下发)。 + * 限流:10 分钟内最多重建 RECOVER_MAX_TIMES 次,超出则暂停并隐藏,避免无限重建。 + */ + private recoverWindow(reason: string): void { + if (this.recovering) return; + const now = Date.now(); + this.recoverAt = this.recoverAt.filter((at) => now - at < RECOVER_WINDOW_MS); + if (this.recoverAt.length >= RECOVER_MAX_TIMES) { + logger.error(`灵动岛已连续重建 ${RECOVER_MAX_TIMES} 次(${reason}),暂停自动恢复并隐藏窗口`); + this.hideImmediately(); + return; + } + this.recoverAt.push(now); + this.recovering = true; + // 1) 立刻隐藏死窗口:不然屏幕上会留一帧"点不动的幽灵胶囊" + try { + this.setInteractive(false, '重建窗口'); + this.win?.hide(); + } catch { + // 忽略:窗口可能已销毁 + } + // 2) 销毁旧窗口并重建 + const old = this.win; + this.win = null; + this.ready = false; + try { + if (old && !old.isDestroyed()) old.destroy(); + } catch { + // 忽略 + } + void this.init(this.rendererUrl) + .then(() => { + // 新窗口是 show:false 创建的,必须按当前状态同步一次可见性/命中 + this.syncWindow(); + logger.info(`灵动岛窗口已重建(${reason}),状态与外观已重新下发`); + }) + .catch((error) => { + logger.error(`灵动岛窗口重建失败:${(error as Error).message}`); + }) + .finally(() => { + this.recovering = false; + }); + } + + /** 当前是否在重建窗口(供冒烟验证) */ + isRecovering(): boolean { + return this.recovering; + } + /** 当前是否接收鼠标(供冒烟验证) */ /** 诊断/冒烟用:开关"失焦自动收起" */ setBlurCollapseEnabled(enabled: boolean): void { @@ -1179,6 +1351,18 @@ const BLUR_GRACE_MS = 500; /** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */ const PREVIEW_DURATION_MS = 30_000; +/** + * 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。 + * 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上** + * 形成"幽灵胶囊":窗口还在、看着正常,但怎么点都没反应(实测复现:崩掉渲染进程后 + * 点击完全不产生任何动作)。所以必须有心跳 + 重建来兜底。 + */ +const RENDERER_ALIVE_INTERVAL_MS = 5_000; +const RENDERER_ALIVE_TIMEOUT_MS = 20_000; +/** 窗口重建限流:一段时间内最多重建几次,避免持续崩坏时无限重建 */ +const RECOVER_MAX_TIMES = 3; +const RECOVER_WINDOW_MS = 10 * 60_000; + interface Bounds { x: number; y: number; @@ -1211,6 +1395,11 @@ export function registerIslandIpc(): void { island.setClassState(payload); }); + // 渲染进程心跳:用于发现"幽灵窗口"(渲染进程卡死但窗口还留在屏幕上,点了没反应) + ipcMain.on('island:alive', () => { + island.noteRendererAlive(); + }); + ipcMain.handle('island:get-state', () => island.getState()); ipcMain.on('island:set-appearance', (_event, patch: Partial) => { diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index e0b5e1c..9d04f11 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -2077,13 +2077,35 @@ async function runIslandChecks( // 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开 const sliver = getSliverSize(); + /** + * 等岛体几何收敛到目标尺寸(不等固定时长)。 + * 原因:Windows 有时会把置顶透明小窗判定为"被遮挡/后台",Chromium 会把 rAF 压到 1 帧/秒 + * (实测打包版细缝态出现过 1.1fps),此时形变会明显变慢;渲染进程 1.2s 后会自动 snap 到目标, + * 所以这里轮询等待即可,固定 sleep 会误判成"细缝尺寸不对"。 + */ + const waitIslandSize = async ( + target: { width: number; height: number }, + timeoutMs = 4000, + ): Promise<{ width: number; height: number } | null> => { + const deadline = Date.now() + timeoutMs; + let last: { width: number; height: number } | null = null; + while (Date.now() < deadline) { + last = (await readIslandGeometry())?.island ?? null; + if (last && Math.abs(last.width - target.width) <= 1 && Math.abs(last.height - target.height) <= 1) { + return last; + } + await sleep(120); + } + return last; + }; island.setAppearance({ ...appearanceBefore, idleSliver: true }); await drainIsland(); - await sleep(420); - const sliverBounds = (await readIslandGeometry())?.island ?? null; + const sliverBounds = await waitIslandSize(sliver); island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false }); - await sleep(700); - const afterSliverNotification = (await readIslandGeometry())?.island ?? null; + const afterSliverNotification = await waitIslandSize({ + width: island.getAppearance().width, + height: island.getAppearance().height, + }); record( '空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)', Math.abs((sliverBounds?.width ?? 0) - sliver.width) <= 1 && @@ -2249,6 +2271,75 @@ async function runIslandChecks( island.setAppearance(appearanceBefore); await sleep(300); + // 4.12) 同一条消息的"双广播"不能把"叫人"降级成普通通知。 + // 服务端 createCall 既发 notification:new(班级房间)又发 call:new(user 房间), + // 两条 DTO 同 id;后到的那条若直接覆盖,胶囊会显示成"新消息"(用户实测截图正是如此), + // 紧急叫人还会被降级成"课中只排队、不立即展开"。 + const mergeCallId = 'smoke-call-merge'; + island.pushNotification( + { ...makeNotification(mergeCallId, 'HIGH', '请 王小明 同学找 张老师'), kind: 'call' }, + { inClass: false }, + ); + await sleep(250); + island.pushNotification(makeNotification(mergeCallId, 'HIGH', '请 王小明 同学找 张老师'), { + inClass: false, + }); + await sleep(450); + const mergedCall = island.getState(); + record( + '双广播(叫人 + 通知)不降级:同 id 合并后仍按"叫人"处理', + mergedCall.active?.id === mergeCallId && mergedCall.active?.kind === 'call' && mergedCall.mode === 'pill', + `mode=${mergedCall.mode} kind=${mergedCall.active?.kind ?? '-'} priority=${mergedCall.active?.priority ?? '-'}`, + ); + const mergeUrgentId = 'smoke-call-merge-urgent'; + island.pushNotification(makeNotification(mergeUrgentId, 'NORMAL', '请 王小明 同学找 张老师'), { + inClass: true, + }); + await sleep(250); + island.pushNotification( + { ...makeNotification(mergeUrgentId, 'URGENT', '请 王小明 同学找 张老师'), kind: 'call' }, + { inClass: true }, + ); + await sleep(450); + const mergedUrgent = island.getState(); + record( + '紧急叫人双广播:合并后仍 URGENT 立即展开(课中不被降级为暂存)', + mergedUrgent.active?.id === mergeUrgentId && + mergedUrgent.mode === 'expanded' && + mergedUrgent.active?.priority === 'URGENT', + `mode=${mergedUrgent.mode} priority=${mergedUrgent.active?.priority ?? '-'} kind=${mergedUrgent.active?.kind ?? '-'}`, + ); + island.setClassState({ inClass: false, currentPeriodEnd: null }); + island.handleAction({ action: 'dismiss' }); + await sleep(300); + + // 4.13) 渲染进程崩溃自愈:岛窗口绝不能变成"屏幕上一帧点不动的幽灵胶囊"。 + // 真实现象(本机实测):崩掉岛渲染进程后,Windows 保留最后一帧 —— 胶囊看着还在, + // 窗口也还在,但点击全部丢失(主进程日志里再无"收到用户动作")。 + // 这里断言主进程会隐藏死窗口并重建,重建后状态回来了、也能重新命中。 + island.pushNotification(makeNotification('smoke-renderer-recover', 'NORMAL', '渲染进程自愈'), { + inClass: false, + }); + await sleep(500); + const windowBeforeCrash = island.getWindow(); + islandWindow?.webContents.forcefullyCrashRenderer(); + await sleep(3000); + const windowAfterCrash = island.getWindow(); + const recovered = island.isReady() && Boolean(windowAfterCrash) && windowAfterCrash !== windowBeforeCrash; + island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5)); + await sleep(250); + const recoveredInteractive = island.getInteractive(); + const recoveredState = island.getState(); + record( + '渲染进程崩溃后自愈(隐藏死窗口 + 重建 + 恢复命中,不再留"点不动的幽灵胶囊")', + recovered && recoveredInteractive && recoveredState.mode === 'pill', + `窗口已重建=${windowAfterCrash !== windowBeforeCrash} ready=${island.isReady()} ` + + `mode=${recoveredState.mode} 命中=${recoveredInteractive}`, + ); + island.setHitTestCursor(null); + island.handleAction({ action: 'dismiss' }); + await sleep(300); + // 收尾:隐藏灵动岛,避免影响后续用例 island.handleAction({ action: 'dismiss' }); await sleep(300); diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index ec85023..246e506 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -16,6 +16,8 @@ export interface IslandBridge { setInteractive(interactive: boolean): void; /** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */ setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void; + /** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */ + alive(): void; } const bridge: IslandBridge = { @@ -36,6 +38,9 @@ const bridge: IslandBridge = { setHitRect: (rect) => { ipcRenderer.send('island:set-hit-rect', rect); }, + alive: () => { + ipcRenderer.send('island:alive'); + }, }; contextBridge.exposeInMainWorld('island', bridge); diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 54c8057..7dd605e 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -573,7 +573,9 @@ export async function scheduleTimelineSelfTest(): Promise { ok, detail: `卡片=${dom.count} 进行中=${dom.hasCurrent} 进度条=${dom.hasProgress} 倒计时=${countdown} ` + - `已结束=${hasPast} 下一节=${hasNext} 大时钟=${dom.clock} 摘要="${dom.headline}"`, + `已结束=${hasPast} 下一节=${hasNext} 大时钟=${dom.clock} 摘要="${dom.headline}"` + + // 失败时能看出"到底渲染了哪几节、各自什么状态",否则只看到"已结束=false"无法定位 + ` 卡片状态=[${dom.tags.map((tag) => `${tag.state}:${tag.text.slice(0, 24)}`).join(' | ')}]`, }; } catch (error) { return { ok: false, detail: `时间轴自检异常:${error instanceof Error ? error.message : String(error)}` }; diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 9939f85..b255543 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -97,6 +97,8 @@ export interface IslandRendererBridge { setInteractive(interactive: boolean): void; /** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */ setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void; + /** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */ + alive(): void; } declare global {