Files
OpenTerminal/src/main/settingsStore.ts
T
Bill 35583b2c15 feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown
Lock screen (main-window overlay, no second window):
- scrypt password verifier in <userData>/lock.json (per-write salt,
  timingSafeEqual); salt/hash/password never leave the main process
- lock now / idle auto-lock / lock at startup, growing failure cooldown,
  lock flags persisted so a quit-and-relaunch cannot bypass the lock
- locked shell and body portals go inert while sessions keep running;
  menu accelerators (reload, DevTools, zoom) are swallowed while locked
- settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja

Security and stability:
- packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv)
- renderer preload runs with sandbox: true
- unreadable known_hosts store fails closed instead of being overwritten
- connect-time secrets gated by the bookmark's auth method (connectPromptFor)
- ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once
- sysinfo polling is refcounted for split panes (forceStopPolling on close)
- session-log index entries are path-contained; settings store writes
  atomically with EPERM/EBUSY retry
- sync-changelog tolerates CRLF checkouts (was a silent no-op)
- retry ssh2 host-key generation (flaky malformed key, ~1/500)

Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e;
GitHub Actions CI (typecheck + 10 offline tests + build)
2026-09-24 22:16:43 +08:00

527 lines
21 KiB
TypeScript

import { app, ipcMain, powerSaveBlocker } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
isHighlightCategory,
isLockAutoDelay,
lockAutoDelayOf,
type AppSettings,
type HighlightRule,
type LockSettings,
type SystemSettings,
type TerminalSettings
} from '../shared/settings'
import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/theme'
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
import { sanitizeProfiles } from '../shared/highlightProfiles'
import { broadcast } from './broadcast'
import { writeJson } from './store'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
/**
* Snapshot of the shared system defaults, derived rather than retyped so the two
* cannot drift (a hand-written copy once shipped closeAction 'ask', which made a
* fresh install prompt on close while the docs and UI promised the tray).
*/
const DEFAULT_SYSTEM: SystemSettings = { ...DEFAULT_SETTINGS.system }
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
/** 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>
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}`)
}
if (rule.caseInsensitive !== undefined && typeof rule.caseInsensitive !== 'boolean') {
warnings.push(`highlightRules[${index}].caseInsensitive ignored (not a boolean)`)
}
if (rule.basic !== undefined && typeof rule.basic !== 'boolean') {
warnings.push(`highlightRules[${index}].basic ignored (not a boolean)`)
}
if (rule.category !== undefined && !isHighlightCategory(rule.category)) {
warnings.push(`highlightRules[${index}].category ignored (unknown value)`)
}
// Value bands are repaired one entry at a time: a band without a usable min or
// colour is dropped, and what survives is sorted so the engine can walk it.
let bands: { min: number; fg: string }[] | undefined
if (rule.bands !== undefined) {
if (Array.isArray(rule.bands)) {
const kept: { min: number; fg: string }[] = []
rule.bands.forEach((entry, bandIndex) => {
const band = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>) : null
const min = typeof band?.min === 'number' ? band.min : Number(band?.min)
const fg = typeof band?.fg === 'string' ? band.fg.trim() : ''
if (band === null || !Number.isFinite(min) || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) {
warnings.push(`highlightRules[${index}].bands[${bandIndex}]: unusable — skipped`)
return
}
kept.push({ min, fg })
})
kept.sort((a, b) => a.min - b.min)
if (kept.length > 0) bands = kept
} else {
warnings.push(`highlightRules[${index}].bands ignored (not an array)`)
}
}
return {
...rule,
id: rule.id,
pattern: rule.pattern,
enabled,
priority,
color,
bands,
// `undefined` rather than `false` so the flag stays absent unless it is on.
caseInsensitive: rule.caseInsensitive === true ? true : undefined,
basic: rule.basic === true ? true : undefined,
category: isHighlightCategory(rule.category) ? rule.category : undefined,
note: typeof rule.note === 'string' ? rule.note : undefined
} as HighlightRule
}
/**
* Preset patterns shipped by earlier versions, keyed by rule id. A built-in rule
* whose pattern is still one of these was never edited by the user, so the
* load-time refresh below may swap in the current preset for it.
*/
const LEGACY_BUILTIN_PATTERNS: Record<string, string[]> = {
okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'],
badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b']
}
/** Built-in ids of the preset set as it stood before `warnstate`/`danger` existed. */
const LEGACY_BUILTIN_IDS = [
'perm',
'path',
'shellkw',
'okstate',
'badstate',
'quoted',
'envvar',
'ipv4',
'datetime',
'numbers',
'url'
]
/**
* Carry an older install's preset rules onto the current ones.
*
* An upgrade must not throw the user's own work away, so both steps are guarded:
* - a built-in rule still holding a shipped pattern is refreshed in place; the
* pattern and case flag come from the preset, while id, colour, priority,
* note and the enabled switch stay exactly as the user left them;
* - preset rules that did not exist yet are appended only while the stored set
* is still exactly the older preset set (nothing deleted, nothing added) —
* the moment the list is the user's own, a rule they removed stays removed.
* Both steps are idempotent: a refreshed pattern no longer matches a legacy one,
* and an upgraded set never matches the older preset id list again.
*/
function refreshBuiltinRules(rules: HighlightRule[], warnings: Warnings): HighlightRule[] {
const presets = new Map(DEFAULT_HIGHLIGHT_RULES.map((rule) => [rule.id, rule]))
let changed = false
const refreshed = rules.map((rule) => {
const legacy = LEGACY_BUILTIN_PATTERNS[rule.id]
const preset = presets.get(rule.id)
if (!legacy || preset === undefined || rule.builtin !== true || !legacy.includes(rule.pattern)) {
return rule
}
changed = true
warnings.push(`highlightRules.${rule.id}: preset pattern updated`)
return { ...rule, pattern: preset.pattern, caseInsensitive: preset.caseInsensitive }
})
// Presets added since are appended only while the stored set is *exactly* the
// older preset set. Anything else — a rule deleted, one added by hand — means
// the set is the user's, and a preset they threw away must not come back on
// every load.
const present = refreshed.map((rule) => rule.id)
const pristine =
present.length === LEGACY_BUILTIN_IDS.length &&
LEGACY_BUILTIN_IDS.every((id) => present.includes(id))
if (!pristine) return changed ? refreshed : rules
const missing = DEFAULT_HIGHLIGHT_RULES.filter((rule) => !present.includes(rule.id))
if (missing.length === 0) return changed ? refreshed : rules
for (const rule of missing) warnings.push(`highlightRules.${rule.id}: new built-in rule added`)
return [...refreshed, ...missing.map((rule) => ({ ...rule }))].sort((a, b) => a.priority - b.priority)
}
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
const rules: HighlightRule[] = []
value.forEach((entry, index) => {
const rule = coerceRule(entry, index, warnings)
if (rule) rules.push(rule)
})
return refreshBuiltinRules(rules, warnings)
}
const THEME_COLOR_KEYS = Object.keys(DEFAULT_DARK.colors) as (keyof ThemeColors)[]
/**
* Validate custom themes, repairing colours instead of dropping the theme.
*
* These reach `getThemeById`, which does a bare `.find()` over the list and
* hands the result to xterm *and* to the window/title-bar colours — an entry
* without an id or without colours used to throw inside `whenReady`, i.e. before
* the tray and updater were wired, leaving a windowless process holding the
* single-instance lock. Every colour missing from the stored entry is filled
* from the built-in dark theme so a theme is always complete.
*/
function sanitizeThemes(value: unknown, warnings: Warnings): TerminalTheme[] {
if (!Array.isArray(value)) return []
const themes: TerminalTheme[] = []
value.forEach((entry, index) => {
if (entry === null || typeof entry !== 'object') {
warnings.push(`customThemes[${index}]: not an object — skipped`)
return
}
const theme = entry as Record<string, unknown>
if (typeof theme.id !== 'string' || theme.id === '' || typeof theme.name !== 'string') {
warnings.push(`customThemes[${index}]: missing id/name — skipped`)
return
}
if (theme.colors === null || typeof theme.colors !== 'object') {
warnings.push(`customThemes[${index}].colors repaired → built-in dark palette`)
}
const candidate =
theme.colors !== null && typeof theme.colors === 'object'
? (theme.colors as Record<string, unknown>)
: null
const colors: ThemeColors = { ...DEFAULT_DARK.colors }
const target = colors as unknown as Record<string, string>
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i
for (const key of THEME_COLOR_KEYS) {
const color = candidate?.[key]
// Only well-formed hex colours survive: xterm would render anything else
// as black, and the theme editor's native colour input needs #rrggbb.
if (typeof color === 'string' && HEX_COLOR.test(color.trim())) {
target[key] = color.trim()
} else if (color !== undefined) {
warnings.push(`customThemes[${index}].colors.${key}: not a hex color — repaired`)
}
}
themes.push({
...theme,
id: theme.id,
name: theme.name,
builtin: theme.builtin === true,
colors
} as TerminalTheme)
})
return themes
}
/**
* Sanitize the lock block. Every field is forced to its type, so a partial
* patch, a hand-edited file or a config written before the block existed all
* land on the defaults; the delay must be one of the offered steps, because an
* arbitrary number here would silently change the idle-lock schedule.
*/
function sanitizeLock(value: unknown, errors: string[]): LockSettings {
const candidate =
value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
if (candidate.autoLockMinutes !== undefined && !isLockAutoDelay(candidate.autoLockMinutes)) {
errors.push('lock.autoLockMinutes')
}
return {
enabled: candidate.enabled === true,
autoLockMinutes: lockAutoDelayOf(candidate.autoLockMinutes),
lockAtStartup: candidate.lockAtStartup === true
}
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const errors: string[] = []
const lock = sanitizeLock(
raw !== null && typeof raw === 'object' ? (raw as { lock?: unknown }).lock : undefined,
errors
)
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
let highlightProfiles: unknown = []
if (raw !== null && typeof raw === 'object') {
const packageSettings = raw as {
terminal?: unknown
customThemes?: unknown
highlightRules?: unknown
highlightProfiles?: unknown
}
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
const candidate = packageSettings.terminal as Record<string, unknown>
const merged: Record<string, unknown> = { ...terminal }
for (const key of TERMINAL_KEYS) {
const keyType = typeof DEFAULT_SETTINGS.terminal[key]
if (candidate[key] !== undefined && typeof candidate[key] === keyType) {
merged[key] = candidate[key]
} else if (candidate[key] !== undefined) {
errors.push(`terminal.${key}`)
}
}
terminal = merged as unknown as TerminalSettings
}
if (Array.isArray(packageSettings.customThemes)) {
customThemes = packageSettings.customThemes
}
if (packageSettings.highlightRules !== undefined) {
highlightRules = packageSettings.highlightRules
}
if (packageSettings.highlightProfiles !== undefined) {
highlightProfiles = packageSettings.highlightProfiles
}
}
let system: unknown = { ...DEFAULT_SYSTEM }
if (raw !== null && typeof raw === 'object') {
const sys = (raw as { system?: unknown }).system
if (sys !== null && typeof sys === 'object') {
const candidate = sys as Record<string, unknown>
const globalShowHide =
typeof candidate.globalShowHide === 'string' ? candidate.globalShowHide : ''
const closeAction =
candidate.closeAction === 'tray' ||
candidate.closeAction === 'exit' ||
candidate.closeAction === 'ask'
? candidate.closeAction
: 'tray'
system = {
launchAtLogin: candidate.launchAtLogin === true,
preventSleep: candidate.preventSleep === true,
globalShowHide,
closeAction,
autoCheckUpdate: candidate.autoCheckUpdate !== false,
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
restoreSession: candidate.restoreSession !== false,
shellIntegration: candidate.shellIntegration === true,
language: isLanguage(candidate.language) ? candidate.language : DEFAULT_LANGUAGE
}
}
}
const themes = sanitizeThemes(customThemes, errors)
const rules = sanitizeRules(highlightRules, errors)
return {
settings: {
terminal,
customThemes: themes,
highlightRules: rules,
highlightProfiles: sanitizeProfiles(
highlightProfiles,
new Set(rules.map((rule) => rule.id)),
(message) => errors.push(message)
),
system: system as SystemSettings,
lock
},
errors
}
}
// ---- real system side effects ------------------------------------------------
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) {
console.error('[settings] setLoginItemSettings failed', err)
}
if (system.preventSleep && sleepBlockerId === undefined) {
sleepBlockerId = powerSaveBlocker.start('prevent-app-suspension')
} else if (!system.preventSleep && sleepBlockerId !== undefined) {
powerSaveBlocker.stop(sleepBlockerId)
sleepBlockerId = undefined
}
// Re-register the global show/hide shortcut whenever system settings change.
applyGlobalShortcut(system.globalShowHide)
}
/** Apply system side effects for the settings loaded at startup. */
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, errors } = deepMerge(raw)
reportWarnings(errors)
return settings
} catch {
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
highlightProfiles: [],
system: { ...DEFAULT_SYSTEM },
lock: { ...DEFAULT_SETTINGS.lock }
}
}
}
let settingsQueue: Promise<unknown> = Promise.resolve()
/**
* One-time reset for existing installs: input suggestions and command history
* are now off by default, but installs that upgraded from earlier versions
* already have `true` persisted — flip those off exactly once, then leave a
* marker so the user's later choices are never touched again.
*/
function migrateDefaultsOnce(): void {
try {
const flag = join(app.getPath('userData'), '.migrate-defaults-v2')
if (existsSync(flag)) return
const current = loadSettings()
if (current.terminal.suggestEnabled || current.terminal.historyEnabled) {
current.terminal.suggestEnabled = false
current.terminal.historyEnabled = false
writeJson(settingsPath(), current)
}
writeFileSync(flag, '', 'utf8')
} catch {
// best effort: a failed migration just keeps the old values
}
}
/**
* Serialize a settings mutation against every other writer. Each queued step
* re-reads the file so the mutation applies on top of the latest state — a
* full-snapshot save can no longer silently revert a change written between
* its read and its write (e.g. tray close-action vs settings UI save).
*/
export function mutateSettings(mutate: (settings: AppSettings) => AppSettings): Promise<AppSettings> {
const run = settingsQueue.then((): AppSettings => {
const merged = deepMerge(mutate(loadSettings())).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
// writeJson gives the same atomic write as every other store, including
// short EPERM/EBUSY retries when Windows holds the destination open.
writeJson(settingsPath(), merged)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
})
// Keep the queue alive when a mutation throws; the caller still sees it.
settingsQueue = run.then(
() => undefined,
() => undefined
)
return run
}
/** Merge a (partial) settings patch into the persisted settings. */
export function saveSettings(next: Partial<AppSettings>): Promise<AppSettings> {
// system/terminal merge one level deep, so a partial patch cannot wipe sibling
// keys. The renderer now sends a minimal patch, so *absent* fields no longer
// win over the stored state; stale-but-sent fields still do, which is why
// main-process writes go through mutateSettings on the freshly read state
// instead of this path.
return mutateSettings((current) => ({
...current,
...next,
terminal: { ...current.terminal, ...next.terminal },
system: { ...current.system, ...next.system },
lock: { ...current.lock, ...next.lock }
}))
}
export function registerSettingsIpc(): void {
migrateDefaultsOnce()
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: Partial<AppSettings>) => saveSettings(next))
}