feat(desktop): init

This commit is contained in:
xxnuo
2026-01-01 17:18:17 +08:00
parent 55926734f3
commit 32d23cd51a
22 changed files with 1701 additions and 143 deletions

View File

@@ -42,8 +42,44 @@ jobs:
name: binaries
path: dist/mtranserver-*
build_desktop:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Sync version from tag
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
fi
echo "Syncing version to ${VERSION}"
bun run bump ${VERSION}
- name: Build Desktop
run: bun run build:electron
- name: Upload Desktop Artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.os }}
path: release/mtranserver-desktop-*
release:
needs: build
needs: [build, build_desktop]
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -57,6 +93,13 @@ jobs:
name: binaries
path: release-assets
- name: Download Desktop Artifacts
uses: actions/download-artifact@v4
with:
pattern: desktop-*
path: release-assets
merge-multiple: true
- name: Determine Release Tag
id: tag
run: |
@@ -105,6 +148,20 @@ jobs:
| Windows | x64 | [mtranserver-${VERSION}-windows-amd64.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-windows-amd64.exe) |
| Windows | x64 (legacy) | [mtranserver-${VERSION}-windows-amd64-legacy.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-windows-amd64-legacy.exe) |
## Desktop Client
| Platform | Architecture | Binary |
|----------|-------------|--------|
| Linux | x64 | [mtranserver-desktop-${VERSION}-linux-x64.AppImage](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.AppImage) |
| Linux | arm64 | [mtranserver-desktop-${VERSION}-linux-arm64.AppImage](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.AppImage) |
| Linux | x64 (deb) | [mtranserver-desktop-${VERSION}-linux-x64.deb](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.deb) |
| Linux | arm64 (deb) | [mtranserver-desktop-${VERSION}-linux-arm64.deb](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.deb) |
| Linux | x64 (rpm) | [mtranserver-desktop-${VERSION}-linux-x64.rpm](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.rpm) |
| Linux | arm64 (rpm) | [mtranserver-desktop-${VERSION}-linux-arm64.rpm](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.rpm) |
| macOS | x64 | [mtranserver-desktop-${VERSION}-mac-x64.dmg](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-mac-x64.dmg) |
| macOS | arm64 | [mtranserver-desktop-${VERSION}-mac-arm64.dmg](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-mac-arm64.dmg) |
| Windows | x64 | [mtranserver-desktop-${VERSION}-win-x64.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-win-x64.exe) |
## Note
**legacy**: 仅适用于使用普通版本会遇到如 'Illegal instruction' 这样问题的旧硬件(无 AVX2 指令集的 CPU大多数人不需要这个因为它更慢。

268
desktop/app.js Normal file
View File

@@ -0,0 +1,268 @@
import { app, dialog, nativeImage, shell, Menu } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
import { loadDesktopConfig, saveDesktopConfig, resetDesktopConfig } from './config.js';
import { resolveLocale, getMessages } from './i18n.js';
import { getFreePort, isPortAvailable } from './ports.js';
import { getServerStatus, startServerWithConfig, stopServerInstance } from './server.js';
import {
createMainWindow,
createSettingsWindow,
showMainWindow,
showSettingsWindow,
updateWindowUrls
} from './windows.js';
import { createTray, updateTrayMenu } from './tray.js';
import { registerIpcHandlers } from './ipc.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let desktopConfig = null;
let locale = 'en';
let messages = getMessages(locale);
let tray = null;
const repoUrl = 'https://github.com/xxnuo/MTranServer';
let portCheckPromise = null;
function getLocalHost(host) {
if (!host || host === '0.0.0.0') return '127.0.0.1';
return host;
}
function getUiUrl(server) {
return `http://${getLocalHost(server.host)}:${server.port}/ui/`;
}
function getDocsUrl(server) {
return `http://${getLocalHost(server.host)}:${server.port}/docs/`;
}
function getSettingsUrl(server) {
return `http://${getLocalHost(server.host)}:${server.port}/ui/settings`;
}
function getStatusLabel() {
const status = getServerStatus();
if (status === 'running') {
return `${messages.trayServiceRunning} (${desktopConfig.server.port})`;
}
if (status === 'starting') return messages.trayServiceRunning;
return messages.trayServiceStopped;
}
function updateTray() {
if (!tray) return;
updateTrayMenu({
messages,
statusLabel: getStatusLabel(),
versionLabel: app.getVersion(),
onOpenMain: showMainWindow,
onOpenBrowserUi: () => shell.openExternal(getUiUrl(desktopConfig.server)),
onOpenBrowserDocs: () => shell.openExternal(getDocsUrl(desktopConfig.server)),
onOpenRepo: () => shell.openExternal(repoUrl),
onOpenSettings: showSettingsWindow,
onRestart: restartServer,
onOpenModels: () => shell.openPath(desktopConfig.server.modelDir),
onOpenConfig: () => shell.openPath(desktopConfig.server.configDir),
onQuit: quitApp
});
}
async function ensurePortAvailable() {
if (portCheckPromise) return portCheckPromise;
portCheckPromise = (async () => {
const host = desktopConfig.server.host || '0.0.0.0';
const available = await isPortAvailable(desktopConfig.server.port, host);
if (available) return true;
const result = await dialog.showMessageBox({
type: 'warning',
buttons: [messages.portInUseUseRandom, messages.portInUseQuit],
defaultId: 0,
cancelId: 1,
message: messages.portInUseTitle,
detail: messages.portInUseDetail.replace('{port}', String(desktopConfig.server.port))
});
if (result.response === 0) {
const newPort = await getFreePort();
desktopConfig.server.port = newPort;
desktopConfig = await saveDesktopConfig(desktopConfig);
return true;
}
await quitApp();
return false;
})();
try {
return await portCheckPromise;
} finally {
portCheckPromise = null;
}
}
async function startServer() {
const ok = await ensurePortAvailable();
if (!ok) return false;
try {
await startServerWithConfig(desktopConfig.server);
return true;
} catch {
dialog.showMessageBox({
type: 'error',
message: messages.serverStartFailed,
detail: messages.serverStartFailedDetail
});
return false;
}
}
async function restartServer() {
try {
await stopServerInstance();
const ok = await startServer();
if (!ok) return false;
updateWindowUrls({
mainUrl: getUiUrl(desktopConfig.server),
settingsUrl: getSettingsUrl(desktopConfig.server)
});
updateTray();
return true;
} catch {
dialog.showMessageBox({
type: 'error',
message: messages.serverRestartFailed
});
return false;
}
}
async function quitApp() {
app.isQuitting = true;
await stopServerInstance();
app.quit();
}
function updateLocale(nextLocale) {
locale = resolveLocale(nextLocale, app.getLocale());
messages = getMessages(locale);
updateTray();
}
function getLoadingUrl() {
const html = `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${messages.appName}</title>
<style>
html,body{margin:0;height:100%;font-family:system-ui,Segoe UI,Arial,sans-serif;background:#f5f5f5;color:#1f2937}
.wrap{height:100%;display:flex;align-items:center;justify-content:center}
.card{padding:24px 32px;border:1px solid #e5e7eb;border-radius:16px;background:#fff;box-shadow:0 12px 30px rgba(0,0,0,0.08);text-align:center;min-width:260px}
.title{font-size:18px;font-weight:600;margin-bottom:8px}
.desc{font-size:14px;color:#6b7280}
.dot{display:inline-block;animation:blink 1.2s infinite}
@keyframes blink{0%,100%{opacity:.2}50%{opacity:1}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="title">${messages.appName}</div>
<div class="desc">${messages.trayServiceStatus}: ${messages.trayServiceRunning} <span class="dot">...</span></div>
</div>
</div>
</body>
</html>
`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
export async function startDesktop() {
app.isQuitting = false;
Menu.setApplicationMenu(null);
desktopConfig = await loadDesktopConfig();
updateLocale(desktopConfig.locale);
const preloadPath = path.join(__dirname, 'preload.cjs');
const loadingUrl = getLoadingUrl();
const mainWindow = createMainWindow({ url: loadingUrl, preload: preloadPath });
createSettingsWindow({ url: loadingUrl, preload: preloadPath, parent: mainWindow });
mainWindow.once('ready-to-show', () => mainWindow.show());
const iconPath = path.join(__dirname, '..', 'images', 'icons', 'icon@16px.png');
const trayIcon = nativeImage.createFromPath(iconPath);
tray = createTray({
icon: trayIcon,
tooltip: messages.appName,
messages,
statusLabel: getStatusLabel(),
versionLabel: app.getVersion(),
onOpenMain: showMainWindow,
onOpenBrowserUi: () => shell.openExternal(getUiUrl(desktopConfig.server)),
onOpenBrowserDocs: () => shell.openExternal(getDocsUrl(desktopConfig.server)),
onOpenRepo: () => shell.openExternal(repoUrl),
onOpenSettings: showSettingsWindow,
onRestart: restartServer,
onOpenModels: () => shell.openPath(desktopConfig.server.modelDir),
onOpenConfig: () => shell.openPath(desktopConfig.server.configDir),
onQuit: quitApp
});
const started = await startServer();
if (!started) {
app.quit();
return;
}
updateWindowUrls({
mainUrl: getUiUrl(desktopConfig.server),
settingsUrl: getSettingsUrl(desktopConfig.server)
});
registerIpcHandlers({
getConfig: async () => ({
config: desktopConfig,
status: getServerStatus(),
version: app.getVersion()
}),
applyConfig: async (config) => {
desktopConfig = await saveDesktopConfig(config);
updateLocale(desktopConfig.locale);
const ok = await restartServer();
if (!ok) return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
},
resetConfig: async () => {
desktopConfig = await resetDesktopConfig();
updateLocale(desktopConfig.locale);
const ok = await restartServer();
if (!ok) return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
},
restartServer: async () => {
const ok = await restartServer();
if (!ok) return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
},
getStatus: async () => ({ status: getServerStatus() }),
openExternal: async (url) => shell.openExternal(url),
openPath: async (targetPath) => shell.openPath(targetPath)
});
app.on('activate', () => showMainWindow());
app.on('before-quit', (event) => {
if (app.isQuitting) return;
event.preventDefault();
quitApp();
});
app.on('window-all-closed', (event) => event.preventDefault());
updateTray();
}
export function focusMainWindow() {
showMainWindow();
}

109
desktop/config.js Normal file
View File

@@ -0,0 +1,109 @@
import fs from 'fs';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
import YAML from 'yaml';
const configRoot = path.join(os.homedir(), '.config', 'mtran');
const serverConfigDir = path.join(configRoot, 'server');
const desktopConfigPath = path.join(serverConfigDir, 'desktop.yml');
function getDefaultModelDir() {
const localModelsDir = path.join(process.cwd(), 'models');
if (fs.existsSync(localModelsDir)) {
return localModelsDir;
}
return path.join(configRoot, 'models');
}
export function getDesktopConfigPath() {
return desktopConfigPath;
}
export function getDesktopConfigDir() {
return serverConfigDir;
}
export function getDefaultDesktopConfig() {
return {
locale: 'system',
server: {
host: '0.0.0.0',
port: 8989,
logLevel: 'warn',
enableWebUI: true,
enableOfflineMode: false,
workerIdleTimeout: 60,
workersPerLanguage: 1,
apiToken: '',
logDir: path.join(configRoot, 'logs'),
logToFile: false,
logConsole: true,
logRequests: false,
maxLengthBreak: 128,
checkUpdate: true,
cacheSize: 1000,
modelDir: getDefaultModelDir(),
configDir: serverConfigDir
}
};
}
function normalizeConfig(input) {
const defaults = getDefaultDesktopConfig();
const server = input?.server || {};
return {
locale: input?.locale || defaults.locale,
server: {
host: server.host ?? defaults.server.host,
port: Number.isFinite(Number(server.port)) ? Number(server.port) : defaults.server.port,
logLevel: server.logLevel ?? defaults.server.logLevel,
enableWebUI: typeof server.enableWebUI === 'boolean' ? server.enableWebUI : defaults.server.enableWebUI,
enableOfflineMode: typeof server.enableOfflineMode === 'boolean' ? server.enableOfflineMode : defaults.server.enableOfflineMode,
workerIdleTimeout: Number.isFinite(Number(server.workerIdleTimeout))
? Number(server.workerIdleTimeout)
: defaults.server.workerIdleTimeout,
workersPerLanguage: Number.isFinite(Number(server.workersPerLanguage))
? Number(server.workersPerLanguage)
: defaults.server.workersPerLanguage,
apiToken: server.apiToken ?? defaults.server.apiToken,
logDir: server.logDir ?? defaults.server.logDir,
logToFile: typeof server.logToFile === 'boolean' ? server.logToFile : defaults.server.logToFile,
logConsole: typeof server.logConsole === 'boolean' ? server.logConsole : defaults.server.logConsole,
logRequests: typeof server.logRequests === 'boolean' ? server.logRequests : defaults.server.logRequests,
maxLengthBreak: Number.isFinite(Number(server.maxLengthBreak))
? Number(server.maxLengthBreak)
: defaults.server.maxLengthBreak,
checkUpdate: typeof server.checkUpdate === 'boolean' ? server.checkUpdate : defaults.server.checkUpdate,
cacheSize: Number.isFinite(Number(server.cacheSize)) ? Number(server.cacheSize) : defaults.server.cacheSize,
modelDir: server.modelDir ?? defaults.server.modelDir,
configDir: server.configDir ?? defaults.server.configDir
}
};
}
export async function loadDesktopConfig() {
try {
const raw = await fsPromises.readFile(desktopConfigPath, 'utf8');
const parsed = YAML.parse(raw);
return normalizeConfig(parsed);
} catch (error) {
const defaults = getDefaultDesktopConfig();
await saveDesktopConfig(defaults);
return defaults;
}
}
export async function saveDesktopConfig(config) {
const normalized = normalizeConfig(config);
await fsPromises.mkdir(serverConfigDir, { recursive: true });
const data = YAML.stringify(normalized);
await fsPromises.writeFile(desktopConfigPath, data, 'utf8');
return normalized;
}
export async function resetDesktopConfig() {
const defaults = getDefaultDesktopConfig();
await saveDesktopConfig(defaults);
return defaults;
}

94
desktop/i18n.js Normal file
View File

@@ -0,0 +1,94 @@
const messages = {
en: {
trayOpenMain: 'Open Main Window',
trayOpenBrowser: 'Open in Browser',
trayOpenUi: 'Main UI',
trayOpenDocs: 'API Docs',
trayOpenRepo: 'Repository',
trayServiceStatus: 'Service Status',
trayServiceRunning: 'Running',
trayServiceStopped: 'Stopped',
trayServiceManagement: 'Service Management',
trayOpenSettings: 'Settings',
trayRestart: 'Restart',
trayData: 'Data',
trayOpenModels: 'Open Model Folder',
trayOpenConfig: 'Open Config Folder',
trayVersion: 'Version',
trayQuit: 'Quit',
appName: 'MTranServer',
portInUseTitle: 'Port is in use',
portInUseDetail: 'Port {port} is already in use. Close the process occupying it, or use a random port to start the server.',
portInUseUseRandom: 'Use Random Port',
portInUseQuit: 'Quit',
serverStartFailed: 'Failed to start server',
serverStartFailedDetail: 'Unable to start MTranServer. Please check configuration and try again.',
serverRestartFailed: 'Failed to restart server'
},
zh: {
trayOpenMain: '打开主界面',
trayOpenBrowser: '浏览器打开',
trayOpenUi: '主界面',
trayOpenDocs: 'API 文档',
trayOpenRepo: '仓库地址',
trayServiceStatus: '服务状态',
trayServiceRunning: '运行中',
trayServiceStopped: '已停止',
trayServiceManagement: '服务管理',
trayOpenSettings: '设置',
trayRestart: '重启',
trayData: '数据',
trayOpenModels: '打开模型目录',
trayOpenConfig: '打开配置目录',
trayVersion: '版本号',
trayQuit: '退出程序',
appName: 'MTranServer',
portInUseTitle: '端口被占用',
portInUseDetail: '端口 {port} 已被占用,请关闭占用该端口的进程,或使用随机端口启动服务。',
portInUseUseRandom: '使用随机端口',
portInUseQuit: '退出',
serverStartFailed: '服务启动失败',
serverStartFailedDetail: '无法启动 MTranServer请检查配置后重试。',
serverRestartFailed: '服务重启失败'
},
ja: {
trayOpenMain: 'メイン画面を開く',
trayOpenBrowser: 'ブラウザで開く',
trayOpenUi: 'メイン画面',
trayOpenDocs: 'API ドキュメント',
trayOpenRepo: 'リポジトリ',
trayServiceStatus: 'サービス状態',
trayServiceRunning: '稼働中',
trayServiceStopped: '停止中',
trayServiceManagement: 'サービス管理',
trayOpenSettings: '設定',
trayRestart: '再起動',
trayData: 'データ',
trayOpenModels: 'モデルフォルダを開く',
trayOpenConfig: '設定フォルダを開く',
trayVersion: 'バージョン',
trayQuit: '終了',
appName: 'MTranServer',
portInUseTitle: 'ポートが使用中です',
portInUseDetail: 'ポート {port} は使用中です。使用中のプロセスを終了するか、ランダムポートで起動してください。',
portInUseUseRandom: 'ランダムポートを使用',
portInUseQuit: '終了',
serverStartFailed: 'サーバーの起動に失敗しました',
serverStartFailedDetail: 'MTranServer を起動できません。設定を確認してください。',
serverRestartFailed: 'サーバーの再起動に失敗しました'
}
};
export function resolveLocale(configLocale, appLocale) {
if (configLocale && configLocale !== 'system') {
return messages[configLocale] ? configLocale : 'en';
}
const lower = (appLocale || 'en').toLowerCase();
if (lower.startsWith('zh')) return 'zh';
if (lower.startsWith('ja')) return 'ja';
return 'en';
}
export function getMessages(locale) {
return messages[locale] || messages.en;
}

11
desktop/ipc.js Normal file
View File

@@ -0,0 +1,11 @@
import { ipcMain } from 'electron';
export function registerIpcHandlers(handlers) {
ipcMain.handle('desktop:get-config', async () => handlers.getConfig());
ipcMain.handle('desktop:apply-config', async (_event, config) => handlers.applyConfig(config));
ipcMain.handle('desktop:reset-config', async () => handlers.resetConfig());
ipcMain.handle('desktop:restart-server', async () => handlers.restartServer());
ipcMain.handle('desktop:get-status', async () => handlers.getStatus());
ipcMain.handle('desktop:open-external', async (_event, url) => handlers.openExternal(url));
ipcMain.handle('desktop:open-path', async (_event, targetPath) => handlers.openPath(targetPath));
}

32
desktop/ports.js Normal file
View File

@@ -0,0 +1,32 @@
import net from 'net';
export async function isPortAvailable(port, host) {
return new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, host);
});
}
export async function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once('error', reject);
server.listen(0, () => {
const address = server.address();
if (address && typeof address !== 'string') {
const port = address.port;
server.close(() => resolve(port));
} else {
reject(new Error('Failed to get port'));
}
});
});
}

12
desktop/preload.cjs Normal file
View File

@@ -0,0 +1,12 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('mtranDesktop', {
isDesktop: true,
getConfig: () => ipcRenderer.invoke('desktop:get-config'),
applyConfig: (config) => ipcRenderer.invoke('desktop:apply-config', config),
resetConfig: () => ipcRenderer.invoke('desktop:reset-config'),
restartServer: () => ipcRenderer.invoke('desktop:restart-server'),
getStatus: () => ipcRenderer.invoke('desktop:get-status'),
openExternal: (url) => ipcRenderer.invoke('desktop:open-external', url),
openPath: (targetPath) => ipcRenderer.invoke('desktop:open-path', targetPath)
});

106
desktop/server.js Normal file
View File

@@ -0,0 +1,106 @@
let currentServer = null;
let stopServer = null;
let status = 'stopped';
let serverModulePromise = null;
const valueFlags = new Set([
'--host',
'--port',
'--log-level',
'--config-dir',
'--model-dir',
'--log-dir',
'--worker-idle-timeout',
'--workers-per-language',
'--api-token',
'--max-length-break',
'--cache-size'
]);
const booleanFlags = new Set([
'--ui',
'--no-ui',
'--offline',
'--log-to-file',
'--log-console',
'--no-log-console',
'--log-requests',
'--check-update',
'--no-check-update'
]);
function stripServerArgs() {
const nextArgs = [];
for (let i = 0; i < process.argv.length; i += 1) {
const arg = process.argv[i];
if (valueFlags.has(arg)) {
i += 1;
continue;
}
const flagMatch = arg.split('=')[0];
if (valueFlags.has(flagMatch) || booleanFlags.has(flagMatch)) {
continue;
}
if (booleanFlags.has(arg)) {
continue;
}
nextArgs.push(arg);
}
process.argv = nextArgs;
}
async function loadServerModule() {
if (!serverModulePromise) {
serverModulePromise = import('../dist/desktop-server.js');
}
return serverModulePromise;
}
function applyConfigToEnv(config) {
stripServerArgs();
process.env.MT_HOST = config.host;
process.env.MT_PORT = String(config.port);
process.env.MT_LOG_LEVEL = config.logLevel;
process.env.MT_ENABLE_UI = String(config.enableWebUI);
process.env.MT_OFFLINE = String(config.enableOfflineMode);
process.env.MT_WORKER_IDLE_TIMEOUT = String(config.workerIdleTimeout);
process.env.MT_WORKERS_PER_LANGUAGE = String(config.workersPerLanguage);
process.env.MT_API_TOKEN = config.apiToken || '';
process.env.MT_LOG_DIR = config.logDir;
process.env.MT_LOG_TO_FILE = String(config.logToFile);
process.env.MT_LOG_CONSOLE = String(config.logConsole);
process.env.MT_LOG_REQUESTS = String(config.logRequests);
process.env.MT_MAX_LENGTH_BREAK = String(config.maxLengthBreak);
process.env.MT_CHECK_UPDATE = String(config.checkUpdate);
process.env.MT_CACHE_SIZE = String(config.cacheSize);
process.env.MT_MODEL_DIR = config.modelDir;
process.env.MT_CONFIG_DIR = config.configDir;
}
export async function startServerWithConfig(config) {
applyConfigToEnv(config);
const serverModule = await loadServerModule();
serverModule.resetConfig();
status = 'starting';
const result = await serverModule.startServer({ handleSignals: false });
currentServer = result.server;
stopServer = result.stop;
status = 'running';
return currentServer;
}
export async function stopServerInstance() {
if (!stopServer) {
status = 'stopped';
return;
}
status = 'stopping';
await stopServer();
currentServer = null;
stopServer = null;
status = 'stopped';
}
export function getServerStatus() {
return status;
}

100
desktop/tray.js Normal file
View File

@@ -0,0 +1,100 @@
import { Menu, Tray } from 'electron';
let tray = null;
export function createTray({
icon,
tooltip,
messages,
statusLabel,
versionLabel,
onOpenMain,
onOpenBrowserUi,
onOpenBrowserDocs,
onOpenRepo,
onOpenSettings,
onRestart,
onOpenModels,
onOpenConfig,
onQuit
}) {
if (tray) return tray;
tray = new Tray(icon);
tray.setToolTip(tooltip);
tray.on('click', () => onOpenMain());
updateTrayMenu({
messages,
statusLabel,
versionLabel,
onOpenMain,
onOpenBrowserUi,
onOpenBrowserDocs,
onOpenRepo,
onOpenSettings,
onRestart,
onOpenModels,
onOpenConfig,
onQuit
});
return tray;
}
export function updateTrayMenu({
messages,
statusLabel,
versionLabel,
onOpenMain,
onOpenBrowserUi,
onOpenBrowserDocs,
onOpenRepo,
onOpenSettings,
onRestart,
onOpenModels,
onOpenConfig,
onQuit
}) {
if (!tray) return;
const contextMenu = Menu.buildFromTemplate([
{
label: messages.trayOpenMain,
click: onOpenMain
},
{
label: messages.trayOpenBrowser,
submenu: [
{ label: messages.trayOpenUi, click: onOpenBrowserUi },
{ label: messages.trayOpenDocs, click: onOpenBrowserDocs },
{ label: messages.trayOpenRepo, click: onOpenRepo }
]
},
{ type: 'separator' },
{
label: `${messages.trayServiceStatus}: ${statusLabel}`,
enabled: false
},
{
label: messages.trayServiceManagement,
submenu: [
{ label: messages.trayOpenSettings, click: onOpenSettings },
{ label: messages.trayRestart, click: onRestart }
]
},
{ type: 'separator' },
{
label: messages.trayData,
submenu: [
{ label: messages.trayOpenModels, click: onOpenModels },
{ label: messages.trayOpenConfig, click: onOpenConfig }
]
},
{
label: `${messages.trayVersion}: ${versionLabel}`,
enabled: false
},
{
label: messages.trayQuit,
click: onQuit
}
]);
tray.setContextMenu(contextMenu);
}

107
desktop/windows.js Normal file
View File

@@ -0,0 +1,107 @@
import { app, BrowserWindow, Menu } from 'electron';
let mainWindow = null;
let settingsWindow = null;
function attachContextMenu(window) {
window.webContents.on('context-menu', () => {
const menu = Menu.buildFromTemplate([
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ type: 'separator' },
{ role: 'selectAll' }
]);
menu.popup({ window });
});
}
export function createMainWindow({ url, preload }) {
if (mainWindow) return mainWindow;
mainWindow = new BrowserWindow({
width: 1280,
height: 720,
show: false,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload
}
});
mainWindow.loadURL(url);
attachContextMenu(mainWindow);
mainWindow.on('close', (event) => {
if (!app.isQuitting && !mainWindow?.isDestroyed()) {
event.preventDefault();
mainWindow.hide();
}
});
return mainWindow;
}
export function createSettingsWindow({ url, preload, parent }) {
if (settingsWindow) return settingsWindow;
settingsWindow = new BrowserWindow({
width: 960,
height: 540,
show: false,
resizable: true,
parent,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload
}
});
settingsWindow.loadURL(url);
attachContextMenu(settingsWindow);
settingsWindow.on('close', (event) => {
if (!app.isQuitting && !settingsWindow?.isDestroyed()) {
event.preventDefault();
settingsWindow.hide();
}
});
return settingsWindow;
}
export function showMainWindow() {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
export function showSettingsWindow() {
if (!settingsWindow) return;
if (settingsWindow.isMinimized()) settingsWindow.restore();
settingsWindow.show();
settingsWindow.focus();
}
export function getMainWindow() {
return mainWindow;
}
export function destroySettingsWindow() {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.destroy();
}
settingsWindow = null;
}
export function updateWindowUrls({ mainUrl, settingsUrl }) {
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
mainWindow.loadURL(mainUrl);
}
if (settingsWindow && !settingsWindow.isDestroyed() && settingsWindow.webContents) {
settingsWindow.loadURL(settingsUrl);
}
}

View File

@@ -1,125 +1,16 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
import { app } from 'electron';
import { focusMainWindow, startDesktop } from './desktop/app.js';
// --- ESM 兼容性处理 ---
// 在 ESM 模式下无法直接使用 __dirname需要通过 import.meta.url 手动生成
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// --- 1. 引入你的 Express 入口文件 ---
// 使用 import 语法直接导入,这会执行 ./dist/main.js 中的代码(启动服务器)
import './dist/main.js';
const PORT = 8989;
let mainWindow;
let tray;
// 防止应用多开
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
app.quit();
} else {
app.on('second-instance', () => {
// 如果用户尝试打开第二个实例,则聚焦当前窗口
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
}
});
app.on('second-instance', () => {
focusMainWindow();
});
app.whenReady().then(() => {
createWindow();
createTray();
});
app.whenReady().then(() => {
startDesktop();
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1024,
height: 768,
show: false, // 启动时不直接显示窗口
webPreferences: {
nodeIntegration: false,
contextIsolation: true
}
});
// 加载 Express 服务地址
mainWindow.loadURL(`http://localhost:${PORT}`);
// 监听关闭事件:点击 X 不退出,而是隐藏到托盘
mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault();
mainWindow.hide();
return false;
}
});
}
function createTray() {
// 2. 准备图标
// 确保你的路径下有 images/icon.png
const iconPath = path.join(__dirname, "images", "icons", 'icon@16px.png');
// 增加一个容错,防止图标路径不对导致报错
let trayIcon;
try {
trayIcon = nativeImage.createFromPath(iconPath);
if (trayIcon.isEmpty()) {
console.warn('警告: 托盘图标为空,请检查路径:', iconPath);
}
} catch (e) {
console.error('加载图标失败:', e);
trayIcon = nativeImage.createEmpty();
}
tray = new Tray(trayIcon);
tray.setToolTip('我的 Express 项目');
// 3. 定义托盘右键菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示主界面',
click: () => mainWindow.show()
},
{
label: '重启页面', // 注意:这里只是刷新页面,不是重启后台服务
click: () => {
mainWindow.reload();
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
app.isQuitting = true;
app.quit();
}
}
]);
tray.setContextMenu(contextMenu);
// 点击托盘图标切换显示/隐藏
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
}
// macOS 特殊处理
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else {
mainWindow.show();
}
});

View File

@@ -19,7 +19,8 @@
"dev": "bun src/main.ts --log-level debug --port 8990 --model-dir ~/.config/mtran/models",
"dev2": "bun src/main.ts --log-level debug --port 8989 --model-dir ~/.config/mtran/models",
"bump": "bun scripts/bump.ts",
"electron": "electron ./electron-main.js",
"electron": "bun run build:lib && electron ./electron-main.js",
"electron:dev": "bun run scripts/electron-dev.ts",
"prepublishOnly": "bun scripts/build.ts --lib",
"push": "npm publish --registry=https://registry.npmjs.org",
"build": "bun scripts/build.ts --single",
@@ -56,7 +57,8 @@
"express-validator": "^7.3.1",
"fzstd": "^0.1.1",
"lru-cache": "^11.2.4",
"swagger-ui-express": "^5.0.1"
"swagger-ui-express": "^5.0.1",
"yaml": "^2.8.1"
},
"engines": {
"node": ">=18.0.0"
@@ -68,7 +70,9 @@
"extraMetadata": {
"main": "electron-main.js"
},
"artifactName": "mtranserver-desktop-${version}-${os}-${arch}.${ext}",
"files": [
"desktop/**/*",
"electron-main.js",
"dist/**/*",
"images/**/*",
@@ -87,7 +91,11 @@
"icon": "images/icon.png"
},
"linux": {
"target": "AppImage",
"target": [
"AppImage",
"deb",
"rpm"
],
"icon": "images/icon.png"
},
"nsis": {

View File

@@ -53,7 +53,7 @@ await $`bun tsoa spec-and-routes`;
if (isLib) {
console.log("Building library...");
await $`bun build src/index.ts src/main.ts --outdir dist --target node --format esm --sourcemap --external zstd-wasm-decoder --external express`;
await $`bun build src/index.ts src/main.ts src/desktop-server.ts --outdir dist --target node --format esm --sourcemap --external zstd-wasm-decoder --external express`;
await $`tsc -p tsconfig.lib.json`;
console.log("Build complete!");
process.exit(0);

116
scripts/electron-dev.ts Normal file
View File

@@ -0,0 +1,116 @@
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const electronBin = path.join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron')
const bunBin = process.execPath
function run(command: string, args: string[]) {
return spawn(command, args, { stdio: 'inherit', env: process.env })
}
function runOnce(command: string, args: string[]) {
return new Promise<void>((resolve, reject) => {
const proc = run(command, args)
proc.on('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`exit ${code}`))
})
})
}
async function runBuildOnce() {
await runOnce(bunBin, ['run', 'build:lib'])
}
function startBuildWatch() {
const args = [
'build',
'src/index.ts',
'src/main.ts',
'src/desktop-server.ts',
'--outdir',
'dist',
'--target',
'node',
'--format',
'esm',
'--sourcemap',
'--external',
'zstd-wasm-decoder',
'--external',
'express',
'--watch'
]
return run(bunBin, args)
}
let electronProcess: ReturnType<typeof run> | null = null
let restartTimer: NodeJS.Timeout | null = null
let restarting = false
function startElectron() {
if (electronProcess) return
electronProcess = run(electronBin, ['./electron-main.js'])
electronProcess.on('exit', () => {
electronProcess = null
})
}
async function stopElectron() {
if (!electronProcess) return
const proc = electronProcess
await new Promise<void>((resolve) => {
proc.once('exit', () => resolve())
proc.kill('SIGTERM')
setTimeout(() => {
if (!proc.killed) {
proc.kill('SIGKILL')
}
}, 3000)
})
}
function scheduleRestart() {
if (restartTimer) clearTimeout(restartTimer)
restartTimer = setTimeout(async () => {
if (restarting) return
restarting = true
await stopElectron()
startElectron()
restarting = false
}, 250)
}
function watchPath(target: string) {
try {
fs.watch(target, () => scheduleRestart())
} catch {
return
}
}
function setupWatchers() {
watchPath(path.join(root, 'dist'))
watchPath(path.join(root, 'desktop'))
watchPath(path.join(root, 'electron-main.js'))
}
async function main() {
await runBuildOnce()
const buildWatcher = startBuildWatch()
startElectron()
setupWatchers()
const cleanup = async () => {
await stopElectron()
buildWatcher.kill('SIGTERM')
process.exit(0)
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
}
main().catch(() => process.exit(1))

View File

@@ -130,3 +130,7 @@ export function setConfig(config: Partial<Config>) {
const current = getConfig();
globalConfig = { ...current, ...config };
}
export function resetConfig() {
globalConfig = null;
}

2
src/desktop-server.ts Normal file
View File

@@ -0,0 +1,2 @@
export { startServer } from '@/server/index.js';
export { resetConfig } from '@/config/index.js';

View File

@@ -14,7 +14,7 @@ import { swaggerStatic } from '@/middleware/swagger.js';
import { checkForUpdate } from '@/utils/update-checker.js';
import { VERSION } from '@/version';
export async function run() {
export async function startServer({ handleSignals = true } = {}) {
const config = getConfig();
logger.info('Initializing MTranServer...');
@@ -69,24 +69,35 @@ export async function run() {
}
});
const shutdown = async () => {
const stop = async () => {
logger.info('Shutting down server...');
cleanupAllEngines();
server.close(() => {
logger.info('Server shutdown complete');
process.exit(0);
});
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
await new Promise<void>((resolve) => server.close(() => resolve()));
logger.info('Server shutdown complete');
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
if (handleSignals) {
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
const timeout = setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
await stop();
clearTimeout(timeout);
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
return { server, stop };
}
export async function run() {
const { server } = await startServer({ handleSignals: true });
return server;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -8,8 +8,15 @@ import { SettingsMenu } from '@/components/SettingsMenu'
import { HistorySheet } from '@/components/HistorySheet'
import { TranslationPanel } from '@/components/TranslationPanel'
import { useHistory } from '@/hooks/use-history'
import { DesktopSettingsPage } from '@/components/DesktopSettingsPage'
function App() {
function getRoute(pathname: string) {
const normalized = pathname.replace(/\/+$/, '')
if (normalized.endsWith('/settings')) return 'settings'
return 'main'
}
function MainPage() {
const { t } = useTranslation()
const [languages, setLanguages] = useState<string[]>([])
const [loadingLanguages, setLoadingLanguages] = useState(true)
@@ -242,4 +249,12 @@ function App() {
)
}
function App() {
const route = useMemo(() => getRoute(window.location.pathname), [])
if (route === 'settings') {
return <DesktopSettingsPage />
}
return <MainPage />
}
export default App

View File

@@ -0,0 +1,405 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import {
applyDesktopConfig,
getDesktopConfig,
isDesktop,
resetDesktopConfig,
restartDesktopServer
} from '@/lib/desktop'
import type { DesktopConfig } from '@/lib/desktop'
type FormState = {
locale: string
server: {
host: string
port: string
logLevel: string
enableWebUI: boolean
enableOfflineMode: boolean
workerIdleTimeout: string
workersPerLanguage: string
apiToken: string
logDir: string
logToFile: boolean
logConsole: boolean
logRequests: boolean
maxLengthBreak: string
checkUpdate: boolean
cacheSize: string
modelDir: string
configDir: string
}
}
function toForm(config: DesktopConfig): FormState {
return {
locale: config.locale,
server: {
host: config.server.host,
port: String(config.server.port),
logLevel: config.server.logLevel,
enableWebUI: config.server.enableWebUI,
enableOfflineMode: config.server.enableOfflineMode,
workerIdleTimeout: String(config.server.workerIdleTimeout),
workersPerLanguage: String(config.server.workersPerLanguage),
apiToken: config.server.apiToken,
logDir: config.server.logDir,
logToFile: config.server.logToFile,
logConsole: config.server.logConsole,
logRequests: config.server.logRequests,
maxLengthBreak: String(config.server.maxLengthBreak),
checkUpdate: config.server.checkUpdate,
cacheSize: String(config.server.cacheSize),
modelDir: config.server.modelDir,
configDir: config.server.configDir
}
}
}
function toNumber(value: string, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
export function DesktopSettingsPage() {
const { t } = useTranslation()
const desktopAvailable = isDesktop()
const [config, setConfig] = useState<DesktopConfig | null>(null)
const [form, setForm] = useState<FormState | null>(null)
const [status, setStatus] = useState('')
const [version, setVersion] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!desktopAvailable) {
setLoading(false)
return
}
getDesktopConfig()
.then((response) => {
if (!response) return
setConfig(response.config)
setForm(toForm(response.config))
setStatus(response.status)
setVersion(response.version)
})
.catch(() => {
toast.error(t('loadingConfigFailed'))
})
.finally(() => setLoading(false))
}, [desktopAvailable, t])
const serverStatusLabel = useMemo(() => {
if (!status) return ''
return status === 'running' ? t('serverStatusRunning') : t('serverStatusStopped')
}, [status, t])
const handleApply = async () => {
if (!form || !config) return
setSaving(true)
const nextConfig: DesktopConfig = {
locale: form.locale,
server: {
...config.server,
host: form.server.host,
port: toNumber(form.server.port, config.server.port),
logLevel: form.server.logLevel,
enableWebUI: form.server.enableWebUI,
enableOfflineMode: form.server.enableOfflineMode,
workerIdleTimeout: toNumber(form.server.workerIdleTimeout, config.server.workerIdleTimeout),
workersPerLanguage: toNumber(form.server.workersPerLanguage, config.server.workersPerLanguage),
apiToken: form.server.apiToken,
logDir: form.server.logDir,
logToFile: form.server.logToFile,
logConsole: form.server.logConsole,
logRequests: form.server.logRequests,
maxLengthBreak: toNumber(form.server.maxLengthBreak, config.server.maxLengthBreak),
checkUpdate: form.server.checkUpdate,
cacheSize: toNumber(form.server.cacheSize, config.server.cacheSize),
modelDir: form.server.modelDir,
configDir: form.server.configDir
}
}
try {
const response = await applyDesktopConfig(nextConfig)
if (response) {
setConfig(response.config)
setForm(toForm(response.config))
setStatus(response.status)
setVersion(response.version)
toast.success(t('settingsApplied'))
}
} catch {
toast.error(t('settingsApplyFailed'))
} finally {
setSaving(false)
}
}
const handleRestart = async () => {
if (!desktopAvailable) return
setSaving(true)
try {
const response = await restartDesktopServer()
if (response) {
setConfig(response.config)
setForm(toForm(response.config))
setStatus(response.status)
setVersion(response.version)
toast.success(t('serverRestarted'))
}
} catch {
toast.error(t('serverRestartFailed'))
} finally {
setSaving(false)
}
}
const handleReset = async () => {
if (!desktopAvailable) return
setSaving(true)
try {
const response = await resetDesktopConfig()
if (response) {
setConfig(response.config)
setForm(toForm(response.config))
setStatus(response.status)
setVersion(response.version)
toast.success(t('settingsReset'))
}
} catch {
toast.error(t('settingsResetFailed'))
} finally {
setSaving(false)
}
}
if (!desktopAvailable) {
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-3xl">
<Alert>
<AlertTitle>{t('desktopOnly')}</AlertTitle>
<AlertDescription>{t('desktopOnlyDesc')}</AlertDescription>
</Alert>
<div className="mt-6">
<Button onClick={() => (window.location.href = '/ui/')}>{t('backToMain')}</Button>
</div>
</div>
</div>
)
}
if (loading || !form || !config) {
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-3xl text-muted-foreground">{t('loadingConfig')}</div>
</div>
)
}
return (
<div className="min-h-screen bg-background p-3 sm:p-4 md:p-6">
<div className="mx-auto max-w-4xl space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">{t('desktopSettings')}</h1>
<p className="text-xs text-muted-foreground">{t('desktopSettingsDesc')}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{t('serverStatus')}: {serverStatusLabel}</span>
{version && <span>{t('version')}: v{version}</span>}
</div>
</div>
<Card className="gap-4 py-4">
<CardHeader className="px-4 pb-3">
<CardTitle>{t('generalSettings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">{t('host')}</label>
<Input value={form.server.host} onChange={(e) => setForm({ ...form, server: { ...form.server, host: e.target.value } })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('port')}</label>
<Input value={form.server.port} onChange={(e) => setForm({ ...form, server: { ...form.server, port: e.target.value } })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('logLevel')}</label>
<Select value={form.server.logLevel} onValueChange={(value) => setForm({ ...form, server: { ...form.server, logLevel: value } })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="debug">debug</SelectItem>
<SelectItem value="info">info</SelectItem>
<SelectItem value="warn">warn</SelectItem>
<SelectItem value="error">error</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('locale')}</label>
<Select value={form.locale} onValueChange={(value) => setForm({ ...form, locale: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('system')}</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
<SelectItem value="ja"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div className="text-sm font-medium">{t('enableWebUI')}</div>
<div className="text-xs text-muted-foreground">{t('enableWebUIDesc')}</div>
</div>
<Switch checked={form.server.enableWebUI} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, enableWebUI: checked } })} />
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div className="text-sm font-medium">{t('enableOfflineMode')}</div>
<div className="text-xs text-muted-foreground">{t('enableOfflineModeDesc')}</div>
</div>
<Switch checked={form.server.enableOfflineMode} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, enableOfflineMode: checked } })} />
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div className="text-sm font-medium">{t('checkUpdate')}</div>
<div className="text-xs text-muted-foreground">{t('checkUpdateDesc')}</div>
</div>
<Switch checked={form.server.checkUpdate} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, checkUpdate: checked } })} />
</div>
</div>
</CardContent>
</Card>
<Card className="gap-4 py-4">
<CardHeader className="px-4 pb-3">
<CardTitle>{t('storageSettings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">{t('modelDir')}</label>
<Input value={form.server.modelDir} onChange={(e) => setForm({ ...form, server: { ...form.server, modelDir: e.target.value } })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('configDir')}</label>
<Input value={form.server.configDir} onChange={(e) => setForm({ ...form, server: { ...form.server, configDir: e.target.value } })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('logDir')}</label>
<Input value={form.server.logDir} onChange={(e) => setForm({ ...form, server: { ...form.server, logDir: e.target.value } })} />
</div>
</div>
</CardContent>
</Card>
<Card className="gap-4 py-4">
<CardHeader className="px-4 pb-3">
<CardTitle>{t('securitySettings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('apiToken')}</label>
<Input value={form.server.apiToken} onChange={(e) => setForm({ ...form, server: { ...form.server, apiToken: e.target.value } })} />
</div>
</CardContent>
</Card>
<Card className="gap-4 py-4">
<CardHeader className="px-4 pb-3">
<CardTitle>{t('performanceSettings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">{t('workerIdleTimeout')}</label>
<Input value={form.server.workerIdleTimeout} onChange={(e) => setForm({ ...form, server: { ...form.server, workerIdleTimeout: e.target.value } })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('workersPerLanguage')}</label>
<Input value={form.server.workersPerLanguage} onChange={(e) => setForm({ ...form, server: { ...form.server, workersPerLanguage: e.target.value } })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('maxLengthBreak')}</label>
<Input value={form.server.maxLengthBreak} onChange={(e) => setForm({ ...form, server: { ...form.server, maxLengthBreak: e.target.value } })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('cacheSize')}</label>
<Input value={form.server.cacheSize} onChange={(e) => setForm({ ...form, server: { ...form.server, cacheSize: e.target.value } })} />
</div>
</div>
</CardContent>
</Card>
<Card className="gap-4 py-4">
<CardHeader className="px-4 pb-3">
<CardTitle>{t('loggingSettings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div className="text-sm font-medium">{t('logToFile')}</div>
<div className="text-xs text-muted-foreground">{t('logToFileDesc')}</div>
</div>
<Switch checked={form.server.logToFile} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, logToFile: checked } })} />
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div className="text-sm font-medium">{t('logConsole')}</div>
<div className="text-xs text-muted-foreground">{t('logConsoleDesc')}</div>
</div>
<Switch checked={form.server.logConsole} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, logConsole: checked } })} />
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div className="text-sm font-medium">{t('logRequests')}</div>
<div className="text-xs text-muted-foreground">{t('logRequestsDesc')}</div>
</div>
<Switch checked={form.server.logRequests} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, logRequests: checked } })} />
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="outline" onClick={() => (window.location.href = '/ui/')}>
{t('backToMain')}
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleReset} disabled={saving}>
{t('resetDefaults')}
</Button>
<Button variant="outline" onClick={handleRestart} disabled={saving}>
{t('restart')}
</Button>
<Button onClick={handleApply} disabled={saving}>
{saving ? t('saving') : t('apply')}
</Button>
</div>
</div>
</div>
</div>
)
}

11
ui/src/desktop.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
interface DesktopApi {
isDesktop: boolean
getConfig: () => Promise<import('./lib/desktop').DesktopConfigResponse>
applyConfig: (config: import('./lib/desktop').DesktopConfig) => Promise<import('./lib/desktop').DesktopConfigResponse>
resetConfig: () => Promise<import('./lib/desktop').DesktopConfigResponse>
restartServer: () => Promise<import('./lib/desktop').DesktopConfigResponse>
}
interface Window {
mtranDesktop?: DesktopApi
}

View File

@@ -64,7 +64,56 @@ const resources = {
languageSearchPlaceholder: 'Search languages...',
languageRecent: 'Recent',
languageAll: 'All Languages',
followSystemLanguage: 'Follow System'
followSystemLanguage: 'Follow System',
desktopSettings: 'Desktop Settings',
desktopSettingsDesc: 'Manage server configuration and desktop preferences',
serverStatus: 'Service Status',
serverStatusRunning: 'Running',
serverStatusStopped: 'Stopped',
generalSettings: 'General',
storageSettings: 'Storage',
securitySettings: 'Security',
performanceSettings: 'Performance',
loggingSettings: 'Logging',
host: 'Host',
port: 'Port',
logLevel: 'Log Level',
locale: 'Language',
enableWebUI: 'Web UI',
enableWebUIDesc: 'Enable the built-in web UI',
enableOfflineMode: 'Offline Mode',
enableOfflineModeDesc: 'Disable online features and updates',
checkUpdate: 'Check Updates',
checkUpdateDesc: 'Check for new versions at startup',
modelDir: 'Model Directory',
configDir: 'Config Directory',
logDir: 'Log Directory',
workerIdleTimeout: 'Worker Idle Timeout (s)',
workersPerLanguage: 'Workers Per Language',
maxLengthBreak: 'Max Length Break',
cacheSize: 'Cache Size',
logToFile: 'Log to File',
logToFileDesc: 'Write logs to file',
logConsole: 'Log to Console',
logConsoleDesc: 'Output logs to console',
logRequests: 'Log Requests',
logRequestsDesc: 'Record each request',
apply: 'Apply',
restart: 'Restart',
resetDefaults: 'Reset to Defaults',
backToMain: 'Back to Main',
loadingConfig: 'Loading configuration...',
loadingConfigFailed: 'Failed to load configuration',
settingsApplied: 'Settings applied',
settingsApplyFailed: 'Failed to apply settings',
serverRestarted: 'Server restarted',
serverRestartFailed: 'Failed to restart server',
settingsReset: 'Defaults restored',
settingsResetFailed: 'Failed to reset defaults',
desktopOnly: 'Desktop only',
desktopOnlyDesc: 'Desktop settings are only available in the desktop client',
version: 'Version',
saving: 'Saving...'
}
},
zh: {
@@ -128,7 +177,56 @@ const resources = {
languageSearchPlaceholder: '搜索语言...',
languageRecent: '最近使用',
languageAll: '全部语言',
followSystemLanguage: '跟随系统'
followSystemLanguage: '跟随系统',
desktopSettings: '桌面端设置',
desktopSettingsDesc: '管理服务配置与桌面偏好',
serverStatus: '服务状态',
serverStatusRunning: '运行中',
serverStatusStopped: '已停止',
generalSettings: '常规',
storageSettings: '存储',
securitySettings: '安全',
performanceSettings: '性能',
loggingSettings: '日志',
host: '主机',
port: '端口',
logLevel: '日志级别',
locale: '语言',
enableWebUI: 'Web UI',
enableWebUIDesc: '启用内置 Web UI',
enableOfflineMode: '离线模式',
enableOfflineModeDesc: '禁用在线功能与更新检查',
checkUpdate: '检查更新',
checkUpdateDesc: '启动时检查新版本',
modelDir: '模型目录',
configDir: '配置目录',
logDir: '日志目录',
workerIdleTimeout: '空闲超时(秒)',
workersPerLanguage: '每语言工作数',
maxLengthBreak: '最大断句长度',
cacheSize: '缓存大小',
logToFile: '写入文件',
logToFileDesc: '将日志写入文件',
logConsole: '控制台日志',
logConsoleDesc: '在控制台输出日志',
logRequests: '请求日志',
logRequestsDesc: '记录每个请求',
apply: '应用',
restart: '重启',
resetDefaults: '恢复默认值',
backToMain: '返回主界面',
loadingConfig: '正在加载配置...',
loadingConfigFailed: '加载配置失败',
settingsApplied: '设置已应用',
settingsApplyFailed: '应用设置失败',
serverRestarted: '服务已重启',
serverRestartFailed: '服务重启失败',
settingsReset: '默认值已恢复',
settingsResetFailed: '恢复默认值失败',
desktopOnly: '仅限桌面端',
desktopOnlyDesc: '桌面端设置仅在桌面客户端可用',
version: '版本',
saving: '保存中...'
}
},
ja: {
@@ -192,7 +290,56 @@ const resources = {
languageSearchPlaceholder: '言語を検索...',
languageRecent: '最近',
languageAll: 'すべての言語',
followSystemLanguage: 'システムに従う'
followSystemLanguage: 'システムに従う',
desktopSettings: 'デスクトップ設定',
desktopSettingsDesc: 'サーバー設定とデスクトップ設定を管理',
serverStatus: 'サービス状態',
serverStatusRunning: '稼働中',
serverStatusStopped: '停止中',
generalSettings: '一般',
storageSettings: 'ストレージ',
securitySettings: 'セキュリティ',
performanceSettings: 'パフォーマンス',
loggingSettings: 'ログ',
host: 'ホスト',
port: 'ポート',
logLevel: 'ログレベル',
locale: '言語',
enableWebUI: 'Web UI',
enableWebUIDesc: '内蔵 Web UI を有効化',
enableOfflineMode: 'オフラインモード',
enableOfflineModeDesc: 'オンライン機能と更新確認を無効化',
checkUpdate: '更新チェック',
checkUpdateDesc: '起動時に新しいバージョンを確認',
modelDir: 'モデルディレクトリ',
configDir: '設定ディレクトリ',
logDir: 'ログディレクトリ',
workerIdleTimeout: 'アイドルタイムアウト(秒)',
workersPerLanguage: '言語ごとのワーカー数',
maxLengthBreak: '最大分割長',
cacheSize: 'キャッシュサイズ',
logToFile: 'ファイルに記録',
logToFileDesc: 'ログをファイルに書き込む',
logConsole: 'コンソール出力',
logConsoleDesc: 'コンソールにログを出力',
logRequests: 'リクエストログ',
logRequestsDesc: '各リクエストを記録',
apply: '適用',
restart: '再起動',
resetDefaults: 'デフォルトに戻す',
backToMain: 'メインに戻る',
loadingConfig: '設定を読み込み中...',
loadingConfigFailed: '設定の読み込みに失敗しました',
settingsApplied: '設定を適用しました',
settingsApplyFailed: '設定の適用に失敗しました',
serverRestarted: 'サーバーを再起動しました',
serverRestartFailed: 'サーバーの再起動に失敗しました',
settingsReset: 'デフォルトを復元しました',
settingsResetFailed: 'デフォルトの復元に失敗しました',
desktopOnly: 'デスクトップ専用',
desktopOnlyDesc: 'デスクトップ設定はデスクトップクライアントのみ利用できます',
version: 'バージョン',
saving: '保存中...'
}
}
}

52
ui/src/lib/desktop.ts Normal file
View File

@@ -0,0 +1,52 @@
export type DesktopServerConfig = {
host: string
port: number
logLevel: string
enableWebUI: boolean
enableOfflineMode: boolean
workerIdleTimeout: number
workersPerLanguage: number
apiToken: string
logDir: string
logToFile: boolean
logConsole: boolean
logRequests: boolean
maxLengthBreak: number
checkUpdate: boolean
cacheSize: number
modelDir: string
configDir: string
}
export type DesktopConfig = {
locale: string
server: DesktopServerConfig
}
export type DesktopConfigResponse = {
config: DesktopConfig
status: string
version: string
}
export const isDesktop = () => Boolean(window.mtranDesktop?.isDesktop)
export async function getDesktopConfig() {
if (!window.mtranDesktop) return null
return window.mtranDesktop.getConfig()
}
export async function applyDesktopConfig(config: DesktopConfig) {
if (!window.mtranDesktop) return null
return window.mtranDesktop.applyConfig(config)
}
export async function resetDesktopConfig() {
if (!window.mtranDesktop) return null
return window.mtranDesktop.resetConfig()
}
export async function restartDesktopServer() {
if (!window.mtranDesktop) return null
return window.mtranDesktop.restartServer()
}