feat: multi-language interface (zh-CN/zh-TW/en/ja), multilingual offline changelog, settings robustness

i18n
- shared/i18n: dependency-free t() with flat per-namespace dictionaries
  (common/settings/workspace/terminal/ssh/panels/main), zh-CN fallback
- language picker in Settings -> System; antd ConfigProvider locale follows it
- main process tracks the language too: tray menu, close prompt, ssh/sftp/
  zmodem errors and the log TUI marker are translated; tray rebuilds on change

changelog
- CHANGELOG.md (zh-CN canonical) + .zh-TW/.en/.ja, bundled via ?raw and read
  per interface language with per-version fallback to zh-CN (no network)
- sync-changelog.cjs merges RELEASE_NOTES[.<lang>].md per release; release.cjs
  refuses to publish without a zh-CN entry for the version

settings robustness
- closeAction 'ask' survives the sanitizer (was silently coerced to 'tray',
  which made the 'ask every time' option dead)
- highlight rules are repaired instead of dropped: string priority, 0/1
  enabled, missing fg colour; unknown fields preserved
- load-time warnings are written to settings-warnings.log (deduped, capped)

terminal/UI
- configurable terminal toolbar: open working directory (default on), session
  log recording (off), open logs folder (off)
- global shortcut field records key combos (modifier or F-key required)
- input suggestions + command history default to off, with a one-time reset
  migration for existing installs
- settings dialog scrolling fixed (antd v6 renamed the tabs container), theme
  gallery nested scrollbar removed, joined segmented pickers with readable
  selected-state text

tests: settings-store.mjs (15 checks) added; commands-store.mjs updated for
the new off-by-default history setting
This commit is contained in:
Bill committed 2026-09-20 14:22:29 +08:00
1 parent 34094d607b
commit 4c6a29ef28
82 files changed
+3342 -608

No files matched your search

+5 -1
View File
@@ -4,9 +4,10 @@ import { join } from 'path'
import { registerIpc } from './ipc'
import { killAllPtys } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
import { initTray, markQuitting, onMainWindowClose } from './tray'
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
import { configureAutoUpdater, registerUpdateIpc } from './updater'
import { applyWindowChrome } from './windowChrome'
import { onLanguageChange } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
/** Re-create the main window (tray restore path after all windows are gone). */
@@ -122,6 +123,9 @@ app.whenReady().then(() => {
applyStartupSystemSettings(loadSettings())
createWindow()
initTray(showOrCreate)
// Tray labels are resolved from the dictionary at build time, so the menu has
// to be rebuilt whenever the interface language changes.
onLanguageChange(() => refreshTrayMenu())
configureAutoUpdater()
app.on('activate', () => {
+2 -1
View File
@@ -3,6 +3,7 @@ import fontList from 'font-list'
import { homedir } from 'os'
import { statSync } from 'fs'
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
import { startPolling, stopPolling } from './sysinfo'
@@ -64,7 +65,7 @@ export function registerIpc(): void {
broadcast: (channel, ...args) => broadcast(channel, ...args),
getConnection: (connectionId) => {
const found = connectionsStore.listConnections().find((c) => c.id === connectionId)
if (!found) throw new Error(`连接书签不存在 (${connectionId})`)
if (!found) throw new Error(t('main.ipc.connectionMissing', { id: connectionId }))
return found
},
getSecret: (conn, field) => connectionsStore.getSecret(conn, field),
+5 -3
View File
@@ -17,7 +17,9 @@
* emulation; their committed lines are preserved as-is.
*/
const MARKER = '\n──── [全屏界面(TUI)输出已省略] ────\n'
import { t } from '../shared/i18n'
const MARKER = (): string => `\n──── [${t('main.log.tuiOmitted')}] ────\n`
type Mode = 'text' | 'esc' | 'esc-skip1' | 'csi' | 'osc' | 'osc-esc'
@@ -88,7 +90,7 @@ export class LogSanitizer {
this.alt = false
if (this.altDirty) {
this.altDirty = false
out += MARKER
out += MARKER()
}
}
this.seq = ''
@@ -128,7 +130,7 @@ export class LogSanitizer {
}
if (this.altDirty) {
this.altDirty = false
out += MARKER
out += MARKER()
}
return out
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n'
import { spawn, type IPty } from '@lydell/node-pty'
import { randomUUID } from 'crypto'
import { homedir } from 'os'
@@ -224,10 +225,10 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
const deps = runtimeDeps
if (!deps) {
throw new Error('SSH 会话服务尚未初始化')
throw new Error(t('main.pty.serviceNotReady'))
}
if (!opts.connectionId) {
throw new Error('SSH 会话缺少 connectionId')
throw new Error(t('main.pty.missingConnectionId'))
}
const conn = deps.getConnection(opts.connectionId)
+109 -19
View File
@@ -11,6 +11,7 @@ import {
type TerminalSettings
} from '../shared/settings'
import type { TerminalTheme } from '../shared/theme'
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
@@ -24,32 +25,87 @@ const DEFAULT_SYSTEM: SystemSettings = {
// Must match DEFAULT_SETTINGS.system in @shared/settings — an "ask" here made
// a fresh install prompt on close while the docs and UI promised tray.
closeAction: 'tray',
autoCheckUpdate: true
autoCheckUpdate: true,
language: DEFAULT_LANGUAGE
}
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
/** Light structural check for a persisted highlight rule. */
function isHighlightRule(value: unknown): value is HighlightRule {
if (value === null || typeof value !== 'object') return false
/** Load-time warnings (bad terminal keys, repaired/dropped rules). Surfaced via
* reportWarnings() so a hand-edited or migrated settings.json leaves a trace
* instead of silently losing data. */
type Warnings = string[]
/** Fallback colour for a rule whose own colour is unusable. */
const DEFAULT_RULE_COLOR = '#e3b341'
/** Repair one persisted rule instead of dropping it. Shape problems with an
* obvious fix (priority as "5", enabled as 1/0, a missing fg colour) are
* corrected; only entries that cannot be a rule at all are skipped. Unknown
* extra fields are preserved so newer/older schemas survive a round trip. */
function coerceRule(value: unknown, index: number, warnings: Warnings): HighlightRule | null {
if (value === null || typeof value !== 'object') {
warnings.push(`highlightRules[${index}]: not an object — skipped`)
return null
}
const rule = value as Record<string, unknown>
return (
typeof rule.id === 'string' &&
typeof rule.pattern === 'string' &&
typeof rule.enabled === 'boolean' &&
typeof rule.priority === 'number' &&
rule.color !== null &&
typeof rule.color === 'object' &&
typeof (rule.color as { fg?: unknown }).fg === 'string'
)
if (typeof rule.id !== 'string' || typeof rule.pattern !== 'string') {
warnings.push(`highlightRules[${index}]: missing id/pattern — skipped`)
return null
}
let enabled = true
if (typeof rule.enabled === 'boolean') enabled = rule.enabled
else if (rule.enabled === 1 || rule.enabled === 0) {
enabled = rule.enabled === 1
warnings.push(`highlightRules[${index}].enabled repaired: ${String(rule.enabled)} → ${enabled}`)
} else if (rule.enabled !== undefined) {
warnings.push(`highlightRules[${index}].enabled repaired → true`)
}
const rawPriority = typeof rule.priority === 'number' ? rule.priority : Number(rule.priority)
let priority = 10
if (Number.isFinite(rawPriority)) {
priority = Math.min(100, Math.max(1, Math.round(rawPriority)))
if (priority !== rule.priority) {
warnings.push(`highlightRules[${index}].priority repaired: ${String(rule.priority)} → ${priority}`)
}
} else {
warnings.push(`highlightRules[${index}].priority repaired → ${priority}`)
}
const color: { fg: string; bg?: string } = { fg: DEFAULT_RULE_COLOR }
if (rule.color !== null && typeof rule.color === 'object') {
const candidate = rule.color as Record<string, unknown>
if (typeof candidate.fg === 'string' && candidate.fg !== '') color.fg = candidate.fg
else warnings.push(`highlightRules[${index}].color.fg repaired → ${DEFAULT_RULE_COLOR}`)
if (typeof candidate.bg === 'string' && candidate.bg !== '') color.bg = candidate.bg
} else {
warnings.push(`highlightRules[${index}].color repaired → ${DEFAULT_RULE_COLOR}`)
}
return {
...rule,
id: rule.id,
pattern: rule.pattern,
enabled,
priority,
color,
note: typeof rule.note === 'string' ? rule.note : undefined
} as HighlightRule
}
function sanitizeRules(value: unknown): HighlightRule[] {
function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
// An explicit empty array is a valid choice ("no highlighting"); only
// malformed data falls back to the built-in rules. Returning the defaults for
// [] made deleting the last rule look like it silently failed.
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES
return value.filter(isHighlightRule)
const rules: HighlightRule[] = []
value.forEach((entry, index) => {
const rule = coerceRule(entry, index, warnings)
if (rule) rules.push(rule)
})
return rules
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
@@ -89,7 +145,9 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const globalShowHide =
typeof candidate.globalShowHide === 'string' ? candidate.globalShowHide : ''
const closeAction =
candidate.closeAction === 'tray' || candidate.closeAction === 'exit'
candidate.closeAction === 'tray' ||
candidate.closeAction === 'exit' ||
candidate.closeAction === 'ask'
? candidate.closeAction
: 'tray'
system = {
@@ -100,7 +158,8 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
autoCheckUpdate: candidate.autoCheckUpdate !== false,
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
restoreSession: candidate.restoreSession !== false,
shellIntegration: candidate.shellIntegration === true
shellIntegration: candidate.shellIntegration === true,
language: isLanguage(candidate.language) ? candidate.language : DEFAULT_LANGUAGE
}
}
}
@@ -111,7 +170,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
settings: {
terminal,
customThemes: themes,
highlightRules: sanitizeRules(highlightRules),
highlightRules: sanitizeRules(highlightRules, errors),
system: system as SystemSettings
},
errors
@@ -124,6 +183,9 @@ let sleepBlockerId: number | undefined
/** Apply OS-level effects of the system settings (login item, sleep blocker). */
function applySystemSettings(system: SystemSettings): void {
// The main process renders its own strings (tray menu, dialogs), so it tracks
// the language itself; subscribers rebuild whatever they own.
setLanguage(system.language ?? DEFAULT_LANGUAGE)
try {
app.setLoginItemSettings({ openAtLogin: system.launchAtLogin })
} catch (err) {
@@ -144,10 +206,38 @@ export function applyStartupSystemSettings(settings: AppSettings): void {
applySystemSettings(settings.system)
}
/**
* Persist load-time warnings to `<userData>/settings-warnings.log`.
*
* Repeated identical warnings are collapsed (loadSettings runs on every save and
* on every command-history write) and the file is trimmed when it grows, so a
* broken config leaves a trace without filling the disk.
*/
let lastWarnings = ''
function reportWarnings(warnings: string[]): void {
const line = warnings.join('; ')
if (!line) {
lastWarnings = ''
return
}
if (line === lastWarnings) return
lastWarnings = line
try {
const file = join(app.getPath('userData'), 'settings-warnings.log')
const existing = existsSync(file) ? readFileSync(file, 'utf8') : ''
const kept = existing.length > 64 * 1024 ? existing.slice(-32 * 1024) : existing
writeFileSync(file, `${kept}[${new Date().toISOString()}] ${line}\n`, 'utf8')
} catch {
// best effort: warnings must never break settings loading
}
}
export function loadSettings(): AppSettings {
try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
const { settings } = deepMerge(raw)
const { settings, errors } = deepMerge(raw)
reportWarnings(errors)
return settings
} catch {
return {
+20 -9
View File
@@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'
import { createWriteStream, promises as fsp } from 'fs'
import { basename, join } from 'path'
import { Ipc } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { SftpEntry, TransferProgressEvent } from '../shared/sftp'
/** ssh2 Client lookup injected by pty.ts (kind === 'ssh' sessions only). */
@@ -51,13 +52,20 @@ export function formatMode(mode: number): string {
*/
const sftpCache = new Map<string, SFTPWrapper>()
/**
* Our own "session is gone" error. `isTransportError` classifies on the class,
* not on the message text: the message is translated, the classifier must not
* depend on the active locale.
*/
class SessionGoneError extends Error {}
async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
const cached = sftpCache.get(sessionId)
if (cached) return cached
const client = clientProvider?.(sessionId)
if (!client) {
console.error(`[sftp-debug] provider=${typeof clientProvider} sessionId=${sessionId} cacheSize=${sftpCache.size}`)
throw new Error('SSH 会话不存在或已断开')
throw new SessionGoneError(t('main.sftp.sessionGone'))
}
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
@@ -77,8 +85,9 @@ function evictSftp(sessionId: string): void {
* dropping the whole connection. Only transport-level death retries.
*/
function isTransportError(err: unknown): boolean {
if (err instanceof SessionGoneError) return true
const msg = (err as Error | undefined)?.message ?? ''
return /not connected|会话不存在|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg)
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg)
}
/** Run `fn` with the session's cached sftp; a dead cached channel is evicted and retried once. */
@@ -185,7 +194,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
}
}
if (paths.length > 0 && failures.length === paths.length) {
throw new Error(`删除失败: ${failures.join('; ')}`)
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
}
})
}
@@ -205,19 +214,21 @@ function shQuote(value: string): string {
}
export function chmodRemote(sessionId: string, path: string, mode: string): Promise<void> {
if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(`非法权限值: ${mode}`)
if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(t('main.sftp.invalidMode', { mode }))
return execQuiet(sessionId, `chmod ${mode} ${shQuote(path)}`)
}
export function chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise<void> {
if (!Number.isInteger(uid) || !Number.isInteger(gid)) throw new Error('非法 uid/gid')
if (!Number.isInteger(uid) || !Number.isInteger(gid)) {
throw new Error(t('main.sftp.invalidUidGid'))
}
return execQuiet(sessionId, `chown ${uid}:${gid} ${shQuote(path)}`)
}
/** Run a command on the session's shell channel and wait for it to finish. */
function execQuiet(sessionId: string, cmd: string): Promise<void> {
const client = clientProvider?.(sessionId)
if (!client) throw new Error('SSH 会话不存在或已断开')
if (!client) throw new SessionGoneError(t('main.sftp.sessionGone'))
return new Promise((resolve, reject) => {
client.exec(cmd, (err, stream) => {
if (err) {
@@ -230,7 +241,7 @@ function execQuiet(sessionId: string, cmd: string): Promise<void> {
stderr += d.toString('utf8')
})
stream.on('close', (code: number) => {
if (code) reject(new Error(stderr || `命令退出码 ${code}`))
if (code) reject(new Error(stderr || t('main.sftp.commandExitCode', { code })))
else resolve()
})
})
@@ -290,7 +301,7 @@ export function uploadRemote(
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error('已取消')
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (firstError) throw firstError
while (inflight.size >= UPLOAD_WINDOW) {
await Promise.race(inflight)
@@ -378,7 +389,7 @@ export function downloadRemote(
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error('已取消')
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb =>
sftp.read(handle, buf, 0, CHUNK, pos, cb)
)
+16 -7
View File
@@ -14,6 +14,7 @@
*/
import type { SshConnection, SshSecretOverride } from '../shared/connections'
import { t } from '../shared/i18n'
import { Ipc } from '../shared/ipc'
import { randomUUID } from 'crypto'
import { readFileSync } from 'fs'
@@ -85,7 +86,7 @@ export async function connectSsh(
const armConnectTimer = (): void => {
if (connectTimer) clearTimeout(connectTimer)
connectTimer = setTimeout(() => {
fail(new Error(`连接超时 (${conn.host}:${conn.port})`))
fail(new Error(t('main.ssh.connectTimeout', { host: conn.host, port: conn.port })))
}, timeouts.connect)
}
@@ -129,13 +130,13 @@ export async function connectSsh(
try {
deps.knownHosts.accept(conn.host, conn.port, hostKey, fingerprint)
} catch (err) {
verifierErr = `保存主机指纹失败: ${(err as Error).message}`
verifierErr = t('main.ssh.saveFingerprintFailed', { detail: (err as Error).message })
verify(false)
return
}
verify(true)
} else {
verifierErr = '用户拒绝了主机指纹'
verifierErr = t('main.ssh.hostKeyRejected')
verify(false)
}
})
@@ -156,7 +157,7 @@ export async function connectSsh(
console.error(`[ssh] client error: ${err.message}`)
const message = verifierErr ?? err.message
if (!settled) {
fail(new Error(`连接失败 ${conn.host}:${conn.port}: ${message}`))
fail(new Error(t('main.ssh.connectFailed', { host: conn.host, port: conn.port, detail: message })))
return
}
// Session already established: surface as a session exit and clean up.
@@ -177,7 +178,11 @@ export async function connectSsh(
{ term: 'xterm-256color', cols: 80, rows: 24 },
(err: Error | undefined, shell: ClientChannel) => {
if (err) {
fail(new Error(`无法打开 SSH shell (${conn.host}:${conn.port}): ${err.message}`))
fail(
new Error(
t('main.ssh.shellOpenFailed', { host: conn.host, port: conn.port, detail: err.message })
)
)
return
}
if (settled) {
@@ -240,7 +245,11 @@ export async function connectSsh(
handshake.connect(cfg)
} catch (err) {
// e.g. unparseable privateKey is thrown synchronously by ssh2
fail(new Error(`SSH 连接初始化失败 (${conn.host}:${conn.port}): ${(err as Error).message}`))
fail(
new Error(
t('main.ssh.initFailed', { host: conn.host, port: conn.port, detail: (err as Error).message })
)
)
}
})
}
@@ -305,6 +314,6 @@ function readKeyFile(keyPath: string): string {
try {
return readFileSync(keyPath, 'utf8')
} catch (err) {
throw new Error(`无法读取私钥文件 ${keyPath}: ${(err as Error).message}`)
throw new Error(t('main.ssh.readKeyFailed', { path: keyPath, detail: (err as Error).message }))
}
}
+4 -3
View File
@@ -14,6 +14,7 @@
*/
import { Ipc } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { Client } from 'ssh2'
import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo'
@@ -116,7 +117,7 @@ function pollOnce(id: string, state: PollState): void {
if (state.stopped) return
const client = clientProvider?.(id)
if (!client) {
handleError(id, state, 'SSH 会话不存在或已断开')
handleError(id, state, t('main.sysinfo.sessionGone'))
return
}
@@ -124,7 +125,7 @@ function pollOnce(id: string, state: PollState): void {
client.exec(COLLECT_CMD, (err: Error | undefined, stream) => {
if (state.stopped) return
if (err || !stream) {
handleError(id, state, err?.message || 'SSH exec 失败')
handleError(id, state, err?.message || t('main.sysinfo.execFailed'))
return
}
let out = ''
@@ -220,7 +221,7 @@ function handleOutput(id: string, state: PollState, out: string): void {
broadcastMeta(id, { hostname: parsed.hostname, os: parsed.osInfo })
}
} catch (err) {
handleError(id, state, `解析失败: ${(err as Error).message}`)
handleError(id, state, t('main.sysinfo.parseFailed', { message: (err as Error).message }))
return
}
armNext(id, state)
+28 -13
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings'
import { t } from '@shared/i18n'
import { loadSettings, mutateSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset'
@@ -8,6 +9,8 @@ export type CloseAction = NonNullable<SystemSettings['closeAction']>
let tray: Tray | null = null
let quitting = false
let balloonShown = false
/** Window-restore callback handed to initTray; kept so the menu can be rebuilt. */
let showOrCreateRef: (() => void) | null = null
/** Once true, window close events pass through and the app really quits. */
export function markQuitting(): void {
@@ -42,17 +45,19 @@ function refreshContextMenu(showOrCreate: () => void): void {
persistCloseAction(value)
refreshContextMenu(showOrCreate)
}
// Labels are resolved while the template is built, so rebuilding the menu
// after a language change picks up the new strings.
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: (): void => showMainWindow(showOrCreate) },
{ label: t('main.tray.showWindow'), click: (): void => showMainWindow(showOrCreate) },
{ type: 'separator' },
{ label: '关闭按钮行为', enabled: false },
{ label: '每次询问', type: 'radio', checked: action === 'ask', click: setAction('ask') },
{ label: '最小化到托盘', type: 'radio', checked: action === 'tray', click: setAction('tray') },
{ label: '直接退出', type: 'radio', checked: action === 'exit', click: setAction('exit') },
{ label: t('main.tray.closeAction'), enabled: false },
{ label: t('main.tray.askEveryTime'), type: 'radio', checked: action === 'ask', click: setAction('ask') },
{ label: t('main.tray.minimizeToTray'), type: 'radio', checked: action === 'tray', click: setAction('tray') },
{ label: t('main.tray.exitDirectly'), type: 'radio', checked: action === 'exit', click: setAction('exit') },
{ type: 'separator' },
{
label: '退出',
label: t('main.tray.exit'),
click: (): void => {
markQuitting()
app.quit()
@@ -62,10 +67,20 @@ function refreshContextMenu(showOrCreate: () => void): void {
)
}
/**
* Rebuild the tray menu (after a language change, or any time the labels must
* be current). Safe to call before the tray exists.
*/
export function refreshTrayMenu(): void {
if (!tray || !showOrCreateRef) return
refreshContextMenu(showOrCreateRef)
}
/** Create the tray icon and wire left-click to window restore. */
export function initTray(showOrCreate: () => void): void {
const icon = nativeImage.createFromPath(trayIconPath)
tray = new Tray(icon)
showOrCreateRef = showOrCreate
tray.setToolTip('OpenTerminal')
tray.on('click', (): void => showMainWindow(showOrCreate))
refreshContextMenu(showOrCreate)
@@ -79,8 +94,8 @@ function hideToTray(win: BrowserWindow): void {
iconType: 'info',
// The Windows toast already prints the app name as its header, so the
// balloon title carries the state only — no "OpenTerminal" twice.
title: '已最小化到托盘',
content: '终端会话仍在后台运行,点击托盘图标可恢复窗口。'
title: t('main.tray.balloonTitle'),
content: t('main.tray.balloonContent')
})
}
}
@@ -101,13 +116,13 @@ export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, s
e.preventDefault()
const { response, checkboxChecked } = await dialog.showMessageBox(win, {
type: 'question',
title: '关闭 OpenTerminal',
message: '最小化到托盘,还是直接退出?',
detail: '最小化到托盘时终端会话保持运行。',
buttons: ['最小化到托盘', '直接退出'],
title: t('main.tray.closeTitle'),
message: t('main.tray.closeMessage'),
detail: t('main.tray.closeDetail'),
buttons: [t('main.tray.minimizeToTray'), t('main.tray.exitDirectly')],
defaultId: 0,
cancelId: 1,
checkboxLabel: '记住我的选择,不再询问',
checkboxLabel: t('main.tray.rememberChoice'),
checkboxChecked: false,
noLink: true
})
+2 -1
View File
@@ -1,6 +1,7 @@
import { app, ipcMain, net, session } from 'electron'
import { autoUpdater } from 'electron-updater'
import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
import { t } from '../shared/i18n'
import { broadcast } from './broadcast'
import { loadSettings } from './settingsStore'
import { markQuitting } from './tray'
@@ -77,7 +78,7 @@ async function checkWithFallback(): Promise<void> {
await autoUpdater.checkForUpdates()
} catch (err2) {
const ghErr = err2 instanceof Error ? err2.message : String(err2)
throw new Error(`国内源: ${giteaErr};GitHub: ${ghErr}`)
throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: ghErr }))
}
}
}
+20 -13
View File
@@ -23,6 +23,7 @@ import * as Zmodem from 'zmodem.js'
import { Ipc } from '../shared/ipc'
import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc'
import type { TransferProgressEvent } from '../shared/sftp'
import { t } from '../shared/i18n'
/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */
const OFFER_TIMEOUT_MS = 120_000
@@ -137,7 +138,7 @@ function finalize(
engine.progressEmitted = true
if (ok) emitDone(engine, true)
else emitDone(engine, false, message ?? '传输失败')
else emitDone(engine, false, message ?? t('main.zmodem.transferFailed'))
}
/** Called by the zsession's own 'session_end' event (its `this` is the session). */
@@ -149,7 +150,7 @@ function touchActivity(engine: Engine): void {
if (engine.stallTimer) clearTimeout(engine.stallTimer)
const timer = setTimeout(() => {
if (engine.stallTimer === null) return // already finalized
finalize(engine, false, '传输超时')
finalize(engine, false, t('main.zmodem.transferTimeout'))
}, STALL_TIMEOUT_MS)
engine.stallTimer = timer
}
@@ -196,7 +197,7 @@ async function sendOneFile(
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))
} catch (err) {
rs.destroy()
reject(err instanceof Error ? err : new Error('发送失败'))
reject(err instanceof Error ? err : new Error(t('main.zmodem.sendFailed')))
return
}
engine.bytes += buf.length
@@ -233,7 +234,7 @@ async function runSend(engine: Engine, paths: string[]): Promise<void> {
// Final state + ZMODEM_DONE come from the session_end event fired by close().
await session.close()
} catch (err) {
finalize(engine, false, err instanceof Error ? err.message : '上传失败')
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.uploadFailed'))
}
}
@@ -277,7 +278,7 @@ function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
} catch {
// already gone
}
finalize(engine, false, err instanceof Error ? err.message : '接收失败')
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
})
}
@@ -329,7 +330,9 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
}
touchActivity(engine)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) abortWithoutSession(engine, '未选择文件,已取消')
if (engine.detection && !engine.confirmed) {
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
}
}, OFFER_TIMEOUT_MS)
},
on_retract: () => {
@@ -405,7 +408,9 @@ export function feedZmodem(sessionId: string, data: Buffer): void {
engine.sentry.consume(data)
} catch (err) {
// A zmodem.js Error (peer abort / corrupt frame) ends the session.
if (!engine.doneEmitted) finalize(engine, false, err instanceof Error ? err.message : 'ZMODEM 传输异常')
if (!engine.doneEmitted) {
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.abnormal'))
}
}
}
@@ -423,13 +428,13 @@ export function respondZmodem(resp: ZmodemResponse): void {
if (!engine || engine.confirmed) return
if (resp.cancelled) {
abortWithoutSession(engine, '已取消')
abortWithoutSession(engine, t('main.zmodem.cancelled'))
return
}
const detection = engine.detection
if (!detection || !detection.is_valid()) {
abortWithoutSession(engine, '会话已失效')
abortWithoutSession(engine, t('main.zmodem.sessionInvalid'))
return
}
@@ -441,14 +446,14 @@ export function respondZmodem(resp: ZmodemResponse): void {
}
engine.session = detection.confirm()
if (!engine.session) {
abortWithoutSession(engine, '无法建立 ZMODEM 会话')
abortWithoutSession(engine, t('main.zmodem.sessionCreateFailed'))
return
}
wireSession(engine)
if (engine.mode === 'receive') {
if (!resp.dir) {
abortSession(engine, '未指定保存目录')
abortSession(engine, t('main.zmodem.noSaveDir'))
return
}
engine.dir = resp.dir
@@ -456,11 +461,13 @@ export function respondZmodem(resp: ZmodemResponse): void {
session.on('offer', (offer) => handleOffer(engine, offer))
void session
.start()
.catch((err) => finalize(engine, false, err instanceof Error ? err.message : '接收失败'))
.catch((err) =>
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
)
} else {
const paths = resp.paths ?? []
if (paths.length === 0) {
abortSession(engine, '未选择文件')
abortSession(engine, t('main.zmodem.noFiles'))
return
}
void runSend(engine, paths)