From ca36d4b3ba58c3aeefe0c8aca84ace03ef41ff6e Mon Sep 17 00:00:00 2001 From: hopechen067 <59017045+hopechen067@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:25:19 -0400 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E4=B8=8A=E6=89=98=E7=9B=98=E6=B5=AE?= =?UTF-8?q?=E7=AA=97=E3=80=81=E5=85=A8=E5=B1=80=E7=83=AD=E9=94=AE=E5=92=8C?= =?UTF-8?q?=E5=89=AA=E8=B4=B4=E6=9D=BF=E7=B2=98=E8=B4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- assets/icon.ico | Bin 0 -> 2448 bytes assets/icon.png | Bin 0 -> 1798 bytes assets/tray.png | Bin 0 -> 130 bytes scripts/make-icon.js | 94 ++++++++ src/main/main.js | 420 ++++++++++++++++++++++++++++++++++++ src/main/paste.js | 243 +++++++++++++++++++++ src/preload.js | 25 +++ test/fixtures/popup.html | 29 +++ test/fixtures/settings.html | 35 +++ test/paste.test.js | 45 ++++ 10 files changed, 891 insertions(+) create mode 100644 assets/icon.ico create mode 100644 assets/icon.png create mode 100644 assets/tray.png create mode 100644 scripts/make-icon.js create mode 100644 src/main/main.js create mode 100644 src/main/paste.js create mode 100644 src/preload.js create mode 100644 test/fixtures/popup.html create mode 100644 test/fixtures/settings.html create mode 100644 test/paste.test.js diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9c79e332f8325ccf4b5eccdf6aa0bd84b93969d8 GIT binary patch literal 2448 zcma)8dsI_L9-bQ>VG$yTD0I0Hz*YwdrSY?)6;v-WWM>`Z@&3v z?*09406>5-u(ATMCxb!&z+TwMWPB`!_#SND-uRe+0KkLIa2fcfL;%ha060sJrdgW5 zVh+QWQIX-Xu!oAk%FGo0I%)N3u#1Wc-xGJ@N;=Cf=y|L>|;Q+G7sM7K~Q{FDH z71*DXfuw(>W!pv@&D$3`yms4{>*8fKc>bW-EWhfDq9TEPwR57S9n`%w+MK8)jlTy) zK~zL^xFD2O_|g4;qlLCnKfkqW_vVGG zl&?0%<&+6#t{ml0d{ZxVw+yioZsVd#4|{PaISTP5p!<=BXYF@b(#cQXGe>su*mXsS zbrE}CEHHh7{f%z-!NpGkOL0tffk{f}Dqcw2-Gls)pNyg&TG0M#|}NYrahE{{zX zTGM+gCNlf|r{)wV2PU4oovPZ#HFvpeQMu>f1%eCjqb#unqsb(*xN*qT&YJQy;rL6X zGglt}C26C{4(kcUCxp#B?b8yYOS~5TMFNY=k)yl8CLdvE6EObU(xNxZG(<)S*>2Ky z0sP27ns)#;otysx888o7$vXlD@57rZ`B`Gs2lK;R*}QF$g%uiHS_y9TWLV{duys2K z1n4byG`OeyeetV|Wc%Mg0mF-i0PF;kvKkKoVEV(aCm_hr=G}4a=&ePGlR^HEUm;HF;2S);NjVS~{j7dI!C!bHX)H)rmg+C5)ktrR`i(;~F;t4rvN*Vj4mjqg0>G-ry?Z zZ&lst(|?7L-Pw~$eX{)DkxSFp1CDF-%^3F`v|Fc-$IO}ciq2q6rt?|~#&fqWXu()A zp6CF^IoCUmZSYjQQk05wZ1mlj$J82~8Yhu@3o0<7T<5hJ%-y#(np9j#&eI2A&eV}B z!?+YvNyVioHu_FXYP?1##iRmObT2E1Jjy0xybkMEJfP)m1;y7FwNg6dErjiK)2})o zhBi1C{`!QUdey+p9T}T#m(t-QoHX(nd)5*|H$+`;j{PL8pbtZsJv;WK%8L$tO;#UC znjJW1AS_P%&6m-}y@=$jgJeJDJ>gFvyu94F!tX_jKd8z$Q`j1)jW!@jVMXA}V2GHe zr|Y^GJPn9;+G+bL(G6~^#>b((Mni(Z_Pgl~?FuKT_muk}>#1-8g!emE+4pmfIdJdU zhAk)rCo&+4PhY4NO6WMzu-Qi&{Q7!!A^l2LBoAmlAK zE$4tOaTRlBV|>K`k{B1%Wt3TwMH4I*LCiwWj7~gDox`bCTGlH>M9sbQF7&k{UC zKBZ0M8rR|pcBjnuu?9@%R{PcL_;Brw-`#; zsR(AD+AJTh1hOO)PUyt5I*SP>Nbp?klfwxje1b_ljJIO(Rv8TIn+-bMaWu8r3SK1k zp+eVrA*Ql{J-IF-dKc|j7gxv8X4e-L4*IN{StYT1*GA8I=(TaVOvfo+a(kxzQyepBw_mp7 j`l168t)~>~|M4#kWU>Sz@Jqw^8$%Xh;f9Ip|6~3KzUeux literal 0 HcmV?d00001 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..92886dedc21ba7d3c9e46274a6d12d43a6ab38f9 GIT binary patch literal 1798 zcmb7_e^AnA9LFEBgjrF}oBbGHf6-l+Kl5BmMWZQ(txdr|tTgD{>j0^w>P!a&i8-8UM-rNs>_v7u14>+Zl#tnmYgHQZZ*k2Y7AHK2~ z`&S#eBa=>gTb1zTvD#2Z`^JuIhi*@617YJEe6~K-de6^V+=)>Miko{@w*<_wiy4Y7 zv=*eRUwaUf(;(fP2ie~8A5e5 z+;drR`HUkH)#*F1t4=0T_$tGyhV>rs)|pu({CKwcdcx? zt*`Ek`z|769Bt@)F7i}?-D`MzHTRO{ArQRBPrjW4up)497dRiC7f^&`3f0jMo zo9_XpM`I^znWiXWP~j&*vE~PgXMiCVFP6l<+#3h#@=l$QB{HI(HO1q4s2$v8mBVnC`uS;i*{f?-Y7ZOy?%nm)+Qw#*&| z{v62!X{gEAOUU9Rc6k*@%9um}duG(EOz*zo)qbVyu-(*V)DyBkO6+plc(ySRek~x! zb+#;zDDZ`tt?w8OEWOEGRq_A%HAHIQ0K z*A)Vz$a%ENt3A%{2qg+~&6Z_k73|nW5iLJ`IVKL&`_l7@!22OW>0jW}Bv-bE@TbVx zdCsdn)?T!UDA;GVkjbha)VdssXvyizv^Y@XOD`z`{vkpivMNNKt|4TJoEzdmw0&n0 z{8^oo!!|0*vm>qvm!CBO|BpY)XZ7IAB`p_O%kkoa+4WL3+NMJRbNk=b;u~_FIb5>1 z5nrH4@qHVKg0DNd@vO-=wi#c;?zKwlB%ThuBbhF-3XccAe!xy!WNPz7*zpve#zrc~ zlR%%1!G=zJs|$IsL5uII#sC|Xc*7DdtjkbcriW#BtH-CC!?clHcu0JRDX5bQ$GwJ%5sGIZ}4Jv)Ap3{U9r;##q44B_--Y~S23H3;W(wH*%~WK^$6!3?icITIUv zoz~m1frS*Ow~7ybm&Zg;B#_Pgc~D-Dd;+=JJ7ebbD3ABfk5oKtmZkUHx3vIVCg!0I>=uA^-pY literal 0 HcmV?d00001 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); +});