Files
OpenTerminal/src/main/settingsStore.ts
T
Bill beeb32a76b fix(session): keep panel ids distinct from session ids so restore rebinds panes
面板 id 曾直接复用 pty 会话 id(`addPanel({ id: sessionId })`),而
`updateParameters` 只能换会话、不能改面板 id。于是恢复时面板 id 指向一个
已不存在的会话:分屏少一个、剩下的 pane 背后没有 pty,界面看起来是空白,
也无法输入。

- 新增 `panelId()`,面板 id 与会话 id 彻底分离,并用于所有建面板处
  (新建终端、SSH 连接、分屏复制)
- 新增 `breakIdCoincidence()`:旧快照仍带 `panelId === sessionId`,
  在 `fromJSON` 之前重写 grid/panels/views/activeView 中的 id,
  返回 old→new 映射
- `rebindSessionPanels` 用该映射回查快照,恢复每个 pane 自己的目录
  (否则新 id 查不到记录,cwd 会静默退回 home)

验证(重启恢复 E2E,legacy 快照 + 2 分屏):
  2/2 pane 挂载、2 个独立存活会话、输入可回环、
  cwd 分别回到 D:/AIGC/OpenTerminal 与 C:/Windows、回写快照无 id 冲突;
  restoreSession=false 时正确忽略快照只建 1 个终端。
2026-09-14 11:29:56 +08:00

186 lines
6.7 KiB
TypeScript

import { app, ipcMain, powerSaveBlocker } from 'electron'
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
type AppSettings,
type HighlightRule,
type SystemSettings,
type TerminalSettings
} from '../shared/settings'
import type { TerminalTheme } from '../shared/theme'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
const DEFAULT_SYSTEM: SystemSettings = {
launchAtLogin: false,
preventSleep: false,
globalShowHide: '',
// 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
}
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
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'
)
}
function sanitizeRules(value: unknown): 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)
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const errors: string[] = []
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
if (raw !== null && typeof raw === 'object') {
const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: 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
}
}
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
: '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
}
}
}
const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : []
return {
settings: {
terminal,
customThemes: themes,
highlightRules: sanitizeRules(highlightRules),
system: system as SystemSettings
},
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 {
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)
}
export function loadSettings(): AppSettings {
try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
const { settings } = deepMerge(raw)
return settings
} catch {
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
system: { ...DEFAULT_SYSTEM }
}
}
}
export function saveSettings(next: AppSettings): AppSettings {
const merged = deepMerge({
terminal: next.terminal,
customThemes: next.customThemes,
highlightRules: next.highlightRules,
system: next.system ?? DEFAULT_SYSTEM
}).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
const pretty = JSON.stringify(merged, null, 2)
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, pretty, 'utf8')
renameSync(tmp, path)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
}
export function registerSettingsIpc(): void {
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
}