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 ( +
+
{t('loadingConfig')}
+
+ ) + } + + 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')} + + +
+
+ + setForm({ ...form, server: { ...form.server, modelDir: e.target.value } })} /> +
+
+ + setForm({ ...form, server: { ...form.server, configDir: e.target.value } })} /> +
+
+ + setForm({ ...form, server: { ...form.server, logDir: e.target.value } })} /> +
+
+
+
+ + + + {t('securitySettings')} + + +
+ + setForm({ ...form, server: { ...form.server, apiToken: e.target.value } })} /> +
+
+
+ + + + {t('performanceSettings')} + + +
+
+ + setForm({ ...form, server: { ...form.server, workerIdleTimeout: e.target.value } })} /> +
+
+ + setForm({ ...form, server: { ...form.server, workersPerLanguage: e.target.value } })} /> +
+
+ + setForm({ ...form, server: { ...form.server, maxLengthBreak: e.target.value } })} /> +
+
+ + setForm({ ...form, server: { ...form.server, cacheSize: e.target.value } })} /> +
+
+
+
+ + + + {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() +}