From 32d23cd51ab8544ac3f8d1ad238c9bd9c9ef65e5 Mon Sep 17 00:00:00 2001
From: xxnuo <54252779+xxnuo@users.noreply.github.com>
Date: Thu, 1 Jan 2026 17:18:17 +0800
Subject: [PATCH] feat(desktop): init
---
.github/workflows/build.yml | 59 +++-
desktop/app.js | 268 ++++++++++++++
desktop/config.js | 109 ++++++
desktop/i18n.js | 94 +++++
desktop/ipc.js | 11 +
desktop/ports.js | 32 ++
desktop/preload.cjs | 12 +
desktop/server.js | 106 ++++++
desktop/tray.js | 100 ++++++
desktop/windows.js | 107 ++++++
electron-main.js | 127 +------
package.json | 14 +-
scripts/build.ts | 2 +-
scripts/electron-dev.ts | 116 +++++++
src/config/index.ts | 4 +
src/desktop-server.ts | 2 +
src/server/index.ts | 41 ++-
ui/src/App.tsx | 19 +-
ui/src/components/DesktopSettingsPage.tsx | 405 ++++++++++++++++++++++
ui/src/desktop.d.ts | 11 +
ui/src/i18n/index.ts | 153 +++++++-
ui/src/lib/desktop.ts | 52 +++
22 files changed, 1701 insertions(+), 143 deletions(-)
create mode 100644 desktop/app.js
create mode 100644 desktop/config.js
create mode 100644 desktop/i18n.js
create mode 100644 desktop/ipc.js
create mode 100644 desktop/ports.js
create mode 100644 desktop/preload.cjs
create mode 100644 desktop/server.js
create mode 100644 desktop/tray.js
create mode 100644 desktop/windows.js
create mode 100644 scripts/electron-dev.ts
create mode 100644 src/desktop-server.ts
create mode 100644 ui/src/components/DesktopSettingsPage.tsx
create mode 100644 ui/src/desktop.d.ts
create mode 100644 ui/src/lib/desktop.ts
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e025441..650a0dc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -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),大多数人不需要这个,因为它更慢。
diff --git a/desktop/app.js b/desktop/app.js
new file mode 100644
index 0000000..7ccea66
--- /dev/null
+++ b/desktop/app.js
@@ -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 = `
+
+
+
+
+
+ ${messages.appName}
+
+
+
+
+
+
${messages.appName}
+
${messages.trayServiceStatus}: ${messages.trayServiceRunning} ...
+
+
+
+
+ `;
+ 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();
+}
diff --git a/desktop/config.js b/desktop/config.js
new file mode 100644
index 0000000..89fab71
--- /dev/null
+++ b/desktop/config.js
@@ -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;
+}
diff --git a/desktop/i18n.js b/desktop/i18n.js
new file mode 100644
index 0000000..32e8e9f
--- /dev/null
+++ b/desktop/i18n.js
@@ -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;
+}
diff --git a/desktop/ipc.js b/desktop/ipc.js
new file mode 100644
index 0000000..8bd7100
--- /dev/null
+++ b/desktop/ipc.js
@@ -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));
+}
diff --git a/desktop/ports.js b/desktop/ports.js
new file mode 100644
index 0000000..8161e03
--- /dev/null
+++ b/desktop/ports.js
@@ -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'));
+ }
+ });
+ });
+}
diff --git a/desktop/preload.cjs b/desktop/preload.cjs
new file mode 100644
index 0000000..40d3967
--- /dev/null
+++ b/desktop/preload.cjs
@@ -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)
+});
diff --git a/desktop/server.js b/desktop/server.js
new file mode 100644
index 0000000..ffbcf48
--- /dev/null
+++ b/desktop/server.js
@@ -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;
+}
diff --git a/desktop/tray.js b/desktop/tray.js
new file mode 100644
index 0000000..d9fea4c
--- /dev/null
+++ b/desktop/tray.js
@@ -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);
+}
diff --git a/desktop/windows.js b/desktop/windows.js
new file mode 100644
index 0000000..db7d805
--- /dev/null
+++ b/desktop/windows.js
@@ -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);
+ }
+}
diff --git a/electron-main.js b/electron-main.js
index 5ea578c..886cf4c 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -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();
- }
-});
\ No newline at end of file
diff --git a/package.json b/package.json
index 9b93dfa..d40ce1d 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/scripts/build.ts b/scripts/build.ts
index 09ca14a..30d0b9d 100644
--- a/scripts/build.ts
+++ b/scripts/build.ts
@@ -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);
diff --git a/scripts/electron-dev.ts b/scripts/electron-dev.ts
new file mode 100644
index 0000000..73ff6e3
--- /dev/null
+++ b/scripts/electron-dev.ts
@@ -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((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 | 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((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))
diff --git a/src/config/index.ts b/src/config/index.ts
index b9e757f..203be7a 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -130,3 +130,7 @@ export function setConfig(config: Partial) {
const current = getConfig();
globalConfig = { ...current, ...config };
}
+
+export function resetConfig() {
+ globalConfig = null;
+}
diff --git a/src/desktop-server.ts b/src/desktop-server.ts
new file mode 100644
index 0000000..6119d1b
--- /dev/null
+++ b/src/desktop-server.ts
@@ -0,0 +1,2 @@
+export { startServer } from '@/server/index.js';
+export { resetConfig } from '@/config/index.js';
diff --git a/src/server/index.ts b/src/server/index.ts
index 23d55e2..5f83be8 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -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((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;
}
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index c1b35fb..53d9536 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -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([])
const [loadingLanguages, setLoadingLanguages] = useState(true)
@@ -242,4 +249,12 @@ function App() {
)
}
+function App() {
+ const route = useMemo(() => getRoute(window.location.pathname), [])
+ if (route === 'settings') {
+ return
+ }
+ return
+}
+
export default App
diff --git a/ui/src/components/DesktopSettingsPage.tsx b/ui/src/components/DesktopSettingsPage.tsx
new file mode 100644
index 0000000..5ed8f25
--- /dev/null
+++ b/ui/src/components/DesktopSettingsPage.tsx
@@ -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(null)
+ const [form, setForm] = useState(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 (
+
+
+
+ {t('desktopOnly')}
+ {t('desktopOnlyDesc')}
+
+
+
+
+
+
+ )
+ }
+
+ if (loading || !form || !config) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
{t('desktopSettings')}
+
{t('desktopSettingsDesc')}
+
+
+ {t('serverStatus')}: {serverStatusLabel}
+ {version && {t('version')}: v{version}}
+
+
+
+
+
+ {t('generalSettings')}
+
+
+
+
+
+ setForm({ ...form, server: { ...form.server, host: e.target.value } })} />
+
+
+
+ setForm({ ...form, server: { ...form.server, port: e.target.value } })} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('enableWebUI')}
+
{t('enableWebUIDesc')}
+
+
setForm({ ...form, server: { ...form.server, enableWebUI: checked } })} />
+
+
+
+
{t('enableOfflineMode')}
+
{t('enableOfflineModeDesc')}
+
+
setForm({ ...form, server: { ...form.server, enableOfflineMode: checked } })} />
+
+
+
+
{t('checkUpdate')}
+
{t('checkUpdateDesc')}
+
+
setForm({ ...form, server: { ...form.server, checkUpdate: checked } })} />
+
+
+
+
+
+
+
+ {t('storageSettings')}
+
+
+
+
+
+
+
+
+ {t('securitySettings')}
+
+
+
+
+ setForm({ ...form, server: { ...form.server, apiToken: e.target.value } })} />
+
+
+
+
+
+
+ {t('performanceSettings')}
+
+
+
+
+
+
+
+
+ {t('loggingSettings')}
+
+
+
+
+
+
{t('logToFile')}
+
{t('logToFileDesc')}
+
+
setForm({ ...form, server: { ...form.server, logToFile: checked } })} />
+
+
+
+
{t('logConsole')}
+
{t('logConsoleDesc')}
+
+
setForm({ ...form, server: { ...form.server, logConsole: checked } })} />
+
+
+
+
{t('logRequests')}
+
{t('logRequestsDesc')}
+
+
setForm({ ...form, server: { ...form.server, logRequests: checked } })} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/ui/src/desktop.d.ts b/ui/src/desktop.d.ts
new file mode 100644
index 0000000..39db15e
--- /dev/null
+++ b/ui/src/desktop.d.ts
@@ -0,0 +1,11 @@
+interface DesktopApi {
+ isDesktop: boolean
+ getConfig: () => Promise
+ applyConfig: (config: import('./lib/desktop').DesktopConfig) => Promise
+ resetConfig: () => Promise
+ restartServer: () => Promise
+}
+
+interface Window {
+ mtranDesktop?: DesktopApi
+}
diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts
index 4ac8134..3e075e1 100644
--- a/ui/src/i18n/index.ts
+++ b/ui/src/i18n/index.ts
@@ -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: '保存中...'
}
}
}
diff --git a/ui/src/lib/desktop.ts b/ui/src/lib/desktop.ts
new file mode 100644
index 0000000..42f3b8f
--- /dev/null
+++ b/ui/src/lib/desktop.ts
@@ -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()
+}