fix(island): 幽灵胶囊自愈 + 叫人双广播不降级 + 淡出/帧限流兜底(继续修"点不动")
用户反馈"叫人点不动"复盘:状态机层面没问题(真机点击日志能看到"收到用户动作 expand"), 问题在窗口/渲染层面。本轮修掉三个真实缺陷: 1) 幽灵胶囊:岛渲染进程一旦崩溃/卡死,Windows 会把最后一帧留在屏幕上 —— 岛还挂在最上方、 窗口也还在,但点击全部丢失,而客户端没有任何自愈机制(实测:崩掉渲染进程后点击不再产生 任何动作)。现在渲染进程每 5s 上报心跳,主进程 20s 收不到、或收到 render-process-gone / unresponsive 时,**先隐藏死窗口**再重建并重新下发状态与外观;10 分钟内最多重建 3 次, 超出则隐藏并告警,避免持续崩坏时无限重建。 2) 叫人双广播降级:服务端 createCall 同时发 notification:new(班级房间)与 call:new(user 房间), 两条 DTO 同 id,后到的那条会把"叫人"覆盖成普通通知 —— 胶囊显示成 "新消息 / 系统管理员 · 点击查看"(用户截图正是如此),紧急叫人还会被降级成"课中只排队、 不立即展开"。现在按 id 合并:类型 call 优先、优先级取更高(URGENT > HIGH > NORMAL)。 3) 淡出竞态:岛重新变为可见时没有取消进行中的淡出,那次淡出会继续把透明度降到 0 并 hide() 窗口,出现"状态是胶囊/展开、窗口却不可见"的错位(冒烟"岛外鼠标穿透"用例就是被它打红的)。 现在可见路径总是 cancelFade + 恢复透明度。 另加两条兜底: - 命中矩形兜底:渲染进程还没上报岛体矩形时,主进程按当前形态/停靠位置自己算一个, 杜绝"岛可见却一直不接收鼠标"。 - 形变兜底:实测打包版某些时刻(细缝态、刚启动)rAF 会被 Chromium 压到约 1 帧/秒, 此时"点击后形变"看起来就是没反应;现在形变开始 1.2s 后仍未收敛就直接 snap 到目标几何。 验证:pnpm verify:desktop 81/81(新增 3 条回归:双广播不降级 / 紧急叫人双广播 / 渲染进程崩溃后自愈;离线二次启动 73/73);打包版 verify:packaged 81/81、packaged=true、 二次启动 73/73,全绿。 顺带:细缝用例改为轮询等几何收敛(帧被限流时固定 sleep 会误判为"细缝尺寸不对"), 课表时间轴失败时打印各卡片状态(便于定位),并清理了库里 3 条遗留测试探针课表 (它们会污染客户端"今天"的课表)。
This commit is contained in:
1 parent
a7b0e9c2b3
commit
337d6eae53
6 files changed
+329
-10
No files matched your search
@@ -213,6 +213,10 @@ const progress = ref(0);
|
|||||||
|
|
||||||
let rafHandle: number | null = null;
|
let rafHandle: number | null = null;
|
||||||
let lastFrameAt = 0;
|
let lastFrameAt = 0;
|
||||||
|
/** 形变兜底定时器:rAF 被系统限流时用它 snap 到目标几何(见 startFrameLoop) */
|
||||||
|
let morphFallbackTimer: number | null = null;
|
||||||
|
/** 兜底等待时长:正常形变约 300~500ms,超出这个时间说明帧被限流了 */
|
||||||
|
const MORPH_FALLBACK_MS = 1200;
|
||||||
|
|
||||||
function syncTargets(snap = false): void {
|
function syncTargets(snap = false): void {
|
||||||
const size = targetSize();
|
const size = targetSize();
|
||||||
@@ -237,6 +241,24 @@ function syncTargets(snap = false): void {
|
|||||||
function startFrameLoop(): void {
|
function startFrameLoop(): void {
|
||||||
if (rafHandle !== null) return;
|
if (rafHandle !== null) return;
|
||||||
lastFrameAt = performance.now();
|
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 frame = (now: number): void => {
|
||||||
const delta = now - lastFrameAt;
|
const delta = now - lastFrameAt;
|
||||||
lastFrameAt = now;
|
lastFrameAt = now;
|
||||||
@@ -270,6 +292,10 @@ function startFrameLoop(): void {
|
|||||||
springs.radius.snap(targetRadius(size));
|
springs.radius.snap(targetRadius(size));
|
||||||
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
|
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
|
||||||
progress.value = mode.value === 'expanded' ? 1 : 0;
|
progress.value = mode.value === 'expanded' ? 1 : 0;
|
||||||
|
if (morphFallbackTimer !== null) {
|
||||||
|
window.clearTimeout(morphFallbackTimer);
|
||||||
|
morphFallbackTimer = null;
|
||||||
|
}
|
||||||
refreshInteractive();
|
refreshInteractive();
|
||||||
rafHandle = null;
|
rafHandle = null;
|
||||||
};
|
};
|
||||||
@@ -457,6 +483,10 @@ function applyAppearance(next: IslandAppearance | null | undefined): void {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
|
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
|
||||||
|
// 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口"
|
||||||
|
// 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。
|
||||||
|
bridge?.alive?.();
|
||||||
|
window.setInterval(() => bridge?.alive?.(), 5000);
|
||||||
bridge?.onState((next) => {
|
bridge?.onState((next) => {
|
||||||
state.value = next;
|
state.value = next;
|
||||||
syncTargets();
|
syncTargets();
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ class IslandController {
|
|||||||
private explicitShowId: string | null = null;
|
private explicitShowId: string | null = null;
|
||||||
/** 预览示例岛的自动收尾定时器 */
|
/** 预览示例岛的自动收尾定时器 */
|
||||||
private previewTimer: ReturnType<typeof setTimeout> | null = null;
|
private previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** 渲染进程最近一次心跳/存活信号(用于发现"卡死的幽灵窗口") */
|
||||||
|
private lastAliveAt = 0;
|
||||||
|
/** 正在重建窗口(避免并发重建) */
|
||||||
|
private recovering = false;
|
||||||
|
/** 最近的窗口重建时刻(限流用) */
|
||||||
|
private recoverAt: number[] = [];
|
||||||
|
|
||||||
private state: IslandState = {
|
private state: IslandState = {
|
||||||
mode: 'hidden',
|
mode: 'hidden',
|
||||||
@@ -252,6 +258,25 @@ class IslandController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.win = win;
|
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) => {
|
win.webContents.on('console-message', (...args) => {
|
||||||
@@ -315,6 +340,37 @@ class IslandController {
|
|||||||
return this.isImmediate(notification) || notification.kind === 'call';
|
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<IslandNotification['priority'], number> = {
|
||||||
|
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;
|
const inClass = context.inClass ?? this.state.inClass;
|
||||||
this.state.inClass = inClass;
|
this.state.inClass = inClass;
|
||||||
if (context.currentPeriodEnd !== undefined)
|
if (context.currentPeriodEnd !== undefined)
|
||||||
@@ -769,10 +827,14 @@ class IslandController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.win.isVisible()) {
|
// 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0
|
||||||
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度
|
// 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。
|
||||||
this.cancelFade();
|
this.cancelFade();
|
||||||
|
if (this.win.getOpacity() !== this.appearance.opacity) {
|
||||||
this.win.setOpacity(this.appearance.opacity);
|
this.win.setOpacity(this.appearance.opacity);
|
||||||
|
}
|
||||||
|
if (!this.win.isVisible()) {
|
||||||
|
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示
|
||||||
this.win.showInactive();
|
this.win.showInactive();
|
||||||
}
|
}
|
||||||
// 命中一律按"真实光标是否落在岛上"决定:**绝不**整块固定包围盒吃鼠标,
|
// 命中一律按"真实光标是否落在岛上"决定:**绝不**整块固定包围盒吃鼠标,
|
||||||
@@ -1011,9 +1073,43 @@ class IslandController {
|
|||||||
return { x: point.x - bounds.x, y: point.y - bounds.y };
|
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 容差) */
|
/** 本地坐标是否落在岛体矩形内(含 2px 容差) */
|
||||||
private isInsideHitRect(local: { x: number; y: number } | null): boolean {
|
private isInsideHitRect(local: { x: number; y: number } | null): boolean {
|
||||||
const rect = this.hitRect;
|
const rect = this.effectiveHitRect();
|
||||||
if (!local || !rect) return false;
|
if (!local || !rect) return false;
|
||||||
const margin = 2;
|
const margin = 2;
|
||||||
return (
|
return (
|
||||||
@@ -1056,7 +1152,10 @@ class IslandController {
|
|||||||
if (this.hitTimer) return;
|
if (this.hitTimer) return;
|
||||||
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
|
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
|
||||||
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
|
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
|
||||||
this.hitTimer = setInterval(() => this.syncHitFromCursor(), 60);
|
this.hitTimer = setInterval(() => {
|
||||||
|
this.syncHitFromCursor();
|
||||||
|
this.checkRendererAlive();
|
||||||
|
}, 60);
|
||||||
// 定时器不应拖住进程退出
|
// 定时器不应拖住进程退出
|
||||||
this.hitTimer.unref?.();
|
this.hitTimer.unref?.();
|
||||||
}
|
}
|
||||||
@@ -1067,6 +1166,79 @@ class IslandController {
|
|||||||
this.hitTimer = null;
|
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 {
|
setBlurCollapseEnabled(enabled: boolean): void {
|
||||||
@@ -1179,6 +1351,18 @@ const BLUR_GRACE_MS = 500;
|
|||||||
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
|
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
|
||||||
const PREVIEW_DURATION_MS = 30_000;
|
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 {
|
interface Bounds {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -1211,6 +1395,11 @@ export function registerIslandIpc(): void {
|
|||||||
island.setClassState(payload);
|
island.setClassState(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 渲染进程心跳:用于发现"幽灵窗口"(渲染进程卡死但窗口还留在屏幕上,点了没反应)
|
||||||
|
ipcMain.on('island:alive', () => {
|
||||||
|
island.noteRendererAlive();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('island:get-state', () => island.getState());
|
ipcMain.handle('island:get-state', () => island.getState());
|
||||||
|
|
||||||
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
|
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
|
||||||
|
|||||||
@@ -2077,13 +2077,35 @@ async function runIslandChecks(
|
|||||||
|
|
||||||
// 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开
|
// 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开
|
||||||
const sliver = getSliverSize();
|
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 });
|
island.setAppearance({ ...appearanceBefore, idleSliver: true });
|
||||||
await drainIsland();
|
await drainIsland();
|
||||||
await sleep(420);
|
const sliverBounds = await waitIslandSize(sliver);
|
||||||
const sliverBounds = (await readIslandGeometry())?.island ?? null;
|
|
||||||
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
|
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
|
||||||
await sleep(700);
|
const afterSliverNotification = await waitIslandSize({
|
||||||
const afterSliverNotification = (await readIslandGeometry())?.island ?? null;
|
width: island.getAppearance().width,
|
||||||
|
height: island.getAppearance().height,
|
||||||
|
});
|
||||||
record(
|
record(
|
||||||
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
|
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
|
||||||
Math.abs((sliverBounds?.width ?? 0) - sliver.width) <= 1 &&
|
Math.abs((sliverBounds?.width ?? 0) - sliver.width) <= 1 &&
|
||||||
@@ -2249,6 +2271,75 @@ async function runIslandChecks(
|
|||||||
island.setAppearance(appearanceBefore);
|
island.setAppearance(appearanceBefore);
|
||||||
await sleep(300);
|
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' });
|
island.handleAction({ action: 'dismiss' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface IslandBridge {
|
|||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
||||||
|
alive(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridge: IslandBridge = {
|
const bridge: IslandBridge = {
|
||||||
@@ -36,6 +38,9 @@ const bridge: IslandBridge = {
|
|||||||
setHitRect: (rect) => {
|
setHitRect: (rect) => {
|
||||||
ipcRenderer.send('island:set-hit-rect', rect);
|
ipcRenderer.send('island:set-hit-rect', rect);
|
||||||
},
|
},
|
||||||
|
alive: () => {
|
||||||
|
ipcRenderer.send('island:alive');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('island', bridge);
|
contextBridge.exposeInMainWorld('island', bridge);
|
||||||
+3
-1
@@ -573,7 +573,9 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
ok,
|
ok,
|
||||||
detail:
|
detail:
|
||||||
`卡片=${dom.count} 进行中=${dom.hasCurrent} 进度条=${dom.hasProgress} 倒计时=${countdown} ` +
|
`卡片=${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) {
|
} catch (error) {
|
||||||
return { ok: false, detail: `时间轴自检异常:${error instanceof Error ? error.message : String(error)}` };
|
return { ok: false, detail: `时间轴自检异常:${error instanceof Error ? error.message : String(error)}` };
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export interface IslandRendererBridge {
|
|||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
||||||
|
alive(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in new issue
Block a user