fix(desktop,island): 设置页灵动岛外观改动即生效——IPC 只接受纯数据 + 真实 UI 冒烟回归

用户反馈"灵动岛设置无法生效并且无法实时预览"。根因是设置页把 ref 的响应式 Proxy 直接交给 contextBridge,结构化克隆在进入 preload 函数体之前就抛 `An object could not be cloned.`,界面数字照常变化却半静默失败。

- SettingsView:应用/保存外观时展开成纯对象({ ...island.value })
- desktop.d.ts / preload:显式写明"必须传纯数据",记录该失败模式
- smoke.ts:新增「拖拽设置页真实滑块」用例,同时断言页面数值与主进程外观都变化
This commit is contained in:
LoveChengke committed 2026-09-27 10:43:57 +08:00
1 parent d900280760
commit 14ac16ff29
4 files changed
+113 -4

No files matched your search

+87
View File
@@ -2455,6 +2455,87 @@ async function runIslandRealtimeCheck(
return { delivered, deliveryDetail, markRead, markReadDetail }; return { delivered, deliveryDetail, markRead, markReadDetail };
} }
/**
* 设置页「真实 UI 链路」自检(用户反馈:**灵动岛设置无法生效并且无法实时预览**)。
*
* 为什么必须单独来一条:上面的用例都是直接调 `window.desktop.islandSetAppearance(...)`,
* 绕过了设置页的控件,于是漏掉了真正的失败模式 —— 设置页把 Vue 的响应式 Proxy(`ref.value`)
* 直接交给 `ipcRenderer`,结构化克隆会失败并抛出 `An object could not be cloned.`。
* 这种失败是"半静默"的:滑块数字照常变化、界面不报错,只有主进程里的外观一动不动,
* 用户看到的就是"拖了滑块没反应、预览也没变化"。
*
* 因此这里在真实设置页里合成一次拖拽(mousedown → mousemove → mouseup,与用户操作同一路径),
* 并同时断言「滑块自身的数值」与「主进程里的外观」都变了 —— 只断言前者会漏掉这个 bug。
*/
async function runSettingsPageAppearanceCheck(
win: BrowserWindow,
): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = await window.desktop.islandGetAppearance();
// 先固定基准高度:避免"当前高度恰好等于拖拽落点"导致假通过或假失败
window.desktop.islandSetAppearance({ ...before, height: 44 });
await wait(300);
location.hash = '#/settings';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
runway = document.querySelector('.el-slider__runway');
}
if (!runway) return { ok: false, detail: '设置页未渲染出滑块(未登录或页面未挂载)' };
const rect = runway.getBoundingClientRect();
if (!rect.width) return { ok: false, detail: '滑块宽度为 0,无法拖拽' };
const x = rect.left + rect.width * 0.9;
const y = rect.top + rect.height / 2;
const fire = (target, type, extra) =>
target.dispatchEvent(
new MouseEvent(
type,
Object.assign({ bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }, extra),
),
);
fire(runway, 'mousedown', { buttons: 1 });
await wait(60);
fire(window, 'mousemove', { buttons: 1 });
await wait(60);
fire(window, 'mouseup', {});
await wait(600);
const dragged = await window.desktop.islandGetAppearance();
const label =
Array.from(document.querySelectorAll('.el-form-item__label'))
.map((node) => (node.textContent ?? '').trim())
.find((text) => text.startsWith('高度')) ?? '';
// 复原外观与路由,不影响后续用例
window.desktop.islandSetAppearance({ ...before });
location.hash = '#/schedule';
return { ok: true, baseline: 44, dragged: dragged.height, label };
})()`,
)
.catch((error: unknown) => ({
ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as
| { ok: true; baseline: number; dragged: number; label: string }
| { ok: false; detail: string }
| null;
if (result?.ok) {
return {
ok: result.dragged !== result.baseline && result.label === `高度 ${result.dragged}px`,
detail:
`拖动高度滑块到 90%:页面标签="${result.label}",` +
`主进程外观高度 ${result.baseline} → ${result.dragged}`,
};
}
return { ok: false, detail: result?.detail ?? '未执行' };
}
/** /**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。 * Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退, * 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -2645,6 +2726,12 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
String(online?.detail ?? ''), String(online?.detail ?? ''),
); );
// 设置页真实 UI 链路(用户反馈:"灵动岛设置无法生效并且无法实时预览"):
// 必须真的去拖设置页里的滑块 —— 直接调 window.desktop.* 会绕过
// 「Vue 响应式 Proxy 过不了 IPC 结构化克隆」这个失败模式。
const settingsAppearance = await runSettingsPageAppearanceCheck(win);
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊; // 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读") // 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
const realtime = await runIslandRealtimeCheck(win); const realtime = await runIslandRealtimeCheck(win);
@@ -12,6 +12,10 @@ import type { IslandNotification } from '@classhelper/shared';
/** /**
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下, * 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。 * 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
*
* 注意:contextBridge 在参数跨越"主世界 → 隔离世界"时就做结构化克隆,
* 因此**桥接层无法替调用方兜底**:传 Vue 响应式对象(Proxy)会在进入这里的函数体
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/ */
const bridge: DesktopBridge = { const bridge: DesktopBridge = {
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1', smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
@@ -137,17 +137,21 @@ async function loadIslandAppearance(): Promise<void> {
/** /**
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。 * 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。 * 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
*
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
*/ */
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void { function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
island.value = { ...island.value, ...patch }; island.value = { ...island.value, ...patch };
window.desktop?.islandSetAppearance(island.value); window.desktop?.islandSetAppearance({ ...island.value });
} }
async function saveIslandAppearance(): Promise<void> { async function saveIslandAppearance(): Promise<void> {
if (!window.desktop?.saveConfig) return; if (!window.desktop?.saveConfig) return;
savingIsland.value = true; savingIsland.value = true;
try { try {
await window.desktop.saveConfig({ island: island.value }); await window.desktop.saveConfig({ island: { ...island.value } });
ElMessage.success('个性化设置已保存'); ElMessage.success('个性化设置已保存');
} finally { } finally {
savingIsland.value = false; savingIsland.value = false;
+16 -2
View File
@@ -61,19 +61,33 @@ export interface DesktopBridge {
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */ /** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
smokeTest: boolean; smokeTest: boolean;
getConfig(): Promise<DesktopStoredConfig>; getConfig(): Promise<DesktopStoredConfig>;
/**
* 局部更新配置。
*
* **必须传纯数据(普通对象 / 基本类型)**:传 Vue 的 `ref.value` / `reactive()` 对象
* 会在 contextBridge 跨隔离世界时被结构化克隆拒绝,抛
* `Error: An object could not be cloned.`,而且调用点看起来"只是没生效"。
* 页面里请写成 `saveConfig({ island: { ...island.value } })` 这种展开后的字面量。
*/
saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>; saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>;
clearConfig(): Promise<DesktopStoredConfig>; clearConfig(): Promise<DesktopStoredConfig>;
getAppInfo(): Promise<DesktopAppInfo>; getAppInfo(): Promise<DesktopAppInfo>;
openExternal(url: string): Promise<boolean>; openExternal(url: string): Promise<boolean>;
/* 灵动岛 */ /* 灵动岛 */
/** 把一条通知投递到灵动岛 */ /** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void; islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */ /** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
islandSetClassState(payload: IslandClassStatePayload): void; islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */ /** 读取灵动岛当前状态(设置页/冒烟验证用) */
islandGetState(): Promise<IslandState>; islandGetState(): Promise<IslandState>;
/** 应用灵动岛外观设置(实时生效) */ /**
* 应用灵动岛外观设置(实时生效)。
*
* 同 saveConfig:**必须是纯数据**。设置页曾把 `island`(`ref`)的响应式 Proxy 直接传进来,
* 结果 contextBridge 抛 `An object could not be cloned.`,主进程收不到任何东西 ——
* 现象就是"拖了滑块、数字在变,灵动岛毫无反应,保存也不生效"。
*/
islandSetAppearance(appearance: Partial<IslandAppearance>): void; islandSetAppearance(appearance: Partial<IslandAppearance>): void;
/** 读取当前生效的外观设置 */ /** 读取当前生效的外观设置 */
islandGetAppearance(): Promise<IslandAppearance>; islandGetAppearance(): Promise<IslandAppearance>;