icebear0828 Claude Opus 4.6 commited on
Commit
4a940a5
Β·
1 Parent(s): ec445ea

refactor: extract startServer() + path abstraction for Electron reuse

Browse files

- Export startServer(options?) from src/index.ts so Electron can import it
- Add src/paths.ts: centralized path getters (getConfigDir, getDataDir, etc.)
with setPaths() hook for Electron to redirect to userData
- Replace all process.cwd() calls with path getters across src/
- Extract shared/ from web/ (i18n, theme, hooks, utils) for desktop reuse
- Add /desktop route in web.ts (serves desktop UI if built, no-op otherwise)

CLI behavior is unchanged β€” all path getters default to process.cwd().

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

.gitignore CHANGED
@@ -1,5 +1,8 @@
1
  node_modules/
2
  dist/
 
 
 
3
  data/
4
  public/
5
  docs/
 
1
  node_modules/
2
  dist/
3
+ dist-electron/
4
+ release/
5
+ public-desktop/
6
  data/
7
  public/
8
  docs/
shared/hooks/use-accounts.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+ import type { Account } from "../types";
3
+
4
+ export function useAccounts() {
5
+ const [list, setList] = useState<Account[]>([]);
6
+ const [loading, setLoading] = useState(true);
7
+ const [refreshing, setRefreshing] = useState(false);
8
+ const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
9
+ const [addVisible, setAddVisible] = useState(false);
10
+ const [addInfo, setAddInfo] = useState("");
11
+ const [addError, setAddError] = useState("");
12
+
13
+ const loadAccounts = useCallback(async () => {
14
+ setRefreshing(true);
15
+ try {
16
+ const resp = await fetch("/auth/accounts?quota=true");
17
+ const data = await resp.json();
18
+ setList(data.accounts || []);
19
+ setLastUpdated(new Date());
20
+ } catch {
21
+ setList([]);
22
+ } finally {
23
+ setLoading(false);
24
+ setRefreshing(false);
25
+ }
26
+ }, []);
27
+
28
+ useEffect(() => {
29
+ loadAccounts();
30
+ }, [loadAccounts]);
31
+
32
+ // Listen for OAuth callback success
33
+ useEffect(() => {
34
+ const handler = async (event: MessageEvent) => {
35
+ if (event.data?.type === "oauth-callback-success") {
36
+ setAddVisible(false);
37
+ setAddInfo("accountAdded");
38
+ await loadAccounts();
39
+ }
40
+ };
41
+ window.addEventListener("message", handler);
42
+ return () => window.removeEventListener("message", handler);
43
+ }, [loadAccounts]);
44
+
45
+ const startAdd = useCallback(async () => {
46
+ setAddInfo("");
47
+ setAddError("");
48
+ try {
49
+ const resp = await fetch("/auth/login-start", { method: "POST" });
50
+ const data = await resp.json();
51
+ if (!resp.ok || !data.authUrl) {
52
+ throw new Error(data.error || "failedStartLogin");
53
+ }
54
+ window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
55
+ setAddVisible(true);
56
+
57
+ // Poll for new account
58
+ const prevResp = await fetch("/auth/accounts");
59
+ const prevData = await prevResp.json();
60
+ const prevCount = prevData.accounts?.length || 0;
61
+
62
+ const pollTimer = setInterval(async () => {
63
+ try {
64
+ const r = await fetch("/auth/accounts");
65
+ const d = await r.json();
66
+ if ((d.accounts?.length || 0) > prevCount) {
67
+ clearInterval(pollTimer);
68
+ setAddVisible(false);
69
+ setAddInfo("accountAdded");
70
+ await loadAccounts();
71
+ }
72
+ } catch {}
73
+ }, 2000);
74
+
75
+ setTimeout(() => clearInterval(pollTimer), 5 * 60 * 1000);
76
+ } catch (err) {
77
+ setAddError(err instanceof Error ? err.message : "failedStartLogin");
78
+ }
79
+ }, [loadAccounts]);
80
+
81
+ const submitRelay = useCallback(
82
+ async (callbackUrl: string) => {
83
+ setAddInfo("");
84
+ setAddError("");
85
+ if (!callbackUrl.trim()) {
86
+ setAddError("pleasePassCallback");
87
+ return;
88
+ }
89
+ try {
90
+ const resp = await fetch("/auth/code-relay", {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ callbackUrl }),
94
+ });
95
+ const data = await resp.json();
96
+ if (resp.ok && data.success) {
97
+ setAddVisible(false);
98
+ setAddInfo("accountAdded");
99
+ await loadAccounts();
100
+ } else {
101
+ setAddError(data.error || "failedExchangeCode");
102
+ }
103
+ } catch (err) {
104
+ setAddError(
105
+ "networkError" + (err instanceof Error ? err.message : String(err))
106
+ );
107
+ }
108
+ },
109
+ [loadAccounts]
110
+ );
111
+
112
+ const deleteAccount = useCallback(
113
+ async (id: string) => {
114
+ try {
115
+ const resp = await fetch("/auth/accounts/" + encodeURIComponent(id), {
116
+ method: "DELETE",
117
+ });
118
+ if (!resp.ok) {
119
+ const data = await resp.json();
120
+ return data.error || "failedDeleteAccount";
121
+ }
122
+ await loadAccounts();
123
+ return null;
124
+ } catch (err) {
125
+ return "networkError" + (err instanceof Error ? err.message : "");
126
+ }
127
+ },
128
+ [loadAccounts]
129
+ );
130
+
131
+ return {
132
+ list,
133
+ loading,
134
+ refreshing,
135
+ lastUpdated,
136
+ addVisible,
137
+ addInfo,
138
+ addError,
139
+ refresh: loadAccounts,
140
+ startAdd,
141
+ submitRelay,
142
+ deleteAccount,
143
+ };
144
+ }
shared/hooks/use-status.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+
3
+ export function useStatus(accountCount: number) {
4
+ const [baseUrl, setBaseUrl] = useState("Loading...");
5
+ const [apiKey, setApiKey] = useState("Loading...");
6
+ const [models, setModels] = useState<string[]>(["codex"]);
7
+ const [selectedModel, setSelectedModel] = useState("codex");
8
+
9
+ const loadModels = useCallback(async () => {
10
+ try {
11
+ const resp = await fetch("/v1/models");
12
+ const data = await resp.json();
13
+ const ids: string[] = data.data.map((m: { id: string }) => m.id);
14
+ if (ids.length > 0) {
15
+ setModels(ids);
16
+ const preferred = ids.find((n) => n === "codex");
17
+ if (preferred) setSelectedModel(preferred);
18
+ }
19
+ } catch {
20
+ setModels(["codex"]);
21
+ }
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ async function loadStatus() {
26
+ try {
27
+ const resp = await fetch("/auth/status");
28
+ const data = await resp.json();
29
+ if (!data.authenticated) return;
30
+ setBaseUrl(`${window.location.origin}/v1`);
31
+ setApiKey(data.proxy_api_key || "any-string");
32
+ await loadModels();
33
+ } catch (err) {
34
+ console.error("Status load error:", err);
35
+ }
36
+ }
37
+ loadStatus();
38
+ }, [loadModels, accountCount]);
39
+
40
+ return { baseUrl, apiKey, models, selectedModel, setSelectedModel };
41
+ }
shared/i18n/context.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext } from "preact";
2
+ import { useContext, useState, useCallback } from "preact/hooks";
3
+ import { translations, type LangCode, type TranslationKey } from "./translations";
4
+ import type { ComponentChildren } from "preact";
5
+
6
+ interface I18nContextValue {
7
+ lang: LangCode;
8
+ toggleLang: () => void;
9
+ t: (key: TranslationKey) => string;
10
+ }
11
+
12
+ const I18nContext = createContext<I18nContextValue>(null!);
13
+
14
+ function getInitialLang(): LangCode {
15
+ try {
16
+ const saved = localStorage.getItem("codex-proxy-lang");
17
+ if (saved === "en" || saved === "zh") return saved;
18
+ } catch {}
19
+ return navigator.language.startsWith("zh") ? "zh" : "en";
20
+ }
21
+
22
+ export function I18nProvider({ children }: { children: ComponentChildren }) {
23
+ const [lang, setLang] = useState<LangCode>(getInitialLang);
24
+
25
+ const toggleLang = useCallback(() => {
26
+ setLang((prev) => {
27
+ const next = prev === "en" ? "zh" : "en";
28
+ localStorage.setItem("codex-proxy-lang", next);
29
+ return next;
30
+ });
31
+ }, []);
32
+
33
+ const t = useCallback(
34
+ (key: TranslationKey): string => {
35
+ return translations[lang][key] ?? translations.en[key] ?? key;
36
+ },
37
+ [lang]
38
+ );
39
+
40
+ return (
41
+ <I18nContext.Provider value={{ lang, toggleLang, t }}>
42
+ {children}
43
+ </I18nContext.Provider>
44
+ );
45
+ }
46
+
47
+ export function useT() {
48
+ return useContext(I18nContext).t;
49
+ }
50
+
51
+ export function useI18n() {
52
+ return useContext(I18nContext);
53
+ }
shared/i18n/translations.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const translations = {
2
+ en: {
3
+ serverOnline: "Server Online",
4
+ addAccount: "Add Account",
5
+ toggleTheme: "Toggle theme",
6
+ connectedAccounts: "Connected Accounts",
7
+ connectedAccountsDesc:
8
+ "Manage your AI model proxy services and usage limits.",
9
+ loadingAccounts: "Loading accounts...",
10
+ noAccounts: 'No accounts connected. Click "Add Account" to get started.',
11
+ deleteAccount: "Delete account",
12
+ removeConfirm: "Remove this account?",
13
+ accountAdded: "Account added successfully!",
14
+ active: "Active",
15
+ expired: "Expired",
16
+ rateLimited: "Rate Limited",
17
+ refreshing: "Refreshing",
18
+ disabled: "Disabled",
19
+ freeTier: "Free Tier",
20
+ totalRequests: "Total Requests",
21
+ tokensUsed: "Tokens Used",
22
+ windowRequests: "Requests (Window)",
23
+ windowTokens: "Tokens (Window)",
24
+ totalAll: "Total",
25
+ windowLabel: "window",
26
+ rateLimit: "Rate Limit",
27
+ limitReached: "Limit Reached",
28
+ used: "Used",
29
+ ok: "OK",
30
+ resetsAt: "Resets at",
31
+ apiConfig: "API Configuration",
32
+ baseProxyUrl: "Base Proxy URL",
33
+ defaultModel: "Default Model",
34
+ yourApiKey: "Your API Key",
35
+ apiKeyHint:
36
+ "Use this key to authenticate requests to the proxy. Do not share it.",
37
+ copyUrl: "Copy URL",
38
+ copyApiKey: "Copy API Key",
39
+ anthropicSetup: "Anthropic SDK Setup",
40
+ anthropicCopyAllHint: "Copy all env vars \u2014 paste into terminal or .env file",
41
+ integrationExamples: "Integration Examples",
42
+ copy: "Copy",
43
+ addStep1:
44
+ 'Complete the login in the popup window (if blocked, right-click "Add Account" and open the link in a new tab).',
45
+ addStep2:
46
+ 'After login, the browser will redirect to a <code class="text-xs bg-slate-100 dark:bg-bg-dark px-1.5 py-0.5 rounded">localhost:1455/auth/callback?...</code> page (it may show "unable to connect" \u2014 that\'s normal).',
47
+ addStep3:
48
+ 'Copy the <strong class="text-slate-700 dark:text-text-main">full URL</strong> from the address bar and paste it below.',
49
+ pasteCallback: "Paste callback URL here",
50
+ submit: "Submit",
51
+ submitting: "Submitting...",
52
+ pleasePassCallback: "Please paste the callback URL",
53
+ failedStartLogin: "Failed to start login",
54
+ failedExchangeCode: "Failed to exchange code",
55
+ failedDeleteAccount: "Failed to delete account.",
56
+ networkError: "Network error: ",
57
+ copied: "Copied!",
58
+ copyFailed: "Failed",
59
+ refresh: "Refresh",
60
+ updatedAt: "Updated at",
61
+ footer: "\u00a9 2025 Codex Proxy. All rights reserved.",
62
+ },
63
+ zh: {
64
+ serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
65
+ addAccount: "\u6dfb\u52a0\u8d26\u6237",
66
+ toggleTheme: "\u5207\u6362\u4e3b\u9898",
67
+ connectedAccounts: "\u5df2\u8fde\u63a5\u8d26\u6237",
68
+ connectedAccountsDesc:
69
+ "\u7ba1\u7406\u4f60\u7684 AI \u6a21\u578b\u4ee3\u7406\u670d\u52a1\u548c\u7528\u91cf\u9650\u5236\u3002",
70
+ loadingAccounts: "\u6b63\u5728\u52a0\u8f7d\u8d26\u6237...",
71
+ noAccounts:
72
+ "\u6682\u65e0\u5df2\u8fde\u63a5\u7684\u8d26\u6237\u3002\u70b9\u51fb\u300c\u6dfb\u52a0\u8d26\u6237\u300d\u5f00\u59cb\u4f7f\u7528\u3002",
73
+ deleteAccount: "\u5220\u9664\u8d26\u6237",
74
+ removeConfirm:
75
+ "\u786e\u5b9a\u8981\u79fb\u9664\u6b64\u8d26\u6237\u5417\uff1f",
76
+ accountAdded: "\u8d26\u6237\u6dfb\u52a0\u6210\u529f\uff01",
77
+ active: "\u6d3b\u8dc3",
78
+ expired: "\u5df2\u8fc7\u671f",
79
+ rateLimited: "\u5df2\u9650\u901f",
80
+ refreshing: "\u5237\u65b0\u4e2d",
81
+ disabled: "\u5df2\u7981\u7528",
82
+ freeTier: "\u514d\u8d39\u7248",
83
+ totalRequests: "\u603b\u8bf7\u6c42\u6570",
84
+ tokensUsed: "Token \u7528\u91cf",
85
+ windowRequests: "\u8bf7\u6c42\u6570\uff08\u7a97\u53e3\uff09",
86
+ windowTokens: "Token\uff08\u7a97\u53e3\uff09",
87
+ totalAll: "\u7d2f\u8ba1",
88
+ windowLabel: "\u7a97\u53e3",
89
+ rateLimit: "\u901f\u7387\u9650\u5236",
90
+ limitReached: "\u5df2\u8fbe\u4e0a\u9650",
91
+ used: "\u5df2\u4f7f\u7528",
92
+ ok: "\u6b63\u5e38",
93
+ resetsAt: "\u91cd\u7f6e\u65f6\u95f4",
94
+ apiConfig: "API \u914d\u7f6e",
95
+ baseProxyUrl: "\u4ee3\u7406 URL",
96
+ defaultModel: "\u9ed8\u8ba4\u6a21\u578b",
97
+ yourApiKey: "API \u5bc6\u94a5",
98
+ apiKeyHint:
99
+ "\u4f7f\u7528\u6b64\u5bc6\u94a5\u5411\u4ee3\u7406\u53d1\u9001\u8ba4\u8bc1\u8bf7\u6c42\uff0c\u8bf7\u52ff\u6cc4\u9732\u3002",
100
+ copyUrl: "\u590d\u5236 URL",
101
+ copyApiKey: "\u590d\u5236 API \u5bc6\u94a5",
102
+ anthropicSetup: "Anthropic SDK \u914d\u7f6e",
103
+ anthropicCopyAllHint: "\u590d\u5236\u6240\u6709\u73af\u5883\u53d8\u91cf \u2014 \u7c98\u8d34\u5230\u7ec8\u7aef\u6216 .env \u6587\u4ef6",
104
+ integrationExamples: "\u96c6\u6210\u793a\u4f8b",
105
+ copy: "\u590d\u5236",
106
+ addStep1:
107
+ "\u5728\u5f39\u51fa\u7684\u7a97\u53e3\u4e2d\u5b8c\u6210\u767b\u5f55\uff08\u5982\u5f39\u7a97\u88ab\u62e6\u622a\uff0c\u53f3\u952e\u300c\u6dfb\u52a0\u8d26\u6237\u300d\u6309\u94ae\u5728\u65b0\u6807\u7b7e\u9875\u6253\u5f00\u94fe\u63a5\uff09\u3002",
108
+ addStep2:
109
+ '\u767b\u5f55\u6210\u529f\u540e\uff0c\u6d4f\u89c8\u5668\u4f1a\u8df3\u8f6c\u5230 <code class="text-xs bg-slate-100 dark:bg-bg-dark px-1.5 py-0.5 rounded">localhost:1455/auth/callback?...</code> \u9875\u9762\uff08\u53ef\u80fd\u663e\u793a\u201c\u65e0\u6cd5\u8bbf\u95ee\u201d\u2014\u2014\u8fd9\u662f\u6b63\u5e38\u7684\uff09\u3002',
110
+ addStep3:
111
+ '\u590d\u5236\u5730\u5740\u680f\u4e2d\u7684<strong class="text-slate-700 dark:text-text-main">\u5b8c\u6574 URL</strong>\uff0c\u7c98\u8d34\u5230\u4e0b\u65b9\u8f93\u5165\u6846\u3002',
112
+ pasteCallback: "\u7c98\u8d34\u56de\u8c03 URL",
113
+ submit: "\u63d0\u4ea4",
114
+ submitting: "\u63d0\u4ea4\u4e2d...",
115
+ pleasePassCallback: "\u8bf7\u7c98\u8d34\u56de\u8c03 URL",
116
+ failedStartLogin: "\u767b\u5f55\u542f\u52a8\u5931\u8d25",
117
+ failedExchangeCode: "\u6388\u6743\u7801\u4ea4\u6362\u5931\u8d25",
118
+ failedDeleteAccount: "\u5220\u9664\u8d26\u6237\u5931\u8d25\u3002",
119
+ networkError: "\u7f51\u7edc\u9519\u8bef\uff1a",
120
+ copied: "\u5df2\u590d\u5236\uff01",
121
+ copyFailed: "\u5931\u8d25",
122
+ refresh: "\u5237\u65b0",
123
+ updatedAt: "\u66f4\u65b0\u4e8e",
124
+ footer:
125
+ "\u00a9 2025 Codex Proxy\u3002\u4fdd\u7559\u6240\u6709\u6743\u5229\u3002",
126
+ },
127
+ } as const;
128
+
129
+ export type LangCode = keyof typeof translations;
130
+ export type TranslationKey = keyof (typeof translations)["en"];
shared/theme/context.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext } from "preact";
2
+ import { useContext, useState, useCallback } from "preact/hooks";
3
+ import type { ComponentChildren } from "preact";
4
+
5
+ interface ThemeContextValue {
6
+ isDark: boolean;
7
+ toggle: () => void;
8
+ }
9
+
10
+ const ThemeContext = createContext<ThemeContextValue>(null!);
11
+
12
+ function getInitialDark(): boolean {
13
+ try {
14
+ const saved = localStorage.getItem("codex-proxy-theme");
15
+ if (saved === "dark") return true;
16
+ if (saved === "light") return false;
17
+ } catch {}
18
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
19
+ }
20
+
21
+ export function ThemeProvider({ children }: { children: ComponentChildren }) {
22
+ const [isDark, setIsDark] = useState(getInitialDark);
23
+
24
+ const toggle = useCallback(() => {
25
+ setIsDark((prev) => {
26
+ const next = !prev;
27
+ localStorage.setItem("codex-proxy-theme", next ? "dark" : "light");
28
+ if (next) {
29
+ document.documentElement.classList.add("dark");
30
+ } else {
31
+ document.documentElement.classList.remove("dark");
32
+ }
33
+ return next;
34
+ });
35
+ }, []);
36
+
37
+ return (
38
+ <ThemeContext.Provider value={{ isDark, toggle }}>
39
+ {children}
40
+ </ThemeContext.Provider>
41
+ );
42
+ }
43
+
44
+ export function useTheme() {
45
+ return useContext(ThemeContext);
46
+ }
shared/types.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface AccountQuota {
2
+ rate_limit?: {
3
+ used_percent?: number | null;
4
+ limit_reached?: boolean;
5
+ reset_at?: number | null;
6
+ limit_window_seconds?: number | null;
7
+ };
8
+ }
9
+
10
+ export interface Account {
11
+ id: string;
12
+ email: string;
13
+ status: string;
14
+ planType?: string;
15
+ usage?: {
16
+ request_count?: number;
17
+ input_tokens?: number;
18
+ output_tokens?: number;
19
+ window_request_count?: number;
20
+ window_input_tokens?: number;
21
+ window_output_tokens?: number;
22
+ };
23
+ quota?: AccountQuota;
24
+ }
shared/utils/clipboard.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function fallbackCopy(text: string): boolean {
2
+ const ta = document.createElement("textarea");
3
+ ta.value = text;
4
+ ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
5
+ document.body.appendChild(ta);
6
+ ta.select();
7
+ let ok = false;
8
+ try {
9
+ ok = document.execCommand("copy");
10
+ } catch {}
11
+ document.body.removeChild(ta);
12
+ return ok;
13
+ }
14
+
15
+ export async function clipboardCopy(text: string): Promise<boolean> {
16
+ if (navigator.clipboard?.writeText) {
17
+ try {
18
+ await navigator.clipboard.writeText(text);
19
+ return true;
20
+ } catch {}
21
+ }
22
+ return fallbackCopy(text);
23
+ }
shared/utils/format.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function formatNumber(n: number): string {
2
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
3
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
4
+ return String(n);
5
+ }
6
+
7
+ export function formatWindowDuration(seconds: number, isZh: boolean): string {
8
+ if (seconds >= 86400) {
9
+ const days = Math.floor(seconds / 86400);
10
+ return isZh ? `${days}\u5929` : `${days}d`;
11
+ }
12
+ if (seconds >= 3600) {
13
+ const hours = Math.floor(seconds / 3600);
14
+ return isZh ? `${hours}\u5c0f\u65f6` : `${hours}h`;
15
+ }
16
+ const minutes = Math.floor(seconds / 60);
17
+ return isZh ? `${minutes}\u5206\u949f` : `${minutes}m`;
18
+ }
19
+
20
+ export function formatResetTime(unixSec: number, isZh: boolean): string {
21
+ const d = new Date(unixSec * 1000);
22
+ const now = new Date();
23
+ const time = d.toLocaleTimeString(undefined, {
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ second: "2-digit",
27
+ });
28
+
29
+ if (
30
+ d.getFullYear() === now.getFullYear() &&
31
+ d.getMonth() === now.getMonth() &&
32
+ d.getDate() === now.getDate()
33
+ ) {
34
+ return time;
35
+ }
36
+
37
+ const tomorrow = new Date(now);
38
+ tomorrow.setDate(tomorrow.getDate() + 1);
39
+ if (
40
+ d.getFullYear() === tomorrow.getFullYear() &&
41
+ d.getMonth() === tomorrow.getMonth() &&
42
+ d.getDate() === tomorrow.getDate()
43
+ ) {
44
+ return (isZh ? "\u660e\u5929 " : "Tomorrow ") + time;
45
+ }
46
+
47
+ const date = d.toLocaleDateString(undefined, {
48
+ month: "short",
49
+ day: "numeric",
50
+ });
51
+ return date + " " + time;
52
+ }
src/auth/account-pool.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
  import { resolve, dirname } from "path";
14
  import { randomBytes } from "crypto";
15
  import { getConfig } from "../config.js";
 
16
  import { jitter } from "../utils/jitter.js";
17
  import {
18
  decodeJwtPayload,
@@ -28,8 +29,12 @@ import type {
28
  AccountsFile,
29
  } from "./types.js";
30
 
31
- const ACCOUNTS_FILE = resolve(process.cwd(), "data", "accounts.json");
32
- const LEGACY_AUTH_FILE = resolve(process.cwd(), "data", "auth.json");
 
 
 
 
33
 
34
  // P1-4: Lock TTL β€” auto-release locks older than this
35
  const ACQUIRE_LOCK_TTL_MS = 5 * 60 * 1000; // 5 minutes
@@ -484,12 +489,13 @@ export class AccountPool {
484
  this.persistTimer = null;
485
  }
486
  try {
487
- const dir = dirname(ACCOUNTS_FILE);
 
488
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
489
  const data: AccountsFile = { accounts: [...this.accounts.values()] };
490
- const tmpFile = ACCOUNTS_FILE + ".tmp";
491
  writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
492
- renameSync(tmpFile, ACCOUNTS_FILE);
493
  } catch (err) {
494
  console.error("[AccountPool] Failed to persist accounts:", err instanceof Error ? err.message : err);
495
  }
@@ -497,8 +503,9 @@ export class AccountPool {
497
 
498
  private loadPersisted(): void {
499
  try {
500
- if (!existsSync(ACCOUNTS_FILE)) return;
501
- const raw = readFileSync(ACCOUNTS_FILE, "utf-8");
 
502
  const data = JSON.parse(raw) as AccountsFile;
503
  if (Array.isArray(data.accounts)) {
504
  let needsPersist = false;
@@ -547,10 +554,12 @@ export class AccountPool {
547
 
548
  private migrateFromLegacy(): void {
549
  try {
550
- if (existsSync(ACCOUNTS_FILE)) return; // already migrated
551
- if (!existsSync(LEGACY_AUTH_FILE)) return;
 
 
552
 
553
- const raw = readFileSync(LEGACY_AUTH_FILE, "utf-8");
554
  const data = JSON.parse(raw) as {
555
  token: string;
556
  proxyApiKey?: string | null;
@@ -589,13 +598,13 @@ export class AccountPool {
589
  this.accounts.set(id, entry);
590
 
591
  // Write new format
592
- const dir = dirname(ACCOUNTS_FILE);
593
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
594
  const accountsData: AccountsFile = { accounts: [entry] };
595
- writeFileSync(ACCOUNTS_FILE, JSON.stringify(accountsData, null, 2), "utf-8");
596
 
597
  // Rename old file
598
- renameSync(LEGACY_AUTH_FILE, LEGACY_AUTH_FILE + ".bak");
599
  console.log("[AccountPool] Migrated from auth.json β†’ accounts.json");
600
  } catch (err) {
601
  console.warn("[AccountPool] Migration failed:", err);
 
13
  import { resolve, dirname } from "path";
14
  import { randomBytes } from "crypto";
15
  import { getConfig } from "../config.js";
16
+ import { getDataDir } from "../paths.js";
17
  import { jitter } from "../utils/jitter.js";
18
  import {
19
  decodeJwtPayload,
 
29
  AccountsFile,
30
  } from "./types.js";
31
 
32
+ function getAccountsFile(): string {
33
+ return resolve(getDataDir(), "accounts.json");
34
+ }
35
+ function getLegacyAuthFile(): string {
36
+ return resolve(getDataDir(), "auth.json");
37
+ }
38
 
39
  // P1-4: Lock TTL β€” auto-release locks older than this
40
  const ACQUIRE_LOCK_TTL_MS = 5 * 60 * 1000; // 5 minutes
 
489
  this.persistTimer = null;
490
  }
491
  try {
492
+ const accountsFile = getAccountsFile();
493
+ const dir = dirname(accountsFile);
494
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
495
  const data: AccountsFile = { accounts: [...this.accounts.values()] };
496
+ const tmpFile = accountsFile + ".tmp";
497
  writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
498
+ renameSync(tmpFile, accountsFile);
499
  } catch (err) {
500
  console.error("[AccountPool] Failed to persist accounts:", err instanceof Error ? err.message : err);
501
  }
 
503
 
504
  private loadPersisted(): void {
505
  try {
506
+ const accountsFile = getAccountsFile();
507
+ if (!existsSync(accountsFile)) return;
508
+ const raw = readFileSync(accountsFile, "utf-8");
509
  const data = JSON.parse(raw) as AccountsFile;
510
  if (Array.isArray(data.accounts)) {
511
  let needsPersist = false;
 
554
 
555
  private migrateFromLegacy(): void {
556
  try {
557
+ const accountsFile = getAccountsFile();
558
+ const legacyAuthFile = getLegacyAuthFile();
559
+ if (existsSync(accountsFile)) return; // already migrated
560
+ if (!existsSync(legacyAuthFile)) return;
561
 
562
+ const raw = readFileSync(legacyAuthFile, "utf-8");
563
  const data = JSON.parse(raw) as {
564
  token: string;
565
  proxyApiKey?: string | null;
 
598
  this.accounts.set(id, entry);
599
 
600
  // Write new format
601
+ const dir = dirname(accountsFile);
602
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
603
  const accountsData: AccountsFile = { accounts: [entry] };
604
+ writeFileSync(accountsFile, JSON.stringify(accountsData, null, 2), "utf-8");
605
 
606
  // Rename old file
607
+ renameSync(legacyAuthFile, legacyAuthFile + ".bak");
608
  console.log("[AccountPool] Migrated from auth.json β†’ accounts.json");
609
  } catch (err) {
610
  console.warn("[AccountPool] Migration failed:", err);
src/config.ts CHANGED
@@ -4,6 +4,7 @@ import yaml from "js-yaml";
4
  import { z } from "zod";
5
  import { loadStaticModels } from "./models/model-store.js";
6
  import { triggerImmediateRefresh } from "./models/model-fetcher.js";
 
7
 
8
  const ConfigSchema = z.object({
9
  api: z.object({
@@ -99,7 +100,7 @@ let _fingerprint: FingerprintConfig | null = null;
99
 
100
  export function loadConfig(configDir?: string): AppConfig {
101
  if (_config) return _config;
102
- const dir = configDir ?? resolve(process.cwd(), "config");
103
  const raw = loadYaml(resolve(dir, "default.yaml")) as Record<string, unknown>;
104
  applyEnvOverrides(raw);
105
  _config = ConfigSchema.parse(raw);
@@ -108,7 +109,7 @@ export function loadConfig(configDir?: string): AppConfig {
108
 
109
  export function loadFingerprint(configDir?: string): FingerprintConfig {
110
  if (_fingerprint) return _fingerprint;
111
- const dir = configDir ?? resolve(process.cwd(), "config");
112
  const raw = loadYaml(resolve(dir, "fingerprint.yaml"));
113
  _fingerprint = FingerprintSchema.parse(raw);
114
  return _fingerprint;
@@ -132,7 +133,7 @@ export function mutateClientConfig(patch: Partial<AppConfig["client"]>): void {
132
  /** Reload config from disk (hot-reload after full-update).
133
  * P1-5: Load to temp first, then swap atomically to avoid null window. */
134
  export function reloadConfig(configDir?: string): AppConfig {
135
- const dir = configDir ?? resolve(process.cwd(), "config");
136
  const raw = loadYaml(resolve(dir, "default.yaml")) as Record<string, unknown>;
137
  applyEnvOverrides(raw);
138
  const fresh = ConfigSchema.parse(raw);
@@ -143,7 +144,7 @@ export function reloadConfig(configDir?: string): AppConfig {
143
  /** Reload fingerprint from disk (hot-reload after full-update).
144
  * P1-5: Load to temp first, then swap atomically. */
145
  export function reloadFingerprint(configDir?: string): FingerprintConfig {
146
- const dir = configDir ?? resolve(process.cwd(), "config");
147
  const raw = loadYaml(resolve(dir, "fingerprint.yaml"));
148
  const fresh = FingerprintSchema.parse(raw);
149
  _fingerprint = fresh;
 
4
  import { z } from "zod";
5
  import { loadStaticModels } from "./models/model-store.js";
6
  import { triggerImmediateRefresh } from "./models/model-fetcher.js";
7
+ import { getConfigDir } from "./paths.js";
8
 
9
  const ConfigSchema = z.object({
10
  api: z.object({
 
100
 
101
  export function loadConfig(configDir?: string): AppConfig {
102
  if (_config) return _config;
103
+ const dir = configDir ?? getConfigDir();
104
  const raw = loadYaml(resolve(dir, "default.yaml")) as Record<string, unknown>;
105
  applyEnvOverrides(raw);
106
  _config = ConfigSchema.parse(raw);
 
109
 
110
  export function loadFingerprint(configDir?: string): FingerprintConfig {
111
  if (_fingerprint) return _fingerprint;
112
+ const dir = configDir ?? getConfigDir();
113
  const raw = loadYaml(resolve(dir, "fingerprint.yaml"));
114
  _fingerprint = FingerprintSchema.parse(raw);
115
  return _fingerprint;
 
133
  /** Reload config from disk (hot-reload after full-update).
134
  * P1-5: Load to temp first, then swap atomically to avoid null window. */
135
  export function reloadConfig(configDir?: string): AppConfig {
136
+ const dir = configDir ?? getConfigDir();
137
  const raw = loadYaml(resolve(dir, "default.yaml")) as Record<string, unknown>;
138
  applyEnvOverrides(raw);
139
  const fresh = ConfigSchema.parse(raw);
 
144
  /** Reload fingerprint from disk (hot-reload after full-update).
145
  * P1-5: Load to temp first, then swap atomically. */
146
  export function reloadFingerprint(configDir?: string): FingerprintConfig {
147
+ const dir = configDir ?? getConfigDir();
148
  const raw = loadYaml(resolve(dir, "fingerprint.yaml"));
149
  const fresh = FingerprintSchema.parse(raw);
150
  _fingerprint = fresh;
src/index.ts CHANGED
@@ -21,19 +21,25 @@ import { initTransport } from "./tls/transport.js";
21
  import { loadStaticModels } from "./models/model-store.js";
22
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
23
 
24
- async function main() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  // Load configuration
26
  console.log("[Init] Loading configuration...");
27
- let config: ReturnType<typeof loadConfig>;
28
- try {
29
- config = loadConfig();
30
- loadFingerprint();
31
- } catch (err) {
32
- const msg = err instanceof Error ? err.message : String(err);
33
- console.error(`[Init] Failed to load configuration: ${msg}`);
34
- console.error("[Init] Make sure config/default.yaml and config/fingerprint.yaml exist and are valid YAML.");
35
- process.exit(1);
36
- }
37
 
38
  // Load static model catalog (before transport/auth init)
39
  loadStaticModels();
@@ -75,8 +81,8 @@ async function main() {
75
  app.route("/", webRoutes);
76
 
77
  // Start server
78
- const port = config.server.port;
79
- const host = config.server.host;
80
 
81
  const poolSummary = accountPool.getPoolSummary();
82
  const displayHost = (host === "0.0.0.0" || host === "::") ? "localhost" : host;
@@ -114,9 +120,38 @@ async function main() {
114
  port,
115
  });
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  // P1-7: Graceful shutdown β€” stop accepting, drain, then cleanup
118
  let shutdownCalled = false;
119
- const DRAIN_TIMEOUT_MS = 5_000;
120
  const shutdown = () => {
121
  if (shutdownCalled) return;
122
  shutdownCalled = true;
@@ -128,44 +163,27 @@ async function main() {
128
  }, 10_000);
129
  if (forceExit.unref) forceExit.unref();
130
 
131
- // 1. Stop accepting new connections
132
- server.close(() => {
133
- console.log("[Shutdown] Server closed, cleaning up resources...");
134
- cleanup();
135
- });
136
-
137
- // 2. Grace period for active streams, then force cleanup
138
- setTimeout(() => {
139
- console.log("[Shutdown] Drain timeout reached, cleaning up...");
140
- cleanup();
141
- }, DRAIN_TIMEOUT_MS);
142
-
143
- let cleanupDone = false;
144
- function cleanup() {
145
- if (cleanupDone) return;
146
- cleanupDone = true;
147
- try {
148
- stopUpdateChecker();
149
- stopModelRefresh();
150
- refreshScheduler.destroy();
151
- sessionManager.destroy();
152
- cookieJar.destroy();
153
- accountPool.destroy();
154
- } catch (err) {
155
- console.error("[Shutdown] Error during cleanup:", err instanceof Error ? err.message : err);
156
- }
157
  clearTimeout(forceExit);
158
  process.exit(0);
159
- }
 
 
 
 
160
  };
161
 
162
  process.on("SIGINT", shutdown);
163
  process.on("SIGTERM", shutdown);
164
  }
165
 
166
- main().catch((err) => {
167
- console.error("Fatal error:", err);
168
- // Trigger graceful shutdown instead of hard exit
169
- process.kill(process.pid, "SIGTERM");
170
- setTimeout(() => process.exit(1), 2000).unref();
171
- });
 
 
 
 
21
  import { loadStaticModels } from "./models/model-store.js";
22
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
23
 
24
+ export interface ServerHandle {
25
+ close: () => Promise<void>;
26
+ port: number;
27
+ }
28
+
29
+ export interface StartOptions {
30
+ host?: string;
31
+ port?: number;
32
+ }
33
+
34
+ /**
35
+ * Core startup logic shared by CLI and Electron entry points.
36
+ * Throws on config errors instead of calling process.exit().
37
+ */
38
+ export async function startServer(options?: StartOptions): Promise<ServerHandle> {
39
  // Load configuration
40
  console.log("[Init] Loading configuration...");
41
+ const config = loadConfig();
42
+ loadFingerprint();
 
 
 
 
 
 
 
 
43
 
44
  // Load static model catalog (before transport/auth init)
45
  loadStaticModels();
 
81
  app.route("/", webRoutes);
82
 
83
  // Start server
84
+ const port = options?.port ?? config.server.port;
85
+ const host = options?.host ?? config.server.host;
86
 
87
  const poolSummary = accountPool.getPoolSummary();
88
  const displayHost = (host === "0.0.0.0" || host === "::") ? "localhost" : host;
 
120
  port,
121
  });
122
 
123
+ const close = (): Promise<void> => {
124
+ return new Promise((resolve) => {
125
+ server.close(() => {
126
+ stopUpdateChecker();
127
+ stopModelRefresh();
128
+ refreshScheduler.destroy();
129
+ sessionManager.destroy();
130
+ cookieJar.destroy();
131
+ accountPool.destroy();
132
+ resolve();
133
+ });
134
+ });
135
+ };
136
+
137
+ return { close, port };
138
+ }
139
+
140
+ // ── CLI entry point ──────────────────────────────────────────────────
141
+
142
+ async function main() {
143
+ let handle: ServerHandle;
144
+ try {
145
+ handle = await startServer();
146
+ } catch (err) {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ console.error(`[Init] Failed to start server: ${msg}`);
149
+ console.error("[Init] Make sure config/default.yaml and config/fingerprint.yaml exist and are valid YAML.");
150
+ process.exit(1);
151
+ }
152
+
153
  // P1-7: Graceful shutdown β€” stop accepting, drain, then cleanup
154
  let shutdownCalled = false;
 
155
  const shutdown = () => {
156
  if (shutdownCalled) return;
157
  shutdownCalled = true;
 
163
  }, 10_000);
164
  if (forceExit.unref) forceExit.unref();
165
 
166
+ handle.close().then(() => {
167
+ console.log("[Shutdown] Server closed, cleanup complete.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  clearTimeout(forceExit);
169
  process.exit(0);
170
+ }).catch((err) => {
171
+ console.error("[Shutdown] Error during cleanup:", err instanceof Error ? err.message : err);
172
+ clearTimeout(forceExit);
173
+ process.exit(1);
174
+ });
175
  };
176
 
177
  process.on("SIGINT", shutdown);
178
  process.on("SIGTERM", shutdown);
179
  }
180
 
181
+ // Only run CLI entry when executed directly (not imported by Electron)
182
+ const isDirectRun = process.argv[1]?.includes("index");
183
+ if (isDirectRun) {
184
+ main().catch((err) => {
185
+ console.error("Fatal error:", err);
186
+ process.kill(process.pid, "SIGTERM");
187
+ setTimeout(() => process.exit(1), 2000).unref();
188
+ });
189
+ }
src/models/model-store.ts CHANGED
@@ -13,6 +13,7 @@ import { readFileSync } from "fs";
13
  import { resolve } from "path";
14
  import yaml from "js-yaml";
15
  import { getConfig } from "../config.js";
 
16
 
17
  export interface CodexModelInfo {
18
  id: string;
@@ -46,7 +47,7 @@ let _lastFetchTime: string | null = null;
46
  * Called at startup and on hot-reload.
47
  */
48
  export function loadStaticModels(configDir?: string): void {
49
- const dir = configDir ?? resolve(process.cwd(), "config");
50
  const configPath = resolve(dir, "models.yaml");
51
  const raw = yaml.load(readFileSync(configPath, "utf-8")) as ModelsConfig;
52
 
 
13
  import { resolve } from "path";
14
  import yaml from "js-yaml";
15
  import { getConfig } from "../config.js";
16
+ import { getConfigDir } from "../paths.js";
17
 
18
  export interface CodexModelInfo {
19
  id: string;
 
47
  * Called at startup and on hot-reload.
48
  */
49
  export function loadStaticModels(configDir?: string): void {
50
+ const dir = configDir ?? getConfigDir();
51
  const configPath = resolve(dir, "models.yaml");
52
  const raw = yaml.load(readFileSync(configPath, "utf-8")) as ModelsConfig;
53
 
src/paths.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Centralized path management for CLI and Electron modes.
3
+ *
4
+ * CLI mode (default): all paths relative to process.cwd().
5
+ * Electron mode: paths set by setPaths() before backend imports.
6
+ */
7
+
8
+ import { resolve } from "path";
9
+
10
+ interface PathConfig {
11
+ configDir: string;
12
+ dataDir: string;
13
+ binDir: string;
14
+ publicDir: string;
15
+ desktopPublicDir?: string;
16
+ }
17
+
18
+ let _paths: PathConfig | null = null;
19
+
20
+ /**
21
+ * Set custom paths (called by Electron main process before importing backend).
22
+ * Must be called before any getXxxDir() calls.
23
+ */
24
+ export function setPaths(config: PathConfig): void {
25
+ _paths = config;
26
+ }
27
+
28
+ /** Directory containing YAML config files. */
29
+ export function getConfigDir(): string {
30
+ return _paths?.configDir ?? resolve(process.cwd(), "config");
31
+ }
32
+
33
+ /** Directory for runtime data (accounts.json, cookies.json, etc.). */
34
+ export function getDataDir(): string {
35
+ return _paths?.dataDir ?? resolve(process.cwd(), "data");
36
+ }
37
+
38
+ /** Directory for curl-impersonate binaries. */
39
+ export function getBinDir(): string {
40
+ return _paths?.binDir ?? resolve(process.cwd(), "bin");
41
+ }
42
+
43
+ /** Directory for static web assets (Vite build output). */
44
+ export function getPublicDir(): string {
45
+ return _paths?.publicDir ?? resolve(process.cwd(), "public");
46
+ }
47
+
48
+ /** Directory for desktop-specific static assets (desktop Vite build output). */
49
+ export function getDesktopPublicDir(): string {
50
+ return _paths?.desktopPublicDir ?? resolve(process.cwd(), "public-desktop");
51
+ }
52
+
53
+ /** Whether running in embedded mode (Electron). */
54
+ export function isEmbedded(): boolean {
55
+ return _paths !== null;
56
+ }
src/proxy/cookie-jar.ts CHANGED
@@ -18,8 +18,11 @@ import {
18
  mkdirSync,
19
  } from "fs";
20
  import { resolve, dirname } from "path";
 
21
 
22
- const COOKIE_FILE = resolve(process.cwd(), "data", "cookies.json");
 
 
23
 
24
  interface StoredCookie {
25
  value: string;
@@ -209,7 +212,8 @@ export class CookieJar {
209
  this.persistTimer = null;
210
  }
211
  try {
212
- const dir = dirname(COOKIE_FILE);
 
213
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
214
 
215
  // Persist v2 format with expiry info
@@ -220,9 +224,9 @@ export class CookieJar {
220
  data.accounts[acct][k] = { value: c.value, expires: c.expires };
221
  }
222
  }
223
- const tmpFile = COOKIE_FILE + ".tmp";
224
  writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
225
- renameSync(tmpFile, COOKIE_FILE);
226
  } catch (err) {
227
  console.warn("[CookieJar] Failed to persist:", err instanceof Error ? err.message : err);
228
  }
@@ -230,8 +234,9 @@ export class CookieJar {
230
 
231
  private load(): void {
232
  try {
233
- if (!existsSync(COOKIE_FILE)) return;
234
- const raw = readFileSync(COOKIE_FILE, "utf-8");
 
235
  const data = JSON.parse(raw);
236
 
237
  if (data && data._version === 2 && data.accounts) {
 
18
  mkdirSync,
19
  } from "fs";
20
  import { resolve, dirname } from "path";
21
+ import { getDataDir } from "../paths.js";
22
 
23
+ function getCookieFile(): string {
24
+ return resolve(getDataDir(), "cookies.json");
25
+ }
26
 
27
  interface StoredCookie {
28
  value: string;
 
212
  this.persistTimer = null;
213
  }
214
  try {
215
+ const cookieFile = getCookieFile();
216
+ const dir = dirname(cookieFile);
217
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
218
 
219
  // Persist v2 format with expiry info
 
224
  data.accounts[acct][k] = { value: c.value, expires: c.expires };
225
  }
226
  }
227
+ const tmpFile = cookieFile + ".tmp";
228
  writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
229
+ renameSync(tmpFile, cookieFile);
230
  } catch (err) {
231
  console.warn("[CookieJar] Failed to persist:", err instanceof Error ? err.message : err);
232
  }
 
234
 
235
  private load(): void {
236
  try {
237
+ const cookieFile = getCookieFile();
238
+ if (!existsSync(cookieFile)) return;
239
+ const raw = readFileSync(cookieFile, "utf-8");
240
  const data = JSON.parse(raw);
241
 
242
  if (data && data._version === 2 && data.accounts) {
src/routes/web.ts CHANGED
@@ -4,14 +4,16 @@ import { readFileSync, existsSync } from "fs";
4
  import { resolve } from "path";
5
  import type { AccountPool } from "../auth/account-pool.js";
6
  import { getConfig, getFingerprint } from "../config.js";
 
7
 
8
  export function createWebRoutes(accountPool: AccountPool): Hono {
9
  const app = new Hono();
10
 
11
- const publicDir = resolve(process.cwd(), "public");
 
12
 
13
- // Serve Vite build assets
14
- app.use("/assets/*", serveStatic({ root: "./public" }));
15
 
16
  app.get("/", (c) => {
17
  try {
@@ -24,6 +26,25 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
24
  }
25
  });
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  app.get("/health", async (c) => {
28
  const authenticated = accountPool.isAuthenticated();
29
  const poolSummary = accountPool.getPoolSummary();
@@ -53,7 +74,7 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
53
  .replace("{platform}", config.client.platform)
54
  .replace("{arch}", config.client.arch);
55
 
56
- const promptsDir = resolve(process.cwd(), "config/prompts");
57
  const prompts: Record<string, boolean> = {
58
  "desktop-context.md": existsSync(resolve(promptsDir, "desktop-context.md")),
59
  "title-generation.md": existsSync(resolve(promptsDir, "title-generation.md")),
@@ -63,7 +84,7 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
63
 
64
  // Check for update state
65
  let updateState = null;
66
- const statePath = resolve(process.cwd(), "data/update-state.json");
67
  if (existsSync(statePath)) {
68
  try {
69
  updateState = JSON.parse(readFileSync(statePath, "utf-8"));
 
4
  import { resolve } from "path";
5
  import type { AccountPool } from "../auth/account-pool.js";
6
  import { getConfig, getFingerprint } from "../config.js";
7
+ import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir } from "../paths.js";
8
 
9
  export function createWebRoutes(accountPool: AccountPool): Hono {
10
  const app = new Hono();
11
 
12
+ const publicDir = getPublicDir();
13
+ const desktopPublicDir = getDesktopPublicDir();
14
 
15
+ // Serve Vite build assets (web)
16
+ app.use("/assets/*", serveStatic({ root: publicDir }));
17
 
18
  app.get("/", (c) => {
19
  try {
 
26
  }
27
  });
28
 
29
+ // Desktop UI β€” served at /desktop for Electron
30
+ // Vite builds with base: "/desktop/" so request paths are /desktop/assets/...
31
+ // but files live at public-desktop/assets/..., so strip the /desktop prefix
32
+ app.use("/desktop/assets/*", serveStatic({
33
+ root: desktopPublicDir,
34
+ rewriteRequestPath: (path) => path.replace(/^\/desktop/, ""),
35
+ }));
36
+
37
+ app.get("/desktop", (c) => {
38
+ try {
39
+ const html = readFileSync(resolve(desktopPublicDir, "index.html"), "utf-8");
40
+ return c.html(html);
41
+ } catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ console.error(`[Web] Failed to read desktop HTML: ${msg}`);
44
+ return c.html("<h1>Codex Proxy</h1><p>Desktop UI files not found. Run 'npm run build:desktop' first.</p>");
45
+ }
46
+ });
47
+
48
  app.get("/health", async (c) => {
49
  const authenticated = accountPool.isAuthenticated();
50
  const poolSummary = accountPool.getPoolSummary();
 
74
  .replace("{platform}", config.client.platform)
75
  .replace("{arch}", config.client.arch);
76
 
77
+ const promptsDir = resolve(getConfigDir(), "prompts");
78
  const prompts: Record<string, boolean> = {
79
  "desktop-context.md": existsSync(resolve(promptsDir, "desktop-context.md")),
80
  "title-generation.md": existsSync(resolve(promptsDir, "title-generation.md")),
 
84
 
85
  // Check for update state
86
  let updateState = null;
87
+ const statePath = resolve(getDataDir(), "update-state.json");
88
  if (existsSync(statePath)) {
89
  try {
90
  updateState = JSON.parse(readFileSync(statePath, "utf-8"));
src/tls/curl-binary.ts CHANGED
@@ -15,6 +15,7 @@ import { execFileSync } from "child_process";
15
  import { createConnection } from "net";
16
  import { resolve } from "path";
17
  import { getConfig } from "../config.js";
 
18
 
19
  const IS_WIN = process.platform === "win32";
20
  const BINARY_NAME = IS_WIN ? "curl-impersonate.exe" : "curl-impersonate";
@@ -88,7 +89,7 @@ export function resolveCurlBinary(): string {
88
  }
89
 
90
  // Auto-detect: look for curl-impersonate in bin/
91
- const binPath = resolve(process.cwd(), "bin", BINARY_NAME);
92
  if (existsSync(binPath)) {
93
  _resolved = binPath;
94
  _isImpersonate = true;
 
15
  import { createConnection } from "net";
16
  import { resolve } from "path";
17
  import { getConfig } from "../config.js";
18
+ import { getBinDir } from "../paths.js";
19
 
20
  const IS_WIN = process.platform === "win32";
21
  const BINARY_NAME = IS_WIN ? "curl-impersonate.exe" : "curl-impersonate";
 
89
  }
90
 
91
  // Auto-detect: look for curl-impersonate in bin/
92
+ const binPath = resolve(getBinDir(), BINARY_NAME);
93
  if (existsSync(binPath)) {
94
  _resolved = binPath;
95
  _isImpersonate = true;
src/tls/libcurl-ffi-transport.ts CHANGED
@@ -13,6 +13,7 @@ import { existsSync } from "fs";
13
  import type { IKoffiLib, IKoffiCType, IKoffiRegisteredCallback, KoffiFunction } from "koffi";
14
  import type { TlsTransport, TlsTransportResponse } from "./transport.js";
15
  import { getProxyUrl, getResolvedProfile } from "./curl-binary.js";
 
16
 
17
  // ── libcurl constants ──────────────────────────────────────────────
18
 
@@ -87,7 +88,7 @@ function asyncCall(fn: KoffiFunction, ...args: unknown[]): Promise<number> {
87
  // ── FFI initialization ─────────────────────────────────────────────
88
 
89
  function resolveLibPath(): string | null {
90
- const binDir = resolve(process.cwd(), "bin");
91
  const candidates: string[] = [];
92
 
93
  if (process.platform === "win32") {
@@ -106,7 +107,7 @@ function resolveLibPath(): string | null {
106
  }
107
 
108
  function resolveCaPath(): string | null {
109
- const candidate = resolve(process.cwd(), "bin", "cacert.pem");
110
  return existsSync(candidate) ? candidate : null;
111
  }
112
 
 
13
  import type { IKoffiLib, IKoffiCType, IKoffiRegisteredCallback, KoffiFunction } from "koffi";
14
  import type { TlsTransport, TlsTransportResponse } from "./transport.js";
15
  import { getProxyUrl, getResolvedProfile } from "./curl-binary.js";
16
+ import { getBinDir } from "../paths.js";
17
 
18
  // ── libcurl constants ──────────────────────────────────────────────
19
 
 
88
  // ── FFI initialization ─────────────────────────────────────────────
89
 
90
  function resolveLibPath(): string | null {
91
+ const binDir = getBinDir();
92
  const candidates: string[] = [];
93
 
94
  if (process.platform === "win32") {
 
107
  }
108
 
109
  function resolveCaPath(): string | null {
110
+ const candidate = resolve(getBinDir(), "cacert.pem");
111
  return existsSync(candidate) ? candidate : null;
112
  }
113
 
src/tls/transport.ts CHANGED
@@ -7,6 +7,7 @@
7
 
8
  import { existsSync } from "fs";
9
  import { resolve } from "path";
 
10
 
11
  export interface TlsTransportResponse {
12
  status: number;
@@ -94,7 +95,7 @@ function shouldUseFfi(): boolean {
94
  if (process.platform !== "win32") return false;
95
 
96
  // Check if libcurl-impersonate DLL exists (shipped as libcurl.dll)
97
- const dllPath = resolve(process.cwd(), "bin", "libcurl.dll");
98
  return existsSync(dllPath);
99
  }
100
 
 
7
 
8
  import { existsSync } from "fs";
9
  import { resolve } from "path";
10
+ import { getBinDir } from "../paths.js";
11
 
12
  export interface TlsTransportResponse {
13
  status: number;
 
95
  if (process.platform !== "win32") return false;
96
 
97
  // Check if libcurl-impersonate DLL exists (shipped as libcurl.dll)
98
+ const dllPath = resolve(getBinDir(), "libcurl.dll");
99
  return existsSync(dllPath);
100
  }
101
 
src/translation/shared-utils.ts CHANGED
@@ -7,6 +7,7 @@
7
  import { readFileSync } from "fs";
8
  import { resolve } from "path";
9
  import { getConfig } from "../config.js";
 
10
 
11
  let cachedDesktopContext: string | null = null;
12
 
@@ -18,7 +19,7 @@ export function getDesktopContext(): string {
18
  if (cachedDesktopContext !== null) return cachedDesktopContext;
19
  try {
20
  cachedDesktopContext = readFileSync(
21
- resolve(process.cwd(), "config/prompts/desktop-context.md"),
22
  "utf-8",
23
  );
24
  } catch {
 
7
  import { readFileSync } from "fs";
8
  import { resolve } from "path";
9
  import { getConfig } from "../config.js";
10
+ import { getConfigDir } from "../paths.js";
11
 
12
  let cachedDesktopContext: string | null = null;
13
 
 
19
  if (cachedDesktopContext !== null) return cachedDesktopContext;
20
  try {
21
  cachedDesktopContext = readFileSync(
22
+ resolve(getConfigDir(), "prompts/desktop-context.md"),
23
  "utf-8",
24
  );
25
  } catch {
src/update-checker.ts CHANGED
@@ -11,9 +11,14 @@ import { mutateClientConfig, reloadAllConfigs } from "./config.js";
11
  import { jitterInt } from "./utils/jitter.js";
12
  import { curlFetchGet } from "./tls/curl-fetch.js";
13
  import { mutateYaml } from "./utils/yaml-mutate.js";
 
14
 
15
- const CONFIG_PATH = resolve(process.cwd(), "config/default.yaml");
16
- const STATE_PATH = resolve(process.cwd(), "data/update-state.json");
 
 
 
 
17
  const APPCAST_URL = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
18
  const POLL_INTERVAL_MS = 3 * 24 * 60 * 60 * 1000; // 3 days
19
 
@@ -32,7 +37,7 @@ let _pollTimer: ReturnType<typeof setTimeout> | null = null;
32
  let _updateInProgress = false;
33
 
34
  function loadCurrentConfig(): { app_version: string; build_number: string } {
35
- const raw = yaml.load(readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
36
  const client = raw.client as Record<string, string>;
37
  return {
38
  app_version: client.app_version,
@@ -64,7 +69,7 @@ function parseAppcast(xml: string): {
64
  }
65
 
66
  function applyVersionUpdate(version: string, build: string): void {
67
- mutateYaml(CONFIG_PATH, (data) => {
68
  const client = data.client as Record<string, unknown>;
69
  client.app_version = version;
70
  client.build_number = build;
@@ -84,6 +89,13 @@ function triggerFullUpdate(): void {
84
  console.log("[UpdateChecker] Full update already in progress, skipping");
85
  return;
86
  }
 
 
 
 
 
 
 
87
  _updateInProgress = true;
88
  console.log("[UpdateChecker] Triggering full-update pipeline...");
89
 
@@ -166,8 +178,8 @@ export async function checkForUpdate(): Promise<UpdateState> {
166
 
167
  // Persist state
168
  try {
169
- mkdirSync(resolve(process.cwd(), "data"), { recursive: true });
170
- writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
171
  } catch {
172
  // best-effort persistence
173
  }
 
11
  import { jitterInt } from "./utils/jitter.js";
12
  import { curlFetchGet } from "./tls/curl-fetch.js";
13
  import { mutateYaml } from "./utils/yaml-mutate.js";
14
+ import { getConfigDir, getDataDir, isEmbedded } from "./paths.js";
15
 
16
+ function getConfigPath(): string {
17
+ return resolve(getConfigDir(), "default.yaml");
18
+ }
19
+ function getStatePath(): string {
20
+ return resolve(getDataDir(), "update-state.json");
21
+ }
22
  const APPCAST_URL = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
23
  const POLL_INTERVAL_MS = 3 * 24 * 60 * 60 * 1000; // 3 days
24
 
 
37
  let _updateInProgress = false;
38
 
39
  function loadCurrentConfig(): { app_version: string; build_number: string } {
40
+ const raw = yaml.load(readFileSync(getConfigPath(), "utf-8")) as Record<string, unknown>;
41
  const client = raw.client as Record<string, string>;
42
  return {
43
  app_version: client.app_version,
 
69
  }
70
 
71
  function applyVersionUpdate(version: string, build: string): void {
72
+ mutateYaml(getConfigPath(), (data) => {
73
  const client = data.client as Record<string, unknown>;
74
  client.app_version = version;
75
  client.build_number = build;
 
89
  console.log("[UpdateChecker] Full update already in progress, skipping");
90
  return;
91
  }
92
+
93
+ // Skip fork-based update in embedded (Electron) mode β€” no tsx/scripts available
94
+ if (isEmbedded()) {
95
+ console.log("[UpdateChecker] Embedded mode β€” skipping full-update pipeline");
96
+ return;
97
+ }
98
+
99
  _updateInProgress = true;
100
  console.log("[UpdateChecker] Triggering full-update pipeline...");
101
 
 
178
 
179
  // Persist state
180
  try {
181
+ mkdirSync(getDataDir(), { recursive: true });
182
+ writeFileSync(getStatePath(), JSON.stringify(state, null, 2));
183
  } catch {
184
  // best-effort persistence
185
  }
web/src/App.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { I18nProvider } from "./i18n/context";
2
- import { ThemeProvider } from "./theme/context";
3
  import { Header } from "./components/Header";
4
  import { AccountList } from "./components/AccountList";
5
  import { AddAccount } from "./components/AddAccount";
@@ -7,8 +7,8 @@ import { ApiConfig } from "./components/ApiConfig";
7
  import { AnthropicSetup } from "./components/AnthropicSetup";
8
  import { CodeExamples } from "./components/CodeExamples";
9
  import { Footer } from "./components/Footer";
10
- import { useAccounts } from "./hooks/use-accounts";
11
- import { useStatus } from "./hooks/use-status";
12
 
13
  function Dashboard() {
14
  const accounts = useAccounts();
 
1
+ import { I18nProvider } from "../../shared/i18n/context";
2
+ import { ThemeProvider } from "../../shared/theme/context";
3
  import { Header } from "./components/Header";
4
  import { AccountList } from "./components/AccountList";
5
  import { AddAccount } from "./components/AddAccount";
 
7
  import { AnthropicSetup } from "./components/AnthropicSetup";
8
  import { CodeExamples } from "./components/CodeExamples";
9
  import { Footer } from "./components/Footer";
10
+ import { useAccounts } from "../../shared/hooks/use-accounts";
11
+ import { useStatus } from "../../shared/hooks/use-status";
12
 
13
  function Dashboard() {
14
  const accounts = useAccounts();
web/src/components/AccountCard.tsx CHANGED
@@ -1,8 +1,8 @@
1
  import { useCallback } from "preact/hooks";
2
- import { useT, useI18n } from "../i18n/context";
3
- import type { TranslationKey } from "../i18n/translations";
4
- import { formatNumber, formatResetTime, formatWindowDuration } from "../utils/format";
5
- import type { Account } from "../hooks/use-accounts";
6
 
7
  const avatarColors = [
8
  ["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
 
1
  import { useCallback } from "preact/hooks";
2
+ import { useT, useI18n } from "../../../shared/i18n/context";
3
+ import type { TranslationKey } from "../../../shared/i18n/translations";
4
+ import { formatNumber, formatResetTime, formatWindowDuration } from "../../../shared/utils/format";
5
+ import type { Account } from "../../../shared/types";
6
 
7
  const avatarColors = [
8
  ["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
web/src/components/AccountList.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import { useI18n, useT } from "../i18n/context";
2
  import { AccountCard } from "./AccountCard";
3
- import type { Account } from "../hooks/use-accounts";
4
 
5
  interface AccountListProps {
6
  accounts: Account[];
 
1
+ import { useI18n, useT } from "../../../shared/i18n/context";
2
  import { AccountCard } from "./AccountCard";
3
+ import type { Account } from "../../../shared/types";
4
 
5
  interface AccountListProps {
6
  accounts: Account[];
web/src/components/AddAccount.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState, useCallback } from "preact/hooks";
2
- import { useT } from "../i18n/context";
3
- import type { TranslationKey } from "../i18n/translations";
4
 
5
  interface AddAccountProps {
6
  visible: boolean;
 
1
  import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import type { TranslationKey } from "../../../shared/i18n/translations";
4
 
5
  interface AddAccountProps {
6
  visible: boolean;
web/src/components/AnthropicSetup.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useMemo, useCallback } from "preact/hooks";
2
- import { useT } from "../i18n/context";
3
  import { CopyButton } from "./CopyButton";
4
 
5
  interface AnthropicSetupProps {
 
1
  import { useMemo, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
  import { CopyButton } from "./CopyButton";
4
 
5
  interface AnthropicSetupProps {
web/src/components/ApiConfig.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useT } from "../i18n/context";
2
  import { CopyButton } from "./CopyButton";
3
  import { useCallback } from "preact/hooks";
4
 
 
1
+ import { useT } from "../../../shared/i18n/context";
2
  import { CopyButton } from "./CopyButton";
3
  import { useCallback } from "preact/hooks";
4
 
web/src/components/CodeExamples.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useState, useMemo, useCallback } from "preact/hooks";
2
- import { useT } from "../i18n/context";
3
  import { CopyButton } from "./CopyButton";
4
 
5
  type Protocol = "openai" | "anthropic" | "gemini";
 
1
  import { useState, useMemo, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
  import { CopyButton } from "./CopyButton";
4
 
5
  type Protocol = "openai" | "anthropic" | "gemini";
web/src/components/CopyButton.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useState, useCallback } from "preact/hooks";
2
- import { clipboardCopy } from "../utils/clipboard";
3
- import { useT } from "../i18n/context";
4
- import type { TranslationKey } from "../i18n/translations";
5
 
6
  interface CopyButtonProps {
7
  getText: () => string;
 
1
  import { useState, useCallback } from "preact/hooks";
2
+ import { clipboardCopy } from "../../../shared/utils/clipboard";
3
+ import { useT } from "../../../shared/i18n/context";
4
+ import type { TranslationKey } from "../../../shared/i18n/translations";
5
 
6
  interface CopyButtonProps {
7
  getText: () => string;
web/src/components/Footer.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useT } from "../i18n/context";
2
 
3
  export function Footer() {
4
  const t = useT();
 
1
+ import { useT } from "../../../shared/i18n/context";
2
 
3
  export function Footer() {
4
  const t = useT();
web/src/components/Header.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { useI18n } from "../i18n/context";
2
- import { useTheme } from "../theme/context";
3
 
4
  const SVG_MOON = (
5
  <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
 
1
+ import { useI18n } from "../../../shared/i18n/context";
2
+ import { useTheme } from "../../../shared/theme/context";
3
 
4
  const SVG_MOON = (
5
  <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
web/src/index.css CHANGED
@@ -17,3 +17,29 @@ pre::-webkit-scrollbar { height: 8px; }
17
  pre::-webkit-scrollbar-track { background: #0d1117; }
18
  pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
19
  pre::-webkit-scrollbar-thumb:hover { background: #8b949e; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  pre::-webkit-scrollbar-track { background: #0d1117; }
18
  pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
19
  pre::-webkit-scrollbar-thumb:hover { background: #8b949e; }
20
+
21
+ /* ── Electron macOS: native titlebar integration ────────────────── */
22
+ /* .electron-mac is added to <html> by electron/main.ts at runtime */
23
+
24
+ /* Header becomes a draggable titlebar region */
25
+ .electron-mac header {
26
+ -webkit-app-region: drag;
27
+ }
28
+
29
+ /* Interactive elements inside header must remain clickable */
30
+ .electron-mac header button,
31
+ .electron-mac header a,
32
+ .electron-mac header input,
33
+ .electron-mac header select {
34
+ -webkit-app-region: no-drag;
35
+ }
36
+
37
+ /* Extra top padding so header content clears the traffic lights vertically */
38
+ .electron-mac header > div {
39
+ padding-top: 6px;
40
+ }
41
+
42
+ /* Left padding so logo/title doesn't overlap the traffic lights (β‰ˆ 72px) */
43
+ .electron-mac header > div > div {
44
+ padding-left: 72px;
45
+ }
web/vite.config.ts CHANGED
@@ -1,8 +1,16 @@
1
  import { defineConfig } from "vite";
2
  import preact from "@preact/preset-vite";
 
3
 
4
  export default defineConfig({
5
  plugins: [preact()],
 
 
 
 
 
 
 
6
  build: {
7
  outDir: "../public",
8
  emptyOutDir: true,
 
1
  import { defineConfig } from "vite";
2
  import preact from "@preact/preset-vite";
3
+ import path from "path";
4
 
5
  export default defineConfig({
6
  plugins: [preact()],
7
+ resolve: {
8
+ alias: {
9
+ // Allow shared/ files outside web/ to resolve preact from web/node_modules
10
+ "preact": path.resolve(__dirname, "node_modules/preact"),
11
+ "preact/hooks": path.resolve(__dirname, "node_modules/preact/hooks"),
12
+ },
13
+ },
14
  build: {
15
  outDir: "../public",
16
  emptyOutDir: true,