diff --git a/build/verify-packaged-app.cjs b/build/verify-packaged-app.cjs new file mode 100644 index 00000000..0d6371a0 --- /dev/null +++ b/build/verify-packaged-app.cjs @@ -0,0 +1,212 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const betterSqliteNativeRelativePath = path.join( + "app.asar.unpacked", + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); + +module.exports = async function verifyPackagedApp(context) { + const platform = context?.electronPlatformName; + const arch = normalizeArch(context?.arch); + const appOutDir = context?.appOutDir; + + if (!platform || !appOutDir) { + throw new Error("Packaged app verification did not receive electron-builder platform context."); + } + + const resourcesDir = findResourcesDir(appOutDir, platform); + assertFile(path.join(resourcesDir, "app.asar"), "Packaged app archive"); + + const nativeModule = path.join(resourcesDir, betterSqliteNativeRelativePath); + assertFile(nativeModule, "better-sqlite3 native module"); + + const nativeInfo = inspectNativeModule(nativeModule); + if (nativeInfo.platform !== platform) { + throw new Error( + `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` + + `but electron-builder is packaging ${platform}/${arch}. Rebuild native dependencies for the target platform before packaging.` + ); + } + + if (arch && !nativeArchMatches(nativeInfo, arch)) { + throw new Error( + `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` + + `but electron-builder is packaging ${platform}/${arch}. Run npm run rebuild:sqlite3 on the target platform before packaging.` + ); + } +}; + +function findResourcesDir(appOutDir, platform) { + if (platform !== "darwin") { + return path.join(appOutDir, "resources"); + } + + const appBundle = fs.readdirSync(appOutDir) + .find((entry) => entry.endsWith(".app") && fs.statSync(path.join(appOutDir, entry)).isDirectory()); + if (!appBundle) { + throw new Error(`Could not find a .app bundle in ${appOutDir}`); + } + return path.join(appOutDir, appBundle, "Contents", "Resources"); +} + +function assertFile(file, label) { + let stat; + try { + stat = fs.statSync(file); + } catch { + throw new Error(`${label} is missing: ${file}`); + } + + if (!stat.isFile() || stat.size === 0) { + throw new Error(`${label} is empty or not a file: ${file}`); + } +} + +function inspectNativeModule(file) { + const buffer = fs.readFileSync(file); + if (buffer.length < 32) { + throw new Error(`Native module is too small to identify: ${file}`); + } + + if (buffer[0] === 0x4d && buffer[1] === 0x5a) { + return inspectPe(buffer, file); + } + if (buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46) { + return inspectElf(buffer); + } + + const magicLe = buffer.readUInt32LE(0); + const magicBe = buffer.readUInt32BE(0); + if (magicLe === 0xfeedface || magicLe === 0xfeedfacf || magicLe === 0xcefaedfe || magicLe === 0xcffaedfe) { + return inspectMachO(buffer, magicLe); + } + if (magicBe === 0xcafebabe || magicBe === 0xcafebabf) { + return inspectFatMachO(buffer, magicBe); + } + + throw new Error(`Native module has an unknown binary format: ${file}`); +} + +function inspectPe(buffer, file) { + const peOffset = buffer.readUInt32LE(0x3c); + if (peOffset + 6 >= buffer.length || buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") { + throw new Error(`Native module has an invalid PE header: ${file}`); + } + const machine = buffer.readUInt16LE(peOffset + 4); + return { + arch: peMachineArch(machine), + platform: "win32" + }; +} + +function inspectElf(buffer) { + const machine = buffer.readUInt16LE(18); + return { + arch: elfMachineArch(machine), + platform: "linux" + }; +} + +function inspectMachO(buffer, magicLe) { + const bigEndian = magicLe === 0xcefaedfe || magicLe === 0xcffaedfe; + const cpuType = bigEndian ? buffer.readUInt32BE(4) : buffer.readUInt32LE(4); + return { + arch: machCpuArch(cpuType), + platform: "darwin" + }; +} + +function inspectFatMachO(buffer, magicBe) { + const archs = []; + const count = buffer.readUInt32BE(4); + const entrySize = magicBe === 0xcafebabf ? 32 : 20; + for (let index = 0; index < count; index += 1) { + const offset = 8 + index * entrySize; + if (offset + 8 > buffer.length) { + break; + } + archs.push(machCpuArch(buffer.readUInt32BE(offset))); + } + return { + arch: "universal", + archs, + platform: "darwin" + }; +} + +function peMachineArch(machine) { + if (machine === 0x8664) { + return "x64"; + } + if (machine === 0xaa64) { + return "arm64"; + } + if (machine === 0x014c) { + return "ia32"; + } + return `unknown-pe-${machine.toString(16)}`; +} + +function elfMachineArch(machine) { + if (machine === 0x3e) { + return "x64"; + } + if (machine === 0xb7) { + return "arm64"; + } + if (machine === 0x03) { + return "ia32"; + } + if (machine === 0x28) { + return "armv7l"; + } + return `unknown-elf-${machine.toString(16)}`; +} + +function machCpuArch(cpuType) { + if (cpuType === 0x01000007) { + return "x64"; + } + if (cpuType === 0x0100000c) { + return "arm64"; + } + if (cpuType === 0x00000007) { + return "ia32"; + } + return `unknown-macho-${cpuType.toString(16)}`; +} + +function normalizeArch(arch) { + if (typeof arch === "string") { + return arch; + } + const electronBuilderArchNames = new Map([ + [0, "ia32"], + [1, "x64"], + [2, "armv7l"], + [3, "arm64"], + [4, "universal"] + ]); + return electronBuilderArchNames.get(arch); +} + +function nativeArchMatches(info, arch) { + if (info.arch === arch) { + return true; + } + if (info.arch === "universal") { + return arch === "universal" || Boolean(info.archs?.includes(arch)); + } + return false; +} + +function formatNativeInfo(info) { + return info.arch === "universal" && info.archs?.length + ? `${info.platform}/${info.arch} (${info.archs.join(", ")})` + : `${info.platform}/${info.arch}`; +} diff --git a/electron-builder.json b/electron-builder.json index 29181194..b0a4bf03 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -16,6 +16,7 @@ "directories": { "output": "release/${version}" }, + "afterPack": "build/verify-packaged-app.cjs", "afterAllArtifactBuild": "build/verify-update-metadata.cjs", "protocols": [ { diff --git a/src/main/main-app.ts b/src/main/main-app.ts index 3fedf93b..9c1d9caf 100644 --- a/src/main/main-app.ts +++ b/src/main/main-app.ts @@ -36,7 +36,7 @@ function startPrimaryInstance(): void { queueEnsureConfiguredProxyModeActive("second-instance"); }); - app.whenReady().then(() => { + void app.whenReady().then(() => { configureProxyDesktopIntegration(); try { ensureCcrCliLauncher(); @@ -57,6 +57,15 @@ function startPrimaryInstance(): void { } queueEnsureConfiguredProxyModeActive("activate"); }); + }).catch((error) => { + const detail = formatErrorDetail(error); + console.error(`Failed to initialize ${app.name || "application"}: ${detail}`); + try { + dialog.showErrorBox("Claude Code Router failed to start", detail); + } catch { + // Keep the console log as the fallback diagnostic channel. + } + app.exit(1); }); app.on("before-quit", (event) => { @@ -247,3 +256,7 @@ function logProxySystemProxyIssue(reason: string, status: ReturnType { - console.error(error instanceof Error ? error.message : String(error)); - app.quit(); -}); +let fatalStartupErrorReported = false; + +process.once("uncaughtException", reportFatalStartupError); +process.once("unhandledRejection", reportFatalStartupError); + +void import("./main-app.js") + .then(() => { + process.off("uncaughtException", reportFatalStartupError); + process.off("unhandledRejection", reportFatalStartupError); + }) + .catch((error) => { + reportFatalStartupError(error); + }); + +function reportFatalStartupError(error: unknown): void { + if (fatalStartupErrorReported) { + return; + } + fatalStartupErrorReported = true; + + const detail = formatErrorDetail(error); + console.error(detail); + + try { + dialog.showErrorBox("Claude Code Router failed to start", startupErrorMessage(detail)); + } catch { + // If the platform dialog is unavailable, the console output above still + // preserves the actionable failure for command-line launches. + } + + app.exit(1); +} + +function startupErrorMessage(detail: string): string { + if (isBetterSqliteNativeError(detail)) { + return [ + "The bundled SQLite native module could not be loaded.", + "", + "This usually means the Windows package was built with a missing or incompatible better-sqlite3 binary. Rebuild the app with npm run rebuild:sqlite3 before packaging, or build the Windows artifact on a Windows x64 runner.", + "", + detail + ].join("\n"); + } + + return detail; +} + +function isBetterSqliteNativeError(detail: string): boolean { + return /better[-_]sqlite3|better_sqlite3\.node|NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|Cannot find module ['"]better-sqlite3/i.test(detail); +} + +function formatErrorDetail(error: unknown): string { + if (error instanceof Error) { + return error.stack || error.message; + } + return String(error); +} diff --git a/src/renderer/pages/home/components/network-logs.tsx b/src/renderer/pages/home/components/network-logs.tsx index da6e0600..48117dc7 100644 --- a/src/renderer/pages/home/components/network-logs.tsx +++ b/src/renderer/pages/home/components/network-logs.tsx @@ -10,7 +10,7 @@ import { Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogEntry, RequestLogListFilter, RequestLogPage, requestLogPageSizeOptions, RequestLogStatusFilter, requestLogStatusOptions, Search, Select, - translateOptions, Trash2, useAppText, useCallback, useEffect, useMemo, useRef, + translateOptions, Trash2, useAppNumberLocale, useAppText, useCallback, useEffect, useMemo, useRef, useState } from "../shared"; type NetworkRequestTab = "body" | "header" | "query" | "raw" | "summary"; @@ -489,8 +489,9 @@ const LogRow = memo(function LogRow({ onToggle: (id: number) => void; }) { const t = useAppText(); + const numberLocale = useAppNumberLocale(); const createdAt = useMemo(() => formatLogDateTime(item.createdAt), [item.createdAt]); - const tokenSummary = useMemo(() => formatLogTokenSummary(item, t), [item, t]); + const tokenSummary = useMemo(() => formatLogTokenSummary(item, t, numberLocale), [item, numberLocale, t]); return (
@@ -542,6 +543,7 @@ function LogExpandedDetails({ entry: RequestLogEntry; }) { const t = useAppText(); + const numberLocale = useAppNumberLocale(); const hasCredentialInfo = logHasCredentialInfo(entry); return ( @@ -564,12 +566,12 @@ function LogExpandedDetails({ {entry.credentialChain.length ? ")} /> : null} {hasCredentialInfo ? : null} {entry.retryAttempts.length > 0 ? : null} - - - - - - + + + + + +
{entry.retryAttempts.length > 0 ? : null} diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index bedf620f..7de0745e 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -1563,6 +1563,11 @@ export function useAppText() { return useMemo(() => (value: string) => translateText(copy, value), [copy]); } +export function useAppNumberLocale(): Intl.LocalesArgument { + const copy = useContext(AppI18nContext); + return copy === appCopy.zh ? "zh-CN" : "en-US"; +} + export function translateText(copy: AppCopy, value: string): string { return copy.text[value] ?? value; } diff --git a/src/renderer/pages/home/shared/logs.ts b/src/renderer/pages/home/shared/logs.ts index c9b603b6..9066f1d7 100644 --- a/src/renderer/pages/home/shared/logs.ts +++ b/src/renderer/pages/home/shared/logs.ts @@ -393,7 +393,7 @@ export function formatLogDateTime(value: string): string { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${offset}`; } -export function formatLogTokenSummary(entry: RequestLogEntry, t: (value: string) => string): string { +export function formatLogTokenSummary(entry: RequestLogEntry, t: (value: string) => string, locale?: Intl.LocalesArgument): string { if ( entry.totalTokens === 0 && entry.inputTokens === 0 && @@ -405,18 +405,18 @@ export function formatLogTokenSummary(entry: RequestLogEntry, t: (value: string) return "-"; } const values = [ - `${formatCompactNumber(entry.inputTokens)} ${t("入")}`, - `${formatCompactNumber(entry.outputTokens)} ${t("出")}` + `${formatCompactNumber(entry.inputTokens, locale)} ${t("入")}`, + `${formatCompactNumber(entry.outputTokens, locale)} ${t("出")}` ]; if (entry.cacheReadTokens > 0) { - values.push(`${formatCompactNumber(entry.cacheReadTokens)} ${t("Cache")}`); + values.push(`${formatCompactNumber(entry.cacheReadTokens, locale)} ${t("Cache")}`); } if (entry.cacheWriteTokens > 0) { - values.push(`${formatCompactNumber(entry.cacheWriteTokens)} ${t("Cache write")}`); + values.push(`${formatCompactNumber(entry.cacheWriteTokens, locale)} ${t("Cache write")}`); } if (entry.reasoningTokens > 0) { - values.push(`${formatCompactNumber(entry.reasoningTokens)} ${t("Thinking")}`); + values.push(`${formatCompactNumber(entry.reasoningTokens, locale)} ${t("Thinking")}`); } return values.join(" "); diff --git a/src/renderer/pages/home/shared/usage.ts b/src/renderer/pages/home/shared/usage.ts index dd011291..76cc6c65 100644 --- a/src/renderer/pages/home/shared/usage.ts +++ b/src/renderer/pages/home/shared/usage.ts @@ -137,8 +137,8 @@ export function emptyUsageTotals(): UsageTotals { }; } -export function formatCompactNumber(value: number): string { - return new Intl.NumberFormat(undefined, { +export function formatCompactNumber(value: number, locale?: Intl.LocalesArgument): string { + return new Intl.NumberFormat(locale, { maximumFractionDigits: value >= 1000 ? 1 : 0, notation: value >= 10000 ? "compact" : "standard" }).format(value); diff --git a/src/renderer/pages/tray/components/account-panel.tsx b/src/renderer/pages/tray/components/account-panel.tsx index 75b17f41..10ad8f69 100644 --- a/src/renderer/pages/tray/components/account-panel.tsx +++ b/src/renderer/pages/tray/components/account-panel.tsx @@ -1,5 +1,5 @@ import { - accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotLabel, accountStatusClass, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue, + accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotLabel, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue, LoaderCircle, meterProgress, meterRemainingRatio, ProviderAccountMeter, ProviderAccountSnapshot, RefreshCw, TrayComponentVariants, useTrayText } from "../shared"; @@ -38,7 +38,7 @@ export function AccountSummaryPanel({

{accountSnapshotLabel(snapshot)}