Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand Down Expand Up @@ -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`;源文件已删除的历史无法由重建恢复。

## 你能看到什么
Expand Down
8 changes: 8 additions & 0 deletions docs/optional-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 秒内通过匹配版本的健康检查;失败则停止新进程、恢复备份并重启旧版本。
Expand Down
23 changes: 23 additions & 0 deletions docs/releases/v2.6.2.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import (
)

var (
Version = "2.6.1"
Version = "2.6.2"
Commit = "dev"
BuildDate = "unknown"
)
Expand Down
6 changes: 5 additions & 1 deletion internal/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions internal/platform/open_directory.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 10 additions & 7 deletions internal/server/updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions internal/server/updates_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package server

import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
112 changes: 112 additions & 0 deletions internal/updater/directory.go
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 13 additions & 0 deletions internal/updater/directory_other.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading