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:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -121,7 +121,7 @@ export default function BatchCommandDialog({
|
||||
} finally {
|
||||
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||
}
|
||||
}, [timeout, updateResult])
|
||||
}, [timeout, updateResult, t])
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const commandText = command.trim()
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabId, timeout, updateResult]
|
||||
[tabId, timeout, updateResult, t]
|
||||
)
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Cable, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
|
||||
import { Cable, Cast, Monitor, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
type AvailableLocalShell,
|
||||
} from '../../platform/localTerminal'
|
||||
import { fallbackLocalShells } from '../../utils/platformShells'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
import {
|
||||
buildOpenTabPayload,
|
||||
defaultPortForProtocol,
|
||||
sessionProtocol,
|
||||
} from '../../utils/sessionProtocol'
|
||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
|
||||
interface QuickLauncherProps {
|
||||
@@ -18,6 +22,8 @@ interface QuickLauncherProps {
|
||||
onNewSession: () => void
|
||||
onQuickConnect: () => void
|
||||
onSerialConnect: () => void
|
||||
onRdpConnect?: () => void
|
||||
onVncConnect?: () => void
|
||||
}
|
||||
|
||||
export default function QuickLauncher({
|
||||
@@ -26,6 +32,8 @@ export default function QuickLauncher({
|
||||
onNewSession,
|
||||
onQuickConnect,
|
||||
onSerialConnect,
|
||||
onRdpConnect,
|
||||
onVncConnect,
|
||||
}: QuickLauncherProps) {
|
||||
const { t } = useTranslation(['common', 'sessions'])
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -86,7 +94,7 @@ export default function QuickLauncher({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
|
||||
<LogIn className="w-4 h-4 text-accent-blue" />
|
||||
<span>
|
||||
@@ -95,7 +103,7 @@ export default function QuickLauncher({
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
|
||||
<Plus className="w-4 h-4 text-accent-green" />
|
||||
<Plus className="w-4 h-4 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
|
||||
<small>{t('common:launcher.cards.saveSession.desc')}</small>
|
||||
@@ -108,6 +116,42 @@ export default function QuickLauncher({
|
||||
<small>{t('common:launcher.cards.serial.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
{onRdpConnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run(() => {
|
||||
if (!requirePro('rdp')) return
|
||||
onRdpConnect()
|
||||
})
|
||||
}
|
||||
className="launcher-card"
|
||||
>
|
||||
<Monitor className="w-4 h-4 text-accent-blue" />
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.rdp.title')}</strong>
|
||||
<small>{t('common:launcher.cards.rdp.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onVncConnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run(() => {
|
||||
if (!requirePro('vnc')) return
|
||||
onVncConnect()
|
||||
})
|
||||
}
|
||||
className="launcher-card"
|
||||
>
|
||||
<Cast className="w-4 h-4 text-accent-blue" />
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.vnc.title')}</strong>
|
||||
<small>{t('common:launcher.cards.vnc.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
@@ -131,7 +175,7 @@ export default function QuickLauncher({
|
||||
}))}
|
||||
className="launcher-row"
|
||||
>
|
||||
<Terminal className="w-4 h-4 text-emerald-400" />
|
||||
<Terminal className="w-4 h-4 text-accent-green" />
|
||||
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
|
||||
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
|
||||
</button>
|
||||
@@ -161,7 +205,10 @@ export default function QuickLauncher({
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<span className="flex-1 min-w-0 text-left">
|
||||
<strong className="truncate">{session.name}</strong>
|
||||
<small className="truncate">{session.username}@{session.host}:{session.port || 22}</small>
|
||||
<small className="truncate">
|
||||
{session.username}@{session.host}:
|
||||
{session.port || defaultPortForProtocol(sessionProtocol(session))}
|
||||
</small>
|
||||
</span>
|
||||
<span className="launcher-badge">{session.group || 'default'}</span>
|
||||
</button>
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '../../utils/sessionGroups'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { usePromptDialog } from '../common/PromptDialog'
|
||||
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
|
||||
import { getDisplayResolution } from '../../utils/desktopResolution'
|
||||
|
||||
const logger = createLogger('SessionDialog')
|
||||
|
||||
@@ -152,14 +154,17 @@ export default function SessionDialog({
|
||||
: [],
|
||||
domain: editSession.domain || '',
|
||||
security: editSession.security || 'nla',
|
||||
width: editSession.width || 1920,
|
||||
height: editSession.height || 1080,
|
||||
width: editSession.width || getDisplayResolution().width,
|
||||
height: editSession.height || getDisplayResolution().height,
|
||||
})
|
||||
setShowAdvanced(hasAdvanced)
|
||||
} else {
|
||||
const display = getDisplayResolution()
|
||||
setFormData({
|
||||
...emptyForm,
|
||||
group: normalizeGroupId(defaultGroup),
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
})
|
||||
setTarget('')
|
||||
setShowAdvanced(false)
|
||||
@@ -185,12 +190,15 @@ export default function SessionDialog({
|
||||
prev.port === defaultPortForProtocol(prev.protocol)
|
||||
? defaultPortForProtocol(protocol)
|
||||
: prev.port
|
||||
const display = getDisplayResolution()
|
||||
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
|
||||
return {
|
||||
...prev,
|
||||
protocol,
|
||||
username: nextUser,
|
||||
port: nextPort,
|
||||
authType: 'password',
|
||||
...(applyDisplaySize ? { width: display.width, height: display.height } : {}),
|
||||
}
|
||||
})
|
||||
// Keep host-only target when switching protocol.
|
||||
@@ -394,6 +402,50 @@ export default function SessionDialog({
|
||||
{ value: 'key', label: t('dialog.authKey'), icon: Key },
|
||||
]
|
||||
|
||||
const groupField = (
|
||||
<div>
|
||||
<label htmlFor="session-group" className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
{t('dialog.group')}
|
||||
</label>
|
||||
<select
|
||||
id="session-group"
|
||||
value={normalizeGroupId(formData.group)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value !== '__new__') {
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const raw = await prompt({
|
||||
title: t('groups.new.title'),
|
||||
message: t('groups.new.message'),
|
||||
placeholder: t('groups.new.placeholder'),
|
||||
confirmText: t('groups.new.confirm'),
|
||||
})
|
||||
if (raw == null) return
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
|
||||
})()
|
||||
}}
|
||||
className="ui-input"
|
||||
>
|
||||
{knownGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{groupDisplayName(group)}
|
||||
</option>
|
||||
))}
|
||||
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
||||
<option value={normalizeGroupId(formData.group)}>
|
||||
{groupDisplayName(formData.group)}
|
||||
</option>
|
||||
)}
|
||||
<option value="__new__">{t('groups.newOption')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">{t('groups.hint')}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
@@ -549,73 +601,30 @@ export default function SessionDialog({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{formData.protocol !== 'ssh' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label>
|
||||
<input
|
||||
id="session-width"
|
||||
type="number"
|
||||
value={formData.width}
|
||||
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-height">{t('dialog.height')}</label>
|
||||
<input
|
||||
id="session-height"
|
||||
type="number"
|
||||
value={formData.height}
|
||||
onChange={(e) => setFormData({ ...formData, height: Number(e.target.value) || 1080 })}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</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-group" className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
{t('dialog.group')}
|
||||
</label>
|
||||
<select
|
||||
id="session-group"
|
||||
value={normalizeGroupId(formData.group)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value !== '__new__') {
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const raw = await prompt({
|
||||
title: t('groups.new.title'),
|
||||
message: t('groups.new.message'),
|
||||
placeholder: t('groups.new.placeholder'),
|
||||
confirmText: t('groups.new.confirm'),
|
||||
})
|
||||
if (raw == null) return
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
|
||||
})()
|
||||
}}
|
||||
className="ui-input"
|
||||
>
|
||||
{knownGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{groupDisplayName(group)}
|
||||
</option>
|
||||
))}
|
||||
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
||||
<option value={normalizeGroupId(formData.group)}>
|
||||
{groupDisplayName(formData.group)}
|
||||
</option>
|
||||
)}
|
||||
<option value="__new__">{t('groups.newOption')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">
|
||||
{t('groups.hint')}
|
||||
</p>
|
||||
</div>
|
||||
{formData.protocol !== 'ssh' && (
|
||||
<DesktopResolutionPicker
|
||||
ns="sessions"
|
||||
width={formData.width}
|
||||
height={formData.height}
|
||||
onChange={(next) => setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))}
|
||||
/>
|
||||
)}
|
||||
{/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */}
|
||||
{formData.protocol === 'rdp' && groupField}
|
||||
</section>
|
||||
|
||||
{/* 凭据 */}
|
||||
@@ -647,7 +656,7 @@ export default function SessionDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(formData.authType === 'password' || !isSsh) && (
|
||||
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
|
||||
<input
|
||||
@@ -774,6 +783,7 @@ export default function SessionDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isSsh || formData.protocol === 'vnc') && groupField}
|
||||
</section>
|
||||
|
||||
{/* 高级 */}
|
||||
|
||||
@@ -10,7 +10,11 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
|
||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||
import { shellDisplayFromPath } from '../../utils/shellName'
|
||||
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
import {
|
||||
buildOpenTabPayload,
|
||||
defaultPortForProtocol,
|
||||
sessionProtocol,
|
||||
} from '../../utils/sessionProtocol'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
@@ -273,7 +277,8 @@ export default function WelcomeView({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-txt-faint">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
{session.username}@{session.host}:
|
||||
{session.port || defaultPortForProtocol(sessionProtocol(session))}
|
||||
</span>
|
||||
</span>
|
||||
{session.group ? (
|
||||
|
||||
Reference in new issue
Block a user