diff --git a/.gitignore b/.gitignore index e5c446a..120d7bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ build/linux/appimage/build build/windows/nsis/MicrosoftEdgeWebview2Setup.exe .idea/ .claude/ +u-desk.exe +u-fs-agent-linux diff --git a/app.go b/app.go index 1a75c8f..f7c0ad6 100644 --- a/app.go +++ b/app.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" stdruntime "runtime" "strings" "sync" @@ -18,7 +19,9 @@ import ( "u-desk/internal/common" "u-desk/internal/filesystem" "u-desk/internal/service" + "u-desk/internal/sftp" "u-desk/internal/storage" + "u-desk/internal/storage/models" "u-desk/internal/system" "github.com/wailsapp/wails/v3/pkg/application" @@ -34,6 +37,7 @@ type App struct { configAPI *api.ConfigAPI pdfAPI *api.PdfAPI filesystem *filesystem.FileSystemService + sftpService *sftp.Service isAlwaysOnTop bool mu sync.Mutex } @@ -94,7 +98,10 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) return fmt.Errorf("模块初始化失败: %w", err) } - // 5. 异步初始化:UpdateAPI(涉及网络请求,完全异步) + // 5. 清理遗留的 SFTP 临时预览文件 + sftp.CleanupTempFiles() + + // 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步) go func() { if updateAPI, err := api.NewUpdateAPI("https://c.1216.top/last-version.json"); err == nil { a.mu.Lock() @@ -175,7 +182,7 @@ func (a *App) startFileServer() { fmt.Printf("[文件服务器] 启动失败: %v\n", err) return } - fmt.Println("[文件服务器] 启动在 http://localhost:8073") + fmt.Printf("[文件服务器] 启动在 http://%s\n", filesystem.GetLocalFileServerAddr()) } // ServiceShutdown Wails v3 服务关闭生命周期(替代 v2 的 Shutdown) @@ -202,6 +209,13 @@ func (a *App) ServiceShutdown() error { } else { fmt.Println("[文件服务器] 已关闭") } + + // 关闭所有 SFTP 连接 + 清理临时文件 + if a.sftpService != nil { + sftp.GetManager().Shutdown() + } + sftp.CleanupTempFiles() + return nil } @@ -657,7 +671,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) { // GetFileServerURL 获取本地文件服务器的URL func (a *App) GetFileServerURL() string { - return "http://localhost:8073" + return fmt.Sprintf("http://%s", filesystem.GetLocalFileServerAddr()) } // DetectFileTypeByContent 通过文件内容检测文件类型 @@ -822,3 +836,227 @@ func (a *App) SelectPDFSaveDirectory() (string, error) { return a.pdfAPI.SelectDirectory() } + +// ========== SFTP 接口 ========== + +func (a *App) ensureSftpService() *sftp.Service { + a.mu.Lock() + defer a.mu.Unlock() + if a.sftpService == nil { + a.sftpService = sftp.NewService() + } + return a.sftpService +} + +// SftpConnectRequest SFTP 连接请求 +type SftpConnectRequest struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + KeyPath string `json:"key_path"` + KeyPassphrase string `json:"key_passphrase"` +} + +// SftpConnect 建立 SFTP 连接,返回连接标识符 connID +func (a *App) SftpConnect(req SftpConnectRequest) (string, error) { + config := &sftp.Config{ + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: req.Password, + KeyPath: req.KeyPath, + KeyPassphrase: req.KeyPassphrase, + } + if config.Port == 0 { config.Port = 22 } + if config.Timeout == 0 { config.Timeout = 15 * time.Second } + + svc := a.ensureSftpService() + _, err := svc.GetManager().Connect(config) + if err != nil { + return "", sftp.ToUserMessage(err) + } + + connID := sftp.ConnID(config.Host, config.Port) + return connID, nil +} + +// SftpDisconnect 断开 SFTP 连接 +func (a *App) SftpDisconnect(connID string) error { + parts := strings.SplitN(connID, ":", 2) + if len(parts) < 2 { + return fmt.Errorf("无效的连接标识符") + } + host := parts[0] + port, err := strconv.Atoi(parts[1]) + if err != nil { + return fmt.Errorf("无效的端口号") + } + + sftp.GetManager().Disconnect(host, port) + return nil +} + +// SftpListDir SFTP 列出目录 +func (a *App) SftpListDir(connID string, dirPath string) ([]map[string]interface{}, error) { + return a.ensureSftpService().ListDir(connID, dirPath) +} + +// SftpReadFile SFTP 读取文件内容 +func (a *App) SftpReadFile(connID string, filePath string) (string, error) { + return a.ensureSftpService().ReadFile(connID, filePath) +} + +// SftpWriteFileRequest SFTP 写入请求 +type SftpWriteFileRequest struct { + SessionID string `json:"session_id"` + Path string `json:"path"` + Content string `json:"content"` +} + +// SftpWriteFile SFTP 写入文件 +func (a *App) SftpWriteFile(req SftpWriteFileRequest) error { + return a.ensureSftpService().WriteFile(req.SessionID, req.Path, req.Content) +} + +// SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等) +func (a *App) SftpWriteBase64File(sessionID, filePath, base64Content string) error { + return a.ensureSftpService().WriteBase64File(sessionID, filePath, base64Content) +} + +// SftpGetFileInfo SFTP 获取文件信息 +func (a *App) SftpGetFileInfo(connID string, filePath string) (map[string]interface{}, error) { + return a.ensureSftpService().GetFileInfo(connID, filePath) +} + +// SftpCreateDir SFTP 创建目录 +func (a *App) SftpCreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) { + return a.ensureSftpService().CreateDir(connID, dirPath) +} + +// SftpCreateFile SFTP 创建文件 +func (a *App) SftpCreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) { + return a.ensureSftpService().CreateFile(connID, filePath) +} + +// SftpDeletePath SFTP 删除文件或目录 +func (a *App) SftpDeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) { + return a.ensureSftpService().DeletePath(connID, filePath) +} + +// SftpRenamePathRequest SFTP 重命名请求 +type SftpRenamePathRequest struct { + SessionID string `json:"session_id"` + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` +} + +// SftpRenamePath SFTP 重命名文件或目录 +func (a *App) SftpRenamePath(req SftpRenamePathRequest) (*filesystem.FileOperationResult, error) { + return a.ensureSftpService().RenamePath(req.SessionID, req.OldPath, req.NewPath) +} + +// SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览) +func (a *App) SftpDownloadToTemp(connID string, remotePath string) (string, error) { + return a.ensureSftpService().DownloadToTemp(connID, remotePath) +} + +// SftpGetCommonPaths 获取 SFTP 远程主机常用路径 +func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) { + return a.ensureSftpService().GetCommonPaths(connID) +} + +// SftpGetSystemInfo 获取 SFTP 远程主机系统信息(CPU/内存/磁盘) +func (a *App) SftpGetSystemInfo(connID string) (map[string]interface{}, error) { + return a.ensureSftpService().GetSystemInfo(connID) +} + +// --- 连接配置 CRUD (SQLite 持久化) --- + +type SaveProfileRequest struct { + ID *uint `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + KeyPath string `json:"key_path"` + Type string `json:"type"` + Token string `json:"token"` + LastConnected *int64 `json:"last_connected"` +} + +var profileSvc *service.ProfileService + +func (a *App) ensureProfileSvc() *service.ProfileService { + if profileSvc == nil { + profileSvc = service.NewProfileService() + } + return profileSvc +} + +func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) { + list, err := a.ensureProfileSvc().ListProfiles() + if err != nil { return nil, err } + result := make([]map[string]interface{}, len(list)) + for i, p := range list { + result[i] = map[string]interface{}{ + "id": float64(p.ID), + "name": p.Name, + "host": p.Host, + "port": p.Port, + "username": p.Username, + "password": p.Password, + "keyPath": p.KeyPath, + "type": p.Type, + "token": p.Token, + "lastConnected": p.LastConnected, + "sortOrder": float64(p.SortOrder), + } + } + return result, nil +} + +func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interface{}, error) { + p := &models.ConnectionProfile{ + Name: req.Name, Host: req.Host, Port: req.Port, + Username: req.Username, Password: req.Password, + KeyPath: req.KeyPath, Type: req.Type, Token: req.Token, + } + if req.LastConnected != nil { + t := time.Unix(*req.LastConnected, 0) + p.LastConnected = &t + } + if req.ID != nil { + p.ID = *req.ID + } + if err := a.ensureProfileSvc().SaveProfile(p); err != nil { + return nil, err + } + return map[string]interface{}{"id": float64(p.ID), "success": true}, nil +} + +func (a *App) DeleteConnectionProfile(id uint) error { + return a.ensureProfileSvc().DeleteProfile(id) +} + +func (a *App) GetLocalSystemInfo() (map[string]interface{}, error) { + info := make(map[string]interface{}) + + cpuInfo, err := system.GetCPUInfo() + if err == nil && cpuInfo != nil { + if v, ok := cpuInfo["usage"].(string); ok { info["cpu_usage"] = v } + } + + memInfo, err := system.GetMemoryInfo() + if err == nil && memInfo != nil { + if v, ok := memInfo["usage"].(string); ok { info["mem_usage"] = v } + } + + diskInfos, err := system.GetDiskInfo() + if err == nil && len(diskInfos) > 0 { + if v, ok := diskInfos[0]["usage"].(string); ok { info["disk_usage"] = v } + } + + return info, nil +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 3da0f68..1ac5e2a 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -67,6 +67,7 @@ func main() { { sys.GET("/common-paths", h.CommonPaths) sys.GET("/drives", h.Drives) + sys.GET("/stats", h.Stats) } proxy := api.Group("/proxy") diff --git a/configs/agent.yaml b/configs/agent.yaml index 08d873a..915abae 100644 --- a/configs/agent.yaml +++ b/configs/agent.yaml @@ -21,7 +21,7 @@ log: format: "json" # json / text file_server: - port: 8073 # 内置文件服务器端口(用于媒体预览代理) + port: 2652 # 内置文件服务器端口(用于媒体预览代理) max_file_size: 524288000 # 最大文件大小 500MB security: diff --git a/frontend/bindings/u-desk/app.ts b/frontend/bindings/u-desk/app.ts index d1b3e0d..4b8c116 100644 --- a/frontend/bindings/u-desk/app.ts +++ b/frontend/bindings/u-desk/app.ts @@ -55,6 +55,10 @@ export function CreateFile(path: string): $CancellablePromise { + return $Call.ByID(2675016907, id); +} + /** * DeletePath 删除文件或目录 */ @@ -198,6 +202,12 @@ export function GetFileServerURL(): $CancellablePromise { return $Call.ByID(4117667287); } +export function GetLocalSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> { + return $Call.ByID(2203542363).then(($result: any) => { + return $$createType0($result); + }); +} + /** * GetMemoryInfo 获取内存信息 */ @@ -279,6 +289,12 @@ export function ListZipContents(zipPath: string): $CancellablePromise<{ [_ in st }); } +export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?: any }[]> { + return $Call.ByID(454364767).then(($result: any) => { + return $$createType3($result); + }); +} + /** * OpenPath 使用系统默认程序打开文件或目录 */ @@ -341,6 +357,12 @@ export function SaveBase64File(req: $models.SaveBase64FileRequest): $Cancellable return $Call.ByID(1355120553, req); } +export function SaveConnectionProfile(req: $models.SaveProfileRequest): $CancellablePromise<{ [_ in string]?: any }> { + return $Call.ByID(3622685069, req).then(($result: any) => { + return $$createType0($result); + }); +} + /** * SelectPDFSaveDirectory 选择PDF保存目录 */ @@ -371,6 +393,120 @@ export function SetWindowTitleBarColor(color: number, isDark: boolean): $Cancell return $Call.ByID(1570627619, color, isDark); } +/** + * SftpConnect 建立 SFTP 连接,返回连接标识符 connID + */ +export function SftpConnect(req: $models.SftpConnectRequest): $CancellablePromise { + return $Call.ByID(2742828454, req); +} + +/** + * SftpCreateDir SFTP 创建目录 + */ +export function SftpCreateDir(connID: string, dirPath: string): $CancellablePromise { + return $Call.ByID(586600875, connID, dirPath).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * SftpCreateFile SFTP 创建文件 + */ +export function SftpCreateFile(connID: string, filePath: string): $CancellablePromise { + return $Call.ByID(623026146, connID, filePath).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * SftpDeletePath SFTP 删除文件或目录 + */ +export function SftpDeletePath(connID: string, filePath: string): $CancellablePromise { + return $Call.ByID(1833619836, connID, filePath).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * SftpDisconnect 断开 SFTP 连接 + */ +export function SftpDisconnect(connID: string): $CancellablePromise { + return $Call.ByID(597628874, connID); +} + +/** + * SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览) + */ +export function SftpDownloadToTemp(connID: string, remotePath: string): $CancellablePromise { + return $Call.ByID(1159267603, connID, remotePath); +} + +/** + * SftpGetCommonPaths 获取 SFTP 远程主机常用路径 + */ +export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> { + return $Call.ByID(2874386183, connID).then(($result: any) => { + return $$createType4($result); + }); +} + +/** + * SftpGetFileInfo SFTP 获取文件信息 + */ +export function SftpGetFileInfo(connID: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> { + return $Call.ByID(1959840482, connID, filePath).then(($result: any) => { + return $$createType0($result); + }); +} + +/** + * SftpGetSystemInfo 获取 SFTP 远程主机系统信息(CPU/内存/磁盘) + */ +export function SftpGetSystemInfo(connID: string): $CancellablePromise<{ [_ in string]?: any }> { + return $Call.ByID(1950143653, connID).then(($result: any) => { + return $$createType0($result); + }); +} + +/** + * SftpListDir SFTP 列出目录 + */ +export function SftpListDir(connID: string, dirPath: string): $CancellablePromise<{ [_ in string]?: any }[]> { + return $Call.ByID(2061863855, connID, dirPath).then(($result: any) => { + return $$createType3($result); + }); +} + +/** + * SftpReadFile SFTP 读取文件内容 + */ +export function SftpReadFile(connID: string, filePath: string): $CancellablePromise { + return $Call.ByID(3068590994, connID, filePath); +} + +/** + * SftpRenamePath SFTP 重命名文件或目录 + */ +export function SftpRenamePath(req: $models.SftpRenamePathRequest): $CancellablePromise { + return $Call.ByID(183173937, req).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等) + */ +export function SftpWriteBase64File(sessionID: string, filePath: string, base64Content: string): $CancellablePromise { + return $Call.ByID(139141998, sessionID, filePath, base64Content); +} + +/** + * SftpWriteFile SFTP 写入文件 + */ +export function SftpWriteFile(req: $models.SftpWriteFileRequest): $CancellablePromise { + return $Call.ByID(2401472593, req); +} + /** * VerifyUpdateFile 验证更新文件哈希值 */ diff --git a/frontend/bindings/u-desk/index.ts b/frontend/bindings/u-desk/index.ts index cd4a868..908db4e 100644 --- a/frontend/bindings/u-desk/index.ts +++ b/frontend/bindings/u-desk/index.ts @@ -10,5 +10,9 @@ export { RenamePathRequest, SaveAppConfigRequest, SaveBase64FileRequest, + SaveProfileRequest, + SftpConnectRequest, + SftpRenamePathRequest, + SftpWriteFileRequest, WriteFileRequest } from "./models.js"; diff --git a/frontend/bindings/u-desk/models.ts b/frontend/bindings/u-desk/models.ts index 8666d8e..3f79848 100644 --- a/frontend/bindings/u-desk/models.ts +++ b/frontend/bindings/u-desk/models.ts @@ -109,6 +109,171 @@ export class SaveBase64FileRequest { } } +export class SaveProfileRequest { + "id": number | null; + "name": string; + "host": string; + "port": number; + "username": string; + "password": string; + "key_path": string; + "type": string; + "token": string; + "last_connected": number | null; + + /** Creates a new SaveProfileRequest instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = null; + } + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("host" in $$source)) { + this["host"] = ""; + } + if (!("port" in $$source)) { + this["port"] = 0; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + if (!("password" in $$source)) { + this["password"] = ""; + } + if (!("key_path" in $$source)) { + this["key_path"] = ""; + } + if (!("type" in $$source)) { + this["type"] = ""; + } + if (!("token" in $$source)) { + this["token"] = ""; + } + if (!("last_connected" in $$source)) { + this["last_connected"] = null; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SaveProfileRequest instance from a string or object. + */ + static createFrom($$source: any = {}): SaveProfileRequest { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SaveProfileRequest($$parsedSource as Partial); + } +} + +/** + * SftpConnectRequest SFTP 连接请求 + */ +export class SftpConnectRequest { + "host": string; + "port": number; + "username": string; + "password": string; + "key_path": string; + "key_passphrase": string; + + /** Creates a new SftpConnectRequest instance. */ + constructor($$source: Partial = {}) { + if (!("host" in $$source)) { + this["host"] = ""; + } + if (!("port" in $$source)) { + this["port"] = 0; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + if (!("password" in $$source)) { + this["password"] = ""; + } + if (!("key_path" in $$source)) { + this["key_path"] = ""; + } + if (!("key_passphrase" in $$source)) { + this["key_passphrase"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SftpConnectRequest instance from a string or object. + */ + static createFrom($$source: any = {}): SftpConnectRequest { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SftpConnectRequest($$parsedSource as Partial); + } +} + +/** + * SftpRenamePathRequest SFTP 重命名请求 + */ +export class SftpRenamePathRequest { + "session_id": string; + "old_path": string; + "new_path": string; + + /** Creates a new SftpRenamePathRequest instance. */ + constructor($$source: Partial = {}) { + if (!("session_id" in $$source)) { + this["session_id"] = ""; + } + if (!("old_path" in $$source)) { + this["old_path"] = ""; + } + if (!("new_path" in $$source)) { + this["new_path"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SftpRenamePathRequest instance from a string or object. + */ + static createFrom($$source: any = {}): SftpRenamePathRequest { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SftpRenamePathRequest($$parsedSource as Partial); + } +} + +/** + * SftpWriteFileRequest SFTP 写入请求 + */ +export class SftpWriteFileRequest { + "session_id": string; + "path": string; + "content": string; + + /** Creates a new SftpWriteFileRequest instance. */ + constructor($$source: Partial = {}) { + if (!("session_id" in $$source)) { + this["session_id"] = ""; + } + if (!("path" in $$source)) { + this["path"] = ""; + } + if (!("content" in $$source)) { + this["content"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SftpWriteFileRequest instance from a string or object. + */ + static createFrom($$source: any = {}): SftpWriteFileRequest { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SftpWriteFileRequest($$parsedSource as Partial); + } +} + /** * WriteFileRequest 写入文件请求结构体 */ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9037dbc..e9de754 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2138,7 +2138,6 @@ "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -2311,7 +2310,6 @@ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -2712,7 +2710,6 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3737,7 +3734,6 @@ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4186,7 +4182,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4310,7 +4305,6 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.33.tgz", "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4e9c3f7..b32aad0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -93,8 +93,8 @@ \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index d3b8aa1..ef6e1db 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -21,6 +21,7 @@ export default defineConfig({ resolve: { alias: { '@': resolve(__dirname, 'src'), + '@bindings': resolve(__dirname, 'bindings'), '@wailsio/events': fileURLToPath(new URL('./node_modules/@wailsio/runtime/dist/events.js', import.meta.url)) } }, diff --git a/go.mod b/go.mod index d4d88fd..e0fd180 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,11 @@ require ( github.com/chromedp/chromedp v0.15.1 github.com/glebarez/sqlite v1.11.0 github.com/labstack/echo/v4 v4.15.1 + github.com/pkg/sftp v1.13.10 github.com/shirou/gopsutil/v3 v3.24.5 github.com/wailsapp/wails/v3 v3.0.0-alpha.80 github.com/yuin/goldmark v1.7.16 + golang.org/x/crypto v0.47.0 golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.31.1 @@ -46,6 +48,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/u v1.1.1 // indirect @@ -70,7 +73,6 @@ require ( github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/text v0.33.0 // indirect diff --git a/go.sum b/go.sum index 7277144..4fb171b 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,8 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -126,6 +128,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= diff --git a/internal/agent/config/config.go b/internal/agent/config/config.go index 6b07a2a..463ad83 100644 --- a/internal/agent/config/config.go +++ b/internal/agent/config/config.go @@ -97,7 +97,7 @@ func Default() *Config { Format: "json", }, FileServer: FileServerConfig{ - Port: 8073, + Port: 2652, MaxFileSize: 500 * 1024 * 1024, }, Security: SecurityConfig{ diff --git a/internal/agent/handler/server_handler.go b/internal/agent/handler/server_handler.go index a9d0dbb..86861a8 100644 --- a/internal/agent/handler/server_handler.go +++ b/internal/agent/handler/server_handler.go @@ -46,8 +46,8 @@ func (h *Handler) HTMLPreviewProxy(c echo.Context) error { } theme := c.QueryParam("theme") - targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s", - url.QueryEscape(clean), url.QueryEscape(theme)) + targetURL := fmt.Sprintf("%s/localfs/html-preview?path=%s&theme=%s", + h.cfg.FileServerAddr(), url.QueryEscape(clean), url.QueryEscape(theme)) resp, err := http.Get(targetURL) if err != nil { diff --git a/internal/agent/handler/system_handler.go b/internal/agent/handler/system_handler.go index d11160f..560b042 100644 --- a/internal/agent/handler/system_handler.go +++ b/internal/agent/handler/system_handler.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "os" "runtime" @@ -9,6 +10,9 @@ import ( "u-desk/internal/agent/model" "github.com/labstack/echo/v4" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/mem" ) // Ping 健康检查 @@ -111,3 +115,39 @@ func (h *Handler) Drives(c echo.Context) error { return c.JSON(http.StatusOK, model.OK(drives)) } + +// Stats 返回系统资源使用统计(CPU/内存/磁盘) +func (h *Handler) Stats(c echo.Context) error { + info := make(map[string]interface{}) + + // CPU + if cores, err := cpu.Counts(true); err == nil { + info["cpu_cores"] = cores + } + if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 { + info["cpu_usage"] = fmt.Sprintf("%.0f%%", percents[0]) + } + + // 内存 + if memInfo, err := mem.VirtualMemory(); err == nil { + info["mem_total"] = memInfo.Total + info["mem_used"] = memInfo.Used + info["mem_usage"] = fmt.Sprintf("%.0f%%", memInfo.UsedPercent) + } + + // 磁盘(取根分区) + if partitions, err := disk.Partitions(false); err == nil { + for _, p := range partitions { + if p.Mountpoint == "/" || (runtime.GOOS == "windows" && len(p.Mountpoint) == 3 && p.Mountpoint[1] == ':') { + if usage, err := disk.Usage(p.Mountpoint); err == nil { + info["disk_total"] = usage.Total + info["disk_used"] = usage.Used + info["disk_usage"] = fmt.Sprintf("%.0f%%", usage.UsedPercent) + } + break + } + } + } + + return c.JSON(http.StatusOK, model.OK(info)) +} diff --git a/internal/filesystem/asset_handler.go b/internal/filesystem/asset_handler.go index e1310da..5b1739d 100644 --- a/internal/filesystem/asset_handler.go +++ b/internal/filesystem/asset_handler.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "net" "net/http" "net/url" "os" @@ -15,6 +16,8 @@ import ( "time" ) +const DefaultFileServerPort = 2652 + // 预编译正则表达式(避免每次调用重复编译) var ( // CSS 相关 @@ -44,6 +47,7 @@ var ( // HTML 预览路径修复 locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`) + winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`) ) // HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译) @@ -80,7 +84,7 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) { filePath = filepath.Clean(filePath) // 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头) - if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' { + if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && (len(filePath) < 2 || filePath[1] != ':') { filePath = "/" + filePath } @@ -103,40 +107,22 @@ var ( localFileServerOnce sync.Once ) -// StartLocalFileServer 启动本地文件服务器 +// StartLocalFileServer 启动本地文件服务器(端口被占用时自动递增) func StartLocalFileServer() (string, error) { var initErr error localFileServerOnce.Do(func() { - // 创建多路复用器 mux := http.NewServeMux() - - // 注册 /localfs/ 路由 mux.HandleFunc("/localfs/", handleLocalFileRequest) - - // 注册 HTML 预览专用路由 mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest) - // 创建服务器(固定端口) - server := &http.Server{ - Addr: "localhost:8073", - Handler: mux, + addr, srv, err := listenWithFallback(DefaultFileServerPort, mux) + if err != nil { + initErr = fmt.Errorf("无法绑定端口(%d起始): %w", DefaultFileServerPort, err) + return } - // 启动服务器 - go func() { - log.Printf("[LocalFileServer] 正在启动...") - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("[LocalFileServer] 启动失败: %v", err) - initErr = err - } - }() - - localFileServer = &LocalFileServer{ - server: server, - addr: "localhost:8073", - } - - log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr) + localFileServer = &LocalFileServer{server: srv, addr: addr} + log.Printf("[LocalFileServer] 已启动,监听: %s", addr) }) if localFileServer == nil { @@ -145,6 +131,33 @@ func StartLocalFileServer() (string, error) { return localFileServer.addr, initErr } +// listenWithFallback 从 basePort 开始尝试绑定,递增直到成功(最多试 10 次) +// 返回的 server 已在 goroutine 中 Serve(l),调用方无需再启动 +func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *http.Server, err error) { + for offset := 0; offset < 10; offset++ { + port := basePort + offset + addr = fmt.Sprintf("localhost:%d", port) + l, e := net.Listen("tcp", addr) + if e == nil { + srv = &http.Server{Handler: handler} + go func() { + if se := srv.Serve(l); se != http.ErrServerClosed { + log.Printf("[LocalFileServer] 异常退出: %v", se) + } + }() + return addr, srv, nil + } + log.Printf("[LocalFileServer] 端口 %d 被占用,尝试 %d...", port, port+1) + } + return "", nil, fmt.Errorf("端口 %d-%d 均不可用", basePort, basePort+9) +} + +// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口) +func GetLocalFileServerAddr() string { + if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) } + return localFileServer.addr +} + // handleLocalFileRequest 处理本地文件请求 func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) { // CORS 头:允许所有源访问(因为这是本地文件服务器) @@ -177,7 +190,7 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) { return } // 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /) - if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) { + if !strings.HasPrefix(pathPart, "/") && !winDriveRegex.MatchString(pathPart) { pathPart = "/" + pathPart } diff --git a/internal/filesystem/fs.go b/internal/filesystem/fs.go index 26ab7c0..46d8322 100644 --- a/internal/filesystem/fs.go +++ b/internal/filesystem/fs.go @@ -53,8 +53,8 @@ func OpenPath(path string) error { // ========== 工具函数 ========== -// formatBytes 格式化字节大小为人类可读格式 -func formatBytes(bytes int64) string { +// FormatBytes 格式化字节大小为人类可读格式(导出供 sftp 等外部包使用) +func FormatBytes(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index a5da202..8784e97 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -258,7 +258,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri Path: filepath.ToSlash(path), // 统一使用正斜杠 Name: info.Name(), Size: info.Size(), - SizeStr: formatBytes(info.Size()), + SizeStr: FormatBytes(info.Size()), IsDir: info.IsDir(), ModTime: info.ModTime().Format("2006-01-02 15:04:05"), Mode: info.Mode().String(), @@ -341,7 +341,7 @@ func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error) Path: filepath.ToSlash(path), // 统一使用正斜杠 Name: info.Name(), Size: info.Size(), - SizeStr: formatBytes(info.Size()), + SizeStr: FormatBytes(info.Size()), IsDir: true, ModTime: info.ModTime().Format("2006-01-02 15:04:05"), Mode: info.Mode().String(), @@ -389,7 +389,7 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error Path: filepath.ToSlash(path), // 统一使用正斜杠 Name: info.Name(), Size: info.Size(), - SizeStr: formatBytes(info.Size()), + SizeStr: FormatBytes(info.Size()), IsDir: false, ModTime: info.ModTime().Format("2006-01-02 15:04:05"), Mode: info.Mode().String(), @@ -414,7 +414,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er "name": info.Name(), "path": filepath.ToSlash(path), // 统一使用正斜杠 "size": info.Size(), - "size_str": formatBytes(info.Size()), + "size_str": FormatBytes(info.Size()), "is_dir": info.IsDir(), "mod_time": info.ModTime().Format("2006-01-02 15:04:05"), "mode": info.Mode().String(), @@ -470,7 +470,7 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR Path: filepath.ToSlash(newPath), // 统一使用正斜杠 Name: info.Name(), Size: info.Size(), - SizeStr: formatBytes(info.Size()), + SizeStr: FormatBytes(info.Size()), IsDir: info.IsDir(), ModTime: info.ModTime().Format("2006-01-02 15:04:05"), Mode: info.Mode().String(), diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go new file mode 100644 index 0000000..9d135da --- /dev/null +++ b/internal/service/profile_service.go @@ -0,0 +1,41 @@ +package service + +import ( + "u-desk/internal/storage" + "u-desk/internal/storage/models" +) + +// ProfileService 连接配置 CRUD +type ProfileService struct{} + +func NewProfileService() *ProfileService { return &ProfileService{} } + +func (s *ProfileService) ListProfiles() ([]models.ConnectionProfile, error) { + db := storage.GetDB() + var list []models.ConnectionProfile + err := db.Order("sort_order asc, id asc").Find(&list).Error + return list, err +} + +func (s *ProfileService) GetProfile(id uint) (*models.ConnectionProfile, error) { + db := storage.GetDB() + var p models.ConnectionProfile + err := db.First(&p, id).Error + if err != nil { + return nil, err + } + return &p, nil +} + +func (s *ProfileService) SaveProfile(p *models.ConnectionProfile) error { + db := storage.GetDB() + if p.ID == 0 { + return db.Create(p).Error + } + return db.Save(p).Error +} + +func (s *ProfileService) DeleteProfile(id uint) error { + db := storage.GetDB() + return db.Delete(&models.ConnectionProfile{}, id).Error +} diff --git a/internal/sftp/client.go b/internal/sftp/client.go new file mode 100644 index 0000000..1c16911 --- /dev/null +++ b/internal/sftp/client.go @@ -0,0 +1,260 @@ +package sftp + +import ( + "fmt" + "net" + "os" + "sync" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +// Client SFTP 客户端封装(单连接) +type Client struct { + config *Config + client *sftp.Client + sshClient *ssh.Client + mu sync.Mutex +} + +// Manager 全局 SFTP 连接管理器(以 host:port 为 key 的连接池) +type Manager struct { + clients sync.Map // map[string]*Client +} + +var globalManager = &Manager{} + +// GetManager 获取全局连接管理器 +func GetManager() *Manager { + return globalManager +} + +// Connect 创建或复用 SFTP 连接 +func (m *Manager) Connect(config *Config) (*Client, error) { + key := fmt.Sprintf("%s:%d", config.Host, config.Port) + + if existing, ok := m.clients.Load(key); ok { + c := existing.(*Client) + if c.IsHealthy() { + return c, nil + } + c.Close() + m.clients.Delete(key) + } + + c, err := newClient(config) + if err != nil { + return nil, err + } + + m.clients.Store(key, c) + return c, nil +} + +// GetClient 获取已有连接(不复用也不新建) +func (m *Manager) GetClient(connID string) *Client { + if val, ok := m.clients.Load(connID); ok { + return val.(*Client) + } + return nil +} + +// Disconnect 关闭并移除指定连接 +func (m *Manager) Disconnect(host string, port int) { + key := fmt.Sprintf("%s:%d", host, port) + if val, ok := m.clients.LoadAndDelete(key); ok { + val.(*Client).Close() + } +} + +// Shutdown 关闭所有连接 +func (m *Manager) Shutdown() { + m.clients.Range(func(key, value any) bool { + value.(*Client).Close() + m.clients.Delete(key) + return true + }) +} + +// --- 内部 --- + +func newClient(config *Config) (*Client, error) { + sshConfig := &ssh.ClientConfig{ + Config: ssh.Config{ + KeyExchanges: []string{ + "curve25519-sha256", "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", + "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", + }, + }, + User: config.Username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: config.Timeout, + } + + // 认证方式选择 + if config.KeyPath != "" { + key, err := os.ReadFile(config.KeyPath) + if err != nil { + return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("读取密钥文件失败: %w", err)} + } + var signer ssh.Signer + if config.KeyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(config.KeyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey(key) + } + if err != nil { + return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("解析密钥失败: %w", err)} + } + sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} + } else if config.Password != "" { + pw := config.Password + sshConfig.Auth = []ssh.AuthMethod{ + ssh.Password(pw), + ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range questions { + answers[i] = pw + } + return answers, nil + }), + } + } else { + return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("必须提供密码或密钥文件")} + } + + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + sshConn, err := net.DialTimeout("tcp", addr, config.Timeout) + if err != nil { + return nil, &ConnectionError{Op: "dial", Err: err} + } + + sshConnConn, chans, reqs, err := ssh.NewClientConn(sshConn, addr, sshConfig) + if err != nil { + sshConn.Close() + return nil, &ConnectionError{Op: "handshake", Err: err} + } + + sshClient := ssh.NewClient(sshConnConn, chans, reqs) + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + sshClient.Close() + return nil, &ConnectionError{Op: "sftp_init", Err: err} + } + + return &Client{ + config: config, + client: sftpClient, + sshClient: sshClient, + }, nil +} + +// IsHealthy 检查连接是否健康(先取引用再解锁,避免持锁做 I/O) +func (c *Client) IsHealthy() bool { + c.mu.Lock() + client := c.client + c.mu.Unlock() + if client == nil { + return false + } + _, err := client.Stat("/") + return err == nil +} + +// WithRetry 带重试的操作执行(自动处理断线重连) +func (c *Client) WithRetry(fn func(*sftp.Client) error) error { + const maxRetries = 3 + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration((attempt+1)*2) * time.Second) + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + continue + } + } + + c.mu.Lock() + client := c.client + c.mu.Unlock() + + if client == nil { + lastErr = fmt.Errorf("SFTP 客户端未初始化") + continue + } + + if err := fn(client); err != nil { + if isNetworkError(err) { + lastErr = err + continue + } + return err + } + return nil + } + return fmt.Errorf("操作失败(已重试 %d 次): %w", maxRetries, lastErr) +} + +func (c *Client) reconnect() error { + nc, err := newClient(c.config) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + c.closeLocked() + c.client = nc.client + c.sshClient = nc.sshClient + return nil +} + +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + c.closeLocked() +} + +func (c *Client) closeLocked() { + if c.client != nil { + c.client.Close() + c.client = nil + } + if c.sshClient != nil { + c.sshClient.Close() + c.sshClient = nil + } +} + +// RunCommand 通过 SSH Session 执行远程命令,返回 stdout +func (c *Client) RunCommand(cmd string) (string, error) { + c.mu.Lock() + sshClient := c.sshClient + c.mu.Unlock() + + if sshClient == nil { + return "", fmt.Errorf("SSH 客户端未初始化") + } + + session, err := sshClient.NewSession() + if err != nil { + return "", fmt.Errorf("创建 SSH 会话失败: %w", err) + } + defer session.Close() + + out, err := session.CombinedOutput(cmd) + if err != nil { + return "", fmt.Errorf("执行命令失败 [%s]: %w", cmd, err) + } + return string(out), nil +} + +// RawClient 获取底层 sftp.Client(高级用法) +func (c *Client) RawClient() *sftp.Client { + c.mu.Lock() + defer c.mu.Unlock() + return c.client +} diff --git a/internal/sftp/config.go b/internal/sftp/config.go new file mode 100644 index 0000000..9158198 --- /dev/null +++ b/internal/sftp/config.go @@ -0,0 +1,22 @@ +package sftp + +import "time" + +// Config SFTP 连接配置 +type Config struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + KeyPath string `json:"key_path"` + KeyPassphrase string `json:"key_passphrase"` + Timeout time.Duration `json:"timeout"` +} + +// DefaultConfig 返回默认配置 +func DefaultConfig() *Config { + return &Config{ + Port: 22, + Timeout: 15 * time.Second, + } +} diff --git a/internal/sftp/errors.go b/internal/sftp/errors.go new file mode 100644 index 0000000..f8d5a52 --- /dev/null +++ b/internal/sftp/errors.go @@ -0,0 +1,69 @@ +package sftp + +import ( + "errors" + "fmt" + "strings" +) + +// ConnectionError SFTP 连接错误(区分阶段) +type ConnectionError struct { + Op string // dial / handshake / auth / sftp_init + Err error +} + +func (e *ConnectionError) Error() string { + return fmt.Sprintf("SFTP 连接失败 [%s]: %v", e.Op, e.Err) +} + +func (e *ConnectionError) Unwrap() error { return e.Err } + +// ToUserMessage 将底层错误转换为用户友好的中文错误(返回 error 类型) +func ToUserMessage(err error) error { + if err == nil { + return nil + } + + var connErr *ConnectionError + if errors.As(err, &connErr) { + switch connErr.Op { + case "dial": + return fmt.Errorf("无法连接到服务器 (%v)。请检查地址和端口是否正确", connErr.Err) + case "handshake": + return fmt.Errorf("SSH 握手失败,请检查服务是否正常运行") + case "auth": + return fmt.Errorf("认证失败,请检查用户名和密码/密钥是否正确") + case "sftp_init": + return fmt.Errorf("SFTP 子系统初始化失败,请确认远程服务器支持 SFTP") + } + } + + msg := err.Error() + switch { + case strings.Contains(msg, "unable authenticate"), strings.Contains(msg, "no auth method"): + return fmt.Errorf("认证失败:用户名或密码/密钥不正确") + case strings.Contains(msg, "connection refused"): + return fmt.Errorf("连接被拒绝:目标机器可能未开启 SSH 服务") + case strings.Contains(msg, "timeout"), strings.Contains(msg, "i/o timeout"): + return fmt.Errorf("连接超时:请检查网络连通性或防火墙设置") + case strings.Contains(msg, "host key mismatch"): + return fmt.Errorf("主机密钥不匹配:可能存在中间人攻击风险") + case strings.Contains(msg, "permission denied"): + return fmt.Errorf("权限不足:当前用户无权访问该资源") + case strings.Contains(msg, "no such file"), strings.Contains(msg, "not found"): + return fmt.Errorf("文件或目录不存在") + default: + return err + } +} + +// isNetworkError 判断是否为网络层错误(需要重连) +func isNetworkError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "broken pipe") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "timeout") +} diff --git a/internal/sftp/service.go b/internal/sftp/service.go new file mode 100644 index 0000000..2f29b49 --- /dev/null +++ b/internal/sftp/service.go @@ -0,0 +1,499 @@ +package sftp + +import ( + "encoding/base64" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "u-desk/internal/filesystem" + + sftpclient "github.com/pkg/sftp" +) + +// Service SFTP 文件操作服务 +type Service struct { + manager *Manager +} + +// NewService 创建 SFTP 服务实例 +func NewService() *Service { + return &Service{manager: GetManager()} +} + +// GetManager 获取底层连接管理器(供 App 层调用) +func (s *Service) GetManager() *Manager { + return s.manager +} + +// ConnID 从配置生成连接标识符 +func ConnID(host string, port int) string { + return fmt.Sprintf("%s:%d", host, port) +} + +// --- 核心文件操作 --- + +func (s *Service) ListDir(connID string, dirPath string) ([]map[string]interface{}, error) { + c, err := s.getClient(connID) + if err != nil { + return nil, err + } + + var entries []fs.FileInfo + err = c.WithRetry(func(sc *sftpclient.Client) error { + var e error + entries, e = sc.ReadDir(dirPath) + return e + }) + if err != nil { + return nil, fmt.Errorf("读取目录失败: %w", err) + } + + result := make([]map[string]interface{}, 0, len(entries)) + for _, info := range entries { + fullPath := path.Join(dirPath, info.Name()) + result = append(result, map[string]interface{}{ + "name": info.Name(), + "path": toUnixPath(fullPath), + "is_dir": info.IsDir(), + "size": info.Size(), + "mod_time": info.ModTime().Format("2006-01-02 15:04:05"), + }) + } + return result, nil +} + +func (s *Service) ReadFile(connID string, filePath string) (string, error) { + c, err := s.getClient(connID) + if err != nil { + return "", err + } + + // 大小限制(与本地模式 ReadFile 的 10MB 上限对齐) + const maxSize int64 = 10 << 20 + err = c.WithRetry(func(sc *sftpclient.Client) error { + fi, e := sc.Stat(filePath) + if e != nil { return e } + if fi.Size() > maxSize { + return fmt.Errorf("文件过大 (%s),超过 %d 限制", filesystem.FormatBytes(fi.Size()), maxSize) + } + return nil + }) + if err != nil { + return "", err + } + + var data []byte + err = c.WithRetry(func(sc *sftpclient.Client) error { + f, e := sc.Open(filePath) + if e != nil { + return e + } + defer f.Close() + data, e = io.ReadAll(f) + return e + }) + if err != nil { + return "", fmt.Errorf("读取文件失败: %w", err) + } + return string(data), nil +} + +func (s *Service) WriteFile(connID string, filePath string, content string) error { + c, err := s.getClient(connID) + if err != nil { + return err + } + + return c.WithRetry(func(sc *sftpclient.Client) error { + f, e := sc.Create(filePath) + if e != nil { + return fmt.Errorf("创建文件失败: %w", e) + } + defer f.Close() + _, e = f.Write([]byte(content)) + return e + }) +} + +// WriteBase64File 将 base64 编码的二进制内容写入远程文件(用于粘贴图片等场景) +func (s *Service) WriteBase64File(connID string, filePath string, base64Content string) error { + c, err := s.getClient(connID) + if err != nil { + return err + } + + data, err := base64.StdEncoding.DecodeString(base64Content) + if err != nil { + return fmt.Errorf("base64 解码失败: %w", err) + } + + return c.WithRetry(func(sc *sftpclient.Client) error { + f, e := sc.Create(filePath) + if e != nil { + return fmt.Errorf("创建文件失败: %w", e) + } + defer f.Close() + _, e = f.Write(data) + return e + }) +} + +func (s *Service) GetFileInfo(connID string, filePath string) (map[string]interface{}, error) { + c, err := s.getClient(connID) + if err != nil { + return nil, err + } + + var info fs.FileInfo + err = c.WithRetry(func(sc *sftpclient.Client) error { + var e error + info, e = sc.Stat(filePath) + return e + }) + if err != nil { + return nil, fmt.Errorf("获取文件信息失败: %w", err) + } + + return map[string]interface{}{ + "name": info.Name(), + "path": toUnixPath(filePath), + "size": info.Size(), + "size_str": filesystem.FormatBytes(info.Size()), + "is_dir": info.IsDir(), + "mod_time": info.ModTime().Format("2006-01-02 15:04:05"), + "mode": info.Mode().String(), + }, nil +} + +func (s *Service) CreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) { + c, err := s.getClient(connID) + if err != nil { + return nil, err + } + + err = c.WithRetry(func(sc *sftpclient.Client) error { + return sc.MkdirAll(dirPath) + }) + if err != nil { + return nil, fmt.Errorf("创建目录失败: %w", err) + } + + infoMap, _ := s.GetFileInfo(connID, dirPath) + return toFileOperationResult(infoMap, true), nil +} + +func (s *Service) CreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) { + c, err := s.getClient(connID) + if err != nil { + return nil, err + } + + err = c.WithRetry(func(sc *sftpclient.Client) error { + f, e := sc.Create(filePath) + if e != nil { + return e + } + return f.Close() + }) + if err != nil { + return nil, fmt.Errorf("创建文件失败: %w", err) + } + + infoMap, _ := s.GetFileInfo(connID, filePath) + return toFileOperationResult(infoMap, false), nil +} + +func (s *Service) DeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) { + c, err := s.getClient(connID) + if err != nil { + return nil, err + } + + infoMap, _ := s.GetFileInfo(connID, filePath) + + err = c.WithRetry(func(sc *sftpclient.Client) error { + fi, e := sc.Stat(filePath) + if e != nil { + return e + } + if fi.IsDir() { + // 递归删除目录 + return sc.RemoveAll(filePath) + } + return sc.Remove(filePath) + }) + if err != nil { + return nil, fmt.Errorf("删除失败: %w", err) + } + + result := toFileOperationResult(infoMap, false) + result.Deleted = true + return result, nil +} + +func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesystem.FileOperationResult, error) { + c, err := s.getClient(connID) + if err != nil { + return nil, err + } + + err = c.WithRetry(func(sc *sftpclient.Client) error { + return sc.Rename(oldPath, newPath) + }) + if err != nil { + return nil, fmt.Errorf("重命名失败: %w", err) + } + + infoMap, _ := s.GetFileInfo(connID, newPath) + result := toFileOperationResult(infoMap, false) + result.OldPath = oldPath + return result, nil +} + +// DownloadToTemp 下载远程文件到本地临时目录(用于预览) +func (s *Service) DownloadToTemp(connID string, remotePath string) (string, error) { + c, err := s.getClient(connID) + if err != nil { + return "", err + } + + // 预览文件大小上限 50MB(比编辑模式宽松) + const maxPreviewSize int64 = 50 << 20 + err = c.WithRetry(func(sc *sftpclient.Client) error { + fi, e := sc.Stat(remotePath) + if e != nil { return e } + if fi.Size() > maxPreviewSize { + return fmt.Errorf("预览文件过大: %s", filesystem.FormatBytes(fi.Size())) + } + return nil + }) + if err != nil { + return "", err + } + + tmpDir := os.TempDir() + // 用时间戳+随机数避免同名文件覆盖 + localPath := filepath.Join(tmpDir, fmt.Sprintf("udesk-sftp-preview-%d-%s", time.Now().UnixNano(), filepath.Base(remotePath))) + + err = c.WithRetry(func(sc *sftpclient.Client) error { + src, e := sc.Open(remotePath) + if e != nil { + return e + } + defer src.Close() + + dst, e := os.Create(localPath) + if e != nil { + return e + } + defer dst.Close() + + _, e = io.Copy(dst, src) + return e + }) + if err != nil { + return "", fmt.Errorf("下载文件失败: %w", err) + } + + return localPath, nil +} + +// GetCommonPaths 返回 SFTP 远程主机常用路径 +func (s *Service) GetCommonPaths(connID string) (map[string]string, error) { + c := s.manager.GetClient(connID) + username := "root" + if c != nil { + c.mu.Lock() + username = c.config.Username + c.mu.Unlock() + } + + home := "/root" + if username != "root" && username != "" { + home = fmt.Sprintf("/home/%s", username) + } + + return map[string]string{ + "home": home, + "tmp": "/tmp", + "root": "/", + }, nil +} + +// CleanupTempFiles 清理遗留的临时预览文件 +func CleanupTempFiles() { + tmpDir := os.TempDir() + entries, err := os.ReadDir(tmpDir) + if err != nil { + return + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "udesk-sftp-preview-") { + os.Remove(filepath.Join(tmpDir, entry.Name())) + } + } +} + +// GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存) +func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) { + c, err := s.getClient(connID) + if err != nil { + return nil, err + } + + type cmdResult struct { + key string // "df" | "mem" | "cpu" + output string + err error + } + + // 并发执行三条命令(每条独立超时) + results := make(chan cmdResult, 3) + cmdTimeout := 6 * time.Second + + runCmd := func(key, cmd string) { + out, e := c.RunCommand(cmd) + results <- cmdResult{key, out, e} + } + go runCmd("df", "df -B1 / 2>/dev/null | tail -1") + go runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2") + go runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1") + + var dfOut, memOut, cpuOut string + hasErr := false + for i := 0; i < 3; i++ { + select { + case r := <-results: + switch r.key { + case "df": + dfOut = r.output + case "mem": + memOut = r.output + case "cpu": + cpuOut = r.output + } + if r.err != nil { + hasErr = true + } + case <-time.After(cmdTimeout): + hasErr = true + } + } + + info := make(map[string]interface{}) + + // 解析 CPU 使用率 + cpuOut = strings.TrimSpace(cpuOut) + if usage, err := strconv.ParseFloat(cpuOut, 64); err == nil && usage >= 0 { + info["cpu_usage"] = fmt.Sprintf("%.0f%%", usage) + } + + // 解析磁盘信息: df -B1 / → Filesystem 1M-blocks Used Available Use% Mounted on + dfOut = strings.TrimSpace(dfOut) + if dfFields := strings.Fields(dfOut); len(dfFields) >= 5 { + var diskTotal, diskUsed uint64 + if v, err := strconv.ParseUint(dfFields[1], 10, 64); err == nil { + diskTotal = v + info["disk_total"] = v + } + if v, err := strconv.ParseUint(dfFields[2], 10, 64); err == nil { + diskUsed = v + info["disk_used"] = v + } + if diskTotal > 0 { + info["disk_usage"] = fmt.Sprintf("%.0f%%", float64(diskUsed)/float64(diskTotal)*100) + } + } + + // 解析内存信息: free -b | grep Mem: 或 /proc/meminfo + memOut = strings.TrimSpace(memOut) + if strings.Contains(memOut, "MemTotal:") { + parseProcMeminfo(memOut, info) + } else if fields := strings.Fields(memOut); len(fields) >= 3 { + var memTotal, memUsed uint64 + if v, err := strconv.ParseUint(fields[1], 10, 64); err == nil { + memTotal = v + info["mem_total"] = v + } + if v, err := strconv.ParseUint(fields[2], 10, 64); err == nil { + memUsed = v + info["mem_used"] = v + } + if memTotal > 0 { + info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(memUsed)/float64(memTotal)*100) + } + } + + if hasErr && len(info) == 0 { + return nil, fmt.Errorf("采集远程系统信息失败") + } + return info, nil +} + +func parseProcMeminfo(output string, info map[string]interface{}) { + lines := strings.Split(output, "\n") + memMap := make(map[string]uint64) + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) >= 2 { + key := strings.TrimSuffix(fields[0], ":") + if val, err := strconv.ParseUint(fields[1], 10, 64); err == nil { + memMap[key] = val + } + } + } + total := memMap["MemTotal"] * 1024 // kB → bytes + // 可用内存 ≈ MemAvailable (较新内核) 或 MemFree + Buffers + Cached + free := memMap["MemFree"] + if avail, ok := memMap["MemAvailable"]; ok { + free = avail + } else { + free += memMap["Buffers"] + memMap["Cached"] + } + used := total - free*1024 + + info["mem_total"] = total + info["mem_used"] = used + if total > 0 { + info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(used)/float64(total)*100) + } +} + +// --- 内部辅助 --- + +func (s *Service) getClient(connID string) (*Client, error) { + c := s.manager.GetClient(connID) + if c == nil { + return nil, fmt.Errorf("SFTP 连接不存在: %s", connID) + } + return c, nil +} + +func toUnixPath(p string) string { + return strings.ReplaceAll(p, "\\", "/") +} + +func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.FileOperationResult { + name, _ := m["name"].(string) + p, _ := m["path"].(string) + size, _ := m["size"].(int64) + modTime, _ := m["mod_time"].(string) + mode, _ := m["mode"].(string) + + return &filesystem.FileOperationResult{ + Path: p, + Name: name, + Size: size, + SizeStr: filesystem.FormatBytes(size), + IsDir: isDir, + ModTime: modTime, + Mode: mode, + } +} diff --git a/internal/storage/models/connection_profile.go b/internal/storage/models/connection_profile.go new file mode 100644 index 0000000..89e087e --- /dev/null +++ b/internal/storage/models/connection_profile.go @@ -0,0 +1,22 @@ +package models + +import "time" + +// ConnectionProfile 连接配置模型(SQLite 持久化) +type ConnectionProfile struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Host string `gorm:"type:varchar(255)" json:"host"` + Port int `gorm:"default:22" json:"port"` + Username string `gorm:"type:varchar(100);default:root" json:"username"` + Password string `gorm:"type:text" json:"password"` + KeyPath string `gorm:"type:text" json:"key_path"` + Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp + Token string `gorm:"type:text" json:"token"` + LastConnected *time.Time `json:"last_connected"` + SortOrder int `gorm:"default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ConnectionProfile) TableName() string { return "connection_profiles" } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index b6bd12e..34cc12d 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -63,6 +63,7 @@ func InitFast() (*gorm.DB, error) { // SQLite 的 AutoMigrate 很快,不会造成明显延迟 if err := db.AutoMigrate( &models.AppConfig{}, + &models.ConnectionProfile{}, ); err != nil { return nil, err } diff --git a/wails.json b/wails.json index 9232e1e..333f758 100644 --- a/wails.json +++ b/wails.json @@ -8,6 +8,6 @@ "name": "u-desk", "email": "lxy208@126.com" }, - "frontend:dir": "web", - "wailsjsdir": "./web/src/wailsjs" + "frontend:dir": "frontend", + "wailsjsdir": "./frontend/src/wailsjs" }