diff --git a/app.go b/app.go index b6e0a5e..fe21050 100644 --- a/app.go +++ b/app.go @@ -56,6 +56,7 @@ type App struct { uploadProgressMu sync.Mutex uploadProgress UploadProgress uploadStarted time.Time + exitNoticeOnce sync.Once } // NewApp 创建应用对象。真正的初始化在 startup 里做。 @@ -119,7 +120,35 @@ func (a *App) startup(ctx context.Context) { } } -// shutdown 在窗口关闭时被 Wails 调用,用来收尾。 +// beforeClose 在关闭窗口前安全停止取视频任务并清理专属 Chrome。 +func (a *App) beforeClose(ctx context.Context) bool { + a.videoTaskMu.Lock() + runner := a.videoTask + a.videoTaskMu.Unlock() + if runner != nil && runner.Snapshot().State == task.StateRunning { + runner.Stop() + a.log.Warn("取视频任务仍在运行,已请求停止;当前下载完成后请再次关闭程序") + a.exitNoticeOnce.Do(func() { + go func() { + _, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{ + Type: runtime.WarningDialog, Title: "正在停止取视频任务", + Message: "已请求停止任务。请等待当前下载完成,再关闭程序。", + }) + }() + }) + return true + } + stateFile := config.ResolveDataPath("浏览器运行状态.json") + closed, err := taobao.CloseBrowserOnExit(stateFile, a.cfg.Taobao.UserDataDir) + if err != nil { + a.log.Warn("退出时保留未能确认归属或关闭的淘宝专属 Chrome:%v", err) + } else if closed { + a.log.Info("退出前已关闭淘宝专属 Chrome") + } + return false +} + +// shutdown 在窗口关闭后释放 SQLite。 func (a *App) shutdown(ctx context.Context) { if a.db != nil { if err := a.db.Close(); err != nil { diff --git a/app_close_test.go b/app_close_test.go new file mode 100644 index 0000000..8a320e8 --- /dev/null +++ b/app_close_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "testing" + "time" + + "cmsp/internal/task" +) + +func Test退出前先停止取视频任务(t *testing.T) { + started := make(chan struct{}) + runner := task.NewRunner(task.Options{ + Load: func(_ context.Context, id string) (task.Product, error) { + return task.Product{ID: id}, nil + }, + Prepare: func(ctx context.Context, _ task.Product) (task.Work, error) { + close(started) + <-ctx.Done() + return task.Work{}, ctx.Err() + }, + }) + if err := runner.Start(context.Background(), []string{"fictional-product"}); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("取视频任务未启动") + } + a := NewApp() + a.videoTask = runner + // 此测试没有 Wails 前端;只检查退出门,不打开系统消息框。 + a.exitNoticeOnce.Do(func() {}) + if !a.beforeClose(context.Background()) { + t.Fatal("任务仍在运行时必须阻止本次退出") + } + deadline := time.Now().Add(2 * time.Second) + for runner.Snapshot().State == task.StateRunning && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := runner.Snapshot().State; got != task.StateStopped { + t.Fatalf("退出前未停止任务:%s", got) + } +} diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 9c032e5..5a7f318 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,12 +2,16 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Business-Rules-and-Glossary.- -wiki_revision: bbb42cb37cc4703c3953c021a7054f862ae312e0 -synchronized_at: 2026-10-05T10:01:40Z +wiki_revision: 6deec49c2ba48aaf96a5842b4d525352afd6cf87 +synchronized_at: 2026-10-06T07:37:40Z # 业务规则与术语 +## 程序退出与淘宝专属 Chrome(2026-10-06) + +关闭 cmsp 时,若取视频任务仍在运行,先请求停止并阻止本次退出;当前商品处理结束后,使用者再次关闭程序。没有运行中的取视频任务时,仅当运行状态文件中的 PID、Chrome 路径、专属 Profile、调试端口与当前进程及 CDP 页面均可验证,才通过 CDP 请求 Chrome 正常退出;超时后重新核对 PID 归属,再强制结束该专属进程树。归属不明或调试端口不可验证时保留浏览器并记录原因,不影响 cmsp 退出。不会关闭日常 Chrome,也不删除 Chrome Profile 或读取登录凭据。 + ## 指定商品视频上传规则(2026-09-30) 单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 Shopee ID 子目录下排序首个 mp4,先完成时长、格式、像素与大小校验。写入前在 SQLite 持久化幂等键。HTTP 202 和 `processing`/`unknown` 均不代表成功;再次操作仅查询原键,`succeeded` 才更新本地视频与上传状态。ERPGo 预检返回 200 且 `video: []` 时才确认远端当前无视频;返回 502 / `HHH_UPSTREAM_ERROR` / `stage=check` 表示视频状态未知,显示“视频状态无法确认,上传未提交”,不创建上传操作、不发送 PUT,保留本地 MP4 与 requestId。批量路径的该商品上传状态为 `unconfirmed`,不记作上传失败。`tempVideoUrl` 或 `videoUploadIdStr` 非空表示远端仍有处理中标记:单商品预览停止,执行前再次检查;批量遇到该商品标为待处理,不创建新操作、不发送 PUT。只有普通已存在视频且没有处理中标记时,单商品才可在明确覆盖确认后继续。用户确认后弹窗立即关闭,上传按钮在调用期间继续显示运行中;弹窗关闭不代表远端成功。单商品已有本地未决操作时,确认框改为“恢复原上传操作”,只查询或恢复原键,不提示覆盖、不发新 PUT;操作明确失败后才按新上传流程检查当前视频。远端操作 processing/unknown 或操作查询异常保留原键,商品上传状态为 `unconfirmed`,列表显示“待确认”;只有本次操作返回 `succeeded` 才显示“已上传”。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 3b2a215..aacace8 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,12 +2,18 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Local-Development-and-Verification.- -wiki_revision: 69f9ab4712b8efd0aa6f31e08d39b51503c88957 -synchronized_at: 2026-10-06T07:03:18Z +wiki_revision: 697d2e353c3b4b3f6648b7a08e4752235c42c7e7 +synchronized_at: 2026-10-06T07:37:41Z # 本地开发与验证 +## 退出前关闭淘宝专属 Chrome 的验证(2026-10-06,#36) + +`app_close_test.go` 验证运行中的取视频任务先请求停止且本次窗口关闭被阻止;`internal/taobao/chrome_test.go` 验证专属 PID/Profile/调试端口匹配时正常关闭、超时后重新核对 PID 才强制结束,以及归属或 CDP 不明时保留浏览器;`internal/taobao/cdp_test.go` 验证 `Browser.close` 发送至本机调试端口,拒绝外部 WebSocket 地址。从仓库根目录执行 `go test ./... -count=1`、`go test -race ./...`、`go vet ./...`、`python dev_scripts/harness.py check --strict` 和 Wails Windows 构建。 + +真机验收:启动 cmsp 专属 Chrome,关闭 cmsp 后确认该专属进程退出、日常 Chrome 仍在、Profile 保留;取视频任务中关闭程序时确认先停任务、待当前处理结束再次关闭。模拟测试和构建不能代替真机进程退出及 Wails 窗口交互验证。 + ## 批量上传进度验证(2026-10-06) `UploadVideos` 使用后端内存快照与 `upload:progress` 事件报告当前批次,前端每秒轮询 `GetUploadProgress` 兜底。每个商品处理返回后从 SQLite 读取上传状态并累计一次;并发事件用批次号和已处理数避免旧事件回退显示,列表刷新共用串行合并队列并在批次结束时等待最后一次刷新。`TestUploadBatchRespectsConfiguredConcurrency` 覆盖并发 1/3 与去重总数,`TestUploadProgressClassifiesSQLiteOutcomesConcurrently` 覆盖终态分类,`TestUploadProgressAdvancesBeforeBatchFinishes` 覆盖未完成批次的逐商品进度及待确认不计入已上传。运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、`npm --prefix frontend run build` 和 Wails Windows 构建;真实店铺上传仍需使用者明确确认并单独验收。 diff --git a/internal/taobao/cdp.go b/internal/taobao/cdp.go index 1cb1094..500dec1 100644 --- a/internal/taobao/cdp.go +++ b/internal/taobao/cdp.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" "strings" "sync" @@ -248,6 +249,31 @@ func cdpVersionAvailable(port int) bool { return fetchDebugJSON(port, "json/version", &payload) == nil } +// requestBrowserClose 只连接已验证的本机调试端口,请求 Chrome 正常退出。 +func requestBrowserClose(port int) error { + var version struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := fetchDebugJSON(port, "json/version", &version); err != nil { + return err + } + endpoint, err := url.Parse(version.WebSocketDebuggerURL) + if err != nil || endpoint.Scheme != "ws" || (endpoint.Hostname() != "127.0.0.1" && endpoint.Hostname() != "localhost") || endpoint.Port() != strconv.Itoa(port) { + return fmt.Errorf("Chrome 浏览器调试地址不属于已验证的本机端口") + } + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = cdpRequestTimeout + conn, _, err := dialer.Dial(endpoint.String(), nil) + if err != nil { + return fmt.Errorf("连接 Chrome 浏览器调试端口失败:%w", err) + } + defer conn.Close() + if err := conn.SetWriteDeadline(time.Now().Add(cdpRequestTimeout)); err != nil { + return err + } + return conn.WriteJSON(map[string]any{"id": 1, "method": "Browser.close"}) +} + func cdpHasPage(port int) bool { targets, err := fetchDebugTargets(port) if err != nil { diff --git a/internal/taobao/cdp_test.go b/internal/taobao/cdp_test.go index 6a2c213..9f8fdf9 100644 --- a/internal/taobao/cdp_test.go +++ b/internal/taobao/cdp_test.go @@ -2,9 +2,71 @@ package taobao import ( "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" "testing" + "time" + + "github.com/gorilla/websocket" ) +func Test正常关闭Chrome通过浏览器CDP端点(t *testing.T) { + methods := make(chan string, 1) + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + fmt.Fprintf(w, `{"webSocketDebuggerUrl":"ws://%s/devtools/browser/test"}`, r.Host) + case "/devtools/browser/test": + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("WebSocket 升级失败:%v", err) + return + } + defer conn.Close() + var request struct { + Method string `json:"method"` + } + if err := conn.ReadJSON(&request); err != nil { + t.Errorf("读取关闭请求失败:%v", err) + return + } + methods <- request.Method + default: + t.Errorf("访问了意外端点:%s", r.URL.Path) + } + })) + defer server.Close() + parsed, _ := url.Parse(server.URL) + port, _ := strconv.Atoi(parsed.Port()) + if err := requestBrowserClose(port); err != nil { + t.Fatal(err) + } + select { + case method := <-methods: + if method != "Browser.close" { + t.Fatalf("关闭命令错误:%s", method) + } + case <-time.After(2 * time.Second): + t.Fatal("未收到 Browser.close 命令") + } +} + +func Test关闭Chrome拒绝外部WebSocket地址(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"webSocketDebuggerUrl":"ws://example.invalid/devtools/browser/test"}`) + })) + defer server.Close() + parsed, _ := url.Parse(server.URL) + port, _ := strconv.Atoi(parsed.Port()) + if err := requestBrowserClose(port); err == nil { + t.Fatal("浏览器关闭请求不能连接外部地址") + } +} + func TestCDP响应跳过事件并匹配目标ID(t *testing.T) { messages := [][]byte{ []byte(`{"method":"Page.loadEventFired","params":{"timestamp":1}}`), diff --git a/internal/taobao/chrome.go b/internal/taobao/chrome.go index 846806c..56435af 100644 --- a/internal/taobao/chrome.go +++ b/internal/taobao/chrome.go @@ -146,6 +146,107 @@ func CloseBrowser(stateFile, profile string) (bool, error) { return true, nil } +type exitBrowserOps struct { + exists func(int) bool + command func(int) (string, error) + cdpReady func(int) bool + graceful func(int) error + force func(int) error + waitExit func(int, time.Duration) bool +} + +// CloseBrowserOnExit 只在退出前关闭可验证归属的专属 Chrome。 +// 正常关闭超时后再验证一次 PID,才允许强制结束该进程树。 +func CloseBrowserOnExit(stateFile, profile string) (bool, error) { + return closeBrowserOnExitWith(stateFile, profile, exitBrowserOps{ + exists: processExists, + command: processCommandLine, + cdpReady: func(port int) bool { return cdpVersionAvailable(port) && cdpHasPage(port) }, + graceful: requestBrowserClose, + force: killProcessTree, + waitExit: waitForProcessExit, + }) +} + +func closeBrowserOnExitWith(stateFile, profile string, ops exitBrowserOps) (bool, error) { + state, found, err := readBrowserState(stateFile) + if err != nil || !found { + return false, err + } + if !ops.exists(state.PID) { + return false, nil + } + if err := verifyExitBrowserOwner(state, profile, ops.command); err != nil { + return false, err + } + if state.Port <= 0 || !ops.cdpReady(state.Port) { + return false, fmt.Errorf("无法确认专属 Chrome 的调试端口和页面,未关闭浏览器") + } + _ = ops.graceful(state.Port) + if !ops.waitExit(state.PID, 3*time.Second) && ops.exists(state.PID) { + // 正常关闭期间 PID 可能退出并被复用,强制结束前必须重新核对命令行。 + if err := verifyExitBrowserOwner(state, profile, ops.command); err != nil { + return false, err + } + if err := ops.force(state.PID); err != nil { + return false, fmt.Errorf("关闭专属 Chrome 失败:%w", err) + } + if !ops.waitExit(state.PID, 2*time.Second) { + return false, fmt.Errorf("专属 Chrome 在强制关闭后仍未退出") + } + } + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + return true, fmt.Errorf("浏览器已关闭,但删除运行状态文件失败:%w", err) + } + return true, nil +} + +func verifyExitBrowserOwner(state BrowserState, profile string, command func(int) (string, error)) error { + if state.PID <= 0 || strings.TrimSpace(profile) == "" || strings.TrimSpace(state.ProfilePath) == "" || strings.TrimSpace(state.ChromePath) == "" || normalizeWindowsPath(state.ProfilePath) != normalizeWindowsPath(profile) { + return fmt.Errorf("浏览器运行状态与当前专属 Profile 不匹配,未关闭浏览器") + } + line, err := command(state.PID) + if err != nil { + return fmt.Errorf("无法校验专属 Chrome 的进程归属,未关闭浏览器:%w", err) + } + chrome := normalizeWindowsPath(state.ChromePath) + normalized := strings.TrimSpace(normalizeWindowsText(line)) + if !commandBelongsToProfile(line, profile) || !commandHasDebugPort(line, state.Port) || !(normalized == chrome || strings.HasPrefix(normalized, chrome+" ")) { + return fmt.Errorf("PID %d 不属于本程序专属 Chrome,未关闭浏览器", state.PID) + } + return nil +} + +func commandHasDebugPort(line string, port int) bool { + if port <= 0 { + return false + } + command := normalizeWindowsText(line) + target := "--remote-debugging-port=" + strconv.Itoa(port) + for offset := 0; ; { + index := strings.Index(command[offset:], target) + if index < 0 { + return false + } + end := offset + index + len(target) + if end == len(command) || command[end] == ' ' || command[end] == '\t' { + return true + } + offset = end + } +} + +func waitForProcessExit(pid int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for processExists(pid) { + if time.Now().After(deadline) { + return false + } + time.Sleep(250 * time.Millisecond) + } + return true +} + func validateBrowserConfig(cfg config.TaobaoConfig, openURL string) error { if strings.TrimSpace(cfg.ChromePath) == "" { return fmt.Errorf("Chrome 可执行文件路径不能为空") diff --git a/internal/taobao/chrome_test.go b/internal/taobao/chrome_test.go index c0cb04a..aa2d858 100644 --- a/internal/taobao/chrome_test.go +++ b/internal/taobao/chrome_test.go @@ -1,10 +1,153 @@ package taobao import ( + "errors" + "os" + "path/filepath" "strings" "testing" + "time" ) +func Test退出时只关闭确认归属的专属Chrome(t *testing.T) { + profile := `C:\Users\测试\淘宝专属浏览器` + chrome := `C:\Program Files\Google\Chrome\Application\chrome.exe` + stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json") + if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: chrome}); err != nil { + t.Fatal(err) + } + commandLine := `"` + chrome + `" --user-data-dir="` + profile + `" --remote-debugging-port=19666` + commandCalls, gracefulCalls, forceCalls := 0, 0, 0 + ops := exitBrowserOps{ + exists: func(int) bool { return true }, + command: func(int) (string, error) { + commandCalls++ + return commandLine, nil + }, + cdpReady: func(int) bool { return true }, + graceful: func(int) error { + gracefulCalls++ + return nil + }, + force: func(int) error { + forceCalls++ + return nil + }, + waitExit: func(_ int, timeout time.Duration) bool { return timeout == 3*time.Second }, + } + closed, err := closeBrowserOnExitWith(stateFile, profile, ops) + if err != nil || !closed || commandCalls != 1 || gracefulCalls != 1 || forceCalls != 0 { + t.Fatalf("正常关闭路径错误:closed=%v err=%v command=%d graceful=%d force=%d", closed, err, commandCalls, gracefulCalls, forceCalls) + } + if _, err := os.Stat(stateFile); !os.IsNotExist(err) { + t.Fatalf("成功关闭后应删除运行状态文件:%v", err) + } +} + +func Test退出强制关闭前再次核对PID归属(t *testing.T) { + profile := `C:\Users\测试\淘宝专属浏览器` + chrome := `C:\Program Files\Google\Chrome\Application\chrome.exe` + for _, changed := range []bool{false, true} { + t.Run(map[bool]string{false: "仍归属本程序", true: "PID已复用"}[changed], func(t *testing.T) { + stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json") + if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: chrome}); err != nil { + t.Fatal(err) + } + checks, forced := 0, 0 + ops := exitBrowserOps{ + exists: func(int) bool { return true }, + command: func(int) (string, error) { + checks++ + if changed && checks == 2 { + return `"C:\Program Files\Google\Chrome\Application\chrome.exe" --user-data-dir=C:\Users\其他用户`, nil + } + return `"` + chrome + `" --user-data-dir="` + profile + `" --remote-debugging-port=19666`, nil + }, + cdpReady: func(int) bool { return true }, + graceful: func(int) error { return errors.New("CDP 关闭失败") }, + force: func(int) error { forced++; return nil }, + waitExit: func(_ int, timeout time.Duration) bool { return timeout == 2*time.Second }, + } + closed, err := closeBrowserOnExitWith(stateFile, profile, ops) + if checks != 2 || forced != map[bool]int{false: 1, true: 0}[changed] { + t.Fatalf("强制关闭前归属校验错误:closed=%v err=%v checks=%d forced=%d", closed, err, checks, forced) + } + if changed && (closed || err == nil) { + t.Fatalf("PID 复用时必须保留浏览器:closed=%v err=%v", closed, err) + } + if !changed && (!closed || err != nil) { + t.Fatalf("归属一致时应关闭:closed=%v err=%v", closed, err) + } + }) + } +} + +func Test退出时归属不明不发送关闭命令(t *testing.T) { + stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json") + if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: `C:\Users\甲\专属`, ChromePath: `C:\Chrome\chrome.exe`}); err != nil { + t.Fatal(err) + } + called := false + ops := exitBrowserOps{ + exists: func(int) bool { return true }, + command: func(int) (string, error) { return `C:\Chrome\chrome.exe --user-data-dir=C:\Users\甲\专属`, nil }, + cdpReady: func(int) bool { called = true; return true }, + graceful: func(int) error { called = true; return nil }, + force: func(int) error { called = true; return nil }, + waitExit: func(int, time.Duration) bool { called = true; return true }, + } + closed, err := closeBrowserOnExitWith(stateFile, `C:\Users\乙\专属`, ops) + if closed || err == nil || called { + t.Fatalf("Profile 不匹配不应关闭:closed=%v err=%v called=%v", closed, err, called) + } +} + +func Test退出时CDP不可验证则保留Chrome(t *testing.T) { + profile := `C:\Users\测试\淘宝专属浏览器` + stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json") + if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: `C:\Chrome\chrome.exe`}); err != nil { + t.Fatal(err) + } + called := false + ops := exitBrowserOps{ + exists: func(int) bool { return true }, + command: func(int) (string, error) { + return `C:\Chrome\chrome.exe --user-data-dir=` + profile + ` --remote-debugging-port=19666`, nil + }, + cdpReady: func(int) bool { return false }, + graceful: func(int) error { called = true; return nil }, + force: func(int) error { called = true; return nil }, + waitExit: func(int, time.Duration) bool { called = true; return true }, + } + closed, err := closeBrowserOnExitWith(stateFile, profile, ops) + if closed || err == nil || called { + t.Fatalf("CDP 不可验证时不得关闭:closed=%v err=%v called=%v", closed, err, called) + } +} + +func Test退出时调试端口不匹配则保留Chrome(t *testing.T) { + profile := `C:\Users\测试\淘宝专属浏览器` + stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json") + if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: `C:\Chrome\chrome.exe`}); err != nil { + t.Fatal(err) + } + called := false + ops := exitBrowserOps{ + exists: func(int) bool { return true }, + command: func(int) (string, error) { + return `C:\Chrome\chrome.exe --user-data-dir=` + profile + ` --remote-debugging-port=19667`, nil + }, + cdpReady: func(int) bool { called = true; return true }, + graceful: func(int) error { called = true; return nil }, + force: func(int) error { called = true; return nil }, + waitExit: func(int, time.Duration) bool { called = true; return true }, + } + closed, err := closeBrowserOnExitWith(stateFile, profile, ops) + if closed || err == nil || called { + t.Fatalf("调试端口不匹配时不得关闭:closed=%v err=%v called=%v", closed, err, called) + } +} + func Test命令行归属判断(t *testing.T) { profile := `C:\Users\测试 用户\淘宝浏览器\默认账号` cases := []struct { diff --git a/main.go b/main.go index 5288726..942322c 100644 --- a/main.go +++ b/main.go @@ -60,8 +60,9 @@ func main() { AssetServer: &assetserver.Options{ Assets: assets, }, - OnStartup: app.startup, - OnShutdown: app.shutdown, + OnStartup: app.startup, + OnBeforeClose: app.beforeClose, + OnShutdown: app.shutdown, // 打开浏览器默认右键菜单,这样在商品主图弹窗上右键才有「图片另存为」。 // // 代价:整个界面右键都会出现浏览器菜单,其中包含「检查」,