1062 lines
44 KiB
JavaScript
1062 lines
44 KiB
JavaScript
/**
|
||
* 主进程:创建悬浮窗口、托盘、IPC 处理、用 Windows API 立即触发粘贴
|
||
*/
|
||
// 必须在 require('electron') 之前执行!
|
||
// 某些环境(如 npm bash)会把 ELECTRON_RUN_AS_NODE=1 设进环境,
|
||
// 这会让 Electron 退化成普通 Node.js,无法访问 electron API。
|
||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||
|
||
const {
|
||
app,
|
||
BrowserWindow,
|
||
Tray,
|
||
Menu,
|
||
ipcMain,
|
||
clipboard,
|
||
dialog,
|
||
screen,
|
||
nativeImage,
|
||
shell,
|
||
globalShortcut,
|
||
} = require('electron');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const { spawn } = require('child_process');
|
||
|
||
// 必须发生在 app.whenReady 之前:通过开关抑制一些无害但刷屏的 Chromium 警告
|
||
// (GPU 进程退出、Network service 重启等)。
|
||
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache');
|
||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
|
||
|
||
// ==================== 单实例锁 ====================
|
||
// 本应用「关闭窗口 = 隐藏到托盘」,用户很容易忘记它还在后台跑,
|
||
// 于是又去双击一次桌面快捷方式。没有这把锁的话会起第二个实例,后果是:
|
||
// 1. 托盘里出现两个图标,点哪个都只控制其中一半;
|
||
// 2. 两个实例各自持有整份数据的内存副本,都会往同一个 data.json 写 ——
|
||
// 后保存的那个会把先保存的改动整段覆盖掉,用户新加的短语凭空消失;
|
||
// 3. Ctrl+Shift+V 全局快捷键只有先启动的那个能注册成功。
|
||
// 拿不到锁就直接退出,并让已经在跑的那个实例把窗口亮出来。
|
||
//
|
||
// 注意:CommonJS 模块被包在函数里,这里的顶层 return 在 CJS 下也是合法的,
|
||
// 但 ESM 化(package.json 加 "type": "module" 或改 .mjs)后会立刻崩。
|
||
// 改用 process.exit 更稳,跨模块系统都安全。
|
||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||
if (!gotSingleInstanceLock) {
|
||
app.quit();
|
||
process.exit(0);
|
||
}
|
||
app.on('second-instance', () => {
|
||
// 第二个实例启动了 → 把我们的悬浮窗唤出来,等价于用户点了托盘图标
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
showFloating();
|
||
}
|
||
});
|
||
|
||
// 必须发生在 app.whenReady 之前:把 Electron 的缓存目录显式指向用户数据里的子目录,
|
||
// 避免每次启动时它去尝试「迁移默认缓存到 userData」——Windows 上迁移失败会刷一堆
|
||
// cache_util_win.cc ERROR,虽然不影响功能但日志很难看。
|
||
try {
|
||
app.setPath('cache', path.join(app.getPath('appData'), 'quick-paste-cache'));
|
||
} catch (e) {
|
||
console.warn('set cache path failed:', e);
|
||
}
|
||
|
||
// 仅在 Windows 上引入 koffi 调用系统 DLL
|
||
let koffi = null;
|
||
let user32 = null;
|
||
let kernel32 = null;
|
||
let SetForegroundWindow = null;
|
||
let GetForegroundWindow = null;
|
||
let GetWindowThreadProcessId = null;
|
||
let AttachThreadInput = null;
|
||
let GetCurrentThreadId = null;
|
||
let IsWindow = null;
|
||
let keybd_event = null;
|
||
|
||
if (process.platform === 'win32') {
|
||
try {
|
||
koffi = require('koffi');
|
||
user32 = koffi.load('user32.dll');
|
||
kernel32 = koffi.load('kernel32.dll');
|
||
|
||
SetForegroundWindow = user32.func('SetForegroundWindow', 'bool', ['void*']);
|
||
GetForegroundWindow = user32.func('GetForegroundWindow', 'void*', []);
|
||
GetWindowThreadProcessId = user32.func('GetWindowThreadProcessId', 'uint32', ['void*', 'void*']);
|
||
AttachThreadInput = user32.func('AttachThreadInput', 'bool', ['uint32', 'uint32', 'bool']);
|
||
IsWindow = user32.func('IsWindow', 'bool', ['void*']);
|
||
keybd_event = user32.func('keybd_event', 'void', ['uint8', 'uint8', 'uint32', 'intptr']);
|
||
|
||
// GetCurrentThreadId 在 kernel32.dll
|
||
GetCurrentThreadId = kernel32.func('GetCurrentThreadId', 'uint32', []);
|
||
} catch (e) {
|
||
console.error('koffi load failed:', e);
|
||
}
|
||
}
|
||
|
||
const VK_CONTROL = 0x11;
|
||
const VK_V = 0x56;
|
||
const KEYEVENTF_KEYUP = 0x02;
|
||
|
||
const isDev = !app.isPackaged;
|
||
// 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥
|
||
let userDataPath = null;
|
||
|
||
// 构建标记:用户跑应用时看到这个 banner 就知道是含本次「快捷键自定义」的最新代码。
|
||
// 调整 settings 面板里的快捷键录制 / 恢复默认相关问题时同步修改此字符串。
|
||
const BUILD_TAG = 'shortcut-customize-2026-09-16';
|
||
console.log(`[quick-paste] build: ${BUILD_TAG}`);
|
||
|
||
let mainWindow = null;
|
||
let tray = null;
|
||
let isQuitting = false;
|
||
|
||
// 始终置顶开关:默认开启(这是悬浮粘贴工具的核心体验)。
|
||
// 主进程持有「真相源」:窗口本身可能已被用户从 Windows 任务栏右键菜单里改过,
|
||
// 但渲染进程保存的偏好才是用户期望的,启动时用它覆盖回去。
|
||
let alwaysOnTop = true;
|
||
|
||
// 开机自启动触发的本次启动:默认 false。
|
||
// `app.whenReady` 里会调 app.getLoginItemSettings() 检测本次启动是否来自登录项,
|
||
// 如果是(wasOpenedAtLogin / wasOpenedAsHidden 任一为真),
|
||
// 窗口就不在 ready-to-show 自动弹出,保持隐藏在托盘里——
|
||
// 避免用户每次登录 Windows 都被迫看到一个悬浮窗跳出来。
|
||
let startedAtLogin = false;
|
||
|
||
// 上次前台窗口句柄(用于粘贴前主动切回)
|
||
let lastFgHwnd = null;
|
||
let ourThreadId = 0;
|
||
const ourPid = process.pid;
|
||
// 前台窗口轮询定时器
|
||
let fgTrackTimer = null;
|
||
|
||
/* ==================== 数据持久化 ==================== */
|
||
const DATA_SCHEMA_VERSION = 1;
|
||
// 短语内容上限:100KB,挡住手抖粘贴整个文件 / 滥用脚本导致剪贴板异常
|
||
const MAX_PHRASE_CONTENT_LENGTH = 100 * 1024;
|
||
|
||
// 只读窗口位置(不参与完整数据校验,启动早期就要拿到)
|
||
function readSavedBounds() {
|
||
try {
|
||
if (!userDataPath || !fs.existsSync(userDataPath)) return null;
|
||
const raw = fs.readFileSync(userDataPath, 'utf-8');
|
||
const data = JSON.parse(raw);
|
||
const b = data && data.settings && data.settings.windowBounds;
|
||
if (
|
||
b && Number.isFinite(b.x) && Number.isFinite(b.y) &&
|
||
Number.isFinite(b.width) && Number.isFinite(b.height)
|
||
) {
|
||
// 多显示器:找到窗口中心点最近的显示器,把窗口整体夹回它的工作区。
|
||
// 副屏拔了、分辨率改了导致窗口跑到屏幕外的情况都不会丢。
|
||
const displays = screen.getAllDisplays();
|
||
const cx = b.x + b.width / 2;
|
||
const cy = b.y + b.height / 2;
|
||
const nearest = screen.getDisplayNearestPoint({ x: cx, y: cy }) || displays[0];
|
||
const wa = nearest.workArea;
|
||
const width = Math.max(280, Math.min(b.width, wa.width, 800));
|
||
const height = Math.max(360, Math.min(b.height, wa.height, 900));
|
||
// 关键:x/y 必须保证窗口完整落在 wa 内,否则用户找不到窗口
|
||
const x = Math.max(wa.x, Math.min(b.x, wa.x + wa.width - width));
|
||
const y = Math.max(wa.y, Math.min(b.y, wa.y + wa.height - height));
|
||
return { x, y, width, height };
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
function uid(prefix = 'id') {
|
||
// 8 字节随机熵 ≈ 1.8e19 组合,比原来 4 字符 (~65K) 几乎不可能碰撞
|
||
return `${prefix}_${Date.now().toString(36)}_${crypto.randomBytes(8).toString('hex')}`;
|
||
}
|
||
|
||
function getDefaultData() {
|
||
return {
|
||
version: DATA_SCHEMA_VERSION,
|
||
groups: [
|
||
{
|
||
id: 'grp_default',
|
||
name: '常用',
|
||
phrases: [
|
||
{ id: 'phr_1', title: '你好', content: '你好,很高兴认识你!', fav: false, useCount: 0 },
|
||
{ id: 'phr_2', title: '收到', content: '收到,我会尽快处理。', fav: false, useCount: 0 },
|
||
],
|
||
},
|
||
{
|
||
id: 'grp_work',
|
||
name: '工作',
|
||
phrases: [
|
||
{ id: 'phr_3', title: '开会通知', content: '各位同事,今天下午 3 点在会议室 A 开会,请准时参加。', fav: false, useCount: 0 },
|
||
],
|
||
},
|
||
],
|
||
activeGroupId: 'grp_default',
|
||
recent: [],
|
||
settings: {},
|
||
};
|
||
}
|
||
|
||
function loadData() {
|
||
try {
|
||
if (fs.existsSync(userDataPath)) {
|
||
const raw = fs.readFileSync(userDataPath, 'utf-8');
|
||
const data = JSON.parse(raw);
|
||
if (data && Array.isArray(data.groups)) {
|
||
// 容错:每条 group 必须有 id/name/phrases 数组,过滤掉畸形项
|
||
const cleaned = data.groups
|
||
.filter((g) => g && typeof g === 'object')
|
||
.map((g) => ({
|
||
id: typeof g.id === 'string' ? g.id : uid('grp'),
|
||
name: typeof g.name === 'string' ? g.name : '未命名分组',
|
||
phrases: Array.isArray(g.phrases)
|
||
? g.phrases
|
||
.filter((p) => p && typeof p === 'object' && typeof p.content === 'string')
|
||
.map((p) => ({
|
||
id: typeof p.id === 'string' ? p.id : uid('phr'),
|
||
title: typeof p.title === 'string' ? p.title : '',
|
||
// 截断异常大的内容(防手抖 / 防被外部脚本注入巨型数据)
|
||
content: p.content.slice(0, MAX_PHRASE_CONTENT_LENGTH),
|
||
// 收藏标志。旧数据只有 pinned,这里一次性迁移成 fav。
|
||
// 迁移后必须丢弃 pinned:否则 fav 已经被用户关掉、pinned 还留着 true 时,
|
||
// 下次启动这个 `p.fav || p.pinned` 会把收藏又打开 —— 取消收藏永远存不住。
|
||
fav: typeof p.fav === 'boolean' ? p.fav : !!p.pinned,
|
||
// 使用次数(被粘贴/复制过的次数,统计用)
|
||
useCount: typeof p.useCount === 'number' && p.useCount >= 0 ? p.useCount : 0,
|
||
}))
|
||
: [],
|
||
}));
|
||
// 空分组必须保留!之前这里会把 phrases 为空的分组过滤掉,
|
||
// 于是「新建分组」「清空短语」之后一重启,那个分组就凭空消失了。
|
||
// 只有整份数据连一个分组都没有时才回落到默认数据。
|
||
const groups = cleaned.length > 0 ? cleaned : getDefaultData().groups;
|
||
// 最近使用列表:渲染进程维护的 [{id, ts}]
|
||
const recent = Array.isArray(data.recent)
|
||
? data.recent
|
||
.filter((r) => r && typeof r === 'object' && typeof r.id === 'string')
|
||
.slice(0, 50)
|
||
: [];
|
||
return {
|
||
version: DATA_SCHEMA_VERSION,
|
||
groups,
|
||
activeGroupId:
|
||
typeof data.activeGroupId === 'string' &&
|
||
groups.some((g) => g.id === data.activeGroupId)
|
||
? data.activeGroupId
|
||
: groups[0].id,
|
||
recent,
|
||
// 透传 settings(如 windowBounds),让用户在主进程直接读写它
|
||
settings: data.settings && typeof data.settings === 'object' ? data.settings : {},
|
||
};
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('load data error:', e);
|
||
}
|
||
return getDefaultData();
|
||
}
|
||
|
||
// 原子化写盘:先写临时文件再 rename,避免崩溃时留下损坏的 data.json
|
||
// 返回 { ok, error }:渲染端能区分成功 / 失败,把失败 toast 出来
|
||
function saveData(data) {
|
||
try {
|
||
fs.mkdirSync(path.dirname(userDataPath), { recursive: true });
|
||
// 注入版本号;后续做迁移时按这个字段分支
|
||
const payload = JSON.stringify({ ...data, version: DATA_SCHEMA_VERSION }, null, 2);
|
||
const tmp = userDataPath + '.tmp';
|
||
fs.writeFileSync(tmp, payload, 'utf-8');
|
||
fs.renameSync(tmp, userDataPath);
|
||
return { ok: true };
|
||
} catch (e) {
|
||
console.error('save data error:', e);
|
||
return { ok: false, error: e.message || String(e) };
|
||
}
|
||
}
|
||
|
||
/* ==================== 窗口创建 ==================== */
|
||
// 把 alwaysOnTop 的「真相源」应用到窗口上:不存在时直接 return。
|
||
// 注意:必须保留旧的 setAlwaysOnTop(true, 'floating') 默认行为路径,
|
||
// showFloating / pasteByHiding 仍然依赖它把窗口拉回顶层。
|
||
function applyAlwaysOnTop() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||
try {
|
||
// 'floating' 是 Windows 上的「在最前面」层级,比 normal top 更高,
|
||
// 大多数全屏应用都能压住它。
|
||
mainWindow.setAlwaysOnTop(alwaysOnTop, 'floating');
|
||
} catch (e) {
|
||
console.error('applyAlwaysOnTop error:', e);
|
||
}
|
||
}
|
||
|
||
// 窗口位置记忆:用户拖到哪里下次就在哪里启动
|
||
// 写盘是 debounce 的(300ms)避免拖动过程刷文件
|
||
//
|
||
// Bug 修复:原实现 `loadData() → 修改 → saveData()` 会从盘上读全量数据再合并窗口位置。
|
||
// 如果渲染层在这 300ms 窗口内也调了 data:save 并完成落盘,主进程后续写盘会把磁盘
|
||
// 上那份更新过的数据(用户最新的短语改动)覆盖掉,造成数据丢失。
|
||
// 改为:缓存最近一次由 data:save 写入的完整数据快照,更新窗口位置时直接复用这份
|
||
// 快照,绝不再从盘读。
|
||
let cachedDataSnapshot = null;
|
||
// 启动期一次性读盘快照:createWindow 读 alwaysOnTop、whenReady 读 savedAcc
|
||
// 原本各调一次 loadData —— 整份 JSON 解析 + 字段容错走两遍,对几百条短语的小库
|
||
// 都能感知到。合到一处,createWindow/applyGlobalShortcut 直接读这里的字段。
|
||
let bootDataSnapshot = null;
|
||
let saveBoundsTimer = null;
|
||
function scheduleSaveBounds() {
|
||
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
||
saveBoundsTimer = setTimeout(() => {
|
||
saveBoundsTimer = null;
|
||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||
try {
|
||
const b = mainWindow.getBounds();
|
||
// 优先用最近一次 data:save 传入的快照;快照不存在(应用刚启动、还没
|
||
// 任何渲染层写入)时再退回到读盘路径。
|
||
if (cachedDataSnapshot) {
|
||
cachedDataSnapshot.settings = {
|
||
...(cachedDataSnapshot.settings || {}),
|
||
windowBounds: b,
|
||
};
|
||
saveData(cachedDataSnapshot);
|
||
} else {
|
||
const data = loadData();
|
||
data.settings = { ...(data.settings || {}), windowBounds: b };
|
||
saveData(data);
|
||
cachedDataSnapshot = data;
|
||
}
|
||
} catch (e) {
|
||
console.error('save bounds error:', e);
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
function createWindow() {
|
||
const display = screen.getPrimaryDisplay();
|
||
const { width: sw, height: sh } = display.workAreaSize;
|
||
// 优先用上次保存的位置/大小;首启动或位置跑到屏幕外时回落默认
|
||
const saved = readSavedBounds();
|
||
const bounds = saved || {
|
||
width: 380,
|
||
height: 560,
|
||
x: Math.max(0, sw - 400),
|
||
y: 80,
|
||
};
|
||
|
||
// 启动时把磁盘上的 alwaysOnTop 偏好读进来;
|
||
// 复用 whenReady 里已经读过的 bootDataSnapshot,避免再 loadData 一遍(几百条短语
|
||
// 时 JSON 解析走两遍会感知到;旧数据没这个字段时 loadData 已经容错,bootDataSnapshot
|
||
// 至少有 settings = {},取默认值 true 即可)。
|
||
if (bootDataSnapshot) {
|
||
const savedAlways = bootDataSnapshot.settings && bootDataSnapshot.settings.alwaysOnTop;
|
||
alwaysOnTop = savedAlways === undefined ? true : !!savedAlways;
|
||
} else {
|
||
// 兜底:bootDataSnapshot 没拿到(loadData 抛错被吞)→ 默认开启
|
||
alwaysOnTop = true;
|
||
}
|
||
|
||
mainWindow = new BrowserWindow({
|
||
...bounds,
|
||
minWidth: 280,
|
||
minHeight: 360,
|
||
// 上限:避免被拖成全屏把内容扯稀烂;长内容让用户自己加大字号或滚动
|
||
maxWidth: 800,
|
||
maxHeight: 900,
|
||
// 任务栏 / Alt-Tab 缩略图显示的图标。ico 内置 16/32/48 等多尺寸,
|
||
// Windows 会按场景自动挑合适的;Linux/macOS 下 Electron 也会自动转换。
|
||
icon: path.join(__dirname, 'icon.ico'),
|
||
frame: false,
|
||
// 初始值用 alwaysOnTop 偏好的临时快照(之前的临时快照就是模块级变量本身,
|
||
// 这里直接读取即可);setAlwaysOnTop 由 applyAlwaysOnTop() 统一收口。
|
||
alwaysOnTop,
|
||
resizable: true,
|
||
maximizable: false,
|
||
minimizable: true,
|
||
skipTaskbar: false,
|
||
backgroundColor: '#08090A',
|
||
title: '快速粘贴',
|
||
show: false,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
sandbox: false,
|
||
},
|
||
});
|
||
|
||
applyAlwaysOnTop();
|
||
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||
|
||
// 窗口保持可聚焦(这样搜索框能直接接收键盘事件),但显示用 showInactive 不抢外部焦点。
|
||
// 旧的 setNoActivate(true) 会让整窗口 setFocusable(false),搜索框也跟着收不到按键,
|
||
// 那是无法接受的硬 bug。点短语走 hide → paste → show 路径,不依赖 noActivate 模式。
|
||
mainWindow.setFocusable(true);
|
||
|
||
// 拖动 / 拉伸后记住位置
|
||
mainWindow.on('move', scheduleSaveBounds);
|
||
mainWindow.on('resize', scheduleSaveBounds);
|
||
|
||
mainWindow.once('ready-to-show', () => {
|
||
// 开机自启动触发的启动 → 窗口保持隐藏在托盘里,
|
||
// 用户按 Ctrl+Shift+V 或点托盘图标才会显示,避免每次登录都跳出来。
|
||
if (!startedAtLogin) showFloating();
|
||
});
|
||
|
||
// 关闭按钮 → 最小化到托盘,而不是退出
|
||
mainWindow.on('close', (e) => {
|
||
if (!isQuitting) {
|
||
e.preventDefault();
|
||
mainWindow.hide();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 显示悬浮窗:用 showInactive 避免把焦点从目标输入框抢走。
|
||
// 窗口本身保持 setFocusable(true)(见 createWindow),让搜索框能直接接收键盘事件。
|
||
// 弹窗(编辑短语/新建分组等)需要主动接管键盘,仍由 setTyping 调用 mainWindow.focus()。
|
||
function showFloating() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||
mainWindow.showInactive();
|
||
// showInactive 之后层级有时会被 Windows 重置;重新应用一次置顶偏好
|
||
// 否则用户关掉置顶 → 隐藏 → 再唤起时窗口可能"复活"为置顶态。
|
||
applyAlwaysOnTop();
|
||
}
|
||
|
||
/* ==================== 托盘 ==================== */
|
||
// 托盘图标使用项目根目录的 icon.ico:它内置了 16/32 等多种尺寸,
|
||
// Windows 任务栏托盘会自动挑选最合适的那一档显示。
|
||
// 原 fallback 路径(assets/tray.png)不再维护 —— 始终从 icon.ico 加载。
|
||
const TRAY_ICON_PATH = path.join(__dirname, 'icon.ico');
|
||
|
||
function createTray() {
|
||
let trayIcon;
|
||
if (fs.existsSync(TRAY_ICON_PATH)) {
|
||
trayIcon = nativeImage.createFromPath(TRAY_ICON_PATH);
|
||
} else {
|
||
// 极端兜底:icon.ico 不在时给个透明占位,避免 Tray 构造抛错把托盘拉崩
|
||
trayIcon = nativeImage.createEmpty();
|
||
}
|
||
|
||
tray = new Tray(trayIcon);
|
||
tray.setToolTip('快速粘贴');
|
||
|
||
const menu = Menu.buildFromTemplate([
|
||
{ label: '显示 / 隐藏', click: () => toggleWindow() },
|
||
{ type: 'separator' },
|
||
{
|
||
label: '退出',
|
||
click: () => {
|
||
isQuitting = true;
|
||
app.quit();
|
||
},
|
||
},
|
||
]);
|
||
tray.setContextMenu(menu);
|
||
tray.on('click', () => toggleWindow());
|
||
}
|
||
|
||
function toggleWindow() {
|
||
// isVisible()/hide()/show() 都会抛 "Object has been destroyed"
|
||
// 在 quit 路径下(isQuitting=true、will-quit 还没反注册全局快捷键之前)可能撞上,
|
||
// 这里显式拦一下,避免未捕获异常把渲染 / 托盘拉崩。
|
||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||
if (mainWindow.isVisible()) {
|
||
mainWindow.hide();
|
||
} else {
|
||
showFloating();
|
||
}
|
||
}
|
||
|
||
/* ==================== 窗口句柄工具 ==================== */
|
||
// koffi 返回的 HWND 是 External 指针对象,Electron 的 getNativeWindowHandle() 返回 Buffer,
|
||
// 两者直接用 !== 比较永远不相等 → 必须统一换算成数值地址再比。
|
||
function hwndAddr(hwnd) {
|
||
if (!hwnd || !koffi) return 0n;
|
||
try {
|
||
return BigInt(koffi.address(hwnd));
|
||
} catch {
|
||
return 0n;
|
||
}
|
||
}
|
||
|
||
// 本进程所有窗口的句柄地址(兜底用)
|
||
// 注意:用 process.arch 判断位数比看 Buffer.length 更稳——
|
||
// Electron 在不同版本上 getNativeWindowHandle 返回 Buffer 的长度有时不完全等于本机指针宽度
|
||
const IS_64BIT = process.arch === 'x64' || process.arch === 'arm64';
|
||
function ownWindowAddrs() {
|
||
const set = new Set();
|
||
for (const win of BrowserWindow.getAllWindows()) {
|
||
if (win.isDestroyed()) continue;
|
||
try {
|
||
const buf = win.getNativeWindowHandle();
|
||
set.add(IS_64BIT ? buf.readBigUInt64LE(0) : BigInt(buf.readUInt32LE(0)));
|
||
} catch {}
|
||
}
|
||
return set;
|
||
}
|
||
|
||
// 这个窗口是不是我们自己的(悬浮窗、弹窗等)
|
||
// 直接走 ownWindowAddrs —— BrowserWindow.getNativeWindowHandle() 是 Electron 自己的稳定 API,
|
||
// 比用 koffi `out()` 拿 PID 简单(koffi 在不同版本下 out 参数的回写行为不一致)。
|
||
// 120ms 一次的轮询 + 遍历 1~3 个 BrowserWindow 完全可以忽略不计。
|
||
function isOwnWindow(hwnd) {
|
||
return ownWindowAddrs().has(hwndAddr(hwnd));
|
||
}
|
||
|
||
/* ==================== 前台窗口跟踪 ==================== */
|
||
function startForegroundTracking() {
|
||
if (process.platform !== 'win32' || !GetForegroundWindow) return;
|
||
ourThreadId = GetCurrentThreadId();
|
||
|
||
// 每 120ms 记录一次前台窗口,只记「不属于本应用」的窗口。
|
||
// 用户点悬浮窗时保持上一次的目标不变,这样连点多条短语都会粘到同一个输入框。
|
||
fgTrackTimer = setInterval(() => {
|
||
try {
|
||
const hwnd = GetForegroundWindow();
|
||
if (!hwnd || hwndAddr(hwnd) === 0n) return;
|
||
if (isOwnWindow(hwnd)) return;
|
||
lastFgHwnd = hwnd;
|
||
} catch {}
|
||
}, 120);
|
||
}
|
||
|
||
// 取当前可用的粘贴目标(窗口还在、且不是我们自己)
|
||
function getPasteTarget() {
|
||
if (!lastFgHwnd || !IsWindow) return null;
|
||
try {
|
||
if (!IsWindow(lastFgHwnd)) return null;
|
||
if (isOwnWindow(lastFgHwnd)) return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
return lastFgHwnd;
|
||
}
|
||
|
||
// 把目标窗口拉回前台,返回是否成功
|
||
function focusTarget(hwnd) {
|
||
try {
|
||
// 已经在前台(悬浮窗不抢焦点时的常态)→ 什么都不用做
|
||
if (hwndAddr(GetForegroundWindow()) === hwndAddr(hwnd)) return true;
|
||
|
||
const targetTid = GetWindowThreadProcessId(hwnd, null);
|
||
if (!targetTid) return false;
|
||
if (targetTid === ourThreadId) return true;
|
||
|
||
// 临时附加输入线程,绕过前台窗口锁定
|
||
AttachThreadInput(ourThreadId, targetTid, true);
|
||
try {
|
||
SetForegroundWindow(hwnd);
|
||
} finally {
|
||
AttachThreadInput(ourThreadId, targetTid, false);
|
||
}
|
||
return hwndAddr(GetForegroundWindow()) === hwndAddr(hwnd);
|
||
} catch (e) {
|
||
console.error('focusTarget error:', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/* ==================== 立即触发 Ctrl+V ==================== */
|
||
function sendCtrlV() {
|
||
if (process.platform === 'win32' && keybd_event) {
|
||
try {
|
||
keybd_event(VK_CONTROL, 0, 0, 0);
|
||
keybd_event(VK_V, 0, 0, 0);
|
||
keybd_event(VK_V, 0, KEYEVENTF_KEYUP, 0);
|
||
keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0);
|
||
} catch (e) {
|
||
console.error('sendCtrlV error:', e);
|
||
}
|
||
} else if (process.platform === 'darwin') {
|
||
spawn('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
|
||
} else {
|
||
spawn('xdotool', ['key', 'ctrl+v']);
|
||
}
|
||
}
|
||
|
||
// 兜底方案:不知道该粘到哪 → 隐藏自己把焦点让给下面的窗口,粘完再无焦点显示回来
|
||
// 拆成两个有明确语义的常量:
|
||
// PASTE_HIDE_DELAY_MS:hide → keybd_event 之间的离场时间,避免 Ctrl+V 误发给「正在
|
||
// 隐藏中」的本窗口(hide 是异步的,立即 sendCtrlV 可能撞上)
|
||
// PASTE_RESTORE_DELAY_MS:sendCtrlV → showInactive 之间让目标应用接收并真正把
|
||
// 剪贴板内容落到输入框。覆盖多数应用从切前台 → 输入框就绪的时间
|
||
const PASTE_HIDE_DELAY_MS = 50;
|
||
const PASTE_RESTORE_DELAY_MS = 200;
|
||
// 用单 timer 取代「每次都 setTimeout」:连点多条短语时多个回显计时器会互相抢着触发,
|
||
// 最后一个回显完成之前其它都会执行 → 窗口一闪一闪。把后续请求合并到同一个 timer,
|
||
// 只回显一次即可。
|
||
let pasteHideTimer = null;
|
||
let pasteRestoreTimer = null;
|
||
function pasteByHiding() {
|
||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||
mainWindow.hide();
|
||
}
|
||
// 清掉前一轮还没触发的回显任务(窗口已经隐藏,再回调一次也是同一个状态)
|
||
if (pasteRestoreTimer) { clearTimeout(pasteRestoreTimer); pasteRestoreTimer = null; }
|
||
if (pasteHideTimer) clearTimeout(pasteHideTimer);
|
||
pasteHideTimer = setTimeout(() => {
|
||
pasteHideTimer = null;
|
||
sendCtrlV();
|
||
pasteRestoreTimer = setTimeout(() => {
|
||
pasteRestoreTimer = null;
|
||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
|
||
mainWindow.showInactive();
|
||
// 同 showFloating:showInactive 会重置层级,必须按当前偏好重新应用
|
||
applyAlwaysOnTop();
|
||
}
|
||
}, PASTE_RESTORE_DELAY_MS - PASTE_HIDE_DELAY_MS);
|
||
}, PASTE_HIDE_DELAY_MS);
|
||
}
|
||
|
||
/* ==================== 占位符展开 ==================== */
|
||
// 在粘贴/复制前把 {date} / {time} / {datetime} / {weekday} / {clipboard} 替换成实际值。
|
||
// 在主进程做是为了 `{clipboard}` 能可靠地读到系统剪贴板(避免渲染层权限差异)。
|
||
// 调用方需自己先快照原剪贴板内容(防止被自身的 writeText 覆盖)。
|
||
const WEEKDAY_CN = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||
function expandPlaceholders(text, clipboardSnapshot = '') {
|
||
const now = new Date();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const replacements = {
|
||
date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
|
||
time: `${pad(now.getHours())}:${pad(now.getMinutes())}`,
|
||
weekday: WEEKDAY_CN[now.getDay()],
|
||
clipboard: clipboardSnapshot,
|
||
};
|
||
replacements.datetime = `${replacements.date} ${replacements.time}`;
|
||
// 单次正则遍历替代 5 次 replace。支持 \{xxx\} 反斜杠转义——
|
||
// 想在短语里写「{date}」字面量教别人用占位符时,加一个反斜杠就行。
|
||
return text.replace(
|
||
/\\?\{(date|time|datetime|weekday|clipboard)\}/g,
|
||
(m, key) => m.startsWith('\\') ? `{${key}}` : replacements[key]
|
||
);
|
||
}
|
||
|
||
/* ==================== IPC ==================== */
|
||
// windowBounds 只由主进程维护(move/resize 事件)。渲染进程保存的是它启动时读到的
|
||
// 那份 settings 快照,里面的 windowBounds 已经过期——直接落盘会把用户刚拖好的窗口
|
||
// 位置回滚。所以每次写盘前都用「当前真实窗口位置」覆盖这个字段。
|
||
function withOwnedSettings(incoming) {
|
||
const out = { ...(incoming || {}) };
|
||
const settings = { ...(out.settings || {}) };
|
||
let bounds = null;
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
try {
|
||
bounds = mainWindow.getBounds();
|
||
} catch {}
|
||
}
|
||
// 窗口不可用(退出过程中)时回落到磁盘上已有的值,避免把字段清掉
|
||
if (!bounds) {
|
||
try {
|
||
const disk = loadData();
|
||
bounds = disk && disk.settings ? disk.settings.windowBounds : null;
|
||
} catch {}
|
||
}
|
||
if (bounds) settings.windowBounds = bounds;
|
||
out.settings = settings;
|
||
return out;
|
||
}
|
||
|
||
ipcMain.handle('data:load', () => loadData());
|
||
ipcMain.handle('data:save', (_, data) => {
|
||
const enriched = withOwnedSettings(data);
|
||
const res = saveData(enriched);
|
||
// 关键:先 saveData 再决定是否更新快照。saveData 失败时保持上一次成功状态,
|
||
// 否则 scheduleSaveBounds 会把磁盘上本不该存在的那份失败数据当成「真实状态」写盘。
|
||
if (res && res.ok) {
|
||
// 维护一份最近一次写入的全量数据快照。scheduleSaveBounds 用它来同步窗口位置,
|
||
// 不再从盘读 —— 避免和渲染层的并发保存产生覆盖。
|
||
// enriched 是 withOwnedSettings 返回的新对象(顶层 + settings 都已是新对象),
|
||
// 直接复用即可;后续 scheduleSaveBounds 会改写 cachedDataSnapshot.settings.windowBounds
|
||
// 为新对象,不会影响 enriched。
|
||
cachedDataSnapshot = enriched;
|
||
// data:save 刚刚成功落盘,待处理的窗口位置合并任务无意义了 —— 下一次
|
||
// move/resize 会重新 schedule。提前清掉避免它再用一份过期快照覆盖磁盘。
|
||
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
||
}
|
||
return res;
|
||
});
|
||
// 同步版本:用于 beforeunload 场景,sendSync 会阻塞渲染进程直到写盘完成
|
||
ipcMain.on('data:saveSync', (e, data) => {
|
||
const enriched = withOwnedSettings(data);
|
||
const res = saveData(enriched);
|
||
if (res && res.ok) {
|
||
cachedDataSnapshot = enriched;
|
||
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
||
}
|
||
e.returnValue = res;
|
||
});
|
||
|
||
// 仅复制到剪贴板,不触发粘贴;和 `paste` 共用同一套占位符展开,保持一致性
|
||
ipcMain.handle('clipboard:copy', (_, text) => {
|
||
if (typeof text !== 'string' || text.length === 0) {
|
||
return { ok: false, error: '内容为空' };
|
||
}
|
||
// 同样的 100KB 上限挡住滥用:和 paste 对齐
|
||
if (text.length > MAX_PHRASE_CONTENT_LENGTH) {
|
||
return { ok: false, error: `内容过长(${text.length} > ${MAX_PHRASE_CONTENT_LENGTH})` };
|
||
}
|
||
try {
|
||
// 含 {clipboard} 时,先快照原剪贴板再覆盖,避免读到自身写出去的内容
|
||
const snapshot = text.includes('{clipboard}') ? clipboard.readText() : '';
|
||
const expanded = expandPlaceholders(text, snapshot);
|
||
clipboard.writeText(expanded);
|
||
return { ok: true };
|
||
} catch (e) {
|
||
console.error('clipboard:copy error:', e);
|
||
return { ok: false, error: e.message || String(e) };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('paste', (_, text, opts) => {
|
||
if (typeof text !== 'string' || text.length === 0) {
|
||
return { ok: false, error: '内容为空' };
|
||
}
|
||
// 剪贴板巨型写入会卡顿甚至触发系统限制:超过 100KB 就拒绝
|
||
if (text.length > MAX_PHRASE_CONTENT_LENGTH) {
|
||
return { ok: false, error: `内容过长(${text.length} > ${MAX_PHRASE_CONTENT_LENGTH})` };
|
||
}
|
||
|
||
// 1. 含 {clipboard} 时必须先快照原剪贴板,否则下一步 writeText 会覆盖掉要读的内容
|
||
const clipboardSnapshot = text.includes('{clipboard}') ? clipboard.readText() : '';
|
||
const expanded = expandPlaceholders(text, clipboardSnapshot);
|
||
|
||
// 2. 写入剪贴板
|
||
try {
|
||
clipboard.writeText(expanded);
|
||
} catch (e) {
|
||
console.error('clipboard.writeText error:', e);
|
||
return { ok: false, error: '剪贴板写入失败' };
|
||
}
|
||
|
||
// 2.5 用户在设置里开了「粘贴时隐藏悬浮窗」→ 必须走 pasteByHiding,
|
||
// 因为只有它会在 PASTE_RESTORE_DELAY_MS 后把窗口重新显示出来。
|
||
// (之前由渲染进程先调 window:hide 再走下面的「有目标」分支,
|
||
// 那条分支不负责重显,窗口就一直留在隐藏状态回不来了。)
|
||
if (opts && opts.hideFirst) {
|
||
pasteByHiding();
|
||
return { ok: true, hasTarget: !!getPasteTarget() };
|
||
}
|
||
|
||
// 3. 取目标窗口(非 Windows 平台没有句柄跟踪,一律走兜底)
|
||
const target = process.platform === 'win32' && keybd_event ? getPasteTarget() : null;
|
||
|
||
// 4. 没有已知目标 → 兜底:隐藏自己让焦点回落,粘完再无焦点显示回来
|
||
if (!target) {
|
||
pasteByHiding();
|
||
// 告诉渲染端:剪贴板已写入,但目标窗口未知,需要用户手动点一下输入框
|
||
return { ok: true, hasTarget: false };
|
||
}
|
||
|
||
// 5. 有明确目标:拉回前台后直接注入 Ctrl+V。
|
||
// 这里刻意不隐藏 / 重显悬浮窗 —— 连点多条短语时窗口一直在原地不闪,
|
||
// 目标输入框也始终是同一个,不用再回去点一次输入框。
|
||
// 如果拉前台失败(被系统拒绝、目标在切换中等),退回兜底走隐藏-粘贴-回显流程,
|
||
// 避免把 Ctrl+V 误发到自己窗口里。
|
||
if (!focusTarget(target)) {
|
||
pasteByHiding();
|
||
return { ok: true, hasTarget: false };
|
||
}
|
||
sendCtrlV();
|
||
|
||
return { ok: true, hasTarget: true };
|
||
});
|
||
|
||
ipcMain.handle('window:hide', () => { if (mainWindow) mainWindow.hide(); });
|
||
// 切换「始终置顶」。渲染进程负责把偏好写进 data.settings(持久化),
|
||
// 主进程只负责:1) 立即应用;2) 后续 showFloating / pasteByHiding 路径拿这个状态。
|
||
ipcMain.handle('window:setAlwaysOnTop', (_, on) => {
|
||
alwaysOnTop = !!on;
|
||
applyAlwaysOnTop();
|
||
return alwaysOnTop;
|
||
});
|
||
// 调整窗口透明度(用户设置里 60~100)
|
||
ipcMain.handle('window:setOpacity', (_, value) => {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return false;
|
||
// 注意:不能用 `Number(value) || 1`——0 || 1 === 1 把 0 当成 falsy 落到默认值了。
|
||
// 用 Number.isFinite 显式判 NaN/Infinity/非数字字符串。
|
||
const v = Math.max(0.6, Math.min(1, Number(value)));
|
||
if (!Number.isFinite(v)) return false;
|
||
try {
|
||
mainWindow.setOpacity(v);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('setOpacity error:', e);
|
||
return false;
|
||
}
|
||
});
|
||
ipcMain.handle('app:quit', () => { isQuitting = true; app.quit(); });
|
||
|
||
// 开机自启动:读写由 app.setLoginItemSettings / getLoginItemSettings 代理。
|
||
// Windows:写到 HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run,
|
||
// openAsHidden=true 时执行参数追加 --hidden;
|
||
// macOS:注册到「系统设置 → 通用 → 登录项」;
|
||
// Linux:在 ~/.config/autostart/ 下放 .desktop 文件(仅支持 openAtLogin)。
|
||
//
|
||
// 启动后用 getLoginItemSettings().wasOpenedAsHidden / wasOpenedAtLogin 判断本次是否被登录项拉起,
|
||
// 是的话窗口留在托盘里不弹(见 ready-to-show 分支)。
|
||
ipcMain.handle('app:getAutoLaunch', () => {
|
||
try {
|
||
const ls = app.getLoginItemSettings();
|
||
return { ok: true, enabled: !!(ls && ls.openAtLogin) };
|
||
} catch (e) {
|
||
// 拿不到 OS 状态时回退到 false,渲染层展示关闭态;用户点过开关后下次刷新就会以 OS 为准。
|
||
console.error('getAutoLaunch error:', e);
|
||
return { ok: false, enabled: false, error: e.message || String(e) };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('app:setAutoLaunch', (_, enabled) => {
|
||
const want = !!enabled;
|
||
try {
|
||
app.setLoginItemSettings({
|
||
openAtLogin: want,
|
||
// Windows 会把这个值翻译成执行参数里的 --hidden,启动时被 getLoginItemSettings 读到;
|
||
// macOS 会让应用在登录时以隐藏状态启动。
|
||
// Linux 忽略此字段,但写一个稳定的值对 API 一致性无害。
|
||
openAsHidden: true,
|
||
});
|
||
return { ok: true, enabled: want };
|
||
} catch (e) {
|
||
// dev 模式下(isPackaged=false)调用此 API 经常失败:electron.exe 路径在不同机器上不一致,
|
||
// 注册表项会写到错位置;不过 `npm start` 启动的用户本来也不会真的想开机自启。
|
||
// 把异常吞掉 + 返回错误,让渲染层弹 toast 提示。
|
||
console.error('setAutoLaunch error:', e);
|
||
return { ok: false, enabled: false, error: e.message || String(e) };
|
||
}
|
||
});
|
||
|
||
// 渲染进程要打字了(打开编辑弹窗)→ 临时让窗口获得焦点;
|
||
// 关掉弹窗后只把焦点还给原来的目标窗口,窗口本身仍保持可聚焦
|
||
// (搜索框需要持续可用;如果这里 setNoActivate(true) 会让搜索框跟着失能)。
|
||
ipcMain.handle('window:setTyping', (_, typing) => {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return false;
|
||
if (process.platform !== 'win32') return true;
|
||
|
||
try {
|
||
if (typing) {
|
||
mainWindow.focus();
|
||
} else {
|
||
const target = getPasteTarget();
|
||
// 没有目标(比如刚启动、还没有任何前台窗口轮询过)→ 啥也不做,让窗口自然失焦
|
||
if (target) focusTarget(target);
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
console.error('setTyping error:', e);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// 返回数据文件路径(绝对路径)
|
||
ipcMain.handle('app:getDataPath', () => userDataPath);
|
||
|
||
// 在文件管理器中定位数据文件
|
||
ipcMain.handle('app:revealDataFile', () => {
|
||
try {
|
||
if (fs.existsSync(userDataPath)) {
|
||
shell.showItemInFolder(userDataPath);
|
||
} else {
|
||
// 文件还不存在(首次启动未保存)→ 打开所在目录
|
||
shell.openPath(path.dirname(userDataPath));
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
console.error('reveal data file error:', e);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// 在系统默认浏览器中打开外部链接
|
||
// 渲染进程走的是 file:// + contextIsolation,直接 <a target="_blank"> 在 Electron 里
|
||
// 不会自动跳出当前窗口。让渲染进程通过 IPC 把 URL 交给主进程,由 shell.openExternal
|
||
// 调用系统浏览器打开 —— 这样也顺便强制校验协议头,避免被劫持去打开 file:// 之类。
|
||
ipcMain.handle('app:openExternal', (_evt, url) => {
|
||
try {
|
||
const u = String(url || '').trim();
|
||
if (!/^https?:\/\//i.test(u)) {
|
||
console.warn('openExternal rejected non-http(s) url:', u);
|
||
return false;
|
||
}
|
||
shell.openExternal(u);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('openExternal error:', e);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// 导出数据:弹保存对话框,把当前数据写到用户选的 .json 文件
|
||
ipcMain.handle('data:export', async () => {
|
||
try {
|
||
const current = loadData();
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
const defaultName = `quick-paste-${stamp}.json`;
|
||
const res = await dialog.showSaveDialog(mainWindow || undefined, {
|
||
title: '导出短语',
|
||
defaultPath: defaultName,
|
||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||
});
|
||
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
|
||
fs.writeFileSync(res.filePath, JSON.stringify(current, null, 2), 'utf-8');
|
||
return { ok: true, filePath: res.filePath };
|
||
} catch (e) {
|
||
console.error('export error:', e);
|
||
return { ok: false, error: e.message };
|
||
}
|
||
});
|
||
|
||
// 渲染层获取当前已注册的全局快捷键(设置面板首次打开时显示)
|
||
ipcMain.handle('shortcuts:getGlobalToggle', () => currentGlobalShortcut);
|
||
|
||
// 修改全局快捷键:渲染层在设置面板里点「录制」拿到新组合键后调用。
|
||
// 1. 校验格式:必须是非空字符串且至少有一个 `+`(modifier + key 的最小形态)
|
||
// 2. 落盘:复用 withOwnedSettings → saveData 链路,确保 windowBounds 跟着更新;
|
||
// settings.shortcuts 是新字段,withOwnedSettings 只改 windowBounds、不动其它字段,
|
||
// 所以 spread 后透传即可。
|
||
// 3. 注册新值到系统:失败时回滚旧值;注册成功才更新 currentGlobalShortcut。
|
||
ipcMain.handle('shortcuts:setGlobalToggle', (_, accelerator) => {
|
||
if (typeof accelerator !== 'string' || !accelerator.trim() || !accelerator.includes('+')) {
|
||
return { ok: false, error: '快捷键格式无效' };
|
||
}
|
||
const acc = accelerator.trim();
|
||
// 与当前一致:直接返回成功,避免无意义的 unregister/register 抖动
|
||
if (acc === currentGlobalShortcut) return { ok: true, accelerator: acc, unchanged: true };
|
||
|
||
// 落盘先于 register:注册失败时数据已存,但当前快捷键仍是旧值。
|
||
// 用户重启后会从 settings.shortcuts.globalToggle 再尝试一次(虽然大概率仍失败)。
|
||
// 如果反过来:register 成功但落盘失败,重启后快捷键回到旧值——这条更糟糕,
|
||
// 用户明明看到界面变了,重启又被回滚。所以先写盘、再注册。
|
||
try {
|
||
const current = loadData();
|
||
const newSettings = {
|
||
...(current.settings || {}),
|
||
shortcuts: { ...((current.settings && current.settings.shortcuts) || {}), globalToggle: acc },
|
||
};
|
||
const enriched = withOwnedSettings({ ...current, settings: newSettings });
|
||
const res = saveData(enriched);
|
||
if (!res || !res.ok) return { ok: false, error: (res && res.error) || '保存失败' };
|
||
cachedDataSnapshot = enriched;
|
||
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
||
} catch (e) {
|
||
console.error('save shortcut error:', e);
|
||
return { ok: false, error: e.message || String(e) };
|
||
}
|
||
|
||
// 尝试注册到系统。失败 → 数据已写盘但快捷键未切换;返回失败让 UI 提示用户,
|
||
// 但用户重启时会再次尝试(此时如果那个占用程序退了就成功了)。
|
||
const ok = applyGlobalShortcut(acc);
|
||
if (!ok) {
|
||
return { ok: false, error: '快捷键已被其它应用占用,请换一个', accelerator: acc };
|
||
}
|
||
return { ok: true, accelerator: acc };
|
||
});
|
||
|
||
/* ==================== 全局快捷键 ==================== */
|
||
// 默认唤出 / 收起快捷键。注册来源不再写死:读 data.settings.shortcuts.globalToggle,
|
||
// 没有或格式异常时回落到这里。用户在设置面板改了之后通过 IPC 重注册。
|
||
const DEFAULT_GLOBAL_TOGGLE = 'CommandOrControl+Shift+V';
|
||
// 跟踪「当前已注册到系统」的那一个。change 时需要先 unregister 掉旧的再 register 新的,
|
||
// 否则旧组合会一直占着直到进程退出。启动时会被真实注册值覆盖。
|
||
let currentGlobalShortcut = DEFAULT_GLOBAL_TOGGLE;
|
||
|
||
// 重新注册全局快捷键:
|
||
// 1. 注销旧的(可能是默认值,也可能是用户改过的),避免旧组合一直占着系统
|
||
// 2. register 新值;失败(被其它程序占用)时回滚到旧值,保证至少有一个组合能用
|
||
// 3. 整个流程不抛错——注册失败时主进程只 warn,应用照常运行(README 86 行约定)
|
||
function applyGlobalShortcut(accelerator) {
|
||
// 注销旧值:可能从来没注册成功过,catch 兜底
|
||
if (currentGlobalShortcut && currentGlobalShortcut !== accelerator) {
|
||
try { globalShortcut.unregister(currentGlobalShortcut); } catch {}
|
||
}
|
||
// 兜底:unregisterAll 清场,避免旧注册残留;本应用目前只有这一条全局快捷键,安全
|
||
try { globalShortcut.unregisterAll(); } catch {}
|
||
|
||
try {
|
||
const ok = globalShortcut.register(accelerator, () => toggleWindow());
|
||
if (!ok) {
|
||
console.warn('全局快捷键注册失败,可能被其它应用占用:', accelerator);
|
||
// 回滚:旧值能注册就还原
|
||
try { globalShortcut.register(currentGlobalShortcut, () => toggleWindow()); } catch {}
|
||
return false;
|
||
}
|
||
currentGlobalShortcut = accelerator;
|
||
return true;
|
||
} catch (e) {
|
||
console.warn('globalShortcut.register error:', e);
|
||
try { globalShortcut.register(currentGlobalShortcut, () => toggleWindow()); } catch {}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/* ==================== 生命周期 ==================== */
|
||
|
||
app.whenReady().then(() => {
|
||
userDataPath = path.join(app.getPath('userData'), 'data.json');
|
||
|
||
// 启动期一次性读盘:createWindow 读 alwaysOnTop、applyGlobalShortcut 读 savedAcc,
|
||
// 共用这一份解析过的数据,避免 JSON 解析 + 字段容错走两遍。
|
||
try {
|
||
bootDataSnapshot = loadData();
|
||
} catch {
|
||
bootDataSnapshot = null;
|
||
}
|
||
|
||
// 检测本次启动是否由「开机自启动」触发:
|
||
// wasOpenedAsHidden 表示 setLoginItemSettings({openAsHidden:true}) 给执行参数加了 --hidden;
|
||
// wasOpenedAtLogin 是更广义的判断(macOS 等)。
|
||
// 二者任一为真都说明我们是被登录项拉起来的,此时不要自动弹出悬浮窗。
|
||
try {
|
||
const ls = app.getLoginItemSettings();
|
||
startedAtLogin = !!(ls && (ls.wasOpenedAtLogin || ls.wasOpenedAsHidden));
|
||
} catch {}
|
||
|
||
createWindow();
|
||
createTray();
|
||
|
||
// 启动前台窗口跟踪
|
||
startForegroundTracking();
|
||
|
||
// 注册全局唤出快捷键:优先用用户改过的值,没有或格式异常时用默认值。
|
||
// 复用 bootDataSnapshot,省一次 loadData。
|
||
let savedAcc = DEFAULT_GLOBAL_TOGGLE;
|
||
if (bootDataSnapshot) {
|
||
const fromSettings = bootDataSnapshot.settings
|
||
&& bootDataSnapshot.settings.shortcuts
|
||
&& bootDataSnapshot.settings.shortcuts.globalToggle;
|
||
if (typeof fromSettings === 'string' && fromSettings.trim() && fromSettings.includes('+')) {
|
||
savedAcc = fromSettings;
|
||
}
|
||
}
|
||
applyGlobalShortcut(savedAcc);
|
||
});
|
||
|
||
app.on('will-quit', () => {
|
||
// 退出时反注册所有全局快捷键,避免下次的注册失败
|
||
try { globalShortcut.unregisterAll(); } catch {}
|
||
});
|
||
|
||
app.on('before-quit', () => {
|
||
isQuitting = true;
|
||
// 清理窗口位置合并任务:避免退出后定时器回调访问已销毁的 mainWindow
|
||
// (回调里有 isDestroyed 检查不会崩,但清理掉更对称、也省一次空写盘)
|
||
if (saveBoundsTimer) {
|
||
clearTimeout(saveBoundsTimer);
|
||
saveBoundsTimer = null;
|
||
}
|
||
// 清理前台窗口轮询定时器,避免退出后仍在访问主窗口
|
||
if (fgTrackTimer) {
|
||
clearInterval(fgTrackTimer);
|
||
fgTrackTimer = null;
|
||
}
|
||
// 清理回显 timer,避免退出后还会触发 mainWindow.showInactive 撞到销毁态
|
||
if (pasteRestoreTimer) {
|
||
clearTimeout(pasteRestoreTimer);
|
||
pasteRestoreTimer = null;
|
||
}
|
||
// 清理 hide→paste 延迟 timer,同上理由(回调里 sendCtrlV 会在已销毁进程上下文里跑)
|
||
if (pasteHideTimer) {
|
||
clearTimeout(pasteHideTimer);
|
||
pasteHideTimer = null;
|
||
}
|
||
});
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||
else if (mainWindow) showFloating();
|
||
}); |