chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+341 -319
View File
@@ -22,6 +22,7 @@ import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
import { getDisplayResolution } from '../../utils/desktopResolution'
import { useToast } from '../../hooks/useToast'
import { translateErrorMessage } from '../../i18n/format'
import AppOverlay from '../common/AppOverlay'
const logger = createLogger('SessionDialog')
@@ -45,6 +46,18 @@ type KeyInputMode = 'file' | 'text'
const DEFAULT_KEY_PATH = '~/.ssh/id_ed25519'
function defaultDisplayName(protocol: SessionProtocol, host: string, username: string): string {
if (!host) return ''
return protocol === 'vnc' ? host : `${username}@${host}`
}
function isGeneratedDisplayName(name: string, host: string, previousHost = ''): boolean {
const trimmed = name.trim()
if (!trimmed) return true
if (trimmed.includes('@')) return true
return [host, previousHost].filter(Boolean).some((value) => trimmed === value || trimmed === `vnc:${value}`)
}
function isPemPrivateKey(value: string | undefined | null): boolean {
return Boolean(value && value.includes('-----BEGIN'))
}
@@ -220,10 +233,9 @@ export default function SessionDialog({
host: parsed.host,
port: targetIncludesPort(trimmed) ? parsed.port : prev.port,
username: parsed.username || prev.username,
name:
prev.name.trim() && !prev.name.includes('@')
? prev.name
: `${parsed.username || prev.username}@${parsed.host}`,
name: isGeneratedDisplayName(prev.name, parsed.host, prev.host)
? `${parsed.username || prev.username}@${parsed.host}`
: prev.name,
}))
}
return
@@ -235,12 +247,13 @@ export default function SessionDialog({
...prev,
host,
port: portMatch ? Number(portMatch[1]) : prev.port,
name:
prev.name.trim() && !prev.name.includes('@') && !prev.name.startsWith('vnc:')
? prev.name
: formData.protocol === 'vnc'
? `vnc:${host}`
: `${prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')}@${host}`,
name: isGeneratedDisplayName(prev.name, host, prev.host)
? defaultDisplayName(
formData.protocol,
host,
prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')
)
: prev.name,
}))
}
@@ -320,7 +333,7 @@ export default function SessionDialog({
: undefined
const sessionData = {
name: formData.name.trim() || (formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`),
name: formData.name.trim() || defaultDisplayName(formData.protocol, host, username),
host,
port,
username,
@@ -436,8 +449,7 @@ export default function SessionDialog({
return (
<>
<div className="fixed inset-0 z-[100] flex justify-end">
{/* Backdrop */}
<AppOverlay open className="z-[100] flex justify-end">
<div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
{/* Drawer */}
@@ -450,7 +462,7 @@ export default function SessionDialog({
{/* Header */}
<div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
<Server className="w-4.5 h-4.5 h-4 w-4" />
<Server className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
@@ -507,7 +519,11 @@ export default function SessionDialog({
autoFocus={!isEditing}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('dialog.displayNamePlaceholder')}
placeholder={
formData.protocol === 'vnc'
? t('dialog.displayNamePlaceholderVnc')
: t('dialog.displayNamePlaceholder')
}
className="ui-input"
/>
</div>
@@ -576,12 +592,13 @@ export default function SessionDialog({
setFormData((prev) => ({
...prev,
username,
name:
prev.name.trim() && !prev.name.includes('@')
? prev.name
: prev.host
? `${username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')}@${prev.host}`
: prev.name,
name: isGeneratedDisplayName(prev.name, prev.host)
? defaultDisplayName(
prev.protocol,
prev.host,
username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')
)
: prev.name,
}))
}}
placeholder={
@@ -595,20 +612,34 @@ export default function SessionDialog({
</div>
)}
{formData.protocol === 'rdp' && (
<div>
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
<>
<div>
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
<div>
<label className="ui-label" htmlFor="session-domain">
{t('dialog.domain')}
</label>
<input
id="session-domain"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
className="ui-input font-mono"
placeholder={t('dialog.domainPlaceholder')}
/>
</div>
</>
)}
{formData.protocol !== 'ssh' && (
<DesktopResolutionPicker
@@ -622,168 +653,155 @@ export default function SessionDialog({
{formData.protocol === 'rdp' && groupField}
</section>
{/* 凭据 */}
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('dialog.login')}
</div>
{isSsh && (
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{authTypes.map((type) => {
const Icon = type.icon
const active = formData.authType === type.value
return (
<button
key={type.value}
type="button"
onClick={() => setFormData({ ...formData, authType: type.value })}
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
<Icon className="h-3.5 w-3.5" />
{type.label}
</button>
)
})}
{/* 凭据:SSH / VNC。RDP 的密码和域已在基本区,避免重复。 */}
{(isSsh || formData.protocol === 'vnc') && (
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('dialog.login')}
</div>
)}
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
<div>
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
)}
{formData.protocol === 'rdp' && (
<div>
<label className="ui-label" htmlFor="session-domain">
{t('dialog.domain')}
</label>
<input
id="session-domain"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
className="ui-input font-mono"
placeholder={t('dialog.domainPlaceholder')}
/>
</div>
)}
{isSsh && formData.authType === 'key' && (
<div className="space-y-2.5">
<div>
<div className="ui-label">{t('dialog.keySource')}</div>
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{(
[
['file', t('dialog.keyFile')],
['text', t('dialog.keyText')],
] as const
).map(([value, label]) => {
const active = formData.keyInputMode === value
return (
<button
key={value}
type="button"
onClick={() => setFormData({ ...formData, keyInputMode: value })}
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{formData.keyInputMode === 'file' ? (
<div>
<label htmlFor="session-private-key" className="ui-label">
{t('dialog.privateKeyPath')}
</label>
<div className="flex gap-1.5">
<input
id="session-private-key"
type="text"
value={formData.privateKeyPath}
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
placeholder={DEFAULT_KEY_PATH}
className="ui-input flex-1"
/>
{isSsh && (
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{authTypes.map((type) => {
const Icon = type.icon
const active = formData.authType === type.value
return (
<button
key={type.value}
type="button"
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
onClick={async () => {
const result = await window.opsxApi?.showOpenDialog({
title: t('dialog.pickKeyDialogTitle'),
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
})
if (!result?.canceled && result?.filePaths?.[0]) {
setFormData({
...formData,
privateKeyPath: result.filePaths[0],
keyInputMode: 'file',
})
}
}}
onClick={() => setFormData({ ...formData, authType: type.value })}
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{t('dialog.pickKeyFile')}
<Icon className="h-3.5 w-3.5" />
{type.label}
</button>
</div>
</div>
) : (
<div>
<label htmlFor="session-private-key-text" className="ui-label">
{t('dialog.privateKeyText')}
</label>
<textarea
id="session-private-key-text"
value={formData.privateKeyText}
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
rows={6}
spellCheck={false}
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
/>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
</div>
)}
)
})}
</div>
)}
{((isSsh && formData.authType === 'password') || formData.protocol === 'vnc') && (
<div>
<label htmlFor="session-passphrase" className="ui-label">
{t('dialog.passphrase')}
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-passphrase"
id="session-password"
type="password"
value={formData.passphrase}
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
placeholder={t('dialog.passphrasePlaceholder')}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
</div>
)}
)}
{(isSsh || formData.protocol === 'vnc') && groupField}
</section>
{isSsh && formData.authType === 'key' && (
<div className="space-y-2.5">
<div>
<div className="ui-label">{t('dialog.keySource')}</div>
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{(
[
['file', t('dialog.keyFile')],
['text', t('dialog.keyText')],
] as const
).map(([value, label]) => {
const active = formData.keyInputMode === value
return (
<button
key={value}
type="button"
onClick={() => setFormData({ ...formData, keyInputMode: value })}
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{formData.keyInputMode === 'file' ? (
<div>
<label htmlFor="session-private-key" className="ui-label">
{t('dialog.privateKeyPath')}
</label>
<div className="flex gap-1.5">
<input
id="session-private-key"
type="text"
value={formData.privateKeyPath}
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
placeholder={DEFAULT_KEY_PATH}
className="ui-input flex-1"
/>
<button
type="button"
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
onClick={async () => {
const result = await window.opsxApi?.showOpenDialog({
title: t('dialog.pickKeyDialogTitle'),
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
})
if (!result?.canceled && result?.filePaths?.[0]) {
setFormData({
...formData,
privateKeyPath: result.filePaths[0],
keyInputMode: 'file',
})
}
}}
>
{t('dialog.pickKeyFile')}
</button>
</div>
</div>
) : (
<div>
<label htmlFor="session-private-key-text" className="ui-label">
{t('dialog.privateKeyText')}
</label>
<textarea
id="session-private-key-text"
value={formData.privateKeyText}
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
rows={6}
spellCheck={false}
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
/>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
</div>
)}
<div>
<label htmlFor="session-passphrase" className="ui-label">
{t('dialog.passphrase')}
</label>
<input
id="session-passphrase"
type="password"
value={formData.passphrase}
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
placeholder={t('dialog.passphrasePlaceholder')}
className="ui-input"
/>
</div>
</div>
)}
{groupField}
</section>
)}
{/* 高级 */}
<div className="border-dark-700/50 overflow-hidden rounded-xl border">
@@ -794,7 +812,7 @@ export default function SessionDialog({
>
<span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span>
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
{showAdvanced ? t('dialog.collapse') : t('dialog.advancedCollapsed')}
{showAdvanced ? t('dialog.collapse') : isSsh ? t('dialog.advancedCollapsed') : t('dialog.tags')}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
</span>
</button>
@@ -817,146 +835,150 @@ export default function SessionDialog({
</div>
</div>
<div>
<label className="ui-label">{t('dialog.startupCommand')}</label>
<input
type="text"
value={formData.startupCommand}
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
placeholder={t('dialog.startupCommandPlaceholder')}
className="ui-input-sm font-mono"
/>
</div>
{isSsh && (
<>
<div>
<label className="ui-label">{t('dialog.startupCommand')}</label>
<input
type="text"
value={formData.startupCommand}
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
placeholder={t('dialog.startupCommandPlaceholder')}
className="ui-input-sm font-mono"
/>
</div>
<div>
<label className="ui-label">{t('dialog.proxy')}</label>
<div className="mb-2 flex gap-1">
{(
[
{ value: 'none', label: t('dialog.proxyNone') },
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
).map((t) => (
<button
key={t.value}
type="button"
onClick={() => {
if (t.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
return
}
<div>
<label className="ui-label">{t('dialog.proxy')}</label>
<div className="mb-2 flex gap-1">
{(
[
{ value: 'none', label: t('dialog.proxyNone') },
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
if (option.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
return
}
setFormData({
...formData,
proxy: {
...formData.proxy,
type: option.value,
port:
formData.proxy.port ||
(option.value === 'http' ? 8080 : option.value === 'socks5' ? 1080 : 0),
},
jumpSessionIds: option.value === 'none' ? formData.jumpSessionIds : [],
})
}}
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
formData.proxy.type === option.value
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-dark-600 text-txt-muted hover:border-dark-500'
}`}
>
{option.label}
</button>
))}
</div>
{formData.proxy.type !== 'none' && (
<div className="space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<input
type="text"
value={formData.proxy.host}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, host: e.target.value },
})
}
placeholder={t('dialog.proxyHostPlaceholder')}
className="ui-input-sm col-span-2"
/>
<input
type="number"
value={formData.proxy.port || ''}
onChange={(e) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
port: parseInt(e.target.value) || 0,
},
})
}
placeholder={t('dialog.proxyPortPlaceholder')}
className="ui-input-sm"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<input
type="text"
value={formData.proxy.username}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, username: e.target.value },
})
}
placeholder={t('dialog.proxyUserPlaceholder')}
autoComplete="off"
className="ui-input-sm"
/>
<input
type="password"
value={formData.proxy.password}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, password: e.target.value },
})
}
placeholder={t('dialog.proxyPasswordPlaceholder')}
autoComplete="new-password"
className="ui-input-sm"
/>
</div>
</div>
)}
</div>
<div>
<label htmlFor="session-jump-host" className="ui-label">
{t('dialog.jumpHost')}
</label>
<select
id="session-jump-host"
value={formData.jumpSessionIds[0] || ''}
onChange={(event) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
type: t.value,
port:
formData.proxy.port ||
(t.value === 'http' ? 8080 : t.value === 'socks5' ? 1080 : 0),
},
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
jumpSessionIds: event.target.value ? [event.target.value] : [],
proxy: event.target.value
? { type: 'none', host: '', port: 0, username: '', password: '' }
: formData.proxy,
})
}}
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
formData.proxy.type === t.value
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-dark-600 text-txt-muted hover:border-dark-500'
}`}
}
className="ui-input-sm"
>
{t.label}
</button>
))}
</div>
{formData.proxy.type !== 'none' && (
<div className="space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<input
type="text"
value={formData.proxy.host}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, host: e.target.value },
})
}
placeholder={t('dialog.proxyHostPlaceholder')}
className="ui-input-sm col-span-2"
/>
<input
type="number"
value={formData.proxy.port || ''}
onChange={(e) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
port: parseInt(e.target.value) || 0,
},
})
}
placeholder={t('dialog.proxyPortPlaceholder')}
className="ui-input-sm"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<input
type="text"
value={formData.proxy.username}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, username: e.target.value },
})
}
placeholder={t('dialog.proxyUserPlaceholder')}
autoComplete="off"
className="ui-input-sm"
/>
<input
type="password"
value={formData.proxy.password}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, password: e.target.value },
})
}
placeholder={t('dialog.proxyPasswordPlaceholder')}
autoComplete="new-password"
className="ui-input-sm"
/>
</div>
<option value="">{t('dialog.jumpNoneAlt')}</option>
{jumpCandidates.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.username}@{session.host})
</option>
))}
</select>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
</div>
)}
</div>
<div>
<label htmlFor="session-jump-host" className="ui-label">
{t('dialog.jumpHost')}
</label>
<select
id="session-jump-host"
value={formData.jumpSessionIds[0] || ''}
onChange={(event) =>
setFormData({
...formData,
jumpSessionIds: event.target.value ? [event.target.value] : [],
proxy: event.target.value
? { type: 'none', host: '', port: 0, username: '', password: '' }
: formData.proxy,
})
}
className="ui-input-sm"
>
<option value="">{t('dialog.jumpNoneAlt')}</option>
{jumpCandidates.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.username}@{session.host})
</option>
))}
</select>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
</div>
</>
)}
</div>
)}
</div>
@@ -985,7 +1007,7 @@ export default function SessionDialog({
</div>
</form>
</aside>
</div>
</AppOverlay>
{PromptDialogComponent}
</>
)