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:
1 parent
34094d607b
commit
4c6a29ef28
82 files changed
+3342
-608
No files matched your search
+5
-1
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user