Private
Public Access
1
0

新增:SFTP直连+连接池+autoConnect+文件服务器端口自动回退

- SFTP模块:连接/断开/文件CRUD/系统信息采集/base64二进制写入
- 连接池:多服务器同时在线,瞬间切换profile
- autoConnect:启动时自动连接所有非本地服务器
- 端口自动回退:listenWithFallback消除TOCTOU,解决端口冲突崩溃
- 文件服务器URL集中管理:file-server.ts消除8+处硬编码端口
- Sidebar设置面板:添加服务器/自动连接/自动刷新开关
- 修复:validateFilePath越界panic、正则预编译
- 修复:注释准确性(RemoveAll/端口8073/动态端口文档)
This commit is contained in:
2026-05-04 15:33:19 +08:00
parent 6eaaa56eb6
commit 6bee55b96f
41 changed files with 2620 additions and 458 deletions

View File

@@ -1,39 +1,61 @@
/**
* 连接管理器 — 管理本地/远程传输层切换
* 连接管理器 — 管理本地/远程/SFTP 传输层切换
* 配置持久化SQLite后端local profile 仅内存
*/
import type { FsTransport } from './transport'
import { WailsTransport } from './wails-transport'
import { HttpTransport } from './http-transport'
import { SftpTransport } from './sftp-transport'
import { getFileServerBaseURL } from './file-server'
import {
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
SftpGetSystemInfo, GetLocalSystemInfo,
} from '@bindings/u-desk/app'
export type ConnectionType = 'local' | 'remote'
export type ConnectionType = 'local' | 'remote' | 'sftp'
export interface ConnectionProfile {
id: string
id: string | number
name: string
host: string
port: number
token: string
type: ConnectionType
username?: string
password?: string
keyPath?: string
lastConnected?: number
}
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
const PROFILES_KEY = 'fs_connection_profiles'
const ACTIVE_KEY = 'fs_active_connection'
export interface SystemInfo {
cpuCores?: number
cpuUsage?: string
memTotal?: number
memUsed?: number
memUsage?: string
diskTotal?: number
diskUsed?: number
diskUsage?: string
_error?: boolean
_errorMsg?: string
}
class ConnectionManagerImpl {
private _transport: FsTransport | null = null
/** 连接池profileId → transport 实例 */
private _pool = new Map<string, FsTransport>()
private _profiles: ConnectionProfile[] = []
private _activeId: string | null = null
private _state: ConnectionState = 'disconnected'
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
private _connectSeq = 0
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
private _sysInfoCache = new Map<string, SystemInfo>()
constructor() {
this.loadProfiles()
this.initDefaultLocal()
this.loadFromDB()
}
private initDefaultLocal() {
@@ -48,26 +70,68 @@ class ConnectionManagerImpl {
if (!this._profiles.find(p => p.id === localProfile.id)) {
this._profiles.unshift(localProfile)
}
// 默认连接本地
if (!this._activeId) {
this._activeId = localProfile.id
}
this.applyActive()
// local 直接入池,无需连接
this._pool.set('local-default', new WailsTransport())
this.setState('connected')
}
private loadProfiles() {
/** 从 SQLite 加载配置 */
private async loadFromDB() {
try {
const raw = localStorage.getItem(PROFILES_KEY)
if (raw) this._profiles = JSON.parse(raw)
this._activeId = localStorage.getItem(ACTIVE_KEY)
const list = await LoadConnectionProfiles()
if (list && list.length > 0) {
this._profiles = list.map((p: any) => ({
...p,
id: String(p.id),
lastConnected: p.lastConnected || p.last_connected ? new Date(p.lastConnected || p.last_connected).getTime() : undefined,
}))
const hasLocal = this._profiles.some(p => p.type === 'local')
if (!hasLocal) {
this._profiles.unshift({
id: 'local-default', name: '本地', host: '', port: 0, token: '', type: 'local',
})
}
}
} catch { /* 首次使用 */ }
this.notifyChange()
this._profiles.forEach(p => {
if (p.type === 'local') { this.fetchSystemInfo(p.id).catch(() => {}) }
})
const autoConnect = localStorage.getItem('desk:autoConnect')
if (autoConnect !== 'false') {
for (const p of this._profiles) {
if (p.type !== 'local') {
this.buildAndPool(String(p.id), p).catch(() => {})
}
}
}
}
private saveProfiles() {
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
if (this._activeId) {
localStorage.setItem(ACTIVE_KEY, this._activeId)
}
/** 保存/更新单个 profile 到 SQLite */
private async persistProfile(profile: ConnectionProfile) {
if (profile.type === 'local') return
const id = profile.id !== 'local-default' ? Number(profile.id) : 0
await SaveConnectionProfile({
id: id > 0 ? id : undefined,
name: profile.name,
host: profile.host,
port: profile.port,
username: profile.username || 'root',
password: profile.password || '',
keyPath: profile.keyPath || '',
type: profile.type,
token: profile.token || '',
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
})
}
/** 从 SQLite 删除 profile */
private async removePersisted(id: string) {
if (id === 'local-default') return
await DeleteConnectionProfile(Number(id))
}
private setState(state: ConnectionState) {
@@ -83,117 +147,236 @@ class ConnectionManagerImpl {
this._stateChangeCallbacks.push(cb)
}
get state(): ConnectionState {
return this._state
onSystemInfoChange(cb: (profileId: string, info: SystemInfo) => void) {
this._sysInfoChangeCallbacks.push(cb)
}
get profiles(): ConnectionProfile[] {
return [...this._profiles]
}
get state(): ConnectionState { return this._state }
get profiles(): ConnectionProfile[] { return [...this._profiles] }
get activeProfile(): ConnectionProfile | null {
return this._profiles.find(p => p.id === this._activeId) ?? null
}
/** 获取当前激活的 transport从池中取 */
getTransport(): FsTransport {
if (!this._transport) {
this.applyActive()
if (this._activeId && this._pool.has(this._activeId)) {
return this._pool.get(this._activeId)!
}
return this._transport!
return this._pool.get('local-default')!
}
/** 获取指定 profile 的 transport用于跨 profile 操作如采集系统信息) */
getTransportFor(profileId: string): FsTransport | null {
return this._pool.get(profileId) ?? null
}
getFileServerBaseURL(): string {
if (this._transport instanceof HttpTransport) {
const t = this.getTransport()
if (t instanceof HttpTransport) {
const profile = this.activeProfile
if (!profile) return ''
const scheme = profile.port === 443 ? 'https' : 'http'
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
return `${scheme}://${profile.host}${port}`
}
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
if (t instanceof SftpTransport) { return getFileServerBaseURL() }
return ''
}
isSftp(): boolean { return this.activeProfile?.type === 'sftp' }
isRemote(): boolean {
return this.activeProfile?.type === 'remote'
const t = this.activeProfile?.type
return t === 'remote' || t === 'sftp'
}
connect(profileId: string): void {
getSystemInfo(profileId: string): SystemInfo | undefined {
return this._sysInfoCache.get(profileId)
}
async fetchSystemInfo(profileId: string): Promise<SystemInfo> {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return
if (!profile) return { _error: true }
const info: SystemInfo = {}
try {
if (profile.type === 'local') {
const data = await GetLocalSystemInfo()
if (data) Object.assign(info, data)
} else if (profile.type === 'sftp') {
const t = this.getTransportFor(profileId)
if (t instanceof SftpTransport && t.sessionId) {
const data = await SftpGetSystemInfo(t.sessionId)
if (data) Object.assign(info, snakeToCamel(data))
}
} else if (profile.type === 'remote') {
const t = this.getTransportFor(profileId)
if (t instanceof HttpTransport) {
const baseUrl = this.getFileServerBaseURL()
if (baseUrl) {
const token = profile.token || ''
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${baseUrl}/api/v1/system/stats`, { headers })
if (res.ok) {
const json = await res.json()
const data = json.data ?? json
if (data) Object.assign(info, snakeToCamel(data))
}
}
}
}
} catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) }
this._sysInfoCache.set(profileId, info)
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
return info
}
/**
* 切换到指定 profile连接池模式
* 池中有 → 直接复用,瞬间切换
* 池中无 → 新建连接并存入池
*/
async connect(profileId: string): Promise<void> {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
this._activeId = profileId
this.saveProfiles()
this.applyActive()
// 池中已有,直接复用
if (this._pool.has(profileId)) {
this.setState('connected')
return
}
// 新建连接并入池
await this.buildAndPool(profileId, profile)
}
/** 断开指定 profile 并从池移除 */
disconnectProfile(profileId: string): void {
if (profileId === 'local-default') return
const t = this._pool.get(profileId)
if (t instanceof SftpTransport) { t.disconnect() }
this._pool.delete(profileId)
}
/** 断开所有远程连接(保留 local */
disconnectAll(): void {
for (const [id, t] of this._pool) {
if (id !== 'local-default' && t instanceof SftpTransport) {
t.disconnect()
}
}
this._pool.clear()
this._pool.set('local-default', new WailsTransport())
this._activeId = 'local-default'
this.setState('connected')
}
disconnect(): void {
this._activeId = 'local-default'
this.saveProfiles()
this.applyActive()
this.disconnectAll()
}
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
const newProfile: ConnectionProfile = {
...profile,
id: crypto.randomUUID(),
}
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
this._profiles.push(newProfile)
this.saveProfiles()
await this.persistProfile(newProfile)
this.notifyChange()
return newProfile
}
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
async updateProfile(id: string, updates: Partial<ConnectionProfile>): Promise<void> {
const idx = this._profiles.findIndex(p => p.id === id)
if (idx >= 0) {
this._profiles[idx] = { ...this._profiles[idx], ...updates }
this.saveProfiles()
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
if (needsReapply && id === this._activeId) {
this.applyActive()
await this.persistProfile(this._profiles[idx])
// 连接参数变更 → 淘汰旧连接,下次 connect 时重建
const EVICT_KEYS = ['host', 'port', 'token', 'username', 'password', 'keyPath'] as const
if (EVICT_KEYS.some(k => k in updates)) {
this.disconnectProfile(id)
if (id === this._activeId) {
this.connect(id).catch(err => console.warn('[SFTP] 编辑后重连失败:', err))
}
}
this.notifyChange()
}
}
removeProfile(id: string): void {
if (id === 'local-default') return // 不允许删除本地配置
async removeProfile(id: string): Promise<void> {
if (id === 'local-default') return
this._profiles = this._profiles.filter(p => p.id !== id)
this.disconnectProfile(id)
if (this._activeId === id) {
this._activeId = 'local-default'
this.setState('connected')
}
this.saveProfiles()
this.applyActive()
await this.removePersisted(id)
this.notifyChange()
}
private applyActive() {
const profile = this.activeProfile
const seq = ++this._connectSeq
if (!profile || profile.type === 'local') {
this._transport = new WailsTransport()
/** 新建 transport 并存入连接池 */
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
if (profile.type === 'local') {
this._pool.set(profileId, new WailsTransport())
this.setState('connected')
} else {
return
}
if (profile.type === 'sftp') {
if (!profile.password && !profile.keyPath) {
this._pool.set(profileId, new WailsTransport())
this.setState('error')
return
}
this.setState('connecting')
try {
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
// 快速连通性检查(用轻量 ping 代替 getCommonPaths
this._transport.getFileInfo('/').then(() => {
if (seq !== this._connectSeq) return // 已被后续连接覆盖
this.setState('connected')
this.updateProfile(profile.id!, { lastConnected: Date.now() })
}).catch(() => {
if (seq !== this._connectSeq) return
this.setState('error')
})
} catch {
const t = new SftpTransport(profile)
await t.connect()
this._pool.set(profileId, t)
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
} catch (err) {
this._pool.delete(profileId)
this.setState('error')
throw err
}
return
}
// remote / http agent
this.setState('connecting')
try {
const t = new HttpTransport(profile.host, profile.port, profile.token)
this._pool.set(profileId, t)
// 验证连接可用性
t.getFileInfo('/').then(() => {
if (this._activeId !== profileId) return
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
}).catch(() => {
if (this._activeId !== profileId) return
this.setState('error')
})
} catch (err) {
this._pool.delete(profileId)
this.setState('error')
throw err
}
}
}
export const connectionManager = new ConnectionManagerImpl()
/** snake_case → camelCase */
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
for (const [k, v] of Object.entries(obj)) {
result[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = v
}
return result
}