Improve app packaging diagnostics and usage formatting

This commit is contained in:
musi 2026-06-30 13:57:06 +08:00
parent 13517cc2a0
commit f6174752b7
10 changed files with 365 additions and 25 deletions

View file

@ -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}`;
}

View file

@ -16,6 +16,7 @@
"directories": {
"output": "release/${version}"
},
"afterPack": "build/verify-packaged-app.cjs",
"afterAllArtifactBuild": "build/verify-update-metadata.cjs",
"protocols": [
{

View file

@ -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<typeof prox
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function formatErrorDetail(error: unknown): string {
return error instanceof Error ? error.stack || error.message : String(error);
}

View file

@ -1,4 +1,4 @@
import { app } from "electron";
import { app, dialog } from "electron";
import { setRuntimeAppPaths } from "./app-paths";
setRuntimeAppPaths({
@ -7,7 +7,60 @@ setRuntimeAppPaths({
userData: app.getPath("userData")
});
void import("./main-app.js").catch((error) => {
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);
}

View file

@ -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 (
<div>
@ -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 ? <LogMetric label={t("Credential chain")} value={entry.credentialChain.join(" > ")} /> : null}
{hasCredentialInfo ? <LogMetric label={t("Credential saturated")} value={entry.credentialSaturated ? t("Yes") : t("No")} /> : null}
{entry.retryAttempts.length > 0 ? <LogMetric label={t("Retry attempts")} value={String(entry.retryAttempts.length)} /> : null}
<LogMetric label={t("输入")} value={formatCompactNumber(entry.inputTokens)} />
<LogMetric label={t("输出")} value={formatCompactNumber(entry.outputTokens)} />
<LogMetric label={t("Thinking")} value={formatCompactNumber(entry.reasoningTokens)} />
<LogMetric label={t("缓存读取")} value={formatCompactNumber(entry.cacheReadTokens)} />
<LogMetric label={t("缓存写入")} value={formatCompactNumber(entry.cacheWriteTokens)} />
<LogMetric label={t("总计")} value={formatCompactNumber(entry.totalTokens)} />
<LogMetric label={t("输入")} value={formatCompactNumber(entry.inputTokens, numberLocale)} />
<LogMetric label={t("输出")} value={formatCompactNumber(entry.outputTokens, numberLocale)} />
<LogMetric label={t("Thinking")} value={formatCompactNumber(entry.reasoningTokens, numberLocale)} />
<LogMetric label={t("缓存读取")} value={formatCompactNumber(entry.cacheReadTokens, numberLocale)} />
<LogMetric label={t("缓存写入")} value={formatCompactNumber(entry.cacheWriteTokens, numberLocale)} />
<LogMetric label={t("总计")} value={formatCompactNumber(entry.totalTokens, numberLocale)} />
<LogMetric label={t("Cost")} value={formatUsdCost(entry.costUsd ?? 0)} />
</div>
{entry.retryAttempts.length > 0 ? <LogRetryAttempts attempts={entry.retryAttempts} /> : null}

View file

@ -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;
}

View file

@ -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(" ");

View file

@ -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);

View file

@ -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({
<h3 className="truncate text-[11px] font-bold text-slate-100">{accountSnapshotLabel(snapshot)}</h3>
<button
aria-label={t("Refresh")}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition disabled:cursor-not-allowed disabled:opacity-50 ${accountStatusButtonClass(snapshot.status)}`}
className={`m-0 inline-flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent p-0 shadow-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${accountStatusButtonClass(snapshot.status)}`}
disabled={refreshing || !onRefresh}
onClick={() => {
void onRefresh?.();
@ -59,7 +59,16 @@ export function AccountSummaryPanel({
}
function accountStatusButtonClass(status: ProviderAccountSnapshot["status"]): string {
return `${accountStatusClass(status)} hover:border-white/20 hover:bg-white/[.08] hover:text-slate-50`;
if (status === "critical" || status === "error") {
return "text-rose-100 hover:text-rose-50";
}
if (status === "warning") {
return "text-amber-100 hover:text-amber-50";
}
if (status === "ok") {
return "text-teal-100 hover:text-teal-50";
}
return "text-slate-200 hover:text-slate-50";
}
function AccountMeters({

View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatLogTokenSummary } from "../../src/renderer/pages/home/shared/logs.ts";
import { formatCompactNumber } from "../../src/renderer/pages/home/shared/usage.ts";
import type { RequestLogEntry } from "../../src/shared/app.ts";
test("formatCompactNumber can be bound to the UI language locale", () => {
assert.equal(formatCompactNumber(123456, "en-US"), "123.5K");
assert.equal(formatCompactNumber(123456, "zh-CN"), "12.3万");
});
test("formatLogTokenSummary uses the provided locale for token counts", () => {
const entry: RequestLogEntry = {
cacheReadTokens: 0,
cacheWriteTokens: 0,
client: "test",
costUsd: 0,
createdAt: "2026-06-30T00:00:00.000Z",
credentialChain: [],
credentialSaturated: false,
durationMs: 10,
id: 1,
inputTokens: 123456,
isStream: false,
method: "POST",
model: "test-model",
ok: true,
outputTokens: 12000,
path: "/v1/messages",
provider: "test-provider",
reasoningTokens: 0,
requestBody: { encoding: "utf8", text: "" },
requestHeaders: {},
requestId: "req_test",
retryAttempts: [],
responseHeaders: {},
statusCode: 200,
totalTokens: 135456,
url: "https://example.test/v1/messages"
};
const translate = (value: string) => value;
assert.equal(formatLogTokenSummary(entry, translate, "en-US"), "123.5K 入 12K 出");
assert.equal(formatLogTokenSummary(entry, translate, "zh-CN"), "12.3万 入 1.2万 出");
});