Files
OpenTerminal/src/renderer/src/workspace/ConnectFlow.tsx
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

178 lines
5.2 KiB
TypeScript

import { useState } from 'react'
import { Button, Input, Modal } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
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 (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.
*
* Workspace moves `phase` secret→connecting when the secret is confirmed and
* back to null when the attempt settles. Each dialog is `key`-ed by the
* connection id so a new attempt never inherits previously typed secrets.
*/
export type ConnectStage = 'idle' | 'password' | 'passphrase' | 'connecting'
export interface ConnectFlowProps {
/** one pending double-click request; null clears the flow */
conn: SshConnection | null
/** 'connecting' once a secret (if any) has been committed and openSession started */
phase: 'secret' | 'connecting'
onConfirmed: (secretOverride: SshSecretOverride) => void
onCancel: () => void
}
export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowProps): React.JSX.Element {
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'
: prompt === 'password'
? 'password'
: prompt === 'passphrase'
? 'passphrase'
: 'connecting'
// A fresh connection must not inherit a previous attempt's typed value, so
// each stage modal carries a per-connection `key` that forces a new mount.
const key = conn?.id ?? 'none'
if (conn == null) return <></>
if (stage === 'password') {
return (
<SecretModal
key={key}
title={t('workspace.connect.passwordTitle')}
description={`${conn.username}@${conn.host}:${conn.port}`}
placeholder={t('workspace.connect.passwordPlaceholder')}
value={password}
onChange={(v) => setPassword(v)}
onOk={() => onConfirmed({ password })}
onCancel={onCancel}
/>
)
}
if (stage === 'passphrase') {
return (
<SecretModal
key={key}
title={t('workspace.connect.passphraseTitle')}
description={`${conn.username}@${conn.host}:${conn.port}`}
placeholder={t('workspace.connect.passphrasePlaceholder')}
value={passphrase}
onChange={(v) => setPassphrase(v)}
onOk={() => onConfirmed({ passphrase })}
onCancel={onCancel}
/>
)
}
if (stage === 'connecting') {
return (
<Modal
key={key}
open
title={t('workspace.connect.connecting')}
closable={false}
maskClosable={false}
keyboard={false}
footer={null}
width={320}
>
<div className="connect-flow-connecting">
<LoadingOutlined spin style={{ fontSize: 26 }} />
<span className="connect-flow-connecting-text">
{t('workspace.connect.connectingNamed', { name: conn?.name != null ? conn.name : '…' })}
</span>
</div>
</Modal>
)
}
return <></>
}
function SecretModal({
key,
title,
description,
placeholder,
value,
onChange,
onOk,
onCancel
}: {
/** forces per-connection mount */
key: string
title: string
description: string
placeholder: string
value: string
onChange: (value: string) => void
onOk: () => void
onCancel: () => void
}): React.JSX.Element {
const [submitting, setSubmitting] = useState(false)
const handleOk = (): void => {
setSubmitting(true)
onOk()
}
return (
<Modal
key={key}
open
title={title}
okText={t('workspace.connect.action')}
cancelText={t('common.cancel')}
onOk={handleOk}
onCancel={onCancel}
confirmLoading={submitting}
destroyOnHidden
width={380}
>
<div className="connect-flow-secret">
<div className="connect-flow-secret-target">{description}</div>
<Input.Password
autoFocus
autoComplete="off"
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
onPressEnter={handleOk}
disabled={submitting}
/>
</div>
</Modal>
)
}
/**
* Small "连接中…" indicator rendered in the toolbar while a connection attempt
* is in flight.
*/
export function ConnectSpinnerButton({ busy }: { busy: boolean }): React.JSX.Element | null {
return busy ? (
<Button size="small" type="text" disabled className="workspace-connect-busy-button">
<LoadingOutlined spin />
{t('workspace.connect.connecting')}
</Button>
) : null
}