接上托盘浮窗、全局热键和剪贴板粘贴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ff0b6a2cc
commit
ca36d4b3ba
10 files changed
+891
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 B |
@@ -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(','));
|
||||
@@ -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 `<!doctype html><html><head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;background:#1c1c1c;color:#f3f3f3;font:14px/1.5 sans-serif;padding:16px">
|
||||
<div>找不到页面 src/renderer/${name}</div>
|
||||
<div>后端已经启动。前端文件还没放进来,所以先显示这张说明。</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
// 托盘常驻,关窗口不退出。
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||
<title>Xtranslate</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font: 14px/1.4 sans-serif; color: #f5f5f5; }
|
||||
main { margin: 8px; padding: 12px 14px; border-radius: 12px; background: #1f1f1f; }
|
||||
#status { color: #9ad; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div id="status">后端浮窗(fixture)</div>
|
||||
</main>
|
||||
<script>
|
||||
const status = document.getElementById('status');
|
||||
if (window.xt) {
|
||||
window.xt.onShow((payload) => {
|
||||
status.textContent = '已呼出 ' + payload.engine + ' / ' + payload.direction;
|
||||
});
|
||||
} else {
|
||||
status.textContent = 'preload 没有挂上 window.xt';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||
<title>Xtranslate 设置</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; font: 14px/1.5 sans-serif; background: #161616; color: #f3f3f3; }
|
||||
pre { white-space: pre-wrap; color: #cde; }
|
||||
button { margin-right: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Xtranslate 设置(fixture)</h1>
|
||||
<p>这是后端自测页。正式界面在 src/renderer/settings.html。</p>
|
||||
<button id="reload" type="button">读取配置</button>
|
||||
<pre id="out">等待 preload…</pre>
|
||||
<script>
|
||||
const out = document.getElementById('out');
|
||||
async function show() {
|
||||
if (!window.xt) {
|
||||
out.textContent = 'preload 没有挂上 window.xt';
|
||||
return;
|
||||
}
|
||||
const config = await window.xt.getConfig();
|
||||
out.textContent = JSON.stringify(config, null, 2);
|
||||
}
|
||||
document.getElementById('reload').addEventListener('click', () => {
|
||||
show().catch((err) => { out.textContent = err.message; });
|
||||
});
|
||||
if (window.xt) window.xt.onConfigChanged(() => { show(); });
|
||||
show().catch((err) => { out.textContent = err.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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: '<b>old</b>',
|
||||
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, '<b>old</b>');
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user