icebear0828 Claude Opus 4.6 commited on
Commit
0c2aed2
·
1 Parent(s): bc98e69

feat: auto-release pipeline + multi-mode update system + commit hash display

Browse files

- sync-electron.yml: auto bump patch version + create tag after merge, concurrency group
- release.yml: replace CHANGELOG extraction with GitHub auto-generated release notes
- self-update.ts: support git/docker/electron deploy modes, GitHub Releases API, periodic checker
- Header: show commit hash in status badge, expandable update panel with changelog
- use-update-status: add applyUpdate, mode/commits/release fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

.github/workflows/release.yml ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release Electron App
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: write
11
+
12
+ jobs:
13
+ build:
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ include:
18
+ - os: windows-latest
19
+ platform: win
20
+ artifact: windows
21
+ - os: macos-latest
22
+ platform: mac
23
+ artifact: macos
24
+ - os: ubuntu-latest
25
+ platform: linux
26
+ artifact: linux
27
+
28
+ runs-on: ${{ matrix.os }}
29
+
30
+ steps:
31
+ - name: Checkout
32
+ uses: actions/checkout@v4
33
+
34
+ - name: Setup Node.js
35
+ uses: actions/setup-node@v4
36
+ with:
37
+ node-version: 20
38
+ cache: npm
39
+
40
+ - name: Install root dependencies
41
+ run: npm ci --ignore-scripts
42
+
43
+ - name: Setup curl-impersonate
44
+ run: npx tsx scripts/setup-curl.ts
45
+ env:
46
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47
+
48
+ - name: Install web dependencies
49
+ run: cd web && npm ci
50
+
51
+ - name: Install desktop dependencies
52
+ run: cd desktop && npm ci
53
+
54
+ - name: Build app
55
+ run: npm run app:build
56
+
57
+ - name: Pack (${{ matrix.platform }})
58
+ run: npx electron-builder --config electron-builder.yml --${{ matrix.platform }} --publish never
59
+
60
+ - name: Upload artifacts
61
+ uses: actions/upload-artifact@v4
62
+ with:
63
+ name: release-${{ matrix.artifact }}
64
+ path: |
65
+ release/*.exe
66
+ release/*.dmg
67
+ release/*.AppImage
68
+ release/*.yml
69
+ release/*.yaml
70
+ if-no-files-found: ignore
71
+
72
+ release:
73
+ needs: build
74
+ runs-on: ubuntu-latest
75
+ if: startsWith(github.ref, 'refs/tags/v')
76
+
77
+ steps:
78
+ - name: Checkout
79
+ uses: actions/checkout@v4
80
+
81
+ - name: Download all artifacts
82
+ uses: actions/download-artifact@v4
83
+ with:
84
+ path: artifacts
85
+ merge-multiple: true
86
+
87
+ - name: Create GitHub Release
88
+ uses: softprops/action-gh-release@v2
89
+ with:
90
+ generate_release_notes: true
91
+ draft: false
92
+ prerelease: false
93
+ files: artifacts/*
.github/workflows/sync-electron.yml CHANGED
@@ -5,6 +5,10 @@ on:
5
  branches: [master]
6
  workflow_dispatch:
7
 
 
 
 
 
8
  permissions:
9
  contents: write
10
  issues: write
@@ -29,9 +33,65 @@ jobs:
29
  echo "CONFLICT=true" >> "$GITHUB_ENV"
30
  }
31
 
32
- - name: Push if merged successfully
33
  if: env.CONFLICT != 'true'
34
- run: git push origin electron
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  - name: Create issue on conflict
37
  if: env.CONFLICT == 'true'
 
5
  branches: [master]
6
  workflow_dispatch:
7
 
8
+ concurrency:
9
+ group: sync-electron
10
+ cancel-in-progress: true
11
+
12
  permissions:
13
  contents: write
14
  issues: write
 
33
  echo "CONFLICT=true" >> "$GITHUB_ENV"
34
  }
35
 
36
+ - name: Auto bump + tag if new commits
37
  if: env.CONFLICT != 'true'
38
+ run: |
39
+ # Find last version tag
40
+ LAST_TAG=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null || echo "")
41
+ if [ -z "$LAST_TAG" ]; then
42
+ echo "No previous tag found, skipping auto-bump"
43
+ echo "BUMP=false" >> "$GITHUB_ENV"
44
+ exit 0
45
+ fi
46
+
47
+ # Check for new non-merge commits since last tag
48
+ NEW_COMMITS=$(git log "${LAST_TAG}..HEAD" --no-merges --oneline | wc -l)
49
+ if [ "$NEW_COMMITS" -eq 0 ]; then
50
+ echo "No new non-merge commits since $LAST_TAG, skipping bump"
51
+ echo "BUMP=false" >> "$GITHUB_ENV"
52
+ exit 0
53
+ fi
54
+
55
+ echo "Found $NEW_COMMITS new commit(s) since $LAST_TAG"
56
+
57
+ # Parse current version and bump patch
58
+ CURRENT="${LAST_TAG#v}"
59
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
60
+ NEW_PATCH=$((PATCH + 1))
61
+ NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
62
+ NEW_TAG="v${NEW_VERSION}"
63
+
64
+ # Check tag doesn't already exist (concurrent protection)
65
+ if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then
66
+ echo "Tag $NEW_TAG already exists, skipping"
67
+ echo "BUMP=false" >> "$GITHUB_ENV"
68
+ exit 0
69
+ fi
70
+
71
+ # Bump version in package.json
72
+ node -e "
73
+ const fs = require('fs');
74
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
75
+ pkg.version = '${NEW_VERSION}';
76
+ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
77
+ "
78
+
79
+ git add package.json
80
+ git commit -m "chore: bump version to ${NEW_VERSION}"
81
+ git tag -a "$NEW_TAG" -m "Release ${NEW_TAG}"
82
+
83
+ echo "BUMP=true" >> "$GITHUB_ENV"
84
+ echo "NEW_TAG=$NEW_TAG" >> "$GITHUB_ENV"
85
+ echo "Bumped to $NEW_VERSION, tagged $NEW_TAG"
86
+
87
+ - name: Push electron branch (with tags if bumped)
88
+ if: env.CONFLICT != 'true'
89
+ run: |
90
+ if [ "$BUMP" = "true" ]; then
91
+ git push origin electron --follow-tags
92
+ else
93
+ git push origin electron
94
+ fi
95
 
96
  - name: Create issue on conflict
97
  if: env.CONFLICT == 'true'
shared/hooks/use-update-status.ts CHANGED
@@ -5,7 +5,11 @@ export interface UpdateStatus {
5
  version: string;
6
  commit: string | null;
7
  can_self_update: boolean;
 
8
  commits_behind: number | null;
 
 
 
9
  update_in_progress: boolean;
10
  };
11
  codex: {
@@ -24,7 +28,10 @@ export interface CheckResult {
24
  commits_behind: number;
25
  current_commit: string | null;
26
  latest_commit: string | null;
27
- update_applied?: boolean;
 
 
 
28
  error?: string;
29
  };
30
  codex?: {
@@ -43,12 +50,13 @@ export function useUpdateStatus() {
43
  const [checking, setChecking] = useState(false);
44
  const [result, setResult] = useState<CheckResult | null>(null);
45
  const [error, setError] = useState<string | null>(null);
 
46
 
47
  const load = useCallback(async () => {
48
  try {
49
  const resp = await fetch("/admin/update-status");
50
  if (resp.ok) {
51
- setStatus(await resp.json());
52
  }
53
  } catch { /* ignore */ }
54
  }, []);
@@ -61,12 +69,11 @@ export function useUpdateStatus() {
61
  setResult(null);
62
  try {
63
  const resp = await fetch("/admin/check-update", { method: "POST" });
64
- const data: CheckResult = await resp.json();
65
  if (!resp.ok) {
66
  setError("Check failed");
67
  } else {
68
  setResult(data);
69
- // Refresh status after check
70
  await load();
71
  }
72
  } catch (err) {
@@ -76,5 +83,23 @@ export function useUpdateStatus() {
76
  }
77
  }, [load]);
78
 
79
- return { status, checking, result, error, checkForUpdate };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  }
 
5
  version: string;
6
  commit: string | null;
7
  can_self_update: boolean;
8
+ mode: "git" | "docker" | "electron";
9
  commits_behind: number | null;
10
+ commits: { hash: string; message: string }[];
11
+ release: { version: string; body: string; url: string } | null;
12
+ update_available: boolean;
13
  update_in_progress: boolean;
14
  };
15
  codex: {
 
28
  commits_behind: number;
29
  current_commit: string | null;
30
  latest_commit: string | null;
31
+ commits: { hash: string; message: string }[];
32
+ release: { version: string; body: string; url: string } | null;
33
+ update_available: boolean;
34
+ mode: "git" | "docker" | "electron";
35
  error?: string;
36
  };
37
  codex?: {
 
50
  const [checking, setChecking] = useState(false);
51
  const [result, setResult] = useState<CheckResult | null>(null);
52
  const [error, setError] = useState<string | null>(null);
53
+ const [applying, setApplying] = useState(false);
54
 
55
  const load = useCallback(async () => {
56
  try {
57
  const resp = await fetch("/admin/update-status");
58
  if (resp.ok) {
59
+ setStatus(await resp.json() as UpdateStatus);
60
  }
61
  } catch { /* ignore */ }
62
  }, []);
 
69
  setResult(null);
70
  try {
71
  const resp = await fetch("/admin/check-update", { method: "POST" });
72
+ const data = await resp.json() as CheckResult;
73
  if (!resp.ok) {
74
  setError("Check failed");
75
  } else {
76
  setResult(data);
 
77
  await load();
78
  }
79
  } catch (err) {
 
83
  }
84
  }, [load]);
85
 
86
+ const applyUpdate = useCallback(async () => {
87
+ setApplying(true);
88
+ setError(null);
89
+ try {
90
+ const resp = await fetch("/admin/apply-update", { method: "POST" });
91
+ const data = await resp.json() as { started: boolean; error?: string };
92
+ if (!resp.ok || !data.started) {
93
+ setError(data.error ?? "Apply failed");
94
+ } else {
95
+ await load();
96
+ }
97
+ } catch (err) {
98
+ setError(err instanceof Error ? err.message : "Network error");
99
+ } finally {
100
+ setApplying(false);
101
+ }
102
+ }, [load]);
103
+
104
+ return { status, checking, result, error, checkForUpdate, applyUpdate, applying };
105
  }
shared/i18n/translations.ts CHANGED
@@ -141,6 +141,14 @@ export const translations = {
141
  speed: "Speed",
142
  speedStandard: "Standard",
143
  speedFast: "Fast",
 
 
 
 
 
 
 
 
144
  },
145
  zh: {
146
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -287,6 +295,14 @@ export const translations = {
287
  speed: "\u901f\u5ea6",
288
  speedStandard: "\u6807\u51c6",
289
  speedFast: "\u5feb\u901f",
 
 
 
 
 
 
 
 
290
  },
291
  } as const;
292
 
 
141
  speed: "Speed",
142
  speedStandard: "Standard",
143
  speedFast: "Fast",
144
+ updateAvailable: "Update Available",
145
+ updateNow: "Update Now",
146
+ applyingUpdate: "Updating...",
147
+ viewChanges: "View Changes",
148
+ hideChanges: "Hide",
149
+ newVersion: "New version",
150
+ dockerUpdateCmd: "Run to update:",
151
+ downloadUpdate: "Download",
152
  },
153
  zh: {
154
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
295
  speed: "\u901f\u5ea6",
296
  speedStandard: "\u6807\u51c6",
297
  speedFast: "\u5feb\u901f",
298
+ updateAvailable: "\u6709\u53ef\u7528\u66f4\u65b0",
299
+ updateNow: "\u7acb\u5373\u66f4\u65b0",
300
+ applyingUpdate: "\u66f4\u65b0\u4e2d...",
301
+ viewChanges: "\u67e5\u770b\u53d8\u66f4",
302
+ hideChanges: "\u6536\u8d77",
303
+ newVersion: "\u65b0\u7248\u672c",
304
+ dockerUpdateCmd: "\u6267\u884c\u66f4\u65b0\u547d\u4ee4\uff1a",
305
+ downloadUpdate: "\u4e0b\u8f7d",
306
  },
307
  } as const;
308
 
src/index.ts CHANGED
@@ -19,6 +19,7 @@ import { ProxyPool } from "./proxy/proxy-pool.js";
19
  import { createProxyRoutes } from "./routes/proxies.js";
20
  import { createResponsesRoutes } from "./routes/responses.js";
21
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
 
22
  import { initProxy } from "./tls/curl-binary.js";
23
  import { initTransport } from "./tls/transport.js";
24
  import { loadStaticModels } from "./models/model-store.js";
@@ -117,6 +118,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
117
 
118
  // Start background update checker
119
  startUpdateChecker();
 
120
 
121
  // Start background model refresh (requires auth to be ready)
122
  startModelRefresh(accountPool, cookieJar, proxyPool);
@@ -134,6 +136,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
134
  return new Promise((resolve) => {
135
  server.close(() => {
136
  stopUpdateChecker();
 
137
  stopModelRefresh();
138
  refreshScheduler.destroy();
139
  proxyPool.destroy();
 
19
  import { createProxyRoutes } from "./routes/proxies.js";
20
  import { createResponsesRoutes } from "./routes/responses.js";
21
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
22
+ import { startProxyUpdateChecker, stopProxyUpdateChecker } from "./self-update.js";
23
  import { initProxy } from "./tls/curl-binary.js";
24
  import { initTransport } from "./tls/transport.js";
25
  import { loadStaticModels } from "./models/model-store.js";
 
118
 
119
  // Start background update checker
120
  startUpdateChecker();
121
+ startProxyUpdateChecker();
122
 
123
  // Start background model refresh (requires auth to be ready)
124
  startModelRefresh(accountPool, cookieJar, proxyPool);
 
136
  return new Promise((resolve) => {
137
  server.close(() => {
138
  stopUpdateChecker();
139
+ stopProxyUpdateChecker();
140
  stopModelRefresh();
141
  refreshScheduler.destroy();
142
  proxyPool.destroy();
src/routes/web.ts CHANGED
@@ -6,7 +6,7 @@ import type { AccountPool } from "../auth/account-pool.js";
6
  import { getConfig, getFingerprint } from "../config.js";
7
  import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, isEmbedded } from "../paths.js";
8
  import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
9
- import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress } from "../self-update.js";
10
 
11
  export function createWebRoutes(accountPool: AccountPool): Hono {
12
  const app = new Hono();
@@ -127,13 +127,18 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
127
  app.get("/admin/update-status", (c) => {
128
  const proxyInfo = getProxyInfo();
129
  const codexState = getUpdateState();
 
130
 
131
  return c.json({
132
  proxy: {
133
  version: proxyInfo.version,
134
  commit: proxyInfo.commit,
135
  can_self_update: canSelfUpdate(),
136
- commits_behind: null as number | null,
 
 
 
 
137
  update_in_progress: isProxyUpdateInProgress(),
138
  },
139
  codex: {
@@ -150,34 +155,42 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
150
 
151
  app.post("/admin/check-update", async (c) => {
152
  const results: {
153
- proxy?: { commits_behind: number; current_commit: string | null; latest_commit: string | null; update_applied?: boolean; error?: string };
 
 
 
 
 
 
 
 
 
154
  codex?: { update_available: boolean; current_version: string; latest_version: string | null; version_changed?: boolean; error?: string };
155
  } = {};
156
 
157
- // 1. Proxy self-update check
158
- if (canSelfUpdate()) {
159
- try {
160
- const proxyResult = await checkProxySelfUpdate();
161
- results.proxy = {
162
- commits_behind: proxyResult.commitsBehind,
163
- current_commit: proxyResult.currentCommit,
164
- latest_commit: proxyResult.latestCommit,
165
- };
166
-
167
- // Auto-apply if behind
168
- if (proxyResult.commitsBehind > 0 && !isProxyUpdateInProgress()) {
169
- const applyResult = await applyProxySelfUpdate();
170
- results.proxy.update_applied = applyResult.started;
171
- if (applyResult.error) results.proxy.error = applyResult.error;
172
- }
173
- } catch (err) {
174
- results.proxy = {
175
- commits_behind: 0,
176
- current_commit: null,
177
- latest_commit: null,
178
- error: err instanceof Error ? err.message : String(err),
179
- };
180
- }
181
  }
182
 
183
  // 2. Codex fingerprint check
@@ -208,5 +221,14 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
208
  });
209
  });
210
 
 
 
 
 
 
 
 
 
 
211
  return app;
212
  }
 
6
  import { getConfig, getFingerprint } from "../config.js";
7
  import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, isEmbedded } from "../paths.js";
8
  import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
9
+ import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress, getCachedProxyUpdateResult, getDeployMode } from "../self-update.js";
10
 
11
  export function createWebRoutes(accountPool: AccountPool): Hono {
12
  const app = new Hono();
 
127
  app.get("/admin/update-status", (c) => {
128
  const proxyInfo = getProxyInfo();
129
  const codexState = getUpdateState();
130
+ const cached = getCachedProxyUpdateResult();
131
 
132
  return c.json({
133
  proxy: {
134
  version: proxyInfo.version,
135
  commit: proxyInfo.commit,
136
  can_self_update: canSelfUpdate(),
137
+ mode: getDeployMode(),
138
+ commits_behind: cached?.commitsBehind ?? null,
139
+ commits: cached?.commits ?? [],
140
+ release: cached?.release ? { version: cached.release.version, body: cached.release.body, url: cached.release.url } : null,
141
+ update_available: cached?.updateAvailable ?? false,
142
  update_in_progress: isProxyUpdateInProgress(),
143
  },
144
  codex: {
 
155
 
156
  app.post("/admin/check-update", async (c) => {
157
  const results: {
158
+ proxy?: {
159
+ commits_behind: number;
160
+ current_commit: string | null;
161
+ latest_commit: string | null;
162
+ commits: Array<{ hash: string; message: string }>;
163
+ release: { version: string; body: string; url: string } | null;
164
+ update_available: boolean;
165
+ mode: string;
166
+ error?: string;
167
+ };
168
  codex?: { update_available: boolean; current_version: string; latest_version: string | null; version_changed?: boolean; error?: string };
169
  } = {};
170
 
171
+ // 1. Proxy update check (all modes)
172
+ try {
173
+ const proxyResult = await checkProxySelfUpdate();
174
+ results.proxy = {
175
+ commits_behind: proxyResult.commitsBehind,
176
+ current_commit: proxyResult.currentCommit,
177
+ latest_commit: proxyResult.latestCommit,
178
+ commits: proxyResult.commits,
179
+ release: proxyResult.release ? { version: proxyResult.release.version, body: proxyResult.release.body, url: proxyResult.release.url } : null,
180
+ update_available: proxyResult.updateAvailable,
181
+ mode: proxyResult.mode,
182
+ };
183
+ } catch (err) {
184
+ results.proxy = {
185
+ commits_behind: 0,
186
+ current_commit: null,
187
+ latest_commit: null,
188
+ commits: [],
189
+ release: null,
190
+ update_available: false,
191
+ mode: getDeployMode(),
192
+ error: err instanceof Error ? err.message : String(err),
193
+ };
 
194
  }
195
 
196
  // 2. Codex fingerprint check
 
221
  });
222
  });
223
 
224
+ app.post("/admin/apply-update", async (c) => {
225
+ if (!canSelfUpdate()) {
226
+ c.status(400);
227
+ return c.json({ started: false, error: "Self-update not available in this deploy mode" });
228
+ }
229
+ const result = await applyProxySelfUpdate();
230
+ return c.json(result);
231
+ });
232
+
233
  return app;
234
  }
src/self-update.ts CHANGED
@@ -1,6 +1,8 @@
1
  /**
2
- * Proxy self-update — checks for new commits on GitHub and applies via git pull.
3
- * Only works in CLI mode (where .git exists). Docker/Electron show manual instructions.
 
 
4
  */
5
 
6
  import { execFile, execFileSync } from "child_process";
@@ -11,39 +13,64 @@ import { isEmbedded } from "./paths.js";
11
 
12
  const execFileAsync = promisify(execFile);
13
 
 
 
 
 
14
  export interface ProxyInfo {
15
  version: string;
16
  commit: string | null;
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  export interface ProxySelfUpdateResult {
20
  commitsBehind: number;
21
  currentCommit: string | null;
22
  latestCommit: string | null;
 
 
 
 
23
  }
24
 
25
  let _proxyUpdateInProgress = false;
26
  let _gitAvailable: boolean | null = null;
 
 
 
 
27
 
28
  /** Read proxy version from package.json + current git commit hash. */
29
  export function getProxyInfo(): ProxyInfo {
30
  let version = "unknown";
31
  try {
32
- const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
33
  version = pkg.version ?? "unknown";
34
  } catch { /* ignore */ }
35
 
36
  let commit: string | null = null;
37
- if (canSelfUpdate()) {
38
- try {
39
- const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
40
- cwd: process.cwd(),
41
- encoding: "utf-8",
42
- timeout: 5000,
43
- });
44
- commit = out.trim() || null;
45
- } catch { /* ignore */ }
46
- }
47
 
48
  return { version, commit };
49
  }
@@ -53,13 +80,11 @@ export function canSelfUpdate(): boolean {
53
  if (isEmbedded()) return false;
54
  if (_gitAvailable !== null) return _gitAvailable;
55
 
56
- // Check .git directory exists
57
  if (!existsSync(resolve(process.cwd(), ".git"))) {
58
  _gitAvailable = false;
59
  return false;
60
  }
61
 
62
- // Check git command is available
63
  try {
64
  execFileSync("git", ["--version"], {
65
  cwd: process.cwd(),
@@ -74,51 +99,137 @@ export function canSelfUpdate(): boolean {
74
  return _gitAvailable;
75
  }
76
 
 
 
 
 
 
 
 
77
  /** Whether a proxy self-update is currently in progress. */
78
  export function isProxyUpdateInProgress(): boolean {
79
  return _proxyUpdateInProgress;
80
  }
81
 
82
- /** Fetch latest from origin and check how many commits behind. */
83
- export async function checkProxySelfUpdate(): Promise<ProxySelfUpdateResult> {
84
- const cwd = process.cwd();
 
85
 
86
- // Get current commit
87
- let currentCommit: string | null = null;
88
  try {
89
- const { stdout } = await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd, timeout: 5000 });
90
- currentCommit = stdout.trim() || null;
91
- } catch { /* ignore */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- // Fetch latest
 
94
  try {
95
- await execFileAsync("git", ["fetch", "origin", "master", "--quiet"], { cwd, timeout: 30000 });
96
- } catch (err) {
97
- console.warn("[SelfUpdate] git fetch failed:", err instanceof Error ? err.message : err);
98
- return { commitsBehind: 0, currentCommit, latestCommit: currentCommit };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  }
 
100
 
101
- // Count commits behind
102
- let commitsBehind = 0;
103
- let latestCommit: string | null = null;
104
- try {
105
- const { stdout: countOut } = await execFileAsync(
106
- "git", ["rev-list", "HEAD..origin/master", "--count"], { cwd, timeout: 5000 },
107
- );
108
- commitsBehind = parseInt(countOut.trim(), 10) || 0;
109
 
110
- const { stdout: latestOut } = await execFileAsync(
111
- "git", ["rev-parse", "--short", "origin/master"], { cwd, timeout: 5000 },
112
- );
113
- latestCommit = latestOut.trim() || null;
114
- } catch { /* ignore */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- return { commitsBehind, currentCommit, latestCommit };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  }
118
 
119
  /**
120
  * Apply proxy self-update: git pull + npm install + npm run build.
121
- * Runs in background. Returns immediately; check isProxyUpdateInProgress() for status.
122
  */
123
  export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?: string }> {
124
  if (_proxyUpdateInProgress) {
@@ -148,3 +259,39 @@ export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?
148
  return { started: false, error: msg };
149
  }
150
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /**
2
+ * Proxy self-update — detects available updates in three deployment modes:
3
+ * - CLI (git): git fetch + commit log
4
+ * - Docker (no .git): GitHub Releases API
5
+ * - Electron (embedded): GitHub Releases API
6
  */
7
 
8
  import { execFile, execFileSync } from "child_process";
 
13
 
14
  const execFileAsync = promisify(execFile);
15
 
16
+ const GITHUB_REPO = "icebear0828/codex-proxy";
17
+ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
18
+ const INITIAL_DELAY_MS = 10_000; // 10 seconds after startup
19
+
20
  export interface ProxyInfo {
21
  version: string;
22
  commit: string | null;
23
  }
24
 
25
+ export interface CommitInfo {
26
+ hash: string;
27
+ message: string;
28
+ }
29
+
30
+ export interface GitHubReleaseInfo {
31
+ version: string;
32
+ tag: string;
33
+ body: string;
34
+ url: string;
35
+ publishedAt: string;
36
+ }
37
+
38
+ export type DeployMode = "git" | "docker" | "electron";
39
+
40
  export interface ProxySelfUpdateResult {
41
  commitsBehind: number;
42
  currentCommit: string | null;
43
  latestCommit: string | null;
44
+ commits: CommitInfo[];
45
+ release: GitHubReleaseInfo | null;
46
+ updateAvailable: boolean;
47
+ mode: DeployMode;
48
  }
49
 
50
  let _proxyUpdateInProgress = false;
51
  let _gitAvailable: boolean | null = null;
52
+ let _cachedResult: ProxySelfUpdateResult | null = null;
53
+ let _checkTimer: ReturnType<typeof setInterval> | null = null;
54
+ let _initialTimer: ReturnType<typeof setTimeout> | null = null;
55
+ let _checking = false;
56
 
57
  /** Read proxy version from package.json + current git commit hash. */
58
  export function getProxyInfo(): ProxyInfo {
59
  let version = "unknown";
60
  try {
61
+ const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8")) as { version?: string };
62
  version = pkg.version ?? "unknown";
63
  } catch { /* ignore */ }
64
 
65
  let commit: string | null = null;
66
+ try {
67
+ const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
68
+ cwd: process.cwd(),
69
+ encoding: "utf-8",
70
+ timeout: 5000,
71
+ });
72
+ commit = out.trim() || null;
73
+ } catch { /* ignore */ }
 
 
74
 
75
  return { version, commit };
76
  }
 
80
  if (isEmbedded()) return false;
81
  if (_gitAvailable !== null) return _gitAvailable;
82
 
 
83
  if (!existsSync(resolve(process.cwd(), ".git"))) {
84
  _gitAvailable = false;
85
  return false;
86
  }
87
 
 
88
  try {
89
  execFileSync("git", ["--version"], {
90
  cwd: process.cwd(),
 
99
  return _gitAvailable;
100
  }
101
 
102
+ /** Determine deployment mode. */
103
+ export function getDeployMode(): DeployMode {
104
+ if (isEmbedded()) return "electron";
105
+ if (canSelfUpdate()) return "git";
106
+ return "docker";
107
+ }
108
+
109
  /** Whether a proxy self-update is currently in progress. */
110
  export function isProxyUpdateInProgress(): boolean {
111
  return _proxyUpdateInProgress;
112
  }
113
 
114
+ /** Return cached proxy update result (set by periodic checker or manual check). */
115
+ export function getCachedProxyUpdateResult(): ProxySelfUpdateResult | null {
116
+ return _cachedResult;
117
+ }
118
 
119
+ /** Get commit log between HEAD and origin/master. */
120
+ async function getCommitLog(cwd: string): Promise<CommitInfo[]> {
121
  try {
122
+ const { stdout } = await execFileAsync(
123
+ "git", ["log", "HEAD..origin/master", "--oneline", "--format=%h %s"],
124
+ { cwd, timeout: 10000 },
125
+ );
126
+ return stdout.trim().split("\n")
127
+ .filter((line) => line.length > 0)
128
+ .map((line) => {
129
+ const spaceIdx = line.indexOf(" ");
130
+ return {
131
+ hash: line.substring(0, spaceIdx),
132
+ message: line.substring(spaceIdx + 1),
133
+ };
134
+ });
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
 
140
+ /** Check GitHub Releases API for the latest version. */
141
+ async function checkGitHubRelease(): Promise<GitHubReleaseInfo | null> {
142
  try {
143
+ const resp = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
144
+ headers: { Accept: "application/vnd.github.v3+json" },
145
+ signal: AbortSignal.timeout(15000),
146
+ });
147
+ if (!resp.ok) return null;
148
+ const data = await resp.json() as {
149
+ tag_name?: string;
150
+ body?: string | null;
151
+ html_url?: string;
152
+ published_at?: string;
153
+ };
154
+ return {
155
+ version: String(data.tag_name ?? "").replace(/^v/, ""),
156
+ tag: String(data.tag_name ?? ""),
157
+ body: String(data.body ?? ""),
158
+ url: String(data.html_url ?? ""),
159
+ publishedAt: String(data.published_at ?? ""),
160
+ };
161
+ } catch {
162
+ return null;
163
  }
164
+ }
165
 
166
+ /** Fetch latest from origin and check how many commits behind. */
167
+ export async function checkProxySelfUpdate(): Promise<ProxySelfUpdateResult> {
168
+ const mode = getDeployMode();
 
 
 
 
 
169
 
170
+ if (mode === "git") {
171
+ const cwd = process.cwd();
172
+
173
+ let currentCommit: string | null = null;
174
+ try {
175
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd, timeout: 5000 });
176
+ currentCommit = stdout.trim() || null;
177
+ } catch { /* ignore */ }
178
+
179
+ try {
180
+ await execFileAsync("git", ["fetch", "origin", "master", "--quiet"], { cwd, timeout: 30000 });
181
+ } catch (err) {
182
+ console.warn("[SelfUpdate] git fetch failed:", err instanceof Error ? err.message : err);
183
+ const result: ProxySelfUpdateResult = {
184
+ commitsBehind: 0, currentCommit, latestCommit: currentCommit,
185
+ commits: [], release: null, updateAvailable: false, mode,
186
+ };
187
+ _cachedResult = result;
188
+ return result;
189
+ }
190
+
191
+ let commitsBehind = 0;
192
+ let latestCommit: string | null = null;
193
+ try {
194
+ const { stdout: countOut } = await execFileAsync(
195
+ "git", ["rev-list", "HEAD..origin/master", "--count"], { cwd, timeout: 5000 },
196
+ );
197
+ commitsBehind = parseInt(countOut.trim(), 10) || 0;
198
+
199
+ const { stdout: latestOut } = await execFileAsync(
200
+ "git", ["rev-parse", "--short", "origin/master"], { cwd, timeout: 5000 },
201
+ );
202
+ latestCommit = latestOut.trim() || null;
203
+ } catch { /* ignore */ }
204
+
205
+ const commits = commitsBehind > 0 ? await getCommitLog(cwd) : [];
206
 
207
+ const result: ProxySelfUpdateResult = {
208
+ commitsBehind, currentCommit, latestCommit,
209
+ commits, release: null,
210
+ updateAvailable: commitsBehind > 0, mode,
211
+ };
212
+ _cachedResult = result;
213
+ return result;
214
+ }
215
+
216
+ // Docker or Electron — GitHub Releases API
217
+ const release = await checkGitHubRelease();
218
+ const currentVersion = getProxyInfo().version;
219
+ const updateAvailable = release !== null && release.version !== currentVersion;
220
+
221
+ const result: ProxySelfUpdateResult = {
222
+ commitsBehind: 0, currentCommit: null, latestCommit: null,
223
+ commits: [], release: updateAvailable ? release : null,
224
+ updateAvailable, mode,
225
+ };
226
+ _cachedResult = result;
227
+ return result;
228
  }
229
 
230
  /**
231
  * Apply proxy self-update: git pull + npm install + npm run build.
232
+ * Only works in git (CLI) mode.
233
  */
234
  export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?: string }> {
235
  if (_proxyUpdateInProgress) {
 
259
  return { started: false, error: msg };
260
  }
261
  }
262
+
263
+ /** Run a background check (guards against concurrent execution). */
264
+ async function runCheck(): Promise<void> {
265
+ if (_checking) return;
266
+ _checking = true;
267
+ try {
268
+ await checkProxySelfUpdate();
269
+ } catch (err) {
270
+ console.warn("[SelfUpdate] Periodic check failed:", err instanceof Error ? err.message : err);
271
+ } finally {
272
+ _checking = false;
273
+ }
274
+ }
275
+
276
+ /** Start periodic proxy update checking (initial check after 10s, then every 6h). */
277
+ export function startProxyUpdateChecker(): void {
278
+ _initialTimer = setTimeout(() => {
279
+ runCheck();
280
+ }, INITIAL_DELAY_MS);
281
+
282
+ _checkTimer = setInterval(() => {
283
+ runCheck();
284
+ }, CHECK_INTERVAL_MS);
285
+ }
286
+
287
+ /** Stop periodic proxy update checking. */
288
+ export function stopProxyUpdateChecker(): void {
289
+ if (_initialTimer) {
290
+ clearTimeout(_initialTimer);
291
+ _initialTimer = null;
292
+ }
293
+ if (_checkTimer) {
294
+ clearInterval(_checkTimer);
295
+ _checkTimer = null;
296
+ }
297
+ }
vitest.config.ts CHANGED
@@ -1,8 +1,21 @@
1
  import { defineConfig } from "vitest/config";
 
2
 
3
  export default defineConfig({
 
 
 
 
 
 
 
4
  test: {
5
- root: "src",
6
  environment: "node",
 
 
 
 
 
 
7
  },
8
  });
 
1
  import { defineConfig } from "vitest/config";
2
+ import { resolve } from "path";
3
 
4
  export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ "@src": resolve(__dirname, "src"),
8
+ "@helpers": resolve(__dirname, "tests/_helpers"),
9
+ "@fixtures": resolve(__dirname, "tests/_fixtures"),
10
+ },
11
+ },
12
  test: {
 
13
  environment: "node",
14
+ include: [
15
+ "src/**/*.{test,spec}.ts",
16
+ "tests/unit/**/*.{test,spec}.ts",
17
+ "tests/integration/**/*.{test,spec}.ts",
18
+ "tests/e2e/**/*.{test,spec}.ts",
19
+ ],
20
  },
21
  });
web/src/App.tsx CHANGED
@@ -30,11 +30,8 @@ function useUpdateMessage() {
30
  if (r.proxy?.error) {
31
  parts.push(`Proxy: ${r.proxy.error}`);
32
  color = "text-red-500";
33
- } else if (r.proxy?.update_applied) {
34
- parts.push(t("updateApplied"));
35
- color = "text-blue-500";
36
- } else if (r.proxy && r.proxy.commits_behind > 0) {
37
- parts.push(`Proxy: ${r.proxy.commits_behind} ${t("commits")} ${t("proxyBehind")}`);
38
  color = "text-amber-500";
39
  }
40
 
@@ -54,7 +51,18 @@ function useUpdateMessage() {
54
  color = "text-red-500";
55
  }
56
 
57
- return { ...update, msg, color };
 
 
 
 
 
 
 
 
 
 
 
58
  }
59
 
60
  function Dashboard() {
@@ -77,6 +85,8 @@ function Dashboard() {
77
  updateStatusMsg={update.msg}
78
  updateStatusColor={update.color}
79
  version={update.status?.proxy.version ?? null}
 
 
80
  />
81
  <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
82
  <div class="flex flex-col w-full max-w-[960px] gap-6">
@@ -164,7 +174,9 @@ function ProxySettingsPage() {
164
  updateStatusMsg={update.msg}
165
  updateStatusColor={update.color}
166
  version={update.status?.proxy.version ?? null}
 
167
  isProxySettings
 
168
  />
169
  <ProxySettings />
170
  </>
 
30
  if (r.proxy?.error) {
31
  parts.push(`Proxy: ${r.proxy.error}`);
32
  color = "text-red-500";
33
+ } else if (r.proxy?.update_available) {
34
+ parts.push(t("updateAvailable"));
 
 
 
35
  color = "text-amber-500";
36
  }
37
 
 
51
  color = "text-red-500";
52
  }
53
 
54
+ const proxyUpdate = update.status?.proxy.update_available
55
+ ? {
56
+ mode: update.status.proxy.mode,
57
+ updateAvailable: true as const,
58
+ commits: update.status.proxy.commits,
59
+ release: update.status.proxy.release,
60
+ onApply: update.applyUpdate,
61
+ applying: update.applying,
62
+ }
63
+ : null;
64
+
65
+ return { ...update, msg, color, proxyUpdate };
66
  }
67
 
68
  function Dashboard() {
 
85
  updateStatusMsg={update.msg}
86
  updateStatusColor={update.color}
87
  version={update.status?.proxy.version ?? null}
88
+ commit={update.status?.proxy.commit ?? null}
89
+ proxyUpdate={update.proxyUpdate}
90
  />
91
  <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
92
  <div class="flex flex-col w-full max-w-[960px] gap-6">
 
174
  updateStatusMsg={update.msg}
175
  updateStatusColor={update.color}
176
  version={update.status?.proxy.version ?? null}
177
+ commit={update.status?.proxy.commit ?? null}
178
  isProxySettings
179
+ proxyUpdate={update.proxyUpdate}
180
  />
181
  <ProxySettings />
182
  </>
web/src/components/Header.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import { useI18n } from "../../../shared/i18n/context";
2
  import { useTheme } from "../../../shared/theme/context";
3
 
@@ -13,6 +14,15 @@ const SVG_SUN = (
13
  </svg>
14
  );
15
 
 
 
 
 
 
 
 
 
 
16
  interface HeaderProps {
17
  onAddAccount: () => void;
18
  onCheckUpdate: () => void;
@@ -20,12 +30,15 @@ interface HeaderProps {
20
  updateStatusMsg: string | null;
21
  updateStatusColor: string;
22
  version: string | null;
 
23
  isProxySettings?: boolean;
 
24
  }
25
 
26
- export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version, isProxySettings }: HeaderProps) {
27
  const { lang, toggleLang, t } = useI18n();
28
  const { isDark, toggle: toggleTheme } = useTheme();
 
29
 
30
  return (
31
  <header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
@@ -55,7 +68,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
55
  )}
56
  </div>
57
  {/* Actions */}
58
- <div class="flex items-center gap-3">
59
  <div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
60
  <span class="relative flex h-2.5 w-2.5">
61
  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
@@ -68,13 +81,16 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
68
  {version && (
69
  <span class="text-[0.65rem] font-mono text-primary/70">v{version}</span>
70
  )}
 
 
 
71
  </div>
72
  {/* Star on GitHub */}
73
  <a
74
  href="https://github.com/icebear0828/codex-proxy"
75
  target="_blank"
76
  rel="noopener noreferrer"
77
- class="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-50 border border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700/30 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
78
  >
79
  <svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
80
  <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
@@ -85,7 +101,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
85
  <button
86
  onClick={onCheckUpdate}
87
  disabled={checking}
88
- class="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
89
  >
90
  <svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
91
  <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
@@ -98,7 +114,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
98
  {updateStatusMsg && !checking && (
99
  <button
100
  onClick={onCheckUpdate}
101
- class={`hidden sm:inline text-xs font-medium ${updateStatusColor} hover:underline`}
102
  >
103
  {updateStatusMsg}
104
  </button>
@@ -124,7 +140,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
124
  <>
125
  <a
126
  href="#/proxy-settings"
127
- class="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
128
  >
129
  <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
130
  <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
@@ -148,6 +164,76 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
148
  </div>
149
  </div>
150
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  </header>
152
  );
153
  }
 
1
+ import { useState } from "preact/hooks";
2
  import { useI18n } from "../../../shared/i18n/context";
3
  import { useTheme } from "../../../shared/theme/context";
4
 
 
14
  </svg>
15
  );
16
 
17
+ export interface ProxyUpdateInfo {
18
+ mode: "git" | "docker" | "electron";
19
+ updateAvailable: boolean;
20
+ commits: { hash: string; message: string }[];
21
+ release: { version: string; body: string; url: string } | null;
22
+ onApply: () => void;
23
+ applying: boolean;
24
+ }
25
+
26
  interface HeaderProps {
27
  onAddAccount: () => void;
28
  onCheckUpdate: () => void;
 
30
  updateStatusMsg: string | null;
31
  updateStatusColor: string;
32
  version: string | null;
33
+ commit?: string | null;
34
  isProxySettings?: boolean;
35
+ proxyUpdate?: ProxyUpdateInfo | null;
36
  }
37
 
38
+ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, proxyUpdate }: HeaderProps) {
39
  const { lang, toggleLang, t } = useI18n();
40
  const { isDark, toggle: toggleTheme } = useTheme();
41
+ const [showChangelog, setShowChangelog] = useState(false);
42
 
43
  return (
44
  <header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
 
68
  )}
69
  </div>
70
  {/* Actions */}
71
+ <div class="flex items-center gap-2">
72
  <div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
73
  <span class="relative flex h-2.5 w-2.5">
74
  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
 
81
  {version && (
82
  <span class="text-[0.65rem] font-mono text-primary/70">v{version}</span>
83
  )}
84
+ {commit && (
85
+ <span class="text-[0.6rem] font-mono text-primary/40">{commit.slice(0, 7)}</span>
86
+ )}
87
  </div>
88
  {/* Star on GitHub */}
89
  <a
90
  href="https://github.com/icebear0828/codex-proxy"
91
  target="_blank"
92
  rel="noopener noreferrer"
93
+ class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-50 border border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700/30 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
94
  >
95
  <svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
96
  <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
 
101
  <button
102
  onClick={onCheckUpdate}
103
  disabled={checking}
104
+ class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
105
  >
106
  <svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
107
  <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
 
114
  {updateStatusMsg && !checking && (
115
  <button
116
  onClick={onCheckUpdate}
117
+ class={`hidden md:inline text-xs font-medium ${updateStatusColor} hover:underline`}
118
  >
119
  {updateStatusMsg}
120
  </button>
 
140
  <>
141
  <a
142
  href="#/proxy-settings"
143
+ class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
144
  >
145
  <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
146
  <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
 
164
  </div>
165
  </div>
166
  </div>
167
+ {/* Expandable update panel */}
168
+ {proxyUpdate && proxyUpdate.updateAvailable && (
169
+ <div class="border-t border-amber-200 dark:border-amber-700/30 bg-amber-50/80 dark:bg-amber-900/10">
170
+ <div class="px-4 md:px-8 lg:px-40 flex justify-center">
171
+ <div class="w-full max-w-[960px] py-2">
172
+ <div class="flex items-center justify-between">
173
+ <span class="text-xs font-medium text-amber-700 dark:text-amber-400">
174
+ {proxyUpdate.mode === "git"
175
+ ? `${proxyUpdate.commits.length} ${t("commits")} ${t("proxyBehind")}`
176
+ : `${t("newVersion")} v${proxyUpdate.release?.version}`}
177
+ </span>
178
+ <button
179
+ onClick={() => setShowChangelog(!showChangelog)}
180
+ class="text-xs font-medium text-amber-600 dark:text-amber-400 hover:underline"
181
+ >
182
+ {showChangelog ? t("hideChanges") : t("viewChanges")}
183
+ </button>
184
+ </div>
185
+ {showChangelog && (
186
+ <div class="mt-2">
187
+ {proxyUpdate.mode === "git" ? (
188
+ <>
189
+ <ul class="space-y-0.5 text-xs text-slate-600 dark:text-text-dim max-h-48 overflow-y-auto">
190
+ {proxyUpdate.commits.map((c) => (
191
+ <li key={c.hash} class="flex gap-2">
192
+ <code class="text-primary/70 shrink-0">{c.hash}</code>
193
+ <span>{c.message}</span>
194
+ </li>
195
+ ))}
196
+ </ul>
197
+ <button
198
+ onClick={proxyUpdate.onApply}
199
+ disabled={proxyUpdate.applying}
200
+ class="mt-2 px-3 py-1.5 text-xs font-semibold bg-primary text-white rounded-md hover:bg-primary-hover disabled:opacity-50 transition-colors"
201
+ >
202
+ {proxyUpdate.applying ? t("applyingUpdate") : t("updateNow")}
203
+ </button>
204
+ </>
205
+ ) : (
206
+ <>
207
+ {proxyUpdate.release && (
208
+ <pre class="text-xs text-slate-600 dark:text-text-dim whitespace-pre-wrap max-h-48 overflow-y-auto mb-2">
209
+ {proxyUpdate.release.body}
210
+ </pre>
211
+ )}
212
+ {proxyUpdate.mode === "docker" ? (
213
+ <div class="flex items-center gap-2">
214
+ <span class="text-xs text-slate-500 dark:text-text-dim">{t("dockerUpdateCmd")}</span>
215
+ <code class="text-xs bg-slate-100 dark:bg-bg-dark px-2 py-0.5 rounded font-mono select-all">
216
+ docker compose up -d --build
217
+ </code>
218
+ </div>
219
+ ) : (
220
+ <a
221
+ href={proxyUpdate.release?.url}
222
+ target="_blank"
223
+ rel="noopener noreferrer"
224
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-primary text-white rounded-md hover:bg-primary-hover transition-colors"
225
+ >
226
+ {t("downloadUpdate")}
227
+ </a>
228
+ )}
229
+ </>
230
+ )}
231
+ </div>
232
+ )}
233
+ </div>
234
+ </div>
235
+ </div>
236
+ )}
237
  </header>
238
  );
239
  }