feat(desktop): simplify

This commit is contained in:
xxnuo
2026-01-10 00:26:59 +08:00
parent c8acf05e02
commit 104a73b482
6 changed files with 281 additions and 155 deletions

View File

@@ -1,4 +1,4 @@
import { app, dialog, nativeImage, shell, Menu } from 'electron';
import { app, dialog, nativeImage, shell, Menu, screen } from 'electron';
import fs from 'fs/promises';
import fsSync from 'fs';
import path from 'path';
@@ -7,13 +7,6 @@ import { loadDesktopConfig, saveDesktopConfig, resetDesktopConfig, getDefaultDes
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';
@@ -83,11 +76,10 @@ function updateTray() {
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,
onOpenSettings: () => shell.openExternal(getSettingsUrl(desktopConfig.server)),
onRestart: restartServer,
onOpenModels: () => shell.openPath(desktopConfig.server.modelDir),
onOpenConfig: () => shell.openPath(desktopConfig.server.configDir),
@@ -167,10 +159,6 @@ async function restartServer() {
await ensureWritableDirs();
const ok = await startServer();
if (!ok) return false;
updateWindowUrls({
mainUrl: getUiUrl(desktopConfig.server),
settingsUrl: getSettingsUrl(desktopConfig.server)
});
updateTray();
return true;
} catch {
@@ -232,40 +220,16 @@ async function ensureWritableDirs() {
}
}
function getLoadingUrl() {
const html = `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${messages.appName}</title>
<style>
html,body{margin:0;height:100%;font-family:system-ui,Segoe UI,Arial,sans-serif;background:#f5f5f5;color:#1f2937}
.wrap{height:100%;display:flex;align-items:center;justify-content:center}
.card{padding:24px 32px;border:1px solid #e5e7eb;border-radius:16px;background:#fff;box-shadow:0 12px 30px rgba(0,0,0,0.08);text-align:center;min-width:260px}
.title{font-size:18px;font-weight:600;margin-bottom:8px}
.desc{font-size:14px;color:#6b7280}
.dot{display:inline-block;animation:blink 1.2s infinite}
@keyframes blink{0%,100%{opacity:.2}50%{opacity:1}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="title">${messages.appName}</div>
<div class="desc">${messages.trayServiceStatus}: ${messages.trayServiceRunning} <span class="dot">...</span></div>
</div>
</div>
</body>
</html>
`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
function buildDesktopResponse() {
return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
}
export async function startDesktop() {
app.isQuitting = false;
Menu.setApplicationMenu(null);
if (app.dock?.hide) {
app.dock.hide();
}
desktopConfig = await loadDesktopConfig();
updateLocale(desktopConfig.locale);
desktopLogPath = path.join(desktopConfig.server.configDir, 'desktop.log');
@@ -273,12 +237,6 @@ export async function startDesktop() {
await logDesktop('desktop starting');
await ensureWritableDirs();
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 trayIcon = getTrayIcon();
tray = createTray({
icon: trayIcon,
@@ -286,11 +244,10 @@ export async function startDesktop() {
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,
onOpenSettings: () => shell.openExternal(getSettingsUrl(desktopConfig.server)),
onRestart: restartServer,
onOpenModels: () => shell.openPath(desktopConfig.server.modelDir),
onOpenConfig: () => shell.openPath(desktopConfig.server.configDir),
@@ -303,52 +260,52 @@ export async function startDesktop() {
return;
}
updateWindowUrls({
mainUrl: getUiUrl(desktopConfig.server),
settingsUrl: getSettingsUrl(desktopConfig.server)
});
const getConfigResponse = async () => buildDesktopResponse();
const applyConfig = async (config) => {
desktopConfig = await saveDesktopConfig(config);
updateLocale(desktopConfig.locale);
const ok = await restartServer();
if (!ok) return buildDesktopResponse();
return buildDesktopResponse();
};
const resetConfig = async () => {
desktopConfig = await resetDesktopConfig();
updateLocale(desktopConfig.locale);
const ok = await restartServer();
if (!ok) return buildDesktopResponse();
return buildDesktopResponse();
};
const restartAndRespond = async () => {
const ok = await restartServer();
if (!ok) return buildDesktopResponse();
return buildDesktopResponse();
};
globalThis.mtranDesktopControl = {
getConfig: getConfigResponse,
applyConfig,
resetConfig,
restartServer: restartAndRespond
};
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() };
},
getConfig: getConfigResponse,
applyConfig,
resetConfig,
restartServer: restartAndRespond,
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();
}

View File

@@ -8,7 +8,6 @@ export function createTray({
messages,
statusLabel,
versionLabel,
onOpenMain,
onOpenBrowserUi,
onOpenBrowserDocs,
onOpenRepo,
@@ -21,12 +20,11 @@ export function createTray({
if (tray) return tray;
tray = new Tray(icon);
tray.setToolTip(tooltip);
tray.on('click', () => onOpenMain());
tray.on('click', () => tray.popUpContextMenu());
updateTrayMenu({
messages,
statusLabel,
versionLabel,
onOpenMain,
onOpenBrowserUi,
onOpenBrowserDocs,
onOpenRepo,
@@ -43,7 +41,6 @@ export function updateTrayMenu({
messages,
statusLabel,
versionLabel,
onOpenMain,
onOpenBrowserUi,
onOpenBrowserDocs,
onOpenRepo,
@@ -56,16 +53,16 @@ export function updateTrayMenu({
if (!tray) return;
const contextMenu = Menu.buildFromTemplate([
{
label: messages.trayOpenMain,
click: onOpenMain
label: messages.trayOpenUi,
click: onOpenBrowserUi
},
{
label: messages.trayOpenBrowser,
submenu: [
{ label: messages.trayOpenUi, click: onOpenBrowserUi },
{ label: messages.trayOpenDocs, click: onOpenBrowserDocs },
{ label: messages.trayOpenRepo, click: onOpenRepo }
]
label: messages.trayOpenDocs,
click: onOpenBrowserDocs
},
{
label: messages.trayOpenRepo,
click: onOpenRepo
},
{ type: 'separator' },
{

View File

@@ -24,6 +24,27 @@ export interface Config {
}
let globalConfig: Config | null = null;
let fileConfigCache: Partial<Config> | null = null;
function getConfigFilePath(homeDir: string) {
return path.join(homeDir, 'server.json');
}
function readConfigFile(homeDir: string): Partial<Config> {
if (fileConfigCache) return fileConfigCache;
const configPath = getConfigFilePath(homeDir);
try {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
fileConfigCache = parsed as Partial<Config>;
return fileConfigCache;
}
} catch {
return {};
}
return {};
}
function getArgValue(flag: string): string | null {
const index = process.argv.indexOf(flag);
@@ -88,12 +109,13 @@ export function getConfig(): Config {
}
const homeDir = path.join(os.homedir(), '.config', 'mtran');
const fileConfig = readConfigFile(homeDir);
const configDir = getString('--config-dir', 'MT_CONFIG_DIR', path.join(homeDir, 'server'));
const configDir = getString('--config-dir', 'MT_CONFIG_DIR', fileConfig.configDir || path.join(homeDir, 'server'));
const localModelsDir = path.join(process.cwd(), 'models');
const defaultModelDir = fs.existsSync(localModelsDir) ? localModelsDir : path.join(homeDir, 'models');
const modelDir = getString('--model-dir', 'MT_MODEL_DIR', defaultModelDir);
const logDir = getString('--log-dir', 'MT_LOG_DIR', path.join(homeDir, 'logs'));
const modelDir = getString('--model-dir', 'MT_MODEL_DIR', fileConfig.modelDir || defaultModelDir);
const logDir = getString('--log-dir', 'MT_LOG_DIR', fileConfig.logDir || path.join(homeDir, 'logs'));
globalConfig = {
homeDir,
@@ -101,26 +123,26 @@ export function getConfig(): Config {
modelDir,
logDir,
logLevel: getString('--log-level', 'MT_LOG_LEVEL', 'warn'),
host: getString('--host', 'MT_HOST', '0.0.0.0'),
port: getString('--port', 'MT_PORT', '8989'),
logLevel: getString('--log-level', 'MT_LOG_LEVEL', fileConfig.logLevel || 'warn'),
host: getString('--host', 'MT_HOST', fileConfig.host || '0.0.0.0'),
port: getString('--port', 'MT_PORT', fileConfig.port || '8989'),
enableWebUI: getBool('--ui', 'MT_ENABLE_UI', true),
enableOfflineMode: getBool('--offline', 'MT_OFFLINE', false),
enableWebUI: getBool('--ui', 'MT_ENABLE_UI', fileConfig.enableWebUI ?? true),
enableOfflineMode: getBool('--offline', 'MT_OFFLINE', fileConfig.enableOfflineMode ?? false),
workerIdleTimeout: getInt('--worker-idle-timeout', 'MT_WORKER_IDLE_TIMEOUT', 60),
workersPerLanguage: getInt('--workers-per-language', 'MT_WORKERS_PER_LANGUAGE', 1),
maxLengthBreak: getInt('--max-length-break', 'MT_MAX_LENGTH_BREAK', 128),
workerIdleTimeout: getInt('--worker-idle-timeout', 'MT_WORKER_IDLE_TIMEOUT', fileConfig.workerIdleTimeout ?? 60),
workersPerLanguage: getInt('--workers-per-language', 'MT_WORKERS_PER_LANGUAGE', fileConfig.workersPerLanguage ?? 1),
maxLengthBreak: getInt('--max-length-break', 'MT_MAX_LENGTH_BREAK', fileConfig.maxLengthBreak ?? 128),
apiToken: getString('--api-token', 'MT_API_TOKEN', ''),
apiToken: getString('--api-token', 'MT_API_TOKEN', fileConfig.apiToken || ''),
logToFile: getBool('--log-to-file', 'MT_LOG_TO_FILE', false),
logConsole: getBool('--log-console', 'MT_LOG_CONSOLE', true),
logRequests: getBool('--log-requests', 'MT_LOG_REQUESTS', false),
logToFile: getBool('--log-to-file', 'MT_LOG_TO_FILE', fileConfig.logToFile ?? false),
logConsole: getBool('--log-console', 'MT_LOG_CONSOLE', fileConfig.logConsole ?? true),
logRequests: getBool('--log-requests', 'MT_LOG_REQUESTS', fileConfig.logRequests ?? false),
checkUpdate: getBool('--check-update', 'MT_CHECK_UPDATE', true),
checkUpdate: getBool('--check-update', 'MT_CHECK_UPDATE', fileConfig.checkUpdate ?? true),
cacheSize: getInt('--cache-size', 'MT_CACHE_SIZE', 1000),
cacheSize: getInt('--cache-size', 'MT_CACHE_SIZE', fileConfig.cacheSize ?? 1000),
};
return globalConfig;
@@ -133,4 +155,26 @@ export function setConfig(config: Partial<Config>) {
export function resetConfig() {
globalConfig = null;
fileConfigCache = null;
}
export function saveConfigFile(config: Partial<Config>) {
const current = getConfig();
const next = { ...current, ...config };
const configPath = getConfigFilePath(current.homeDir);
fs.mkdirSync(current.homeDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(next, null, 2), 'utf8');
fileConfigCache = next;
}
export function clearConfigFile() {
const homeDir = path.join(os.homedir(), '.config', 'mtran');
const configPath = getConfigFilePath(homeDir);
try {
if (fs.existsSync(configPath)) {
fs.unlinkSync(configPath);
}
} catch {
return;
}
}

View File

@@ -1,7 +1,7 @@
import express, { Request, Response, NextFunction } from 'express';
import fs from 'fs/promises';
import swaggerUi from 'swagger-ui-express';
import { getConfig } from '@/config/index.js';
import { getConfig, setConfig, resetConfig, saveConfigFile, clearConfigFile } from '@/config/index.js';
import * as logger from '@/logger/index.js';
import { initRecords } from '@/models/index.js';
import { cleanupAllEngines } from '@/services/index.js';
@@ -36,6 +36,119 @@ export async function startServer({ handleSignals = true } = {}) {
app.use(requestLogger());
}
const getDesktopControl = () => (globalThis as any).mtranDesktopControl;
const getSettingsPayload = () => {
const current = getConfig();
return {
config: {
locale: 'system',
server: {
host: current.host,
port: Number(current.port),
logLevel: current.logLevel,
enableWebUI: current.enableWebUI,
enableOfflineMode: current.enableOfflineMode,
workerIdleTimeout: current.workerIdleTimeout,
workersPerLanguage: current.workersPerLanguage,
apiToken: current.apiToken,
logDir: current.logDir,
logToFile: current.logToFile,
logConsole: current.logConsole,
logRequests: current.logRequests,
maxLengthBreak: current.maxLengthBreak,
checkUpdate: current.checkUpdate,
cacheSize: current.cacheSize,
modelDir: current.modelDir,
configDir: current.configDir
}
},
status: 'running',
version: VERSION
};
};
const toNumber = (value: unknown, fallback: number) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const toBool = (value: unknown, fallback: boolean) => {
if (typeof value === 'boolean') return value;
return fallback;
};
const toString = (value: unknown, fallback: string) => {
if (typeof value === 'string') return value;
return fallback;
};
const applyServerConfig = (input: any) => {
const current = getConfig();
const next = {
host: toString(input.host, current.host),
port: String(toNumber(input.port, Number(current.port))),
logLevel: toString(input.logLevel, current.logLevel),
enableWebUI: toBool(input.enableWebUI, current.enableWebUI),
enableOfflineMode: toBool(input.enableOfflineMode, current.enableOfflineMode),
workerIdleTimeout: toNumber(input.workerIdleTimeout, current.workerIdleTimeout),
workersPerLanguage: toNumber(input.workersPerLanguage, current.workersPerLanguage),
apiToken: toString(input.apiToken, current.apiToken),
logDir: toString(input.logDir, current.logDir),
logToFile: toBool(input.logToFile, current.logToFile),
logConsole: toBool(input.logConsole, current.logConsole),
logRequests: toBool(input.logRequests, current.logRequests),
maxLengthBreak: toNumber(input.maxLengthBreak, current.maxLengthBreak),
checkUpdate: toBool(input.checkUpdate, current.checkUpdate),
cacheSize: toNumber(input.cacheSize, current.cacheSize),
modelDir: toString(input.modelDir, current.modelDir),
configDir: toString(input.configDir, current.configDir)
};
setConfig(next);
saveConfigFile(next);
logger.setLogLevel(next.logLevel as any);
};
app.get('/ui/api/settings', async (_, res) => {
const control = getDesktopControl();
if (control?.getConfig) {
const payload = await control.getConfig();
res.json(payload);
return;
}
res.json(getSettingsPayload());
});
app.post('/ui/api/settings/apply', async (req, res) => {
const control = getDesktopControl();
if (control?.applyConfig) {
const payload = await control.applyConfig(req.body?.config || req.body);
res.json(payload);
return;
}
const input = req.body?.config?.server || req.body?.server || {};
applyServerConfig(input);
res.json(getSettingsPayload());
});
app.post('/ui/api/settings/reset', async (_, res) => {
const control = getDesktopControl();
if (control?.resetConfig) {
const payload = await control.resetConfig();
res.json(payload);
return;
}
clearConfigFile();
resetConfig();
res.json(getSettingsPayload());
});
app.post('/ui/api/settings/restart', async (_, res) => {
const control = getDesktopControl();
if (control?.restartServer) {
const payload = await control.restartServer();
res.json(payload);
return;
}
res.json(getSettingsPayload());
});
RegisterRoutes(app);
app.use('/ui', (req: Request, res: Response, next: NextFunction) => {

View File

@@ -6,14 +6,17 @@ 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
restartDesktopServer,
applyWebConfig,
getWebConfig,
resetWebConfig,
restartWebServer
} from '@/lib/desktop'
import type { DesktopConfig } from '@/lib/desktop'
@@ -73,6 +76,10 @@ function toNumber(value: string, fallback: number) {
export function DesktopSettingsPage() {
const { t } = useTranslation()
const desktopAvailable = isDesktop()
const getConfigData = desktopAvailable ? getDesktopConfig : getWebConfig
const applyConfig = desktopAvailable ? applyDesktopConfig : applyWebConfig
const resetConfig = desktopAvailable ? resetDesktopConfig : resetWebConfig
const restartServer = desktopAvailable ? restartDesktopServer : restartWebServer
const [config, setConfig] = useState<DesktopConfig | null>(null)
const [form, setForm] = useState<FormState | null>(null)
const [status, setStatus] = useState('')
@@ -81,11 +88,7 @@ export function DesktopSettingsPage() {
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!desktopAvailable) {
setLoading(false)
return
}
getDesktopConfig()
getConfigData()
.then((response) => {
if (!response) return
setConfig(response.config)
@@ -97,7 +100,7 @@ export function DesktopSettingsPage() {
toast.error(t('loadingConfigFailed'))
})
.finally(() => setLoading(false))
}, [desktopAvailable, t])
}, [getConfigData, t])
const serverStatusLabel = useMemo(() => {
if (!status) return ''
@@ -131,7 +134,7 @@ export function DesktopSettingsPage() {
}
}
try {
const response = await applyDesktopConfig(nextConfig)
const response = await applyConfig(nextConfig)
if (response) {
setConfig(response.config)
setForm(toForm(response.config))
@@ -147,10 +150,9 @@ export function DesktopSettingsPage() {
}
const handleRestart = async () => {
if (!desktopAvailable) return
setSaving(true)
try {
const response = await restartDesktopServer()
const response = await restartServer()
if (response) {
setConfig(response.config)
setForm(toForm(response.config))
@@ -166,10 +168,9 @@ export function DesktopSettingsPage() {
}
const handleReset = async () => {
if (!desktopAvailable) return
setSaving(true)
try {
const response = await resetDesktopConfig()
const response = await resetConfig()
if (response) {
setConfig(response.config)
setForm(toForm(response.config))
@@ -184,22 +185,6 @@ export function DesktopSettingsPage() {
}
}
if (!desktopAvailable) {
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-3xl">
<Alert>
<AlertTitle>{t('desktopOnly')}</AlertTitle>
<AlertDescription>{t('desktopOnlyDesc')}</AlertDescription>
</Alert>
<div className="mt-6">
<Button onClick={() => (window.location.href = '/ui/')}>{t('backToMain')}</Button>
</div>
</div>
</div>
)
}
if (loading || !form || !config) {
return (
<div className="min-h-screen bg-background p-6">
@@ -250,20 +235,22 @@ export function DesktopSettingsPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('locale')}</label>
<Select value={form.locale} onValueChange={(value) => setForm({ ...form, locale: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('system')}</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
<SelectItem value="ja"></SelectItem>
</SelectContent>
</Select>
</div>
{desktopAvailable && (
<div className="space-y-2">
<label className="text-sm font-medium">{t('locale')}</label>
<Select value={form.locale} onValueChange={(value) => setForm({ ...form, locale: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('system')}</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
<SelectItem value="ja"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Separator />
<div className="grid gap-3 sm:grid-cols-2">

View File

@@ -50,3 +50,31 @@ export async function restartDesktopServer() {
if (!window.mtranDesktop) return null
return window.mtranDesktop.restartServer()
}
async function fetchSettings(path: string, body?: unknown) {
const res = await fetch(`/ui/api/settings${path}`, {
method: body ? 'POST' : 'GET',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
})
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`)
}
return res.json()
}
export async function getWebConfig() {
return fetchSettings('')
}
export async function applyWebConfig(config: DesktopConfig) {
return fetchSettings('/apply', { config })
}
export async function resetWebConfig() {
return fetchSettings('/reset', {})
}
export async function restartWebServer() {
return fetchSettings('/restart', {})
}