接上托盘浮窗、全局热键和剪贴板粘贴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hopechen067andClaude Opus 5.5 committed 2026-10-02 01:25:19 -04:00
1 parent 6ff0b6a2cc
commit ca36d4b3ba
10 files changed
+891

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

+94
View File
@@ -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(','));
+420
View File
@@ -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', () => {
// 托盘常驻,关窗口不退出。
});
+243
View File
@@ -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,
};
+25
View File
@@ -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),
});
+29
View File
@@ -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>
+35
View File
@@ -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>
+45
View File
@@ -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);
});