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:
1 parent
471f8c3e73
commit
35583b2c15
47 files changed
+3058
-105
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 /> }
|
||||
]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user