Spaces:
Paused
Paused
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 +3 -0
- shared/hooks/use-accounts.ts +144 -0
- shared/hooks/use-status.ts +41 -0
- shared/i18n/context.tsx +53 -0
- shared/i18n/translations.ts +130 -0
- shared/theme/context.tsx +46 -0
- shared/types.ts +24 -0
- shared/utils/clipboard.ts +23 -0
- shared/utils/format.ts +52 -0
- src/auth/account-pool.ts +22 -13
- src/config.ts +5 -4
- src/index.ts +65 -47
- src/models/model-store.ts +2 -1
- src/paths.ts +56 -0
- src/proxy/cookie-jar.ts +11 -6
- src/routes/web.ts +26 -5
- src/tls/curl-binary.ts +2 -1
- src/tls/libcurl-ffi-transport.ts +3 -2
- src/tls/transport.ts +2 -1
- src/translation/shared-utils.ts +2 -1
- src/update-checker.ts +18 -6
- web/src/App.tsx +4 -4
- web/src/components/AccountCard.tsx +4 -4
- web/src/components/AccountList.tsx +2 -2
- web/src/components/AddAccount.tsx +2 -2
- web/src/components/AnthropicSetup.tsx +1 -1
- web/src/components/ApiConfig.tsx +1 -1
- web/src/components/CodeExamples.tsx +1 -1
- web/src/components/CopyButton.tsx +3 -3
- web/src/components/Footer.tsx +1 -1
- web/src/components/Header.tsx +2 -2
- web/src/index.css +26 -0
- web/vite.config.ts +8 -0
.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 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 488 |
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
| 489 |
const data: AccountsFile = { accounts: [...this.accounts.values()] };
|
| 490 |
-
const tmpFile =
|
| 491 |
writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
|
| 492 |
-
renameSync(tmpFile,
|
| 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 |
-
|
| 501 |
-
|
|
|
|
| 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 |
-
|
| 551 |
-
|
|
|
|
|
|
|
| 552 |
|
| 553 |
-
const raw = readFileSync(
|
| 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(
|
| 593 |
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
| 594 |
const accountsData: AccountsFile = { accounts: [entry] };
|
| 595 |
-
writeFileSync(
|
| 596 |
|
| 597 |
// Rename old file
|
| 598 |
-
renameSync(
|
| 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 ??
|
| 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 ??
|
| 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 ??
|
| 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 ??
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
// Load configuration
|
| 26 |
console.log("[Init] Loading configuration...");
|
| 27 |
-
|
| 28 |
-
|
| 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 |
-
|
| 132 |
-
|
| 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 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 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 ??
|
| 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 |
-
|
|
|
|
|
|
|
| 23 |
|
| 24 |
interface StoredCookie {
|
| 25 |
value: string;
|
|
@@ -209,7 +212,8 @@ export class CookieJar {
|
|
| 209 |
this.persistTimer = null;
|
| 210 |
}
|
| 211 |
try {
|
| 212 |
-
const
|
|
|
|
| 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 =
|
| 224 |
writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
|
| 225 |
-
renameSync(tmpFile,
|
| 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 |
-
|
| 234 |
-
|
|
|
|
| 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 =
|
|
|
|
| 12 |
|
| 13 |
-
// Serve Vite build assets
|
| 14 |
-
app.use("/assets/*", serveStatic({ root:
|
| 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(
|
| 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(
|
| 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(
|
| 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 =
|
| 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(
|
| 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(
|
| 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(
|
| 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 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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(
|
| 170 |
-
writeFileSync(
|
| 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 "../
|
| 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 "../
|
| 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,
|