From f37b8c7f0fe2fd01029c31850d00c04202c53ec9 Mon Sep 17 00:00:00 2001 From: zJay26 <120452926+zJay26@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:46:38 +0800 Subject: [PATCH] Add configurable update download directory for v2.6.2 --- README.en.md | 4 +- README.md | 4 +- docs/optional-updates.md | 8 ++ docs/releases/v2.6.2.md | 23 ++++++ internal/app/app.go | 2 +- internal/app/update.go | 6 +- internal/platform/open_directory.go | 41 ++++++++++ internal/server/updates.go | 17 ++-- internal/server/updates_test.go | 29 +++++++ internal/updater/directory.go | 112 ++++++++++++++++++++++++++ internal/updater/directory_other.go | 13 +++ internal/updater/directory_test.go | 105 ++++++++++++++++++++++++ internal/updater/directory_windows.go | 7 ++ internal/updater/manager.go | 77 ++++++++++++------ internal/updater/updater_test.go | 4 + internal/web/static/i18n.js | 22 +++++ internal/web/static/index.html | 12 +++ internal/web/static/styles.css | 5 ++ internal/web/static/updates.js | 26 +++++- scripts/build.ps1 | 2 +- scripts/build.sh | 2 +- scripts/demo-api.js | 10 ++- scripts/mock-dashboard.mjs | 2 +- tests/dashboard.spec.mjs | 55 +++++++++++++ 24 files changed, 543 insertions(+), 45 deletions(-) create mode 100644 docs/releases/v2.6.2.md create mode 100644 internal/platform/open_directory.go create mode 100644 internal/updater/directory.go create mode 100644 internal/updater/directory_other.go create mode 100644 internal/updater/directory_test.go create mode 100644 internal/updater/directory_windows.go diff --git a/README.en.md b/README.en.md index 5b24585..c534f2e 100644 --- a/README.en.md +++ b/README.en.md @@ -29,7 +29,7 @@ All statistics stay on the current computer. codex-usage never stores prompts, r ## Install directly -This README covers stable **[v2.6.1](https://github.com/zJay26/codex-usage/releases/tag/v2.6.1)**; see the [release notes](docs/releases/v2.6.1.md) for changes and upgrade boundaries. Download links below always resolve to the latest stable release. +This README covers stable **[v2.6.2](https://github.com/zJay26/codex-usage/releases/tag/v2.6.2)**; see the [release notes](docs/releases/v2.6.2.md) for changes and upgrade boundaries. Download links below always resolve to the latest stable release. | System | amd64 / x64 | arm64 | |---|---|---| @@ -90,6 +90,8 @@ Starting with **v2.5.0**, the application checks GitHub for the latest stable re **Installed v2.5.0 or later can update in the app; versions before v2.5.0 require a manual download and `install` first.** Portable and preview copies only offer version checks and the release page. Checks fetch version information from GitHub, downloads come from this project's Release assets, and neither uploads usage, paths, or conversations. Turning off automatic checks stops background update requests. Re-running `install` remains available for manual upgrades. +**Software updates** lets you set the update download directory, open the folder, or copy its path. The default is `codex-usage` inside your user Downloads directory. Version folders retain the release binaries, and the UI shows the full path of the last download. Directory changes only affect future downloads; existing files stay in place and database backups remain in the local state directory. + Upgrading to v2.6 preserves existing statistics and applies accounting fixes to newly read records. To correct earlier undercounts, back up the state and verify retained JSONL coverage before explicitly running `scan --rebuild`; rebuilding cannot recover history whose source files have been deleted. ## What you can see diff --git a/README.md b/README.md index 21bb843..5b088f5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ## 直接安装 -本文对应稳定版 **[v2.6.1](https://github.com/zJay26/codex-usage/releases/tag/v2.6.1)**;变更和升级边界见 [发布说明](docs/releases/v2.6.1.md)。以下下载链接始终指向最新稳定版。 +本文对应稳定版 **[v2.6.2](https://github.com/zJay26/codex-usage/releases/tag/v2.6.2)**;变更和升级边界见 [发布说明](docs/releases/v2.6.2.md)。以下下载链接始终指向最新稳定版。 | 系统 | amd64 / x64 | arm64 | |---|---|---| @@ -90,6 +90,8 @@ chmod +x codex-usage **已安装 v2.5.0 或更高版本,可直接在软件内选择升级;早于 v2.5.0 的版本需先手动下载并执行 `install`。** 便携版和预览版只提供检查和发布页链接。更新检查只向 GitHub 请求版本信息,下载也来自本项目 Release,不上传用量、路径或对话;关闭自动检查后不会在后台请求更新信息。仍可随时通过重新执行 `install` 手动升级。 +“软件更新”中可以设置**更新包下载目录**,并打开文件夹或复制路径。默认保存到用户下载目录下的 `codex-usage` 文件夹;版本文件夹内保留正式安装包,界面显示最近下载的完整路径。保存新目录只影响后续下载,已有文件留在原处;数据库备份仍保留在本机状态目录。 + v2.6 系列升级会保留现有统计,计量修复适用于新读入的记录。若要修正旧版本已经漏计的历史,需先备份并核对仍保留的 JSONL,再显式执行 `scan --rebuild`;源文件已删除的历史无法由重建恢复。 ## 你能看到什么 diff --git a/docs/optional-updates.md b/docs/optional-updates.md index 03b33c3..02b60d5 100644 --- a/docs/optional-updates.md +++ b/docs/optional-updates.md @@ -8,6 +8,14 @@ 不下载发布说明 HTML,不执行日志或 API 文本,不发送统计数据。下载只接受本项目对应 tag 的官方命名文件及 SHA256SUMS;限制大小、超时和 HTTPS 重定向来源;SHA256 与可选 GitHub asset digest 必须一致。关闭自动检查后仍可手动检查。 +## 下载目录 + +“软件更新”提供下载目录输入框、保存、恢复默认、打开文件夹和复制路径,并显示最近下载文件的完整路径。默认使用 Windows 系统 Downloads 已知文件夹(支持重定向),macOS / Linux 使用 `~/Downloads`,其下创建 `codex-usage`。无法解析用户下载目录时退回状态目录的 `downloads`。 + +`POST /api/v1/updates/preferences` 接受可选 `download_dir` 字段,可与 `auto_check` 一起原子保存;空字符串恢复默认。保存和下载前都检查绝对路径及可写性。更新执行中拒绝修改目录。`GET /api/v1/updates` 返回生效路径、默认路径、自定义设置和最近下载路径;`POST /api/v1/updates/open-directory` 只打开已保存的目录,沿用同源校验,无桌面会话时保留复制路径功能。 + +下载包保存在所选目录下独立的 `codex-usage-v版本-随机编号` 文件夹,文件名使用对应平台的正式发布名称。成功下载后,将安装包复制到私有更新工作目录并再次校验 SHA256;helper 和数据库备份始终留在状态目录中。修改目录只影响未来下载,不迁移或删除已有文件;原来的更新缓存同样保留。 + ## 替换与恢复 下载期间旧服务继续工作。下载校验完成后复制当前程序作为独立 helper,传入状态目录下的任务文件。helper 再次验证摘要和目标程序版本,停止旧进程并等待退出,随后备份旧程序、配置、SQLite 主文件及其 WAL / SHM。目标程序启动后必须在 45 秒内通过匹配版本的健康检查;失败则停止新进程、恢复备份并重启旧版本。 diff --git a/docs/releases/v2.6.2.md b/docs/releases/v2.6.2.md new file mode 100644 index 0000000..501c7d1 --- /dev/null +++ b/docs/releases/v2.6.2.md @@ -0,0 +1,23 @@ +# codex-usage v2.6.2 + +支持在软件中配置更新包下载目录,方便查找和保留安装包。 + +- “软件更新”新增下载目录设置,支持保存、恢复默认、打开文件夹和复制路径。 +- 默认使用用户下载目录下的 `codex-usage` 文件夹;Windows 支持系统下载文件夹重定向。 +- 下载包按版本分目录保存,界面显示最近下载文件的完整路径。更改目录只影响未来下载,已有文件保留在原处。 +- 保存和下载前检查目录是否可写;更新进行中不能切换目录。安装前仍会校验 SHA256,并使用私有副本执行安装;程序和数据库备份继续保留在本机状态目录。 +- 同步中英文界面、窄屏布局及合成演示。更新仍由用户选择,不强制下载安装。 + +已安装 v2.5.0 或更新版本的用户,可在“软件更新”中检查并选择升级。新目录设置在升级完成后可用。 + +--- + +Configure the update download directory inside the application. + +- Software updates now offers a directory setting, Save, Use default, Open folder, and Copy path. +- Defaults to `codex-usage` inside the user's Downloads directory, honoring redirected Windows Downloads folders. +- Keeps release binaries in version folders and displays the last download's full path. Changes apply only to future downloads; existing files stay in place. +- Validates directory access before saving and downloading, prevents directory changes during an update, and verifies a private installer copy before application. Program and database backups remain in the local state directory. +- Includes English/Chinese UI, mobile layouts and synthetic demo support. Installation remains optional and requires the user's choice. + +Installed v2.5.0 or later can check for and select this release in Software updates. Directory settings become available after upgrading. diff --git a/internal/app/app.go b/internal/app/app.go index 02b45c6..eebecd4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -31,7 +31,7 @@ import ( ) var ( - Version = "2.6.1" + Version = "2.6.2" Commit = "dev" BuildDate = "unknown" ) diff --git a/internal/app/update.go b/internal/app/update.go index b440c42..a383627 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -47,7 +47,11 @@ func newUpdater(paths config.Paths) *updater.Manager { return platform.StartUpdateHelper(helper, path, paths.StateDir, job.Managed) } } - return updater.New(paths.StateDir, Version, runtime.GOOS, runtime.GOARCH, apply) + m := updater.New(paths.StateDir, Version, runtime.GOOS, runtime.GOARCH, apply) + if platform.HasGUI() { + m.SetDirectoryOpener(platform.OpenDirectory) + } + return m } func (c CLI) applyUpdate(args []string) error { diff --git a/internal/platform/open_directory.go b/internal/platform/open_directory.go new file mode 100644 index 0000000..097a665 --- /dev/null +++ b/internal/platform/open_directory.go @@ -0,0 +1,41 @@ +package platform + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +func OpenDirectory(path string) error { + if !filepath.IsAbs(path) { + return fmt.Errorf("expected an absolute directory") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("expected a directory") + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("explorer.exe", path) + case "darwin": + cmd = exec.Command("open", path) + case "linux": + if !HasGUI() { + return fmt.Errorf("no desktop session available; copy the directory path instead") + } + cmd = exec.Command("xdg-open", path) + default: + return fmt.Errorf("opening directories is unsupported") + } + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() + return nil +} diff --git a/internal/server/updates.go b/internal/server/updates.go index fe54041..69420c9 100644 --- a/internal/server/updates.go +++ b/internal/server/updates.go @@ -27,13 +27,16 @@ func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/updates/check": err = s.Updates.Check(r.Context()) + case "/api/v1/updates/open-directory": + err = s.Updates.OpenDownloadDirectory() case "/api/v1/updates/preferences", "/api/v1/updates/install": var body struct { - AutoCheck *bool `json:"auto_check"` - Confirm bool `json:"confirm"` - Version string `json:"version"` + AutoCheck *bool `json:"auto_check"` + Confirm bool `json:"confirm"` + Version string `json:"version"` + DownloadDir *string `json:"download_dir"` } - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)) + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) decoder.DisallowUnknownFields() if err = decoder.Decode(&body); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) @@ -44,11 +47,11 @@ func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) { return } if r.URL.Path == "/api/v1/updates/preferences" { - if body.AutoCheck == nil { - http.Error(w, "auto_check is required", http.StatusBadRequest) + if body.AutoCheck == nil && body.DownloadDir == nil { + http.Error(w, "auto_check or download_dir is required", http.StatusBadRequest) return } - err = s.Updates.SetAutoCheck(*body.AutoCheck) + err = s.Updates.SetPreferences(body.AutoCheck, body.DownloadDir) } else { if !body.Confirm || body.Version == "" { http.Error(w, "explicit version confirmation is required", http.StatusBadRequest) diff --git a/internal/server/updates_test.go b/internal/server/updates_test.go index fa78933..8cfef76 100644 --- a/internal/server/updates_test.go +++ b/internal/server/updates_test.go @@ -1,7 +1,9 @@ package server import ( + "encoding/json" "net/http/httptest" + "path/filepath" "strings" "testing" @@ -36,3 +38,30 @@ func TestUpdateAPIRequiresExplicitConfirmationAndSameOrigin(t *testing.T) { t.Fatal("preference not applied") } } + +func TestUpdateDirectoryAPI(t *testing.T) { + s := &Server{Updates: updater.New(t.TempDir(), "2.6.1", "windows", "amd64", nil)} + want := filepath.Join(t.TempDir(), "downloads") + data, _ := json.Marshal(map[string]string{"download_dir": want}) + r := httptest.NewRequest("POST", "http://127.0.0.1/api/v1/updates/preferences", strings.NewReader(string(data))) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != 200 || s.Updates.Status().DownloadDir != want || !s.Updates.Status().AutoCheck { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + opened := false + s.Updates.SetDirectoryOpener(func(path string) error { opened = path == want; return nil }) + r = httptest.NewRequest("POST", "http://127.0.0.1/api/v1/updates/open-directory", nil) + r.Header.Set("Origin", "https://example.com") + w = httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != 403 || opened { + t.Fatal("cross-origin folder open accepted") + } + r.Header.Del("Origin") + w = httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != 200 || !opened { + t.Fatal(w.Code, opened) + } +} diff --git a/internal/updater/directory.go b/internal/updater/directory.go new file mode 100644 index 0000000..a7a5e14 --- /dev/null +++ b/internal/updater/directory.go @@ -0,0 +1,112 @@ +package updater + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func defaultDownloadDirectory(stateDir string) string { + if dir, err := userDownloadsDirectory(); err == nil && filepath.IsAbs(dir) { + return filepath.Join(dir, "codex-usage") + } + return filepath.Join(stateDir, "downloads") +} +func (m *Manager) effectiveDownloadDir(custom string) string { + if custom != "" { + return custom + } + return m.status.DefaultDownloadDir +} +func ensureDownloadDirectory(path string) error { + if !filepath.IsAbs(path) || strings.ContainsAny(path, "\x00\r\n\t") { + return fmt.Errorf("download directory must be an absolute path") + } + if err := os.MkdirAll(path, 0700); err != nil { + return fmt.Errorf("create download directory: %w", err) + } + f, err := os.CreateTemp(path, ".codex-usage-write-check-*") + if err != nil { + return fmt.Errorf("download directory is not writable: %w", err) + } + closeErr := f.Close() + removeErr := os.Remove(f.Name()) + if closeErr != nil { + return closeErr + } + return removeErr +} +func (m *Manager) SetPreferences(autoCheck *bool, downloadDir *string) error { + m.Status() + m.mu.Lock() + defer m.mu.Unlock() + old := m.status + if downloadDir != nil { + if busy(m.status.Phase) { + return fmt.Errorf("wait for the update to finish before changing the download directory") + } + custom := strings.TrimSpace(*downloadDir) + if custom != "" { + custom = filepath.Clean(custom) + } + if err := ensureDownloadDirectory(m.effectiveDownloadDir(custom)); err != nil { + return err + } + m.status.CustomDownloadDir = custom + m.status.DownloadDir = m.effectiveDownloadDir(custom) + } + if autoCheck != nil { + m.status.AutoCheck = *autoCheck + } + if err := m.save(); err != nil { + m.status = old + return err + } + return nil +} +func (m *Manager) SetDirectoryOpener(open func(string) error) { + m.mu.Lock() + defer m.mu.Unlock() + m.openDirectory = open + m.status.CanOpenDownloadDir = open != nil +} +func (m *Manager) OpenDownloadDirectory() error { + m.mu.Lock() + path, open := m.status.DownloadDir, m.openDirectory + m.mu.Unlock() + if open == nil { + return fmt.Errorf("opening folders is unavailable here") + } + if err := ensureDownloadDirectory(path); err != nil { + return err + } + return open(path) +} + +// The public download folder contains only the release binary. Stage a verified +// private copy so that backup/helper files never enter a user-selected folder. +func stageDownload(source, destination, digest string) error { + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + if copyErr == nil { + copyErr = out.Sync() + } + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + return Verify(destination, digest) +} diff --git a/internal/updater/directory_other.go b/internal/updater/directory_other.go new file mode 100644 index 0000000..82f9fce --- /dev/null +++ b/internal/updater/directory_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package updater + +import ( + "os" + "path/filepath" +) + +func userDownloadsDirectory() (string, error) { + home, err := os.UserHomeDir() + return filepath.Join(home, "Downloads"), err +} diff --git a/internal/updater/directory_test.go b/internal/updater/directory_test.go new file mode 100644 index 0000000..7312a68 --- /dev/null +++ b/internal/updater/directory_test.go @@ -0,0 +1,105 @@ +package updater + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDownloadDirectoryPersistsValidatesAndOpens(t *testing.T) { + m, _ := testManager(t, false, nil) + want := filepath.Join(t.TempDir(), "updates with spaces", "下载") + off := false + if err := m.SetPreferences(&off, &want); err != nil { + t.Fatal(err) + } + n := New(m.stateDir, "2.4.1", "windows", "amd64", nil) + if s := n.Status(); s.DownloadDir != want || s.CustomDownloadDir != want || s.AutoCheck { + t.Fatal(s) + } + opened := "" + n.SetDirectoryOpener(func(path string) error { opened = path; return nil }) + if err := n.OpenDownloadDirectory(); err != nil || opened != want { + t.Fatal(opened, err) + } + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("keep me"), 0600); err != nil { + t.Fatal(err) + } + for _, bad := range []string{"relative/path", file, want + "\nwrong"} { + on := true + if err := n.SetPreferences(&on, &bad); err == nil { + t.Fatal("accepted invalid directory", bad) + } + if s := n.Status(); s.DownloadDir != want || s.AutoCheck { + t.Fatal("partial preferences saved", s) + } + } + if err := WriteResult(n.stateDir, Result{Phase: "installing", Target: "2.5.0"}); err != nil { + t.Fatal(err) + } + other := filepath.Join(t.TempDir(), "other") + if err := n.SetPreferences(nil, &other); err == nil { + t.Fatal("changed directory during installation") + } + if err := WriteResult(n.stateDir, Result{Phase: "failed", Target: "2.5.0"}); err != nil { + t.Fatal(err) + } + // Use an isolated default for this test; never create directories in the real Downloads. + n.status.DefaultDownloadDir = filepath.Join(t.TempDir(), "default") + empty := "" + if err := n.SetPreferences(nil, &empty); err != nil { + t.Fatal(err) + } + if s := n.Status(); s.DownloadDir != s.DefaultDownloadDir || s.CustomDownloadDir != "" { + t.Fatal(s) + } + if _, err := os.Stat(want); err != nil { + t.Fatal("old download directory removed", err) + } +} + +func TestDownloadUsesSelectedDirectoryAndPrivateStaging(t *testing.T) { + applied := make(chan string, 1) + m, _ := testManager(t, false, func(path, version, digest string) error { + if err := Verify(path, digest); err != nil { + return err + } + applied <- path + return nil + }) + if err := m.Check(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.Install("2.5.0"); err != nil { + t.Fatal(err) + } + until(t, func() bool { return len(applied) > 0 }) + private := <-applied + if !strings.HasPrefix(private, filepath.Join(m.stateDir, ".codex-usage-updates")+string(os.PathSeparator)) { + t.Fatal("helper staging escaped state directory", private) + } + s := m.Status() + if filepath.Dir(filepath.Dir(s.LastDownload)) != s.DownloadDir || filepath.Base(s.LastDownload) != m.asset || !strings.HasPrefix(filepath.Base(filepath.Dir(s.LastDownload)), "codex-usage-v2.5.0-") { + t.Fatal(s) + } + entries, err := os.ReadDir(filepath.Dir(s.LastDownload)) + if err != nil || len(entries) != 1 { + t.Fatal("backup/helper leaked into download folder", entries, err) + } + if New(m.stateDir, "2.5.0", "windows", "amd64", nil).Status().LastDownload != s.LastDownload { + t.Fatal("last download path not persisted") + } + if err := os.WriteFile(s.LastDownload, []byte("tampered public copy"), 0600); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(private) + if err != nil || string(content) != "test binary contents" { + t.Fatal("public changes affected staged installer", err) + } + if err := stageDownload(s.LastDownload, filepath.Join(t.TempDir(), "staged"), strings.Repeat("0", 64)); err == nil { + t.Fatal("modified download passed staging check") + } +} diff --git a/internal/updater/directory_windows.go b/internal/updater/directory_windows.go new file mode 100644 index 0000000..1fd4115 --- /dev/null +++ b/internal/updater/directory_windows.go @@ -0,0 +1,7 @@ +package updater + +import "golang.org/x/sys/windows" + +func userDownloadsDirectory() (string, error) { + return windows.KnownFolderPath(windows.FOLDERID_Downloads, 0) +} diff --git a/internal/updater/manager.go b/internal/updater/manager.go index 3efb463..f91cb38 100644 --- a/internal/updater/manager.go +++ b/internal/updater/manager.go @@ -14,17 +14,22 @@ import ( ) type Status struct { - Current string `json:"current_version"` - Latest string `json:"latest_version"` - ReleaseURL string `json:"release_url"` - AutoCheck bool `json:"auto_check"` - Available bool `json:"available"` - CanInstall bool `json:"can_install"` - Checking bool `json:"checking"` - CheckedAt time.Time `json:"checked_at"` - Phase string `json:"phase"` - Error string `json:"error,omitempty"` - Target string `json:"target_version,omitempty"` + DownloadDir string `json:"download_dir"` + CustomDownloadDir string `json:"custom_download_dir"` + DefaultDownloadDir string `json:"default_download_dir"` + LastDownload string `json:"last_download,omitempty"` + CanOpenDownloadDir bool `json:"can_open_download_dir"` + Current string `json:"current_version"` + Latest string `json:"latest_version"` + ReleaseURL string `json:"release_url"` + AutoCheck bool `json:"auto_check"` + Available bool `json:"available"` + CanInstall bool `json:"can_install"` + Checking bool `json:"checking"` + CheckedAt time.Time `json:"checked_at"` + Phase string `json:"phase"` + Error string `json:"error,omitempty"` + Target string `json:"target_version,omitempty"` } type Result struct { PID int `json:"pid"` @@ -41,11 +46,14 @@ type Manager struct { apply func(string, string, string) error status Status release Release + openDirectory func(string) error } type saved struct { - AutoCheck bool `json:"auto_check"` - CheckedAt time.Time `json:"checked_at"` - Release Release `json:"release"` + DownloadDir string `json:"download_dir,omitempty"` + LastDownload string `json:"last_download,omitempty"` + AutoCheck bool `json:"auto_check"` + CheckedAt time.Time `json:"checked_at"` + Release Release `json:"release"` } func New(stateDir, version, goos, arch string, apply func(string, string, string) error) *Manager { @@ -56,6 +64,10 @@ func New(stateDir, version, goos, arch string, apply func(string, string, string } m.release = s.Release m.status = Status{Current: version, AutoCheck: s.AutoCheck, CheckedAt: s.CheckedAt, Phase: "idle", CanInstall: apply != nil && m.asset != ""} + m.status.DefaultDownloadDir = defaultDownloadDirectory(stateDir) + m.status.CustomDownloadDir = s.DownloadDir + m.status.DownloadDir = m.effectiveDownloadDir(s.DownloadDir) + m.status.LastDownload = s.LastDownload m.setRelease() return m } @@ -103,7 +115,7 @@ func WriteJSON(path string, value any) error { } } func (m *Manager) save() error { - return WriteJSON(m.file(), saved{m.status.AutoCheck, m.status.CheckedAt, m.release}) + return WriteJSON(m.file(), saved{AutoCheck: m.status.AutoCheck, CheckedAt: m.status.CheckedAt, Release: m.release, DownloadDir: m.status.CustomDownloadDir, LastDownload: m.status.LastDownload}) } func ResultPath(dir string) string { return filepath.Join(dir, ".codex-usage-update-result.json") } func WriteResult(dir string, r Result) error { @@ -133,15 +145,7 @@ func (m *Manager) Status() Status { } func busy(phase string) bool { return phase == "downloading" || phase == "installing" } func (m *Manager) SetAutoCheck(enabled bool) error { - m.mu.Lock() - defer m.mu.Unlock() - old := m.status.AutoCheck - m.status.AutoCheck = enabled - if err := m.save(); err != nil { - m.status.AutoCheck = old - return err - } - return nil + return m.SetPreferences(&enabled, nil) } func (m *Manager) Check(ctx context.Context) error { m.Status() @@ -227,6 +231,29 @@ func (m *Manager) Install(version string) error { return nil } func (m *Manager) prepare(ctx context.Context, r Release) error { + m.mu.Lock() + downloadDir := m.status.DownloadDir + m.mu.Unlock() + if err := ensureDownloadDirectory(downloadDir); err != nil { + return err + } + downloadRun, err := os.MkdirTemp(downloadDir, "codex-usage-"+r.Tag+"-") + if err != nil { + return err + } + downloadPath := filepath.Join(downloadRun, m.asset) + digest, err := download(ctx, m.client, r, m.asset, downloadPath) + if err != nil { + _ = os.Remove(downloadRun) + return err + } + m.mu.Lock() + m.status.LastDownload = downloadPath + err = m.save() + m.mu.Unlock() + if err != nil { + return err + } root := filepath.Join(m.stateDir, ".codex-usage-updates") if err := os.MkdirAll(root, 0700); err != nil { return err @@ -240,7 +267,7 @@ func (m *Manager) prepare(ctx context.Context, r Release) error { name += ".exe" } path := filepath.Join(dir, name) - digest, err := download(ctx, m.client, r, m.asset, path) + err = stageDownload(downloadPath, path, digest) if err != nil { return err } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index c7aef22..3da0bde 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -37,6 +37,10 @@ func testManager(t *testing.T, corrupt bool, apply func(string, string, string) r, body, sums := fixture() downloads := &atomic.Int32{} m := New(t.TempDir(), "2.4.1", "windows", "amd64", apply) + downloadDir := filepath.Join(t.TempDir(), "downloads") + if err := m.SetPreferences(nil, &downloadDir); err != nil { + t.Fatal(err) + } m.client = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { data := "" switch { diff --git a/internal/web/static/i18n.js b/internal/web/static/i18n.js index 22108a3..c25a0e6 100644 --- a/internal/web/static/i18n.js +++ b/internal/web/static/i18n.js @@ -30,6 +30,17 @@ "update.portable": "当前运行的是便携版或预览版。请从发布页下载并安装一次,之后即可在这里更新。", "update.notes": "查看发布说明", "update.auto": "自动检查新版本(每 6 小时)", + "update.downloadDirectory": "更新包下载目录", + "update.directoryHint": "填写完整目录路径。保存后用于下次下载;已有文件会保留在原处。", + "update.directoryUnsaved": "目录尚未保存,请先保存再下载更新。留空保存可恢复默认目录。", + "update.saveDirectory": "保存目录", + "update.defaultDirectory": "恢复默认", + "update.openDirectory": "打开文件夹", + "update.copyDirectory": "复制路径", + "update.directorySaved": "下载目录已保存", + "update.directoryCopied": "目录路径已复制", + "update.copyFailed": "复制失败,请在输入框中手动复制路径", + "update.lastDownload": "最近下载:{path}", "update.check": "检查更新", "update.install": "下载并更新", "update.checking": "正在处理…", @@ -399,6 +410,17 @@ "update.portable": "This is a portable or preview copy. Download and install it once from the release page to enable updates here.", "update.notes": "View release notes", "update.auto": "Automatically check for updates (every 6 hours)", + "update.downloadDirectory": "Update download directory", + "update.directoryHint": "Enter a full directory path. Changes apply to future downloads; existing files stay where they are.", + "update.directoryUnsaved": "Save this directory before downloading an update. Save an empty path to restore the default.", + "update.saveDirectory": "Save directory", + "update.defaultDirectory": "Use default", + "update.openDirectory": "Open folder", + "update.copyDirectory": "Copy path", + "update.directorySaved": "Download directory saved", + "update.directoryCopied": "Directory path copied", + "update.copyFailed": "Copy failed. Select and copy the path from the input instead.", + "update.lastDownload": "Last download: {path}", "update.check": "Check for updates", "update.install": "Download and update", "update.checking": "Working…", diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 366ab34..a069703 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -287,6 +287,18 @@
填写完整目录路径。保存后用于下次下载;已有文件会保留在原处。
+