diff --git a/sundynix-desktop/app.go b/sundynix-desktop/app.go index 09dbd3e..e6910c6 100644 --- a/sundynix-desktop/app.go +++ b/sundynix-desktop/app.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" goruntime "runtime" + "time" "github.com/wailsapp/wails/v3/pkg/application" ) @@ -68,8 +69,14 @@ func (a *App) Notify(title, body string) { } } -func download(url, dst string) error { - resp, err := http.Get(url) +// downloadClient 给下载加超时:默认 http.Get 用的 client 没有超时, +// 上游卡住的话「另存为」会永远转下去,用户只能强杀 app。 +var downloadClient = &http.Client{Timeout: 3 * time.Minute} + +// download 把 url 下载到 dst。失败一律不留残file: +// 中途断开会在用户选定的路径上留个半截 .docx,带着用户起的名字,以后双击打不开。 +func download(url, dst string) (err error) { + resp, err := downloadClient.Get(url) if err != nil { return err } @@ -81,7 +88,17 @@ func download(url, dst string) error { if err != nil { return err } - defer f.Close() + defer func() { + // Close 的错误不能吞:写文件时 io.Copy 成功不代表数据落了盘, + // flush 失败只会在 Close 上报出来——吞掉就是静默截断。 + cerr := f.Close() + if err == nil { + err = cerr + } + if err != nil { + _ = os.Remove(dst) // 失败不留残file + } + }() _, err = io.Copy(f, resp.Body) return err } diff --git a/sundynix-desktop/app_test.go b/sundynix-desktop/app_test.go new file mode 100644 index 0000000..4476c8e --- /dev/null +++ b/sundynix-desktop/app_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// download 是原生「另存为 / 系统打开」真正落盘的那一段,之前零测试。 +// 对话框本身要真窗口没法自动化,但下载这段是纯 HTTP + 文件 IO,必须钉住。 + +func TestDownloadOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("PK\x03\x04fake docx")) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "r.docx") + if err := download(srv.URL, dst); err != nil { + t.Fatalf("下载应成功: %v", err) + } + b, err := os.ReadFile(dst) + if err != nil || string(b) != "PK\x03\x04fake docx" { + t.Fatalf("落盘内容不对: %q err=%v", b, err) + } +} + +func TestDownloadHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "r.docx") + err := download(srv.URL, dst) + if err == nil { + t.Fatal("404 应该报错") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("错误里应带状态码,便于用户判断: %v", err) + } + // 失败时不该在用户选定的路径上留一个空的/半截的 .docx —— + // 用户会以为导出成功,双击却打不开。 + if _, statErr := os.Stat(dst); statErr == nil { + t.Error("下载失败却留下了文件(用户会当成导出成功的空文档)") + } +} + +// io.Copy 中途断开:用户选定路径上不该留半截文件。 +func TestDownloadTruncatedLeavesNoFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "999999") // 声明很长,实际写一点就断 + _, _ = w.Write([]byte("half")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + panic(http.ErrAbortHandler) // 掐断连接 + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "r.docx") + if err := download(srv.URL, dst); err == nil { + t.Fatal("连接中断应该报错") + } + if _, statErr := os.Stat(dst); statErr == nil { + t.Error("中断却留下了半截文件") + } +} + +func TestReadLocalFile(t *testing.T) { + p := filepath.Join(t.TempDir(), "a.txt") + _ = os.WriteFile(p, []byte("你好"), 0o600) + got, err := (&App{}).ReadLocalFile(p) + if err != nil || got != "你好" { + t.Fatalf("读文件: got=%q err=%v", got, err) + } + if _, err := (&App{}).ReadLocalFile(filepath.Join(t.TempDir(), "nope")); err == nil { + t.Error("读不存在的文件应报错") + } +} + +func TestPing(t *testing.T) { + if (&App{}).Ping() == "" { + t.Error("Ping 是前端探活 Go 桥的唯一手段,不能返回空") + } +}