diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..9c79e33
Binary files /dev/null and b/assets/icon.ico differ
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..92886de
Binary files /dev/null and b/assets/icon.png differ
diff --git a/assets/tray.png b/assets/tray.png
new file mode 100644
index 0000000..b27f577
Binary files /dev/null and b/assets/tray.png differ
diff --git a/scripts/make-icon.js b/scripts/make-icon.js
new file mode 100644
index 0000000..b445eda
--- /dev/null
+++ b/scripts/make-icon.js
@@ -0,0 +1,94 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+
+function crc32(buf) {
+ let c = ~0;
+ for (let i = 0; i < buf.length; i += 1) {
+ c ^= buf[i];
+ for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
+ }
+ return ~c >>> 0;
+}
+
+function chunk(type, data) {
+ const body = Buffer.concat([Buffer.from(type), data]);
+ const out = Buffer.alloc(8 + body.length);
+ out.writeUInt32BE(data.length, 0);
+ body.copy(out, 4);
+ out.writeUInt32BE(crc32(body), 8 + data.length);
+ return out;
+}
+
+function png(size) {
+ const raw = Buffer.alloc((size * 4 + 1) * size);
+ for (let y = 0; y < size; y += 1) {
+ const row = y * (size * 4 + 1);
+ raw[row] = 0;
+ for (let x = 0; x < size; x += 1) {
+ const nx = x / (size - 1);
+ const ny = y / (size - 1);
+ const onX = Math.abs(nx - ny) < 0.16 || Math.abs(nx - (1 - ny)) < 0.16;
+ const margin = size * 0.18;
+ const inside = x > margin && y > margin && x < size - margin && y < size - margin;
+ const i = row + 1 + x * 4;
+ if (inside && onX) {
+ raw[i] = 255;
+ raw[i + 1] = 255;
+ raw[i + 2] = 255;
+ raw[i + 3] = 255;
+ } else if (inside) {
+ raw[i] = 32;
+ raw[i + 1] = 122;
+ raw[i + 2] = 214;
+ raw[i + 3] = 255;
+ } else {
+ raw[i + 3] = 0;
+ }
+ }
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8;
+ ihdr[9] = 6;
+ return Buffer.concat([
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
+ chunk('IHDR', ihdr),
+ chunk('IDAT', zlib.deflateSync(raw)),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+function ico(images) {
+ const count = images.length;
+ const header = Buffer.alloc(6 + count * 16);
+ header.writeUInt16LE(0, 0);
+ header.writeUInt16LE(1, 2);
+ header.writeUInt16LE(count, 4);
+ let offset = 6 + count * 16;
+ const parts = [header];
+ images.forEach((image, index) => {
+ const entry = 6 + index * 16;
+ header[entry] = image.size === 256 ? 0 : image.size;
+ header[entry + 1] = image.size === 256 ? 0 : image.size;
+ header.writeUInt16LE(1, entry + 4);
+ header.writeUInt16LE(32, entry + 6);
+ header.writeUInt32LE(image.data.length, entry + 8);
+ header.writeUInt32LE(offset, entry + 12);
+ offset += image.data.length;
+ parts.push(image.data);
+ });
+ return Buffer.concat(parts);
+}
+
+const root = path.join(__dirname, '..', 'assets');
+fs.mkdirSync(root, { recursive: true });
+const sizes = [16, 32, 48, 256];
+const images = sizes.map((size) => ({ size, data: png(size) }));
+fs.writeFileSync(path.join(root, 'tray.png'), images[0].data);
+fs.writeFileSync(path.join(root, 'icon.png'), images[3].data);
+fs.writeFileSync(path.join(root, 'icon.ico'), ico(images));
+console.log('wrote icons', images.map((image) => image.data.length).join(','));
diff --git a/src/main/main.js b/src/main/main.js
new file mode 100644
index 0000000..351b64f
--- /dev/null
+++ b/src/main/main.js
@@ -0,0 +1,420 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const {
+ app,
+ BrowserWindow,
+ Tray,
+ Menu,
+ globalShortcut,
+ ipcMain,
+ nativeImage,
+ screen,
+ clipboard,
+ safeStorage,
+} = require('electron');
+
+const { PROVIDERS } = require('./providers');
+const { createConfigStore, deepMerge } = require('./config');
+const { translate } = require('./engines');
+const { captureForeground, commitPaste } = require('./paste');
+
+const POPUP_WIDTH = 560;
+const POPUP_HEIGHT = 140;
+
+let store = null;
+let tray = null;
+let popup = null;
+let settingsWin = null;
+let quitting = false;
+let activeHotkey = null;
+let targetHwnd = null;
+let suppressBlurUntil = 0;
+const inflight = new Map();
+
+const gotLock = app.requestSingleInstanceLock();
+if (!gotLock) {
+ app.quit();
+} else {
+ app.on('second-instance', () => {
+ showPopup();
+ });
+ app.whenReady().then(start).catch((err) => {
+ console.error('[xtranslate] 启动失败', err && err.message ? err.message : err);
+ app.quit();
+ });
+}
+
+function warn(message) {
+ console.warn('[xtranslate]', message);
+}
+
+function webPrefs() {
+ return {
+ preload: path.join(__dirname, '..', 'preload.js'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ };
+}
+
+function pagePath(name) {
+ const renderer = path.join(__dirname, '..', 'renderer', name);
+ if (fs.existsSync(renderer)) return renderer;
+ const fixture = path.join(__dirname, '..', '..', 'test', 'fixtures', name);
+ if (fs.existsSync(fixture)) return fixture;
+ return null;
+}
+
+function missingHtml(name) {
+ return `
+
+ 找不到页面 src/renderer/${name}
+ 后端已经启动。前端文件还没放进来,所以先显示这张说明。
+`;
+}
+
+async function loadPage(win, name) {
+ const file = pagePath(name);
+ if (!file) {
+ await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(missingHtml(name))}`);
+ return;
+ }
+ try {
+ await win.loadFile(file);
+ } catch (err) {
+ warn(`加载 ${name} 失败:${err && err.message}`);
+ await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(missingHtml(name))}`);
+ }
+}
+
+function trayImage() {
+ const candidates = [
+ path.join(__dirname, '..', '..', 'assets', 'tray.png'),
+ path.join(__dirname, '..', '..', 'assets', 'icon.png'),
+ ];
+ for (const file of candidates) {
+ if (!fs.existsSync(file)) continue;
+ const image = nativeImage.createFromPath(file);
+ if (!image.isEmpty()) return image.resize({ width: 16, height: 16 });
+ }
+ return nativeImage.createFromBuffer(Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA' +
+ 'GklEQVQ4T2NkoBAwUqifgYoBAwMDw3+G/wwMDAwA' +
+ 'BhgBAe0l3n0AAAAASUVORK5CYII=',
+ 'base64',
+ ));
+}
+
+function placePopup() {
+ const cursor = screen.getCursorScreenPoint();
+ const display = screen.getDisplayNearestPoint(cursor);
+ const area = display.workArea;
+ const [width, height] = popup.getSize();
+ const x = Math.round(area.x + (area.width - width) / 2);
+ const y = Math.round(area.y + area.height * 0.28);
+ const maxY = area.y + area.height - height;
+ popup.setPosition(x, Math.min(y, maxY));
+}
+
+function emitShow() {
+ if (!popup || popup.isDestroyed()) return;
+ const payload = { engine: store.get().engine, direction: 'auto' };
+ const send = () => {
+ if (popup && !popup.isDestroyed()) popup.webContents.send('xt:show', payload);
+ };
+ if (popup.webContents.isLoading()) popup.webContents.once('did-finish-load', send);
+ else send();
+}
+
+function showPopup() {
+ if (!popup || popup.isDestroyed()) return;
+ if (popup.isVisible()) {
+ popup.hide();
+ return;
+ }
+ targetHwnd = captureForeground();
+ placePopup();
+ suppressBlurUntil = Date.now() + 300;
+ popup.show();
+ popup.focus();
+ emitShow();
+}
+
+function hidePopup() {
+ if (popup && !popup.isDestroyed() && popup.isVisible()) popup.hide();
+}
+
+function applyHotkey(next) {
+ if (!next || typeof next !== 'string' || !next.trim()) {
+ return { ok: false, message: '热键不能为空' };
+ }
+ if (next === activeHotkey && globalShortcut.isRegistered(next)) return { ok: true };
+ const previous = activeHotkey;
+ if (previous && previous !== next) globalShortcut.unregister(previous);
+ if (globalShortcut.isRegistered(next)) globalShortcut.unregister(next);
+ const ok = globalShortcut.register(next, showPopup);
+ if (!ok) {
+ if (previous && previous !== next) {
+ const back = globalShortcut.register(previous, showPopup);
+ if (!back) warn(`原来的热键「${previous}」也没能重新注册`);
+ }
+ return { ok: false, message: `热键「${next}」注册失败,可能被其他程序占用` };
+ }
+ activeHotkey = next;
+ return { ok: true };
+}
+
+function applyLogin(openAtLogin) {
+ try {
+ app.setLoginItemSettings({ openAtLogin: !!openAtLogin });
+ } catch (err) {
+ warn(`设置开机启动失败:${err && err.message}`);
+ }
+}
+
+function broadcast(pub) {
+ for (const win of BrowserWindow.getAllWindows()) {
+ if (!win.isDestroyed()) win.webContents.send('xt:config', pub);
+ }
+}
+
+function refreshTray() {
+ if (!tray) return;
+ const cfg = store.get();
+ const menu = Menu.buildFromTemplate([
+ { label: '设置', click: () => openSettings() },
+ {
+ label: '开机启动',
+ type: 'checkbox',
+ checked: !!cfg.launchAtLogin,
+ click: (item) => {
+ try {
+ setConfig({ launchAtLogin: item.checked });
+ } catch (err) {
+ warn(err.message);
+ refreshTray();
+ }
+ },
+ },
+ { type: 'separator' },
+ { label: '退出', click: () => app.quit() },
+ ]);
+ tray.setContextMenu(menu);
+}
+
+function setConfig(partial) {
+ const prev = store.get();
+ const merged = deepMerge(prev, partial || {});
+ const hotkeyChanged = merged.hotkey !== prev.hotkey;
+ if (hotkeyChanged) {
+ const registered = applyHotkey(merged.hotkey);
+ if (!registered.ok) throw new Error(registered.message);
+ }
+ let pub;
+ try {
+ pub = store.set(partial || {});
+ } catch (err) {
+ if (hotkeyChanged) applyHotkey(prev.hotkey);
+ throw err;
+ }
+ if (partial && Object.prototype.hasOwnProperty.call(partial, 'launchAtLogin')) {
+ applyLogin(!!store.get().launchAtLogin);
+ }
+ broadcast(pub);
+ refreshTray();
+ return pub;
+}
+
+function openSettings() {
+ if (settingsWin && !settingsWin.isDestroyed()) {
+ settingsWin.show();
+ settingsWin.focus();
+ return;
+ }
+ settingsWin = new BrowserWindow({
+ width: 760,
+ height: 680,
+ show: false,
+ autoHideMenuBar: true,
+ title: 'Xtranslate 设置',
+ webPreferences: webPrefs(),
+ });
+ settingsWin.on('closed', () => {
+ settingsWin = null;
+ });
+ loadPage(settingsWin, 'settings.html')
+ .then(() => {
+ if (settingsWin && !settingsWin.isDestroyed()) settingsWin.show();
+ })
+ .catch((err) => warn(err && err.message));
+}
+
+function createPopup() {
+ popup = new BrowserWindow({
+ width: POPUP_WIDTH,
+ height: POPUP_HEIGHT,
+ frame: false,
+ transparent: true,
+ backgroundColor: '#00000000',
+ alwaysOnTop: true,
+ skipTaskbar: true,
+ show: false,
+ resizable: false,
+ minimizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ webPreferences: webPrefs(),
+ });
+ popup.setAlwaysOnTop(true, 'floating');
+ popup.on('blur', () => {
+ if (Date.now() < suppressBlurUntil) return;
+ hidePopup();
+ });
+ popup.on('close', (event) => {
+ if (!quitting) {
+ event.preventDefault();
+ hidePopup();
+ }
+ });
+ loadPage(popup, 'popup.html').catch((err) => warn(err && err.message));
+}
+
+function createTray() {
+ tray = new Tray(trayImage());
+ tray.setToolTip('Xtranslate');
+ refreshTray();
+}
+
+function registerIpc() {
+ ipcMain.handle('xt:translate', async (event, req) => {
+ const body = req || {};
+ const id = body.id != null ? String(body.id) : `${Date.now()}`;
+ const cfg = store.get();
+ const engine = body.engine || cfg.engine;
+ const ac = new AbortController();
+ inflight.set(id, ac);
+ const started = Date.now();
+ try {
+ const result = await translate({
+ text: body.text,
+ direction: body.direction,
+ engine,
+ signal: ac.signal,
+ config: cfg,
+ onPartial: (text) => {
+ if (!event.sender.isDestroyed()) event.sender.send('xt:partial', { id, text });
+ },
+ });
+ return {
+ id,
+ text: result.text,
+ engine,
+ direction: result.direction,
+ ms: Date.now() - started,
+ };
+ } catch (err) {
+ throw new Error((err && err.message) || '翻译失败');
+ } finally {
+ inflight.delete(id);
+ }
+ });
+
+ ipcMain.handle('xt:cancel', (_event, id) => {
+ const ac = inflight.get(String(id));
+ if (ac) ac.abort();
+ });
+
+ ipcMain.handle('xt:commit', async (_event, text) => {
+ const cfg = store.get();
+ await commitPaste({
+ text: String(text ?? ''),
+ clipboard,
+ hwnd: targetHwnd,
+ restore: cfg.restoreClipboard !== false,
+ hide: async () => hidePopup(),
+ });
+ });
+
+ ipcMain.handle('xt:hide', () => {
+ hidePopup();
+ });
+
+ ipcMain.handle('xt:resize', (_event, height) => {
+ if (!popup || popup.isDestroyed()) return;
+ const next = Math.max(80, Math.min(800, Math.round(Number(height) || POPUP_HEIGHT)));
+ popup.setSize(POPUP_WIDTH, next);
+ });
+
+ ipcMain.handle('xt:getConfig', () => store.getPublic());
+
+ ipcMain.handle('xt:setConfig', (_event, partial) => setConfig(partial || {}));
+
+ ipcMain.handle('xt:getProviders', () => PROVIDERS);
+
+ ipcMain.handle('xt:testEngine', async (_event, partial) => {
+ const cfg = deepMerge(store.get(), partial || {});
+ const started = Date.now();
+ try {
+ const result = await translate({
+ text: '你好',
+ direction: 'zh2en',
+ engine: cfg.engine,
+ config: cfg,
+ });
+ return { ok: true, sample: result.text, ms: Date.now() - started };
+ } catch (err) {
+ return {
+ ok: false,
+ sample: '',
+ ms: Date.now() - started,
+ error: (err && err.message) || '翻译失败',
+ };
+ }
+ });
+
+ ipcMain.handle('xt:openSettings', () => {
+ openSettings();
+ });
+}
+
+async function start() {
+ if (process.platform === 'darwin' && app.dock) app.dock.hide();
+ app.setAppUserModelId('com.xtranslate.app');
+
+ store = createConfigStore({
+ filePath: path.join(app.getPath('userData'), 'config.json'),
+ safeStorage,
+ warn,
+ });
+
+ registerIpc();
+ createTray();
+ createPopup();
+
+ const initial = applyHotkey(store.get().hotkey);
+ if (!initial.ok) warn(initial.message);
+ applyLogin(!!store.get().launchAtLogin);
+
+ console.log('[xtranslate] ready', JSON.stringify({
+ hotkey: store.get().hotkey,
+ registered: !!(activeHotkey && globalShortcut.isRegistered(activeHotkey)),
+ }));
+}
+
+app.on('before-quit', () => {
+ quitting = true;
+});
+
+app.on('will-quit', () => {
+ globalShortcut.unregisterAll();
+ if (tray) {
+ tray.destroy();
+ tray = null;
+ }
+});
+
+app.on('window-all-closed', () => {
+ // 托盘常驻,关窗口不退出。
+});
diff --git a/src/main/paste.js b/src/main/paste.js
new file mode 100644
index 0000000..fc78c96
--- /dev/null
+++ b/src/main/paste.js
@@ -0,0 +1,243 @@
+'use strict';
+
+const INPUT_KEYBOARD = 1;
+const KEYEVENTF_KEYUP = 0x0002;
+const VK_CONTROL = 0x11;
+const VK_V = 0x56;
+const VK_MENU = 0x12;
+const ASFW_ANY = 0xffffffff;
+
+const PASTE_DELAY_MS = 60;
+const RESTORE_DELAY_MS = 400;
+
+function normalizeNewlines(value) {
+ return String(value ?? '').replace(/\r\n/g, '\n');
+}
+
+/** 只有剪贴板里仍是我们刚写进去的文本时才恢复,避免盖掉用户中途复制的内容。 */
+function shouldRestoreClipboard(currentText, writtenText) {
+ return normalizeNewlines(currentText) === normalizeNewlines(writtenText);
+}
+
+function snapshotClipboard(clipboard) {
+ const image = typeof clipboard.readImage === 'function' ? clipboard.readImage() : null;
+ return {
+ text: clipboard.readText(),
+ html: typeof clipboard.readHTML === 'function' ? clipboard.readHTML() : '',
+ rtf: typeof clipboard.readRTF === 'function' ? clipboard.readRTF() : '',
+ image: image && typeof image.isEmpty === 'function' && !image.isEmpty() ? image : null,
+ };
+}
+
+function writeSnapshot(clipboard, snap) {
+ const payload = {};
+ if (snap.text) payload.text = snap.text;
+ if (snap.html) payload.html = snap.html;
+ if (snap.rtf) payload.rtf = snap.rtf;
+ if (snap.image) payload.image = snap.image;
+ if (Object.keys(payload).length === 0) {
+ clipboard.clear();
+ return;
+ }
+ clipboard.write(payload);
+}
+
+let win32 = null;
+
+function loadWin32() {
+ if (win32) return win32;
+ if (process.platform !== 'win32') return null;
+ const koffi = require('koffi');
+ const user32 = koffi.load('user32.dll');
+ const kernel32 = koffi.load('kernel32.dll');
+
+ const KEYBDINPUT = koffi.struct('KEYBDINPUT', {
+ wVk: 'uint16_t',
+ wScan: 'uint16_t',
+ dwFlags: 'uint32_t',
+ time: 'uint32_t',
+ dwExtraInfo: 'uintptr_t',
+ });
+ const MOUSEINPUT = koffi.struct('MOUSEINPUT', {
+ dx: 'long',
+ dy: 'long',
+ mouseData: 'uint32_t',
+ dwFlags: 'uint32_t',
+ time: 'uint32_t',
+ dwExtraInfo: 'uintptr_t',
+ });
+ const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', {
+ uMsg: 'uint32_t',
+ wParamL: 'uint16_t',
+ wParamH: 'uint16_t',
+ });
+ const INPUT = koffi.struct('INPUT', {
+ type: 'uint32_t',
+ u: koffi.union({
+ mi: MOUSEINPUT,
+ ki: KEYBDINPUT,
+ hi: HARDWAREINPUT,
+ }),
+ });
+
+ win32 = {
+ koffi,
+ INPUT,
+ GetForegroundWindow: user32.func('void * __stdcall GetForegroundWindow()'),
+ SetForegroundWindow: user32.func('bool __stdcall SetForegroundWindow(void *hWnd)'),
+ AllowSetForegroundWindow: user32.func('bool __stdcall AllowSetForegroundWindow(uint32_t dwProcessId)'),
+ GetWindowThreadProcessId: user32.func('uint32_t __stdcall GetWindowThreadProcessId(void *hWnd, _Out_ uint32_t *lpdwProcessId)'),
+ AttachThreadInput: user32.func('bool __stdcall AttachThreadInput(uint32_t idAttach, uint32_t idAttachTo, bool fAttach)'),
+ GetCurrentThreadId: kernel32.func('uint32_t __stdcall GetCurrentThreadId()'),
+ SendInput: user32.func('unsigned int __stdcall SendInput(unsigned int cInputs, INPUT *pInputs, int cbSize)'),
+ };
+ return win32;
+}
+
+function captureForeground() {
+ try {
+ const api = loadWin32();
+ if (!api) return null;
+ return api.GetForegroundWindow();
+ } catch (err) {
+ console.warn('[xtranslate] 读取前台窗口失败', err && err.message);
+ return null;
+ }
+}
+
+function keyEvent(vk, down) {
+ return {
+ type: INPUT_KEYBOARD,
+ u: {
+ ki: {
+ wVk: vk,
+ wScan: 0,
+ dwFlags: down ? 0 : KEYEVENTF_KEYUP,
+ time: 0,
+ dwExtraInfo: 0,
+ },
+ },
+ };
+}
+
+function sendInputs(api, events) {
+ return api.SendInput(events.length, events, api.koffi.sizeof(api.INPUT));
+}
+
+function forceForeground(api, hwnd) {
+ if (!hwnd) return false;
+ try {
+ api.AllowSetForegroundWindow(ASFW_ANY);
+ } catch {
+ // 允许失败,后面还有 Alt 键技巧
+ }
+
+ let currentThread = 0;
+ let targetThread = 0;
+ let foregroundThread = 0;
+ try {
+ currentThread = api.GetCurrentThreadId();
+ targetThread = api.GetWindowThreadProcessId(hwnd, [0]) || 0;
+ const foreground = api.GetForegroundWindow();
+ if (foreground) foregroundThread = api.GetWindowThreadProcessId(foreground, [0]) || 0;
+ } catch {
+ // 拿不到线程 id 时仍然尝试 SetForegroundWindow
+ }
+
+ const attached = [];
+ const attach = (from, to) => {
+ if (!from || !to || from === to) return;
+ try {
+ if (api.AttachThreadInput(from, to, true)) attached.push([from, to]);
+ } catch {
+ // 挂不上输入队列就继续走 Alt 键
+ }
+ };
+
+ try {
+ attach(foregroundThread, currentThread);
+ attach(targetThread, currentThread);
+ // 模拟一次 Alt,解开 Windows 的前台锁,否则 SetForegroundWindow 经常被拒绝。
+ sendInputs(api, [keyEvent(VK_MENU, true), keyEvent(VK_MENU, false)]);
+ return !!api.SetForegroundWindow(hwnd);
+ } finally {
+ for (const [from, to] of attached.reverse()) {
+ try {
+ api.AttachThreadInput(from, to, false);
+ } catch {
+ // 解开失败不影响粘贴
+ }
+ }
+ }
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * 备份剪贴板 → 写入译文 → 隐藏浮窗 → 切回目标窗口 → Ctrl+V → 条件恢复。
+ * 非 Windows 只写剪贴板并隐藏。
+ */
+async function commitPaste({ text, clipboard, hide, restore, hwnd, sleepImpl }) {
+ const wait = sleepImpl || sleep;
+ const written = String(text ?? '');
+ const snap = snapshotClipboard(clipboard);
+ clipboard.writeText(written);
+ if (hide) await hide();
+
+ if (process.platform !== 'win32') return;
+
+ let api;
+ try {
+ api = loadWin32();
+ } catch (err) {
+ console.warn('[xtranslate] 无法加载 Win32 粘贴,已只写入剪贴板', err && err.message);
+ return;
+ }
+ if (!api) return;
+
+ try {
+ forceForeground(api, hwnd);
+ } catch (err) {
+ console.warn('[xtranslate] 切回目标窗口失败', err && err.message);
+ }
+
+ await wait(PASTE_DELAY_MS);
+ try {
+ sendInputs(api, [
+ keyEvent(VK_CONTROL, true),
+ keyEvent(VK_V, true),
+ keyEvent(VK_V, false),
+ keyEvent(VK_CONTROL, false),
+ ]);
+ } catch (err) {
+ console.warn('[xtranslate] 发送 Ctrl+V 失败', err && err.message);
+ }
+
+ if (!restore) return;
+ await wait(RESTORE_DELAY_MS);
+ try {
+ if (shouldRestoreClipboard(clipboard.readText(), written)) writeSnapshot(clipboard, snap);
+ } catch (err) {
+ console.warn('[xtranslate] 恢复剪贴板失败', err && err.message);
+ }
+}
+
+function inputStructSize() {
+ const api = loadWin32();
+ if (!api) return null;
+ return api.koffi.sizeof(api.INPUT);
+}
+
+module.exports = {
+ captureForeground,
+ commitPaste,
+ inputStructSize,
+ shouldRestoreClipboard,
+ snapshotClipboard,
+ writeSnapshot,
+ normalizeNewlines,
+ PASTE_DELAY_MS,
+ RESTORE_DELAY_MS,
+};
diff --git a/src/preload.js b/src/preload.js
new file mode 100644
index 0000000..257e44e
--- /dev/null
+++ b/src/preload.js
@@ -0,0 +1,25 @@
+'use strict';
+
+const { contextBridge, ipcRenderer } = require('electron');
+
+function listen(channel, cb) {
+ const listener = (_event, payload) => cb(payload);
+ ipcRenderer.on(channel, listener);
+ return () => ipcRenderer.removeListener(channel, listener);
+}
+
+contextBridge.exposeInMainWorld('xt', {
+ translate: (req) => ipcRenderer.invoke('xt:translate', req),
+ onPartial: (cb) => listen('xt:partial', cb),
+ cancel: (id) => ipcRenderer.invoke('xt:cancel', id),
+ commit: (text) => ipcRenderer.invoke('xt:commit', text),
+ hide: () => ipcRenderer.invoke('xt:hide'),
+ resize: (height) => ipcRenderer.invoke('xt:resize', height),
+ onShow: (cb) => listen('xt:show', cb),
+ getConfig: () => ipcRenderer.invoke('xt:getConfig'),
+ setConfig: (partial) => ipcRenderer.invoke('xt:setConfig', partial),
+ getProviders: () => ipcRenderer.invoke('xt:getProviders'),
+ testEngine: (partial) => ipcRenderer.invoke('xt:testEngine', partial),
+ openSettings: () => ipcRenderer.invoke('xt:openSettings'),
+ onConfigChanged: (cb) => listen('xt:config', cb),
+});
diff --git a/test/fixtures/popup.html b/test/fixtures/popup.html
new file mode 100644
index 0000000..a8d64c5
--- /dev/null
+++ b/test/fixtures/popup.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Xtranslate
+
+
+
+
+ 后端浮窗(fixture)
+
+
+
+
diff --git a/test/fixtures/settings.html b/test/fixtures/settings.html
new file mode 100644
index 0000000..47f5b5c
--- /dev/null
+++ b/test/fixtures/settings.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Xtranslate 设置
+
+
+
+ Xtranslate 设置(fixture)
+ 这是后端自测页。正式界面在 src/renderer/settings.html。
+
+ 等待 preload…
+
+
+
diff --git a/test/paste.test.js b/test/paste.test.js
new file mode 100644
index 0000000..f2e08f3
--- /dev/null
+++ b/test/paste.test.js
@@ -0,0 +1,45 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { shouldRestoreClipboard, snapshotClipboard, writeSnapshot, inputStructSize } = require('../src/main/paste');
+
+test('Win32 INPUT 结构按 64 位对齐是 40 字节', { skip: process.platform !== 'win32' }, () => {
+ assert.equal(inputStructSize(), 40);
+});
+
+test('换行差异不影响“是不是我们写进去的”判断', () => {
+ assert.equal(shouldRestoreClipboard('a\r\nb', 'a\nb'), true);
+ assert.equal(shouldRestoreClipboard('user copied', 'translation'), false);
+});
+
+test('剪贴板快照能带回文本、HTML、RTF 和图片', () => {
+ const image = { isEmpty: () => false, id: 'img' };
+ const written = [];
+ const clipboard = {
+ text: 'old',
+ html: 'old',
+ rtf: '{\\rtf old}',
+ image,
+ readText: () => clipboard.text,
+ readHTML: () => clipboard.html,
+ readRTF: () => clipboard.rtf,
+ readImage: () => clipboard.image,
+ writeText: (value) => {
+ clipboard.text = value;
+ },
+ write: (payload) => written.push(payload),
+ clear: () => written.push('clear'),
+ };
+ const snap = snapshotClipboard(clipboard);
+ clipboard.writeText('translated');
+ assert.equal(shouldRestoreClipboard(clipboard.readText(), 'translated'), true);
+ writeSnapshot(clipboard, snap);
+ assert.equal(written[0].text, 'old');
+ assert.equal(written[0].html, 'old');
+ assert.equal(written[0].rtf, '{\\rtf old}');
+ assert.equal(written[0].image, image);
+
+ clipboard.readText = () => 'user copied later';
+ assert.equal(shouldRestoreClipboard(clipboard.readText(), 'translated'), false);
+});