feat: harden local shell, SFTP, and desktop session reliability

Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-16 16:08:11 +08:00
1 parent 188f3556ed
commit 08696896f9
223 files changed
+13493 -3423

No files matched your search

+348
View File
@@ -0,0 +1,348 @@
/**
* AccountSettings — license email, plan status, activate / devices / clear
* (standalone settings nav tab, not under General)
*/
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import { useEntitlementStore } from '../../stores/entitlementStore'
import {
deactivateLicenseDevice,
listLicenseDevices,
type LicenseDevice,
} from '../../platform/licenseApi'
function looksLikeValidLicenseKey(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
}
function planLabel(
t: (key: string) => string,
plan: 'free' | 'pro',
planType: string | null
): string {
if (plan !== 'pro') return t('account.planFree')
switch (planType) {
case 'yearly':
return t('account.planYearly')
case 'trial':
return t('account.planTrial')
case 'perpetual':
case 'lifetime':
return t('account.planPerpetual')
default:
return t('account.planPerpetual')
}
}
export default function AccountSettings() {
const { t } = useTranslation('settings')
const { t: tc } = useTranslation('common')
const plan = useEntitlementStore((s) => s.plan)
const email = useEntitlementStore((s) => s.email)
const deviceId = useEntitlementStore((s) => s.deviceId)
const licenseKey = useEntitlementStore((s) => s.licenseKey)
const expiresAt = useEntitlementStore((s) => s.expiresAt)
const maxDevices = useEntitlementStore((s) => s.maxDevices)
const planType = useEntitlementStore((s) => s.planType)
const hydrate = useEntitlementStore((s) => s.hydrate)
const setEmail = useEntitlementStore((s) => s.setEmail)
const activate = useEntitlementStore((s) => s.activate)
const clear = useEntitlementStore((s) => s.clear)
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
const [emailInput, setEmailInput] = useState(email)
const [licenseInput, setLicenseInput] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [devices, setDevices] = useState<LicenseDevice[]>([])
const [deviceUsed, setDeviceUsed] = useState(0)
const [deviceMax, setDeviceMax] = useState(0)
const [devicesLoading, setDevicesLoading] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(null)
useEffect(() => {
void hydrate()
}, [hydrate])
useEffect(() => {
setEmailInput(email)
}, [email])
const refreshDevices = useCallback(async () => {
if (plan !== 'pro' || !licenseKey) {
setDevices([])
setDeviceUsed(0)
setDeviceMax(0)
return
}
setDevicesLoading(true)
try {
const result = await listLicenseDevices(licenseKey)
setDevices(
result.devices.map((d) => ({
...d,
isCurrent: Boolean(deviceId && d.deviceId === deviceId),
}))
)
setDeviceUsed(result.used)
setDeviceMax(result.maxDevices || maxDevices || 0)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setDevicesLoading(false)
}
}, [deviceId, licenseKey, maxDevices, plan])
useEffect(() => {
void refreshDevices()
}, [refreshDevices])
const saveEmail = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
try {
await setEmail(emailInput.trim())
setMessage(t('account.saved'))
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}, [emailInput, setEmail, t])
const handleActivate = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
if (!looksLikeValidLicenseKey(licenseInput)) {
setError(tc('pro.invalidKeyFormat'))
setBusy(false)
return
}
if (emailInput.trim()) await setEmail(emailInput.trim())
const result = await activate(licenseInput, emailInput.trim() || undefined)
if (result.success) {
setMessage(t('account.activateOk'))
setLicenseInput('')
await refreshDevices()
} else {
setError(result.error || tc('pro.activateFailed'))
}
setBusy(false)
}, [activate, emailInput, licenseInput, refreshDevices, setEmail, t, tc])
const handleDeactivate = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
await clear()
setDevices([])
setMessage(t('account.saved'))
setBusy(false)
}, [clear, t])
const handleRemoveDevice = useCallback(
async (target: LicenseDevice) => {
if (!licenseKey || target.isCurrent) return
setRemovingId(target.deviceId)
setError(null)
setMessage(null)
try {
await deactivateLicenseDevice(licenseKey, target.deviceId)
setMessage(t('account.deviceRemoved'))
await refreshDevices()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setRemovingId(null)
}
},
[licenseKey, refreshDevices, t]
)
const atSeatLimit = deviceMax > 0 && deviceUsed >= deviceMax
return (
<div className="space-y-6">
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.title')}</h3>
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<Row
label={t('account.plan')}
description={
plan === 'pro'
? expiresAt
? t('account.expires', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('account.lifetime')
: undefined
}
>
<span className="text-sm text-content-primary">
{planLabel(t, plan, planType)}
{maxDevices ? ` · ${t('account.devices', { count: maxDevices })}` : ''}
</span>
</Row>
<Row label={t('account.email')} description={t('account.emailDesc')}>
<div className="flex min-w-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<input
type="email"
autoComplete="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
placeholder="you@example.com"
className="w-56 rounded-lg border border-dark-600 bg-dark-900 px-3 py-1.5 text-sm text-content-primary outline-none focus:border-accent-blue"
/>
<button
type="button"
disabled={busy}
onClick={() => void saveEmail()}
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-content-primary hover:bg-dark-600 disabled:opacity-40"
>
{t('account.saveEmail')}
</button>
</div>
</Row>
<Row label={t('account.deviceId')}>
<code className="max-w-[14rem] truncate text-xs text-txt-muted" title={deviceId || '—'}>
{deviceId || '—'}
</code>
</Row>
</div>
</div>
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.activate')}</h3>
<div className="border-dark-700/45 bg-dark-800/50 space-y-3 overflow-hidden rounded-2xl border p-4">
<textarea
value={licenseInput}
onChange={(e) => setLicenseInput(e.target.value)}
rows={3}
spellCheck={false}
placeholder={t('account.pasteLicense')}
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy || !licenseInput.trim()}
onClick={() => void handleActivate()}
className="rounded-lg bg-accent-blue px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-40"
>
{t('account.activate')}
</button>
<button
type="button"
disabled={busy}
onClick={() => openUpgradeTab()}
className="rounded-lg border border-dark-600 px-3 py-1.5 text-xs text-content-secondary hover:bg-dark-700"
>
{t('account.upgrade')}
</button>
{plan === 'pro' && (
<button
type="button"
disabled={busy}
onClick={() => void handleDeactivate()}
className="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
>
{t('account.deactivate')}
</button>
)}
</div>
{message && <p className="text-xs text-accent-green">{message}</p>}
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
</div>
{plan === 'pro' && licenseKey ? (
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">
{t('account.deviceList')}
{deviceMax > 0 ? ` · ${deviceUsed}/${deviceMax}` : ''}
</h3>
<div className="border-dark-700/45 bg-dark-800/50 overflow-hidden rounded-2xl border">
{atSeatLimit ? (
<p className="border-b border-dark-700/35 px-4 py-2.5 text-xs text-feedback-warning">
{t('account.seatFull')}
</p>
) : null}
{devicesLoading ? (
<div className="flex items-center gap-2 px-4 py-6 text-xs text-txt-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('account.loadingDevices')}
</div>
) : devices.length === 0 ? (
<p className="px-4 py-6 text-xs text-txt-muted">{t('account.noDevices')}</p>
) : (
<ul className="divide-dark-700/35 divide-y">
{devices.map((d) => (
<li
key={d.deviceId || String(d.id)}
className="flex flex-wrap items-center justify-between gap-2 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-content-primary">
{d.deviceName || 'OpsXShell'}
{d.isCurrent ? (
<span className="ml-2 text-xs text-accent-blue">
{t('account.thisDevice')}
</span>
) : null}
</div>
<code className="mt-0.5 block truncate text-[11px] text-txt-muted">
{d.deviceId}
</code>
</div>
{!d.isCurrent ? (
<button
type="button"
disabled={removingId === d.deviceId}
onClick={() => void handleRemoveDevice(d)}
className="rounded-lg border border-red-500/35 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
{removingId === d.deviceId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
t('account.removeDevice')
)}
</button>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
) : null}
</div>
)
}
function Row({
label,
description,
children,
}: {
label: string
description?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
<div className="min-w-[11rem] flex-1 basis-[14rem]">
<div className="text-sm font-medium text-content-primary">{label}</div>
{description ? <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div> : null}
</div>
<div className="ml-auto flex-shrink-0">{children}</div>
</div>
)
}
@@ -202,7 +202,7 @@ export default function AiProviderSettings() {
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
{presetsByFamily(familyId).map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
</option>
))}
</optgroup>
+66 -61
View File
@@ -5,7 +5,6 @@ import { useKeybindingStore } from '../../stores/keybindingStore'
import type { AppLanguage } from '../../i18n/detectLocale'
import {
Monitor,
Palette,
Terminal,
Keyboard,
Shield,
@@ -18,13 +17,16 @@ import {
Copy,
Check,
Sparkles,
UserRound,
} from 'lucide-react'
import AiProviderSettings from './AiProviderSettings'
import AccountSettings from './AccountSettings'
import { themes, type TerminalThemeId } from '../../themes/terminal'
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
interface TabMeta {
id: SettingsTab
@@ -41,11 +43,12 @@ interface RuntimeInfo {
sshBackend: string
dataPath: string
credentialPath: string
logPath: string
}
const TAB_LIST: TabMeta[] = [
{ id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
{ id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette },
{ id: 'account', labelKey: 'nav.account', descriptionKey: 'nav.accountDesc', icon: UserRound },
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
@@ -148,25 +151,13 @@ export default function SettingsView() {
// 当前 tab 元信息
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
// ── 搜索过滤 ──────────────────────────────────────────────
// ── 搜索过滤(跨语种:Language / 语言 / 言語 … 均可命中)────────
const filtered = useMemo((): SettingsTab[] | null => {
if (!searchQuery.trim()) return null
const q = searchQuery.toLowerCase()
const keywords: Record<SettingsTab, string[]> = {
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'],
ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'],
shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'],
ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'],
security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'],
about: [t('nav.about'), 'version', 'about', 'license'],
}
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
.map(([id]) => id)
return matchIds.length > 0 ? matchIds : []
}, [searchQuery, t])
return filterSettingsTabsByQuery(
TAB_LIST.map((tab) => tab.id as SettingsSearchTab),
searchQuery
)
}, [searchQuery])
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
useEffect(() => {
@@ -182,6 +173,7 @@ export default function SettingsView() {
`OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`,
`Data: ${runtimeInfo.dataPath}`,
`Log: ${runtimeInfo.logPath}`,
].join('\n')
: `OpsXShell v${appVersion}`
@@ -197,10 +189,39 @@ export default function SettingsView() {
// ── 渲染内容区 ────────────────────────────────────────────
const renderContent = () => {
switch (activeTab) {
// ──────── 常规 ─────────
// ──────── 通用(主题 / 语言 / 工作区)─────────
case 'general':
return (
<div className="space-y-6">
<SettingGroup title={t('appearance.themeGroup')}>
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">{t('appearance.dark')}</option>
<option value="light">{t('appearance.light')}</option>
<option value="system">{t('appearance.system')}</option>
</select>
</SettingRow>
<SettingRow label={t('language.label')} description={t('language.description')}>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
className="ui-select-pill"
>
<option value="zh-CN">{t('language.zhCN')}</option>
<option value="zh-TW">{t('language.zhTW')}</option>
<option value="en">{t('language.en')}</option>
<option value="ja">{t('language.ja')}</option>
<option value="ko">{t('language.ko')}</option>
<option value="vi">{t('language.vi')}</option>
<option value="ru">{t('language.ru')}</option>
</select>
</SettingRow>
</SettingGroup>
<SettingGroup title={t('general.layout')}>
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
<div className="flex items-center gap-2">
@@ -238,40 +259,8 @@ export default function SettingsView() {
</div>
)
// ──────── 外观 ─────────
case 'appearance':
return (
<div className="space-y-6">
<SettingGroup title={t('appearance.themeGroup')}>
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">{t('appearance.dark')}</option>
<option value="light">{t('appearance.light')}</option>
<option value="system">{t('appearance.system')}</option>
</select>
</SettingRow>
<SettingRow label={t('language.label')} description={t('language.description')}>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
className="ui-select-pill"
>
<option value="zh-CN">{t('language.zhCN')}</option>
<option value="zh-TW">{t('language.zhTW')}</option>
<option value="en">{t('language.en')}</option>
<option value="ja">{t('language.ja')}</option>
<option value="ko">{t('language.ko')}</option>
<option value="vi">{t('language.vi')}</option>
<option value="ru">{t('language.ru')}</option>
</select>
</SettingRow>
</SettingGroup>
</div>
)
case 'account':
return <AccountSettings />
// ──────── 终端 ─────────
case 'terminal':
@@ -520,6 +509,16 @@ export default function SettingsView() {
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
<SettingRow label={t('ssh.keepAliveInterval')} description={t('ssh.keepAliveIntervalDesc')}>
<input
type="number"
min={5}
max={120}
value={settings.keepAliveIntervalSec}
onChange={(e) => setSetting('keepAliveIntervalSec', parseInt(e.target.value) || 15)}
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
<SettingToggle
label={t('ssh.autoReconnect')}
description={t('ssh.autoReconnectDesc')}
@@ -572,10 +571,10 @@ export default function SettingsView() {
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
<input
type="number"
min={1}
max={120}
min={5}
max={240}
value={Math.round(settings.idleTimeoutMs / 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
@@ -662,6 +661,12 @@ export default function SettingsView() {
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code>
</div>
<div>
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div>
<code className="block break-all text-content-secondary">
{runtimeInfo?.logPath ?? t('common:status.reading')}
</code>
</div>
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
</div>
</SettingCard>
@@ -711,9 +716,9 @@ export default function SettingsView() {
key={tab.id}
data-testid={`settings-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
>
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>