feat(desktop,island,shared): 灵动岛支持多条通知竖排列表 + 触摸模式 + 收回动画,产物加凭据门禁

灵动岛列表:多条通知时展开为竖排列表,默认显示 3 条,点「展开更多」铺到屏幕容纳
上限,放不下的收进「更多请前往应用内操作」。卡片高度由主进程与渲染进程各算一次
同一个数 —— 度量表 ISLAND_LIST_METRICS 与排序权重 PRIORITY_RANK 放在 shared,
窗口包围盒随之长高(不够高会把底部按钮裁掉)。

触摸模式:触摸屏上手指既不产生 mousemove、也不移动系统光标,原先两条命中链路都会
让窗口永远停在穿透态、手指点不开岛。现按 navigator.maxTouchPoints 上报后,主进程把
窗口贴合岛体并始终接收输入;非触摸屏机器完全不走这条路。

收回动画:main 先发 island:closing,渲染进程形变收敛后回 closing-done 才淡出,
不再靠猜时长(rAF 被节流时会把卡片缩到一半窗口就没了)。

安全门禁:新增 scripts/check-bundle-secrets.mjs 并接进 build:desktop ——
dist 产物会被打进 app.asar 发到每台学生机、且可解包,冒烟凭据一律改由环境变量注入,
产物里出现种子口令即构建失败。
This commit is contained in:
LoveChengke committed 2026-10-02 00:25:10 +08:00
1 parent d27f9a4b9e
commit 3880eee08e
22 files changed
+2482 -175

No files matched your search

+428 -48
View File
@@ -1,4 +1,5 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron';
import {
DEFAULT_ISLAND_APPEARANCE,
@@ -7,7 +8,10 @@ import {
ISLAND_SIZE_DELTA,
ISLAND_SLIVER_WIDTH,
ISLAND_STYLES,
PRIORITY_RANK,
islandListLayout,
type IslandAppearance,
type IslandListLayout,
type IslandMode,
type IslandNotification,
type IslandState,
@@ -15,6 +19,26 @@ import {
import { logger } from './logger.js';
import { getConfig } from './config.js';
/** 生产环境的灵动岛页面地址(导航白名单的精确比对用) */
const prodIslandUrl = pathToFileURL(path.join(__dirname, '../renderer/island.html')).href;
/**
* 是否允许灵动岛窗口导航到这个地址。
* 与主窗口同一套理由:preload 挂在窗口上而不是 URL 上,被导航走就等于把 `window.desktop`
* 交给了一个陌生页面;而灵动岛还是置顶窗口,陌生页面会停在屏幕最上层。
*/
function isAllowedIslandNavigation(url: string, devUrl: string | null): boolean {
try {
const target = new URL(url);
if (devUrl) return target.origin === new URL(devUrl).origin;
target.hash = '';
target.search = '';
return target.href === prodIslandUrl;
} catch {
return false;
}
}
/**
* 灵动岛(Dynamic Island)主进程控制器。
*
@@ -102,7 +126,18 @@ export interface IslandClassStatePayload {
week?: number;
}
type IslandActionName = 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app';
type IslandActionName =
| 'expand'
| 'collapse'
/** 多条通知时点"展开更多":把列表铺到屏幕放得下的程度 */
| 'expand-list'
| 'dismiss'
/** 多条通知时点"知道了":整批关闭,但不改通知中心的已读状态 */
| 'dismiss-all'
| 'mark-read'
/** 多条通知时点"标为已读":整批已读并关闭 */
| 'mark-all-read'
| 'open-app';
interface IslandActionPayload {
action: IslandActionName;
@@ -127,12 +162,35 @@ class IslandController {
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
private blurCollapseEnabled = true;
private interactive = false;
/**
* 触摸模式:触摸屏机器上由渲染进程按 `navigator.maxTouchPoints` 上报(见 `setTouchMode`)。
*
* 为什么触摸屏必须单独一套:触摸屏上"点不动"是**机制性**的,不是偶发 —— 打开命中的两条链路
* (渲染进程的 mousemove 转发、主进程读系统光标位置)都以"鼠标指针移动"为前提,而手指触摸
* 既不产生 mousemove、也不移动系统光标。于是窗口永远停在 `setIgnoreMouseEvents(true)` 的
* 穿透态,手指点下去只落到桌面(用户反馈:希沃白板上触摸灵动岛无法展开)。
*
* 修法只有一条:**窗口在触摸点处必须可命中**。Electron 没有 SetWindowRgn 那种"窗口区域"能力,
* 所以改成在触摸模式下让窗口**贴合岛体**(岛体 + 阴影留白)并始终接收输入 —— 贴身后多出来的
* 只有投影所需的 10px 留白,不再吞掉桌面点击。
*/
private touchMode = false;
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
/** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */
private hitTimer: ReturnType<typeof setInterval> | null = null;
/** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */
private cursorOverride: { x: number; y: number } | null = null;
/**
* 冒烟/诊断用:让窗口**不接收真实鼠标**(真实点击穿透到桌面)。
*
* 为什么需要:自动化验证时长分钟计,而岛上那套命中逻辑是按"真实光标位置"开的 ——
* 若此时用户正在用同一块屏幕(他自己的客户端和冒烟窗口都在屏幕顶部居中、互相叠着),
* 他点到的是**冒烟这个窗口**,冒烟就会收到人的点击(实测:冒烟从未发出的 collapse / mark-read
* 混进了断言,把「点胶囊展开」「点空白处收起」等用例整片弄红)。
* 打开后所有命中请求一律按"穿透"处理;合成 DOM 点击(executeJavaScript)不受影响。
*/
private testInputPassthrough = false;
/** 上次按矩形立即校正命中的时刻(限流用) */
private lastHitSyncAt = 0;
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
@@ -147,7 +205,10 @@ class IslandController {
private recovering = false;
/** 最近的窗口重建时刻(限流用) */
private recoverAt: number[] = [];
/** 上一次计算锚点所依据的显示器 id:用于发现"光标换屏 / 显示器插拔"并重排窗口 */
private anchorDisplayId: number | null = null;
/** 展开卡可用高度上限(CSS px):由工作区/停靠位置算出,随状态下发给渲染进程 */
private maxCardHeight = 1024;
private state: IslandState = {
mode: 'hidden',
active: null,
@@ -155,6 +216,8 @@ class IslandController {
inClass: false,
currentPeriodEnd: null,
reason: null,
listExpanded: false,
maxCardHeight: 1024,
updatedAt: Date.now(),
};
@@ -164,6 +227,9 @@ class IslandController {
/** 灵动岛点"标为已读"时交给外部(主窗口渲染进程)同步通知中心 */
private markReadHandler: ((id: string) => void) | null = null;
/** 灵动岛一次"标为已读"要标记的多条通知(多条列表时的整批已读) */
private markAllReadHandler: ((ids: string[]) => void) | null = null;
setOpenAppHandler(handler: () => void): void {
this.openAppHandler = handler;
}
@@ -172,6 +238,10 @@ class IslandController {
this.markReadHandler = handler;
}
setMarkAllReadHandler(handler: (ids: string[]) => void): void {
this.markAllReadHandler = handler;
}
async init(rendererUrl: string | null): Promise<void> {
this.rendererUrl = rendererUrl;
// 恢复用户保存的个性化外观(尺寸/位置/透明度/动画…),失败则用默认值
@@ -181,6 +251,7 @@ class IslandController {
this.appearance = { ...DEFAULT_ISLAND_APPEARANCE };
}
recomputeSizes(this.appearance);
this.refreshViewport();
if (this.win && !this.win.isDestroyed()) return;
const win = new BrowserWindow({
@@ -219,6 +290,13 @@ class IslandController {
win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
// 拒绝一切导航与新开窗口(理由见 isAllowedIslandNavigation)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('will-navigate', (event, url) => {
if (isAllowedIslandNavigation(url, this.rendererUrl)) return;
event.preventDefault();
logger.warn(`灵动岛拒绝了页面导航:${url}`);
});
// 注意:这里**不能**调用 `win.setBackgroundMaterial()`(亚克力/mica),
// 见 `applyBackgroundMaterial` 移除处的说明。
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
@@ -254,7 +332,10 @@ class IslandController {
);
return;
}
if (sinceActivated < BLUR_GRACE_MS) {
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
// 上面实测到的 70~520ms 抖动区间,否则手指点开后约 0.5s 会莫名缩回胶囊。
const grace = this.touchMode ? BLUR_GRACE_TOUCH_MS : BLUR_GRACE_MS;
if (sinceActivated < grace) {
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
return;
}
@@ -360,13 +441,10 @@ class IslandController {
): 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;
const priority =
(PRIORITY_RANK[incoming.priority] ?? 0) >= (PRIORITY_RANK[existing.priority] ?? 0)
? incoming.priority
: existing.priority;
return { ...incoming, kind, priority };
}
@@ -376,6 +454,61 @@ class IslandController {
return this.state.queued.find((item) => item.id === id) ?? null;
}
/**
* 当前**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」倒序。
*
* 排序口径与共享常量 `PRIORITY_RANK` 一致:紧急 > 重要 > 普通 > 低,同级按时间新的在前。
* 用户在展开卡里看到的就是这个顺序("默认按重要程度排列")。
*/
private pendingNotifications(): IslandNotification[] {
const merged: IslandNotification[] = [];
if (this.state.active) merged.push(this.state.active);
for (const item of this.state.queued) {
if (!merged.some((existing) => existing.id === item.id)) merged.push(item);
}
return merged.sort((a, b) => {
const rank = (PRIORITY_RANK[b.priority] ?? 0) - (PRIORITY_RANK[a.priority] ?? 0);
if (rank !== 0) return rank;
return String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''));
});
}
/**
* 多条通知时的列表布局(几条、要不要"展开更多"、卡片多高)。
* 只有展开态且待处理 ≥ 2 条时才有列表形态,单条通知仍然是原来那张详情卡。
*/
private listLayout(): IslandListLayout | null {
if (this.state.mode !== 'expanded') return null;
const count = this.pendingNotifications().length;
if (count < 2) return null;
return islandListLayout({
count,
fontSize: this.appearance.fontSize,
maxHeight: this.maxCardHeight,
expanded: this.state.listExpanded,
});
}
/**
* 展开卡在当前屏幕 / 停靠位置下的可用高度上限:
* 从"岛被锚定的那条边"量到屏幕工作区的另一端(也就是任务栏上方)。
*
* 用锚点 `vValue` 而不是窗口矩形来算:锚点与窗口尺寸无关,因此**列表长高后再算还是同一个数**
* (用窗口矩形会自引用:窗口高度取决于列表高度,列表高度又取决于可用高度)。
*/
private refreshViewport(): void {
const area = this.targetDisplay().workArea;
const anchor = this.resolveAnchor();
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
const available =
anchor.vMode === 'bottom'
? anchor.vValue - (area.y + marginY)
: area.y + area.height - marginY - anchor.vValue;
// 下限兜底:屏幕再小也不能用 0 去算布局(否则连一行都排不出来)
this.maxCardHeight = Math.max(MIN_CARD_HEIGHT, Math.floor(available));
this.state.maxCardHeight = this.maxCardHeight;
}
/**
* 收到一条消息。
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
@@ -384,6 +517,7 @@ class IslandController {
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
*/
pushNotification(raw: IslandNotification, context: IslandPushContext = {}): void {
this.state.listExpanded = false;
// 同一 id 的第二条链路(通知/叫人双播):合并而不是覆盖,保住"叫人/紧急"语义
const notification = this.mergeNotification(this.findKnownNotification(raw.id), raw);
const inClass = context.inClass ?? this.state.inClass;
@@ -398,7 +532,7 @@ class IslandController {
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
this.state.active = notification;
this.state.updatedAt = Date.now();
this.setState({ mode: 'expanded' });
this.setState({ mode: 'expanded', listExpanded: false });
this.previewTimer = setTimeout(() => {
this.previewTimer = null;
this.endPreview(notification.id);
@@ -561,7 +695,8 @@ class IslandController {
// 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏,
// 表现为"点不开新作业"——用户反馈的联合通知场景)。
this.explicitShowId = this.state.inClass ? active.id : null;
this.setState({ mode: 'expanded' });
// 每次重新点开都从"只显示前三条"开始(上文的 setState 只在离开 expanded 时复位)
this.setState({ mode: 'expanded', listExpanded: false });
this.scheduleCollapse(
this.isImmediate(active)
? active.kind === 'call'
@@ -572,6 +707,18 @@ class IslandController {
}
break;
}
case 'expand-list': {
// 多条通知时点"展开更多":把列表铺到屏幕放得下的程度(窗口跟着长高,见 setState)
if (this.state.mode === 'hidden' || this.state.listExpanded) break;
const layout = this.listLayout();
if (!layout) break;
logger.info(
`灵动岛:展开更多(共 ${this.pendingNotifications().length} 条,显示 ${layout.rows} 条,提示=${layout.hint ?? '无'})`,
);
this.setState({ mode: 'expanded', listExpanded: true });
this.scheduleCollapse(TIMEOUTS.expanded);
break;
}
case 'collapse':
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
// 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开)
@@ -589,6 +736,11 @@ class IslandController {
case 'dismiss':
this.dismissActive();
break;
case 'dismiss-all':
// 多条通知时点"知道了":整批关闭(通知中心仍是未读,学生回应用里再看)
logger.info(`灵动岛:整批知道了(${this.pendingNotifications().length} 条)`);
this.clearPendingForClose();
break;
case 'mark-read': {
const id = payload.id;
const queue = this.state.queued.filter((item) => item.id !== id);
@@ -604,6 +756,18 @@ class IslandController {
}
break;
}
case 'mark-all-read': {
// 多条通知时点"标为已读":**默认把这一批全部标记已读**再关闭。
// 作业卡的 id 是本地合成的(`homework-<id>`,库里没有这条通知),交给通知中心只会报"通知不存在",
// 因此这里先按 kind 过滤掉,单条模式的 canMarkRead 守卫同理。
const ids = this.pendingNotifications()
.filter((item) => item.kind !== 'homework')
.map((item) => item.id);
logger.info(`灵动岛:整批标为已读(${ids.length} 条)`);
if (ids.length > 0) this.markAllReadHandler?.(ids);
this.clearPendingForClose();
break;
}
case 'open-app':
this.openAppHandler?.();
break;
@@ -676,6 +840,19 @@ class IslandController {
this.hide();
}
/**
* 整批清空并关闭("知道了 / 标为已读"的多条场景)。
* 状态同步落地(渲染进程随之收起卡片,窗口由 `hide()` 淡出)。
*/
private clearPendingForClose(): void {
this.clearTimers();
this.state.active = null;
this.state.queued = [];
this.state.listExpanded = false;
this.hide();
}
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
this.clearTimers();
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
@@ -756,9 +933,7 @@ class IslandController {
*/
private resolveAnchor(): IslandAnchor {
// 多显示器教室电脑:打开"跟随鼠标屏幕"后,岛出现在鼠标所在的那块屏(讲台切换投影时不用改设置)
const display = this.appearance.followCursorDisplay
? screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
: screen.getPrimaryDisplay();
const display = this.targetDisplay();
const area = display.workArea;
const marginX = Math.round(this.appearance.marginX ?? ISLAND_MARGIN);
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
@@ -782,9 +957,38 @@ class IslandController {
return { hMode, hValue, vMode, vValue };
}
/** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */
private targetDisplay(): Electron.Display {
return this.appearance.followCursorDisplay
? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint())
: screen.getPrimaryDisplay();
}
/**
* 锚点所在屏幕变化时重排窗口("跟随鼠标屏幕"、主屏切换、显示器插拔都会走到这里)。
*
* 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间**
* 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。
* 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。
*/
private syncAnchorDisplay(): void {
if (!this.win || this.win.isDestroyed()) return;
const displayId = this.targetDisplay().id;
if (this.anchorDisplayId === displayId) return;
this.anchorDisplayId = displayId;
// 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算
this.refreshViewport();
if (!this.win.isVisible()) return;
this.relayout();
}
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
this.state = { ...this.state, ...patch, updatedAt: Date.now() };
// 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条"
if (this.state.mode !== 'expanded') this.state.listExpanded = false;
// 列表形态比普通展开卡高:窗口要**先**长高,卡片才开始形变,否则底部会被窗口裁掉
if (!options.skipWindow) this.relayout();
this.emit();
if (!options.skipWindow) this.syncWindow();
}
@@ -825,6 +1029,11 @@ class IslandController {
// 展开态才允许获得焦点,这样点击屏幕其他位置会 blur → 自动收起
this.applyFocusable();
// 窗口不可见时先把包围盒对齐到当前形态:列表形态留下的"高窗口"在这里收回去。
// 必须挑"不可见"的时刻做 —— 可见时改大小会把正在形变的卡片裁掉一截。
if (!this.win.isVisible()) this.applyWindowLayout();
// 触摸模式:形态变了,窗口要立刻跟着变大(缩小交给岛体矩形上报后判定,避免裁掉形变中的卡片)
this.syncTouchLayout();
if (this.state.mode === 'hidden') {
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
@@ -857,9 +1066,69 @@ class IslandController {
this.syncHitFromCursor();
}
/**
* 窗口包围盒:
* - 普通(鼠标)模式:固定取"最大形态 + 2×阴影留白",**开合过程中一帧都不动**(照搬 WinIsland);
* 唯一的例外是**多条通知的列表**:它比任何普通形态都高,窗口必须当场长高,否则卡片底部
* (那排按钮)会被窗口裁掉。窗口是在卡片开始形变**之前**改的,用户看不到这一步。
* - 触摸模式:贴合当前形态(岛体 + 2×阴影留白),否则大包围盒的透明区会吞掉桌面的触摸。
*/
private windowBox(): { width: number; height: number } {
const size = this.formSize();
if (!this.touchMode) {
return {
width: Math.round(Math.max(BBOX_SIZE.width, size.width + ISLAND_SHADOW_PAD * 2)),
height: Math.round(Math.max(BBOX_SIZE.height, size.height + ISLAND_SHADOW_PAD * 2)),
};
}
return {
width: Math.round(size.width + ISLAND_SHADOW_PAD * 2),
height: Math.round(size.height + ISLAND_SHADOW_PAD * 2),
};
}
/**
* 触摸模式下重排窗口(贴合岛体):
* **变大立刻生效**(否则形变中的卡片会被窗口裁掉一半);**变小要等岛真的收小**——
* 判据是渲染进程上报的真实岛体矩形已经装得下,见 `cardFitsFormSize`。
*/
private syncTouchLayout(): void {
if (!this.touchMode || !this.win || this.win.isDestroyed()) return;
const target = this.windowBox();
const current = this.win.getBounds();
const shrinking = target.width < current.width || target.height < current.height;
/**
* **窗口可见期间只增不减**(触摸模式)。
*
* 为什么:触摸模式下窗口是"贴合岛体"的(手指要能点到岛),所以形态变化时窗口本该跟着变
* —— 但**收小会让出一块区域**,Windows/DWM 在那一瞬间可能还留着上一帧的内容,
* 用户看到的就是"一缩回/一展开就闪一下、像旧画面残留在那里"(反复反馈、逐帧抓过的那一类)。
* 变大属于"只增不减",不会让出任何区域;所以:
* - 需要**变大**就立刻变大(否则形变中的卡片会被窗口裁掉);
* - 需要**变小**一律等窗口隐藏之后再收(`syncWindow` 里"不可见时对齐包围盒",
* 不可见时改尺寸不会有残影),这也顺带修掉"隐藏态改停靠/边距不生效"那条。
*/
if (shrinking && this.win.isVisible()) return;
this.applyWindowLayout();
}
/** 渲染进程上报的岛体是否已经收进"当前形态"的尺寸里(触摸模式下判断能否缩小窗口) */
private cardFitsFormSize(): boolean {
const rect = this.hitRect;
if (!rect) return false;
const size = this.formSize();
return rect.width <= size.width + 1 && rect.height <= size.height + 1;
}
/** 外观 / 锚点变化后的窗口重排:普通模式按固定包围盒重排,触摸模式走贴合逻辑 */
private relayout(): void {
if (this.touchMode) this.syncTouchLayout();
else this.applyWindowLayout();
}
/**
* 按当前外观把窗口放到锚点上(**只在尺寸/位置设置变化时调用**,开合时不调用)。
* 窗口尺寸 = 最大形态 + 2×阴影留白,因此任何形态的岛都能完整容纳。
* 尺寸见 `windowBox`:普通模式恒为"最大形态 + 阴影留白",触摸模式贴合当前形态。
*/
private applyWindowLayout(): void {
if (!this.win || this.win.isDestroyed()) return;
@@ -876,10 +1145,11 @@ class IslandController {
this.win.setBounds(next);
}
/** 固定包围盒窗口的矩形:以锚点定位,尺寸恒为"最大形态 + 阴影留白" */
/** 窗口矩形:以锚点定位(尺寸由 `windowBox` 决定,触摸模式随形态变化) */
private windowBounds(): Bounds {
const width = Math.round(BBOX_SIZE.width);
const height = Math.round(BBOX_SIZE.height);
const box = this.windowBox();
const width = box.width;
const height = box.height;
const anchor = this.resolveAnchor();
// 窗口比岛大 ISLAND_SHADOW_PAD(给投影留白),因此窗口要再偏移一个 pad,
@@ -1005,8 +1275,16 @@ class IslandController {
* 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。
*/
setAppearance(patch: Partial<IslandAppearance>): void {
this.appearance = normalizeAppearance({ ...this.appearance, ...patch });
// 先丢掉显式 undefined 的键:设置页/冒烟脚本传的都是**部分补丁**,
// 一个残缺字段不该把对应外观重置成默认值(NaN 的兜底在 normalizeAppearance 里)
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(patch ?? {})) {
if (value !== undefined) clean[key] = value;
}
this.appearance = normalizeAppearance({ ...this.appearance, ...clean } as IslandAppearance);
recomputeSizes(this.appearance);
// 字号/边距会影响"列表能放几行",可用高度也随停靠位置变化 —— 重算一次并随状态下发
this.refreshViewport();
if (this.win && !this.win.isDestroyed()) {
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
@@ -1017,7 +1295,7 @@ class IslandController {
}
this.emitAppearance();
// 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口)
this.applyWindowLayout();
this.relayout();
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
if (this.state.mode !== 'hidden') this.syncWindow();
logger.info(
@@ -1039,10 +1317,17 @@ class IslandController {
* "点开了再收起,就再也点不开了"。
*/
setInteractive(interactive: boolean, source = '未知'): void {
if (this.interactive === interactive) return;
this.interactive = interactive;
// 触摸模式:窗口已贴合岛体,必须**始终**接收输入。触摸不会产生光标移动,按光标判定命中的
// 那套逻辑在触摸屏上永远不会打开,一旦让它把命中关掉,手指就再也点不到("点不动")。
// 测试输入穿透:真实鼠标一律不接收(理由见 testInputPassthrough 字段);
// 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效,
// 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。
const passthrough = this.testInputPassthrough && this.cursorOverride === null;
const next = passthrough ? false : this.touchMode ? true : interactive;
if (this.interactive === next) return;
this.interactive = next;
logger.info(
`灵动岛:鼠标命中=${interactive}(来自${source})mode=${this.state.mode} ` +
`灵动岛:鼠标命中=${next}(请求=${interactive},来自${source})mode=${this.state.mode} ` +
`窗口可见=${this.win?.isVisible() ?? false} 岛体矩形=${
this.hitRect ? `${Math.round(this.hitRect.width)}x${Math.round(this.hitRect.height)}` : '无'
}`,
@@ -1050,7 +1335,9 @@ class IslandController {
if (!this.win || this.win.isDestroyed()) return;
// forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程,
// 这样渲染进程才能发现指针进入岛体并重新打开命中。
this.win.setIgnoreMouseEvents(!interactive, { forward: true });
// 注意传的是 next 而不是入参:触摸模式下入参可能是 false(按光标判定),
// 直接用它会把窗口又打回穿透态 —— 那正是"手指点不开"的根因。
this.win.setIgnoreMouseEvents(!next, { forward: true });
}
/** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */
@@ -1062,6 +1349,9 @@ class IslandController {
this.lastHitSyncAt = now;
this.syncHitFromCursor();
}
// 触摸模式:窗口要一直贴合岛体,而"收小"必须等岛真的收小之后再做(否则形变中的卡片被窗口裁掉)。
// 渲染进程每 ≥50ms 就会上报一次真实几何,正好拿来当这个判据。
this.syncTouchLayout();
}
/** 当前岛体矩形(供冒烟验证) */
@@ -1069,6 +1359,42 @@ class IslandController {
return this.hitRect ? { ...this.hitRect } : null;
}
/**
* 设置触摸模式(触摸屏机器由渲染进程上报,冒烟也可显式开关)。
*
* 开启后:窗口贴合岛体(见 `windowBox`)且不再穿透(见 `setInteractive`)——
* 这是让"手指按在岛上"能被 Windows 命中到本窗口的唯一办法(详见 `touchMode` 字段的注释)。
* 关闭后回到"固定大包围盒 + 按光标命中"的原行为,因此非触摸屏机器的表现完全不变。
*/
setTouchMode(enabled: boolean): void {
if (this.touchMode === enabled) return;
this.touchMode = enabled;
logger.info(
`灵动岛:触摸模式=${enabled}(窗口${enabled ? '贴合岛体并始终接收输入' : '回到固定包围盒穿透'})`,
);
// 直接重排到目标包围盒:开启时贴合岛体,关闭时回到"最大形态 + 阴影留白"的固定大包围盒
this.applyWindowLayout();
if (enabled) this.setInteractive(true, '触摸模式');
else this.syncHitFromCursor();
}
/** 是否处于触摸模式(供冒烟验证) */
getTouchMode(): boolean {
return this.touchMode;
}
/**
* 开关"测试输入穿透"(冒烟用,见 `testInputPassthrough`):
* 打开后真实鼠标点击穿透到桌面,不会污染自动化断言;关闭后立刻按真实光标位置恢复命中。
*/
setTestInputPassthrough(enabled: boolean): void {
if (this.testInputPassthrough === enabled) return;
this.testInputPassthrough = enabled;
logger.info(`灵动岛:测试输入穿透=${enabled}(供自动化验证不被并行人工操作打扰)`);
if (enabled) this.setInteractive(false, '测试输入穿透');
else this.syncHitFromCursor();
}
/**
* 诊断 / 冒烟专用:覆盖"光标位置"(屏幕坐标),让命中兜底轮询按指定坐标校正。
*
@@ -1099,23 +1425,39 @@ class IslandController {
return this.hitRect ?? this.expectedHitRect();
}
/** 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底 */
private expectedHitRect(): { x: number; y: number; width: number; height: number } {
let size = SIZES.pill;
/** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
private formSize(): { width: number; height: number } {
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
const layout = this.listLayout();
if (layout) return { width: SIZES.expanded.width, height: layout.height };
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;
return this.state.active?.kind === 'call'
? CALL_SIZE
: this.state.active?.priority === 'URGENT'
? URGENT_SIZE
: SIZES.expanded;
}
if (this.state.mode === 'hidden' && this.appearance.idleSliver) return SLIVER_SIZE;
return SIZES.pill;
}
/**
* 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底。
*
* 定位规则必须与渲染进程的 CSS 完全一致(`islandStyle`):左/右停靠贴边留 pad,
* 居中则水平居中;上/下停靠同理。这里用**窗口实际尺寸**而不是 BBOX_SIZE 计算 ——
* 触摸模式下窗口会贴合岛体,用固定的 BBOX_SIZE 会算出错误偏移。
*/
private expectedHitRect(): { x: number; y: number; width: number; height: number } {
const size = this.formSize();
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 bounds =
this.win && !this.win.isDestroyed()
? this.win.getBounds()
: { width: Math.round(BBOX_SIZE.width), height: Math.round(BBOX_SIZE.height) };
const width = bounds.width;
const height = bounds.height;
const x = position.endsWith('left')
? pad
: position.endsWith('right')
@@ -1171,6 +1513,9 @@ class IslandController {
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
this.hitTimer = setInterval(() => {
// 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳:
// 顺序不能反,否则命中判定会用上一块屏的窗口坐标
this.syncAnchorDisplay();
this.syncHitFromCursor();
this.checkRendererAlive();
}, 60);
@@ -1325,24 +1670,36 @@ export function getSliverSize(): { width: number; height: number } {
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, Math.round(value * 100) / 100));
/**
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
*
* 注意 `Math.max(min, NaN)` 仍是 NaN、`Math.min(max, NaN)` 也是 NaN —— 也就是说"只夹紧"
* 并不能防住 NaN。而 `setAppearance()` 的入参来自 IPC(`island:set-appearance` 只判了 `if (patch)`),
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
*/
const clamp = (value: number, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
};
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
return {
height: clamp(input.height, 36, 72),
width: clamp(input.width, 220, 420),
radius: clamp(input.radius, 8, 32),
opacity: clamp(input.opacity, 0.4, 1),
height: clamp(input.height, 36, 72, fallbackOf.height),
width: clamp(input.width, 220, 420, fallbackOf.width),
radius: clamp(input.radius, 8, 32, fallbackOf.radius),
opacity: clamp(input.opacity, 0.4, 1, fallbackOf.opacity),
accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent,
fontSize: clamp(input.fontSize, 11, 20),
fontSize: clamp(input.fontSize, 11, 20, fallbackOf.fontSize),
animations: input.animations !== false,
speed: clamp(input.speed, 0.5, 2),
speed: clamp(input.speed, 0.5, 2, fallbackOf.speed),
position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center',
alwaysOnTop: input.alwaysOnTop !== false,
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
idleSliver: input.idleSliver === true,
// 边距:0 表示贴着屏幕边缘,上限给到 200/160 是为了"多显示器 + 任务栏在侧面"这类布局
marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200),
marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160),
marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200, fallbackOf.marginX),
marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160, fallbackOf.marginY),
followCursorDisplay: input.followCursorDisplay === true,
};
}
@@ -1370,9 +1727,26 @@ const ISLAND_MARGIN = 8;
*/
const BLUR_GRACE_MS = 500;
/**
* 触摸屏上的失焦宽限期(触摸模式专用,见 `touchMode`)。
*
* 触摸没有光标,`isCursorOnIsland()` 这条"指针还在岛上就判定为激活抖动"的兜底恒为 false,
* 只剩宽限期这一层防线。而实测的假失焦出现在 focus 之后 70~520ms,正好跨过 500ms 的边界,
* 不放开的话手指点开后会偶发地立刻缩回胶囊。取 1200ms 给它 2 倍余量;
* 真正的"点屏幕别处收起"仍会在 1.2s 后生效。
*/
const BLUR_GRACE_TOUCH_MS = 1200;
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
const PREVIEW_DURATION_MS = 30_000;
/**
* 展开卡可用高度的下限(CSS px):屏幕再矮也要留出这么多,
* 否则"列表能放几行"会算出负数。真正的工作区空间由 `refreshViewport` 算,这里只是防呆。
*/
const MIN_CARD_HEIGHT = 120;
/**
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
@@ -1446,6 +1820,12 @@ export function registerIslandIpc(): void {
},
);
// 触摸屏上报(渲染进程按 navigator.maxTouchPoints 判定)→ 窗口改为贴合岛体并始终接收输入。
// 触摸屏上按光标判定命中的那套永远打不开命中,手指点不到岛(详见 IslandController.touchMode)。
ipcMain.on('island:set-touch-mode', (_event, enabled: boolean) => {
island.setTouchMode(enabled === true);
});
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
if (!payload?.action) return;
island.handleAction(payload);