修复: 粘贴图失效与SFTP串台回退等一批问题

粘贴图根修(Ctrl+V直读系统剪贴板,不再依赖不可靠的paste事件)/SFTP串台双刀(拨号期禁回退本机+盘符路径自动回根)/PDF导出目录设置与文案带落盘路径/性能批(连接保存下载防重/md渲染防抖+预热/hljs按需加载/审计锁外置/快捷方式解析超时/webview-watch可停)/前端62类型错清零(含keyPath蛇形字段炸弹)/目录图标字段错位修复
This commit is contained in:
lxy
2026-10-24 13:48:54 +08:00
parent 4675ae381d
commit 75a42c0b6a
48 changed files with 1072 additions and 220 deletions
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"sync/atomic"
"time"
"u-desk/internal/common"
"u-desk/internal/service"
)
@@ -133,7 +134,7 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
return errorResponse("已有下载任务进行中"), nil
}
go func() {
common.Go("download-update", func() {
defer api.downloading.Store(false)
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
@@ -162,7 +163,7 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
}
api.emit("download-complete", resultInfo)
}
}()
})
return successResponse(map[string]interface{}{
"message": "下载已开始",
+89
View File
@@ -0,0 +1,89 @@
package common
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// GetLogsDir 日志目录:~/.u-desk/logs(audit 日志同目录)
func GetLogsDir() string {
return filepath.Join(GetUserDataDir(), "logs")
}
// dailyFile 按天滚动的追加写文件:每次写时发现日期变化即切换新文件。
// 只做按天滚动,不搞大小上限/压缩/清理,崩溃取证够用。
type dailyFile struct {
mu sync.Mutex
dir string
naming func(t time.Time) string
f *os.File
day string
}
func newDailyFile(dir string, naming func(time.Time) string) *dailyFile {
return &dailyFile{dir: dir, naming: naming}
}
func (w *dailyFile) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
now := time.Now()
day := now.Format("2006-01-02")
if w.f != nil && day != w.day {
w.f.Close()
w.f = nil
}
if w.f == nil {
if err := os.MkdirAll(w.dir, 0755); err != nil {
return 0, err
}
f, err := os.OpenFile(filepath.Join(w.dir, w.naming(now)), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return 0, err
}
w.f, w.day = f, day
}
return w.f.Write(p)
}
func (w *dailyFile) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.f == nil {
return nil
}
err := w.f.Close()
w.f = nil
return err
}
// FileLogOut 应用文件日志的底层写出器(stderr + 按天文件 MultiWriter)。
// SetupFileLogging 之后有效;供需要独立过滤级别的 logger 复用同一落盘目的地
// (如 wails 系统日志降到 Warn,业务 slog.Default() 保持 INFO 全量)。
var FileLogOut io.Writer = os.Stderr
// SetupFileLogging slog 双路落盘:stderr + ~/.u-desk/logs/app-YYYY-MM-DD.log。
// windowsgui 下 stderr 被丢弃,文件路是日志唯一可见载体;
// 文件侧打不开时静默降级为仅 stderr,不因日志失败阻断启动。
func SetupFileLogging() {
out := io.Writer(os.Stderr)
w := newDailyFile(GetLogsDir(), func(t time.Time) string {
return fmt.Sprintf("app-%s.log", t.Format("2006-01-02"))
})
// 预开当天文件探测文件侧可用性(Write 内部含懒开/换天逻辑)
if _, err := w.Write(nil); err == nil {
// 文件必须放 MultiWriter 首位:windowsgui 下 stderr 句柄无效,
// MultiWriter 按序短路,stderr 在前会吞掉后续文件路(真机零日志的根因)
out = io.MultiWriter(w, os.Stderr)
} else {
w.Close()
}
FileLogOut = out
slog.SetDefault(slog.New(slog.NewTextHandler(out, nil)))
}
+46
View File
@@ -0,0 +1,46 @@
package common
import (
"fmt"
"os"
"path/filepath"
"runtime/debug"
"sync"
"time"
)
var panicLogMu sync.Mutex
// AppendPanicLog 把一次 panic 的来源名、值与完整栈追加写入
// ~/.u-desk/logs/panic-YYYYMMDD.log。写失败静默放弃:取证失败不能引发二次故障。
func AppendPanicLog(name string, r any) {
dir := GetLogsDir()
panicLogMu.Lock()
defer panicLogMu.Unlock()
if err := os.MkdirAll(dir, 0755); err != nil {
return
}
path := filepath.Join(dir, fmt.Sprintf("panic-%s.log", time.Now().Format("20060102")))
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return
}
defer f.Close()
// debug.Stack 在 defer 内 panic 未展开时调用,包含 panic 原始调用栈
fmt.Fprintf(f, "[%s] goroutine=%s panic=%v\n%s\n",
time.Now().Format("2006-01-02 15:04:05.000"), name, r, debug.Stack())
}
// Go 带 panic 兜底的 goroutine:panic 时写完整取证后仅结束该 goroutine,
// 不让单点 panic 杀掉整个进程(windowsgui 下默认静默退出零痕迹)。
func Go(name string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
AppendPanicLog(name, r)
}
}()
fn()
}()
}
+3 -2
View File
@@ -199,10 +199,11 @@ func RunConcurrent[T any](tasks []T, limit int, fn func(t T)) {
for _, task := range tasks {
sem <- struct{}{}
wg.Add(1)
go func(t T) {
t := task
Go("bounded-parallel", func() {
defer func() { <-sem; wg.Done() }()
fn(t)
}(task)
})
}
wg.Wait()
}
+6 -4
View File
@@ -14,6 +14,8 @@ import (
"strings"
"sync"
"time"
"u-desk/internal/common"
)
const DefaultFileServerPort = 2652
@@ -142,11 +144,11 @@ func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *h
l, e := net.Listen("tcp", addr)
if e == nil {
srv = &http.Server{Handler: handler}
go func() {
common.Go("local-file-server-serve", 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)
@@ -346,11 +348,11 @@ type LocalFileHandler struct {
// NewLocalFileHandler 创建本地文件处理器
func NewLocalFileHandler() *LocalFileHandler {
// 启动本地文件服务器
go func() {
common.Go("local-file-handler-start", func() {
if _, err := StartLocalFileServer(); err != nil {
log.Printf("[LocalFileHandler] 启动本地文件服务器失败: %v", err)
}
}()
})
return &LocalFileHandler{}
}
+59 -26
View File
@@ -3,11 +3,14 @@ package filesystem
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"u-desk/internal/common"
)
// AuditOperation 审计操作类型
@@ -78,7 +81,7 @@ func (a *AuditLogger) ensureReady() error {
}
a.logFile = f
a.initialized = true
go a.backgroundFlush()
common.Go("audit-flush", a.backgroundFlush)
return nil
}
@@ -102,10 +105,14 @@ func (a *AuditLogger) Log(entry AuditLogEntry) error {
// 添加到缓冲区
a.buffer = append(a.buffer, entry)
// 如果缓冲区满了,立即写入
// 如果缓冲区满了,立即写入(缓冲满即刷的热路径语义不变:写+Sync 在已持有的锁内完成)
if len(a.buffer) >= a.bufferSize {
if err := a.flush(); err != nil {
if f, err := a.flush(); err != nil {
return err
} else if f != nil {
if err := f.Sync(); err != nil {
return err
}
}
}
@@ -166,10 +173,11 @@ func (a *AuditLogger) LogRead(path string, size int64, err error) {
_ = a.Log(entry)
}
// flush 将缓冲区写入文件
func (a *AuditLogger) flush() error {
// flush 将缓冲区写入文件(调用方须持有 a.mu),返回刚写入的文件供调用方在锁外 Sync。
// Sync 是慢速磁盘操作,不放在锁内执行,避免阻塞 Log 热路径。
func (a *AuditLogger) flush() (*os.File, error) {
if len(a.buffer) == 0 {
return nil
return nil, nil
}
// 序列化所有条目为JSON(每行一个)
@@ -179,19 +187,25 @@ func (a *AuditLogger) flush() error {
continue // 序列化失败,跳过该条目
}
if _, err := a.logFile.Write(append(data, '\n')); err != nil {
return err
return nil, err
}
}
// 刷新到磁盘
if err := a.logFile.Sync(); err != nil {
return err
}
// 清空缓冲区
a.buffer = a.buffer[:0]
return nil
return a.logFile, nil
}
// flushSynced 锁内写缓冲、锁外 Sync:fsync 不占 a.mu,后台定时刷盘不再阻塞 Log
func (a *AuditLogger) flushSynced() error {
a.mu.Lock()
f, err := a.flush()
a.mu.Unlock()
if err != nil || f == nil {
return err
}
return f.Sync()
}
// backgroundFlush 后台协程,定期刷新缓冲区
@@ -202,14 +216,10 @@ func (a *AuditLogger) backgroundFlush() {
for {
select {
case <-ticker.C:
a.mu.Lock()
_ = a.flush()
a.mu.Unlock()
_ = a.flushSynced()
case <-a.stopChan:
// 停止前刷新一次
a.mu.Lock()
_ = a.flush()
a.mu.Unlock()
_ = a.flushSynced()
return
}
}
@@ -227,31 +237,54 @@ func (a *AuditLogger) Close() error {
return nil
}
// 刷新剩余缓冲区
if err := a.flush(); err != nil {
// 刷新剩余缓冲区(进程退出路径,Sync 在锁内不影响运行期热路径)
if f, err := a.flush(); err != nil {
return err
} else if f != nil {
if err := f.Sync(); err != nil {
return err
}
}
// 关闭文件
return a.logFile.Close()
}
// GetRecentLogs 获取最近的审计日志
// GetRecentLogs 获取最近的审计日志(尾部倒读:文件大于 64KB 时只 Seek 读末尾 64KB,
// 日志单行远小于 64KB,足以覆盖 limit 条最新记录,避免随日志增长全量读盘)
func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
// 读取今天的日志文件
timestamp := time.Now().Format("2006-01-02")
logPath := filepath.Join(logDir, fmt.Sprintf("audit_%s.log", timestamp))
data, err := os.ReadFile(logPath)
f, err := os.Open(logPath)
if err != nil {
return nil, err
}
defer f.Close()
// 解析JSON(每行一个条目)
var entries []AuditLogEntry
const tailSize = 64 * 1024
info, err := f.Stat()
if err != nil {
return nil, err
}
offset := info.Size() - tailSize
if offset < 0 {
offset = 0
}
data := make([]byte, info.Size()-offset)
if _, err := f.ReadAt(data, offset); err != nil && err != io.EOF {
return nil, err
}
// 从文件中间开始读时首个字节位置未必在行边界,丢弃首条残行
lines := parseLines(string(data))
if offset > 0 && len(lines) > 0 {
lines = lines[1:]
}
// 从后往前读取(最新的在前)
// 解析JSON(每行一个条目),从后往前读取(最新的在前)
var entries []AuditLogEntry
start := len(lines) - limit
if start < 0 {
start = 0
+4 -2
View File
@@ -6,6 +6,8 @@ import (
"path/filepath"
"runtime"
"time"
"u-desk/internal/common"
)
// ========== 辅助函数 ==========
@@ -43,10 +45,10 @@ func OpenPath(path string) error {
}
// 给进程一点时间启动
go func() {
common.Go("fs-process-release", func() {
time.Sleep(100 * time.Millisecond)
cmd.Process.Release()
}()
})
return nil
}
+1 -1
View File
@@ -52,7 +52,7 @@ func NewRecycleBin(binPath string) (*RecycleBin, error) {
}
// 启动自动清理协程
go bin.autoCleanup()
common.Go("recycle-autoclean", bin.autoCleanup)
return bin, nil
}
+7 -2
View File
@@ -878,10 +878,15 @@ func (s *FileSystemService) ResolveShortcut(lnkPath string) (targetPath string,
lnkPath,
)
// 执行 PowerShell 命令
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
// 执行 PowerShell 命令(10s 超时:PowerShell 冷启动/被安全软件拖住时不无限挂起)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
output, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("解析快捷方式超时(10s)")
}
return "", fmt.Errorf("解析快捷方式失败: %w", err)
}
+3 -1
View File
@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"time"
"u-desk/internal/common"
)
// ZipFileEntry 表示 zip 文件中的一个文件条目
@@ -231,7 +233,7 @@ func ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
debugLog("[ExtractFileFromZipToTemp] 开始提取: %s from %s", filePath, zipPath)
// 启动临时文件清理协程
go CleanOldTempFiles()
common.Go("zip-clean-temp", CleanOldTempFiles)
// 创建临时目录
tempDir := filepath.Join(os.TempDir(), TempFileDir)
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/fsnotify/fsnotify"
"u-desk/internal/common"
)
type Watcher struct {
@@ -62,7 +63,7 @@ func (w *Watcher) WatchFile(path string) error {
w.watched = absPath
// 后台消费事件
go w.consumeEvents()
common.Go("filewatch-consume", w.consumeEvents)
return nil
}
+4 -4
View File
@@ -210,10 +210,10 @@ func InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash
// 自动重启
if autoRestart {
go func() {
common.Go("update-restart", func() {
time.Sleep(2 * time.Second)
restartApplication()
}()
})
}
return &InstallResult{Success: true, Message: "安装成功"}, nil
@@ -275,10 +275,10 @@ func replaceExecutableFileWindows(newFilePath, execPath string) error {
}
// 延迟删除旧文件
go func() {
common.Go("update-remove-old-exe", func() {
time.Sleep(10 * time.Second)
os.Remove(oldExecPath)
}()
})
return nil
}
+30 -12
View File
@@ -10,11 +10,16 @@ import (
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"u-desk/internal/common"
)
// 连接建立全流程(拨号+认证+SFTP 子系统)的总超时预算
const connectTimeout = 10 * time.Second
// 拨号独立预算,小于总预算:冷连接卡死大多发生在 TCP 拨号,
// 独立预算保证握手认证阶段始终有剩余 deadline 可用
const dialTimeout = 6 * time.Second
// 保活参数:周期发送 keepalive 并等待回复,超过 keepaliveReplyWait 未回复判定连接已死
const (
keepaliveInterval = 30 * time.Second
@@ -45,7 +50,8 @@ func GetManager() *Manager {
}
// Connect 创建或复用 SFTP 连接
func (m *Manager) Connect(config *Config) (*Client, error) {
// onStage 在连接各阶段推进时回调(dial/auth/channel),可为 nil
func (m *Manager) Connect(config *Config, onStage func(stage string)) (*Client, error) {
key := fmt.Sprintf("%s:%d", config.Host, config.Port)
m.mu.Lock()
@@ -60,7 +66,7 @@ func (m *Manager) Connect(config *Config) (*Client, error) {
m.clients.Delete(key)
}
c, err := newClient(config)
c, err := newClient(config, onStage)
if err != nil {
return nil, err
}
@@ -102,8 +108,8 @@ func (m *Manager) Shutdown() {
// --- 内部 ---
// newClient 建立连接并启动保活循环
func newClient(config *Config) (*Client, error) {
c, err := buildClient(config)
func newClient(config *Config, onStage func(stage string)) (*Client, error) {
c, err := buildClient(config, onStage)
if err != nil {
return nil, err
}
@@ -112,7 +118,14 @@ func newClient(config *Config) (*Client, error) {
}
// buildClient 拨号+认证+SFTP 子系统建立(全程受 connectTimeout 约束),不启动保活
func buildClient(config *Config) (*Client, error) {
// onStage 标记各阶段开始:dial=拨号、auth=认证、channel=建通道;SSH 协议链路串行,
// 无法并行加速,分步回调让前端可见当前卡点
func buildClient(config *Config, onStage func(stage string)) (*Client, error) {
notify := func(stage string) {
if onStage != nil {
onStage(stage)
}
}
sshConfig := &ssh.ClientConfig{
Config: ssh.Config{
KeyExchanges: []string{
@@ -160,14 +173,17 @@ func buildClient(config *Config) (*Client, error) {
// JoinHostPort 保证 IPv6 主机地址被方括号包裹,fmt 拼接对 IPv6 产生非法地址
addr := net.JoinHostPort(config.Host, strconv.Itoa(config.Port))
// 拨号与认证、SFTP 子系统握手共享同一总超时预算,认证阶段无界挂起由 deadline 掐断
// 总 deadline 从拨号前起算:拨号占独立 6s 预算,认证与 SFTP 子系统握手受剩余总预算约束,
// 认证阶段无界挂起由 deadline 掐断
deadline := time.Now().Add(connectTimeout)
sshConn, err := net.DialTimeout("tcp", addr, connectTimeout)
notify("dial")
sshConn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return nil, &ConnectionError{Op: "dial", Err: err}
}
sshConn.SetDeadline(deadline)
notify("auth")
sshConnConn, chans, reqs, err := ssh.NewClientConn(sshConn, addr, sshConfig)
if err != nil {
sshConn.Close()
@@ -176,6 +192,7 @@ func buildClient(config *Config) (*Client, error) {
sshClient := ssh.NewClient(sshConnConn, chans, reqs)
notify("channel")
sftpClient, err := sftp.NewClient(sshClient)
sshConn.SetDeadline(time.Time{})
if err != nil {
@@ -196,7 +213,7 @@ func (c *Client) startKeepalive() {
stop := make(chan struct{})
sshClient := c.sshClient
c.stopKeep = stop
go func() {
common.Go("sftp-keepalive", func() {
t := time.NewTicker(keepaliveInterval)
defer t.Stop()
for {
@@ -210,16 +227,16 @@ func (c *Client) startKeepalive() {
return
}
}
}()
})
}
// sshKeepalive 发送一次 keepalive 请求并限时等待回复,超时或出错视作连接已死
func sshKeepalive(sshClient *ssh.Client, wait time.Duration) bool {
done := make(chan error, 1)
go func() {
common.Go("sftp-keepalive-probe", func() {
_, _, err := sshClient.SendRequest("keepalive@openssh.com", true, nil)
done <- err
}()
})
select {
case err := <-done:
return err == nil
@@ -295,7 +312,8 @@ func (c *Client) WithRetry(fn func(*sftp.Client) error) error {
func (c *Client) reconnect() error {
// 用 buildClient 而非 newClient,避免保活循环绑定到即将丢弃的临时容器
nc, err := buildClient(c.config)
// 后台重连无阶段展示需求,回调传 nil
nc, err := buildClient(c.config, nil)
if err != nil {
return err
}
+3 -3
View File
@@ -844,9 +844,9 @@ func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) {
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")
common.Go("sftp-cmd-df", func() { runCmd("df", "df -B1 / 2>/dev/null | tail -1") })
common.Go("sftp-cmd-mem", func() { runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2") })
common.Go("sftp-cmd-cpu", func() { runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1") })
var dfOut, memOut, cpuOut string
hasErr := false