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)
This commit is contained in:
Bill committed 2026-09-24 22:16:43 +08:00
1 parent 471f8c3e73
commit 35583b2c15
47 files changed
+3058 -105

No files matched your search

+92 -6
View File
@@ -7,9 +7,11 @@ import jaJP from 'antd/locale/ja_JP'
import Workspace from '@renderer/workspace/Workspace'
import { SettingsDialog } from '@renderer/settings/SettingsDialog'
import { TransferPanel } from '@renderer/sftp/TransferPanel'
import { LockScreen } from '@renderer/lock/LockScreen'
import { useSettingsStore } from '@renderer/settings/store'
import { getThemeById } from '@shared/theme'
import { DEFAULT_LANGUAGE, syncLanguage, type Language } from '@shared/i18n'
import type { LockSettingsState } from '@shared/ipc'
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
import appIconUrl from '../../../build/icon.png'
@@ -44,11 +46,81 @@ export default function App(): React.JSX.Element {
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const [settingsOpen, setSettingsOpen] = useState(false)
/** Secure default: main may already hold a startup lock before this IPC
* resolves, so the shell must not become interactive while unknown. `null`
* means "not answered yet" and is kept distinct from "locked": the overlay
* is drawn only once main has spoken, otherwise every start would flash a
* lock panel — even for users who never configured a password. */
const [lockState, setLockState] = useState<LockSettingsState | null>(null)
useEffect(() => {
void hydrate()
}, [hydrate])
// Lock state: pulled once (it may already be locked at startup) and then kept
// in sync from main, which owns the state — the renderer never decides.
useEffect(() => {
let alive = true
void window.api.getLockState().then(
(state: LockSettingsState) => {
if (alive) setLockState(state)
},
(err: unknown) => {
console.error('[lock] getLockState failed', err)
// Stay recoverable: fall back to "locked with no verifier" so the panel
// (and its input) is reachable. Main unlocks an unconfigured app on the
// first attempt, so the user is never stuck on the boot layer.
if (alive) {
setLockState({
configured: false,
enabled: false,
autoLockMinutes: 0,
lockAtStartup: false,
locked: true
})
}
}
)
const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state))
return () => {
alive = false
off()
}
}, [])
// Unknown counts as locked: main owns the state and may already be locked.
const locked = lockState === null || lockState.locked
// Locking is app-wide: close antd portals that live outside `.app-root`
// (the settings modal otherwise stays focusable behind the opaque mask),
// and expose the state to window-level hotkey listeners — they see 'true'
// during the unknown window as well, which is the point.
useEffect(() => {
document.documentElement.dataset.locked = locked ? 'true' : 'false'
if (locked) setSettingsOpen(false)
// Portals (antd modals, dropdowns, tooltips) attach to document.body,
// outside the inert `#root` subtree, so a keyboard user could still Tab
// into whatever happened to be open when the lock engaged. Inert every
// other body child while locked; the overlay itself lives inside #root,
// above the inert `.app-root`, and stays reachable.
const appRoot = document.getElementById('root')
const inerted: Element[] = []
if (locked) {
for (const el of Array.from(document.body.children)) {
if (el === appRoot) continue
try {
el.setAttribute('inert', '')
inerted.push(el)
} catch {
// a node that refuses the attribute must not break the lock
}
}
}
return () => {
for (const el of inerted) el.removeAttribute('inert')
}
}, [locked])
// Interface language: t() reads the module-level language at render time, so
// sync it before the tree renders — an effect would paint one frame late.
// Silent on purpose: notifying subscribers during a render is what React
@@ -71,12 +143,26 @@ export default function App(): React.JSX.Element {
return (
<ConfigProvider locale={ANTD_LOCALES[language]}>
<div className="app-root">
<TitleBar />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
</div>
<>
{/* Locked: the shell goes inert (no focus, no pointer, hidden from
assistive tech) but stays mounted — unmounting it would kill the
local/SSH sessions and the transfer list behind the overlay.
`inert` is listed before aria-hidden so focus leaves the subtree
before the browser checks for a focused descendant. */}
<div className="app-root" inert={locked || undefined} aria-hidden={locked || undefined}>
<TitleBar />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
</div>
{/* Unknown state paints the opaque layer alone: the shell stays hidden
and no panel is shown, so startup cannot flash a lock screen. */}
{lockState === null ? (
<div className="lock-screen-boot" />
) : locked ? (
<LockScreen state={lockState} onStateChange={setLockState} />
) : null}
</>
</ConfigProvider>
)
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from 'react'
import { LockOutlined } from '@ant-design/icons'
import { Button, Input } from 'antd'
import type { InputRef } from 'antd'
import { t } from '@shared/i18n'
import type { LockOperationError, LockSettingsState } from '@shared/ipc'
import './lock.css'
/** Message key per failure, so every LockOperationError reads as its own line. */
const ERROR_KEYS: Record<LockOperationError, string> = {
'invalid-password': 'settings.lock.error.invalidPassword',
'wrong-password': 'settings.lock.error.wrongPassword',
cooldown: 'settings.lock.error.cooldown',
'save-failed': 'settings.lock.error.saveFailed'
}
/**
* Failure text for a `LockOperationResult.error`. A `cooldown` error is shown
* with the remaining seconds when main reports them, and with the generic
* wording otherwise — never as "retry in 0 seconds".
*/
export function lockErrorText(error: LockOperationError, cooldownMs?: number): string {
if (error === 'cooldown') {
const seconds = cooldownMs !== undefined && cooldownMs > 0 ? Math.ceil(cooldownMs / 1000) : 0
return seconds > 0
? t('settings.lock.error.cooldown', { n: seconds })
: t('settings.lock.error.cooldownGeneric')
}
return t(ERROR_KEYS[error])
}
export interface LockScreenProps {
state: LockSettingsState
/** publish the state main returned, so App drops the overlay once unlocked */
onStateChange: (state: LockSettingsState) => void
}
/**
* Lock overlay for the main window.
*
* App.tsx renders it as a *sibling* of the app shell rather than inside it: the
* shell stays mounted (and inert) underneath, so PTY/SSH sessions keep running
* and the workspace is not torn down by a lock. The layer is opaque, so no
* terminal output is visible through it.
*
* Deliberately minimal — no session content, no bypass button, and the password
* never leaves this component: it is cleared after every attempt and is not
* logged anywhere.
*/
export function LockScreen({ state, onStateChange }: LockScreenProps): React.JSX.Element {
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<LockOperationError | null>(null)
/** local deadline, so the countdown keeps ticking between main's broadcasts */
const [cooldownUntil, setCooldownUntil] = useState(0)
const [now, setNow] = useState(() => Date.now())
const inputRef = useRef<InputRef>(null)
const cooldownMs = state.cooldownMs ?? 0
useEffect(() => {
setCooldownUntil(cooldownMs > 0 ? Date.now() + cooldownMs : 0)
setNow(Date.now())
}, [cooldownMs])
useEffect(() => {
if (cooldownUntil <= 0) return
const id = window.setInterval(() => setNow(Date.now()), 250)
return () => window.clearInterval(id)
}, [cooldownUntil])
const remainingMs = Math.max(0, cooldownUntil - now)
const cooling = remainingMs > 0
// Focus follows usability: on mount and again when a cooldown runs out, so
// the lock can be answered by typing without reaching for the mouse.
useEffect(() => {
if (!cooling) inputRef.current?.focus()
}, [cooling])
const submit = async (): Promise<void> => {
if (busy || cooling || password.length === 0) return
const attempt = password
setBusy(true)
setError(null)
try {
const result = await window.api.unlockLock({ password: attempt })
setPassword('')
onStateChange(result.state)
if (!result.ok) setError(result.error ?? 'wrong-password')
} catch (err) {
console.error('[lock] unlock request failed', err)
setError('save-failed')
} finally {
setBusy(false)
}
}
const message = cooling
? lockErrorText('cooldown', remainingMs)
: error
? lockErrorText(error, cooldownMs)
: ''
return (
<div
className="lock-screen"
role="dialog"
aria-modal="true"
aria-label={t('settings.lock.title')}
// Modal focus trap: antd popovers/modals render outside the inert app
// root, so Tab must not be allowed to walk into content hidden behind
// this opaque overlay.
onKeyDown={(e) => {
if (e.key === 'Tab') {
e.preventDefault()
inputRef.current?.focus()
}
}}
>
<form
className="lock-screen-panel"
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
>
<LockOutlined className="lock-screen-icon" />
<div className="lock-screen-title">{t('settings.lock.title')}</div>
<div className="lock-screen-desc">{t('settings.lock.screenDesc')}</div>
<Input.Password
ref={inputRef}
className="lock-screen-input"
size="large"
value={password}
disabled={cooling}
visibilityToggle={false}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.passwordPlaceholder')}
onChange={(e) => {
setPassword(e.target.value)
if (error) setError(null)
}}
/>
<Button
className="lock-screen-button"
type="primary"
size="large"
htmlType="submit"
loading={busy}
disabled={cooling || password.length === 0}
>
{busy ? t('settings.lock.unlocking') : t('settings.lock.unlock')}
</Button>
<div className="lock-screen-message" role="status">
{message}
</div>
<div className="lock-screen-hint">{t('settings.lock.screenForgot')}</div>
</form>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
/* ============================================================
Lock overlay (lock/LockScreen.tsx)
Opaque on purpose: nothing of the workspace underneath may
show through. Colors come from the chrome variables, so the
overlay follows the active terminal theme.
============================================================ */
/* Opaque layer shared by the lock screen and the boot layer that App.tsx paints
while the lock state is still unknown (first IPC round trip). Keeping one rule
means the boot layer cannot drift away from the overlay's stacking level. */
.lock-screen,
.lock-screen-boot {
position: fixed;
inset: 0;
/* above the workspace rail (960) and every antd layer: modal 1000,
message 1010, notification 1050 */
z-index: 4000;
background: var(--chrome-bg-deep, #101418);
color: var(--chrome-fg, #cccccc);
}
/* Only the panel-bearing overlay centres anything; the boot layer is empty. */
.lock-screen {
display: flex;
align-items: center;
justify-content: center;
}
.lock-screen-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 340px;
max-width: 80vw;
padding: 30px 28px 22px;
border: 1px solid var(--chrome-border, #2d2d2d);
border-radius: 10px;
background: var(--chrome-bg, #181818);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
text-align: center;
}
.lock-screen-icon {
font-size: 32px;
line-height: 1;
color: var(--tab-accent, #3fb950);
}
.lock-screen-title {
font-size: 17px;
font-weight: 600;
}
.lock-screen-desc {
font-size: 12px;
margin-bottom: 4px;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
}
.lock-screen-input,
.lock-screen-button {
width: 100%;
}
/* Reserved height: the message appears and disappears without moving the
button, so a failed attempt does not shift the input out from under the
pointer. */
.lock-screen-message {
min-height: 18px;
font-size: 12px;
line-height: 18px;
color: #f85149;
}
.lock-screen-hint {
font-size: 11px;
line-height: 1.5;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 38%, transparent);
}
+38 -2
View File
@@ -2,6 +2,7 @@ import ReactDOM from 'react-dom/client'
import { useEffect, type ReactNode } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import type { LockSettingsState } from '@shared/ipc'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { getThemeById } from '@shared/theme'
import App from './App'
@@ -19,7 +20,14 @@ import './global.css'
setInterval(() => {
const now = performance.now()
const lag = now - lastTick - 2000
if (lag > 3000) console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
// Chromium throttles timers in a hidden page down to roughly one per
// minute (intensive throttling), which this watchdog would otherwise
// report as a ~58s "stall" on every tick while the window sits in the
// tray. A hidden window also has nothing user-visible to freeze, so the
// report is skipped until the page is visible again.
if (lag > 3000 && !document.hidden) {
console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
}
lastTick = now
}, 2000)
}
@@ -29,6 +37,16 @@ import './global.css'
if (typeof window !== 'undefined' && !window.api) {
const noop = (): void => undefined
const stubId = (): string => `stub-${Math.random().toString(36).slice(2)}`
/** Browser stub: never configured and never locked, so the lock overlay
* stays out of the way of layout work done in a plain vite page. */
const stubLockState = (over?: Partial<LockSettingsState>): LockSettingsState => ({
configured: false,
enabled: false,
autoLockMinutes: 0,
lockAtStartup: false,
locked: false,
...over
})
const api: AppApi = {
appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }),
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
@@ -111,7 +129,16 @@ if (typeof window !== 'undefined' && !window.api) {
getSessionState: async () => null,
saveSessionState: async () => null,
resolveCwd: async () => null,
reportCwd: async () => null
reportCwd: async () => null,
getLockState: async () => stubLockState(),
setLockPassword: async (input) => ({
ok: true,
state: stubLockState({ configured: (input.newPassword ?? '').length > 0 })
}),
clearLockPassword: async () => ({ ok: true, state: stubLockState() }),
unlockLock: async () => ({ ok: true, state: stubLockState() }),
lockNow: async () => stubLockState(),
onLockStateChanged: () => noop
}
;(window as unknown as { api: AppApi }).api = api
}
@@ -130,6 +157,15 @@ function FontHotkeyListener(): null {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
// The lock overlay leaves Workspace mounted; window-capture hotkeys must
// not mutate font size on a shell hidden behind it. preventDefault matters
// here: returning alone lets the chord reach Electron's default menu
// accelerators (zoomIn/zoomOut/resetZoom), which rescale the whole UI
// behind the opaque mask and make Chromium persist that zoom per origin.
if (document.documentElement.dataset.locked === 'true') {
e.preventDefault()
return
}
if (!e.ctrlKey || e.metaKey) return
const target = e.target as HTMLElement | null
const isXtermHelper =
@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react'
import { Button, Input, Popconfirm, Select, Switch, Tag } from 'antd'
import { t } from '@shared/i18n'
import type { LockOperationResult, LockSettingsState } from '@shared/ipc'
import { LOCK_AUTO_DELAYS, type LockAutoDelay } from '@shared/settings'
import { lockErrorText } from '@renderer/lock/LockScreen'
import { SettingRow } from './fields'
import { useSettingsStore } from './store'
type Feedback = { kind: 'ok' | 'error'; text: string } | null
/**
* 锁屏设置页。
*
* Two sources, on purpose: the switches live in `settings.lock` (persisted
* through the settings store, so they follow the normal save/rollback path),
* while "is a password configured / is the screen locked right now" comes from
* main (`lock.json` holds the verifier, never settings). The password fields
* are write-only: they are sent once and cleared, main never echoes them back.
*/
export function LockSettingsTab(): React.JSX.Element {
const lock = useSettingsStore((s) => s.settings.lock)
const updateLock = useSettingsStore((s) => s.updateLock)
const [lockState, setLockState] = useState<LockSettingsState | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [busy, setBusy] = useState(false)
const [feedback, setFeedback] = useState<Feedback>(null)
useEffect(() => {
let alive = true
void window.api.getLockState().then(
(state: LockSettingsState) => {
if (alive) setLockState(state)
},
(err: unknown) => console.error('[lock] getLockState failed', err)
)
// Keeps `configured` honest when the lock engages or main clears the
// verifier (e.g. an unlock attempt failed and a cooldown started).
const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state))
return () => {
alive = false
off()
}
}, [])
const configured = lockState?.configured === true
const usable = configured && lock.enabled
const clearFields = (): void => {
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
}
/** Run a lock operation and fold its result into the local state + feedback. */
const run = async (op: () => Promise<LockOperationResult>, okText: string): Promise<void> => {
setBusy(true)
setFeedback(null)
try {
const result = await op()
setLockState(result.state)
if (result.ok) {
clearFields()
setFeedback({ kind: 'ok', text: okText })
} else {
setFeedback({
kind: 'error',
text: lockErrorText(result.error ?? 'save-failed', result.state.cooldownMs)
})
}
} catch (err) {
console.error('[lock] operation failed', err)
setFeedback({ kind: 'error', text: lockErrorText('save-failed') })
} finally {
setBusy(false)
}
}
const savePassword = (): void => {
if (newPassword.length === 0) {
setFeedback({ kind: 'error', text: t('settings.lock.password.empty') })
return
}
if (newPassword !== confirmPassword) {
setFeedback({ kind: 'error', text: t('settings.lock.password.mismatch') })
return
}
void run(
() =>
window.api.setLockPassword(configured ? { currentPassword, newPassword } : { newPassword }),
t('settings.lock.password.saved')
)
}
const clearPassword = (): void => {
if (currentPassword.length === 0) {
setFeedback({ kind: 'error', text: t('settings.lock.password.empty') })
return
}
void run(
() => window.api.clearLockPassword({ currentPassword }),
t('settings.lock.password.cleared')
)
}
/** lockNow answers with the state directly, not with an operation result. */
const lockNow = async (): Promise<void> => {
setBusy(true)
setFeedback(null)
try {
setLockState(await window.api.lockNow())
} catch (err) {
console.error('[lock] lockNow failed', err)
setFeedback({ kind: 'error', text: lockErrorText('save-failed') })
} finally {
setBusy(false)
}
}
const autoLockOptions = LOCK_AUTO_DELAYS.map((minutes: LockAutoDelay) => ({
value: minutes,
label:
minutes === 0
? t('settings.lock.autoLockOff')
: t('settings.lock.autoLockMinutes', { n: minutes })
}))
return (
<div className="settings-pane">
<div className="settings-block-label">
{t('settings.lock.password.title')}
<span className="settings-block-hint">{t('settings.lock.password.desc')}</span>
<Tag color={configured ? 'green' : 'default'}>
{configured
? t('settings.lock.password.configured')
: t('settings.lock.password.notConfigured')}
</Tag>
</div>
{configured && (
<SettingRow
label={t('settings.lock.password.current')}
control={
<Input.Password
className="settings-lock-input"
value={currentPassword}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.password.currentPlaceholder')}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
}
/>
)}
<SettingRow
label={t('settings.lock.password.new')}
control={
<Input.Password
className="settings-lock-input"
value={newPassword}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.password.newPlaceholder')}
onChange={(e) => setNewPassword(e.target.value)}
/>
}
/>
<SettingRow
label={t('settings.lock.password.confirm')}
control={
<Input.Password
className="settings-lock-input"
value={confirmPassword}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.password.confirmPlaceholder')}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
}
/>
<div className="settings-lock-actions">
<Button
type="primary"
loading={busy}
disabled={newPassword.length === 0 || confirmPassword.length === 0}
onClick={savePassword}
>
{configured ? t('settings.lock.password.change') : t('settings.lock.password.set')}
</Button>
{configured && (
<Popconfirm
title={t('settings.lock.password.clearTitle')}
description={t('settings.lock.password.clearDesc')}
okText={t('settings.lock.password.clear')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={clearPassword}
>
<Button danger disabled={busy}>
{t('settings.lock.password.clear')}
</Button>
</Popconfirm>
)}
</div>
<div
className={'settings-lock-feedback' + (feedback?.kind === 'error' ? ' is-error' : ' is-ok')}
role="status"
>
{feedback?.text ?? ''}
</div>
<div className="settings-lock-note">{t('settings.lock.password.forgot')}</div>
<SettingRow
label={t('settings.lock.enabled')}
desc={configured ? t('settings.lock.enabledDesc') : t('settings.lock.needPassword')}
control={
<Switch
checked={lock.enabled}
disabled={!configured}
onChange={(checked) => void updateLock({ enabled: checked })}
/>
}
/>
<SettingRow
label={t('settings.lock.autoLock')}
desc={t('settings.lock.autoLockDesc')}
control={
<Select
className="settings-select"
value={lock.autoLockMinutes}
disabled={!usable}
onChange={(value) => void updateLock({ autoLockMinutes: value })}
options={autoLockOptions}
/>
}
/>
<SettingRow
label={t('settings.lock.lockAtStartup')}
desc={t('settings.lock.lockAtStartupDesc')}
control={
<Switch
checked={lock.lockAtStartup}
disabled={!usable}
onChange={(checked) => void updateLock({ lockAtStartup: checked })}
/>
}
/>
<SettingRow
label={t('settings.lock.lockNow')}
desc={t('settings.lock.lockNowDesc')}
control={
<Button disabled={!configured || busy} onClick={() => void lockNow()}>
{t('settings.lock.lockNow')}
</Button>
}
/>
</div>
)
}
@@ -7,6 +7,7 @@ import { useSettingsStore } from './store'
import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs'
import { ThemeSettingsTab } from './ThemeSettingsTab'
import { HighlightTab } from './HighlightTab'
import { LockSettingsTab } from './LockSettingsTab'
import { AboutTab } from './AboutTab'
import { ThemeEditor } from '../theme/editor/ThemeEditor'
import './settings.css'
@@ -91,6 +92,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
)
},
{ key: 'system', label: t('settings.tabs.system'), children: <SystemSettingsTab /> },
{ key: 'lock', label: t('settings.tabs.lock'), children: <LockSettingsTab /> },
{ key: 'about', label: t('settings.tabs.about'), children: <AboutTab /> }
]
+37
View File
@@ -571,6 +571,43 @@
line-height: 1.6;
}
/* ---------- lock tab (settings/LockSettingsTab.tsx) ---------- */
.settings-lock-input {
width: 220px;
}
.settings-lock-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 8px 8px 0;
}
/* Reserved height, so a feedback line appearing does not push the rows below
it around. */
.settings-lock-feedback {
min-height: 18px;
margin: 6px 8px 0;
font-size: 12px;
line-height: 18px;
}
.settings-lock-feedback.is-ok {
color: #3fb950;
}
.settings-lock-feedback.is-error {
color: #f85149;
}
.settings-lock-note {
margin: 4px 8px 16px;
font-size: 12px;
line-height: 1.6;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
/* ---------- shortcut recorder (press-to-record input) ---------- */
.shortcut-input {
+11 -1
View File
@@ -1,4 +1,4 @@
import type { AppSettings, SystemSettings, TerminalSettings } from '@shared/settings'
import type { AppSettings, LockSettings, SystemSettings, TerminalSettings } from '@shared/settings'
import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme } from '@shared/theme'
import { getThemeById } from '@shared/theme'
@@ -19,6 +19,8 @@ export interface SettingsState {
setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise<void>
/** shallow-merge into settings.system and persist */
updateSystem: (partial: Partial<SystemSettings>) => Promise<void>
/** shallow-merge into settings.lock and persist */
updateLock: (partial: Partial<LockSettings>) => Promise<void>
}
/** unsubscriber for the cross-window SETTINGS_CHANGED listener; held module-level so hydrate() is idempotent */
@@ -68,6 +70,8 @@ async function persist(
if (terminal) patch.terminal = terminal as TerminalSettings
const system = diffGroup(prev.system, written.system)
if (system) patch.system = system as SystemSettings
const lock = diffGroup(prev.lock, written.lock)
if (lock) patch.lock = lock as LockSettings
await window.api.saveSettings(patch)
} catch (err) {
console.error(`[settings] ${label} failed, rolling back`, err)
@@ -131,6 +135,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const prev = get().settings
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
await persist(get, set, next, prev, 'updateSystem')
},
updateLock: async (partial) => {
const prev = get().settings
const next: AppSettings = { ...prev, lock: { ...prev.lock, ...partial } }
await persist(get, set, next, prev, 'updateLock')
}
}))
+8 -6
View File
@@ -1,16 +1,17 @@
import { useState } from 'react'
import { Button, Input, Modal } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import type { SshConnection, SshSecretOverride } from '@shared/connections'
import { connectPromptFor, type SshConnection, type SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
/**
* Connect-time gateways for one SSH connection attempt.
*
* `phase` drives which dialog shows:
* - 'secret' → secret prompt modal (password when `askPasswordAtConnect`,
* passphrase when `askPassphraseAtConnect`). This is the only
* connect-time dialog that can be cancelled, because
* - 'secret' → secret prompt modal (kind chosen by the shared
* `connectPromptFor`: password for ask-at-connect password
* auth, passphrase for ask-at-connect key auth). This is the
* only connect-time dialog that can be cancelled, because
* openSession cannot be aborted before it resolves.
* - 'connecting' → an uncancellable "连接中…" modal while openSession flies.
*
@@ -34,14 +35,15 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
const [password, setPassword] = useState('')
const [passphrase, setPassphrase] = useState('')
const prompt = conn != null && phase !== 'connecting' ? connectPromptFor(conn) : null
const stage: ConnectStage =
conn == null
? 'idle'
: phase === 'connecting'
? 'connecting'
: conn.askPasswordAtConnect
: prompt === 'password'
? 'password'
: conn.askPassphraseAtConnect
: prompt === 'passphrase'
? 'passphrase'
: 'connecting'
+12 -7
View File
@@ -25,7 +25,7 @@ import type {
} from 'dockview-react'
import 'dockview-react/dist/styles/dockview.css'
import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections'
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
@@ -660,6 +660,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
*/
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
// Tab cycling must not move an invisible workspace while the lock
// overlay covers the main window.
if (document.documentElement.dataset.locked === 'true') return
const ctrl = e.ctrlKey
const code = e.code
if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return
@@ -764,12 +767,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const handleConnectRequest = useCallback(
(conn: SshConnection): void => {
if (connectInFlightRef.current > 0) return
// Ask-at-connect connections go through the secret prompt first; saved
// credentials must connect IMMEDIATELY — routing them through ConnectFlow
// would only ever *render* a "connecting" spinner without firing openSession.
const needsPassword = conn.auth === 'password' && conn.askPasswordAtConnect
const needsPassphrase = conn.auth === 'privateKey' && conn.askPassphraseAtConnect
if (needsPassword || needsPassphrase) {
// Ask-at-connect connections go through the secret prompt first (the
// prompt kind comes from the shared `connectPromptFor`, so a stale ask
// flag from a switched auth method can never pop the wrong dialog);
// saved credentials must connect IMMEDIATELY — routing them through
// ConnectFlow would only ever *render* a "connecting" spinner without
// firing openSession.
const prompt = connectPromptFor(conn)
if (prompt !== null) {
setRequestedConn(conn)
return
}