by codex: feat. General Improvements
CodexBar Mobile Build / build-android (push) Successful in 21m9s
CodexBar Mobile Build / release (push) Successful in 11s

This commit is contained in:
2026-06-25 14:27:58 +02:00
parent 9dd17cf565
commit 2c31be11c4
33 changed files with 1217 additions and 296 deletions
+76 -2
View File
@@ -19,6 +19,78 @@ function buildHeaders(auth: ClaudeAuth): Record<string, string> {
};
}
function isClaudeWindow(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const window = value as Record<string, unknown>;
return (
typeof window.utilization === "number" &&
Number.isFinite(window.utilization) &&
(window.resets_at === undefined ||
window.resets_at === null ||
typeof window.resets_at === "string")
);
}
export function parseClaudeOrgsResponse(data: unknown): ClaudeOrg[] {
if (
!Array.isArray(data) ||
data.some(
(org) =>
!org ||
typeof org !== "object" ||
typeof (org as Record<string, unknown>).uuid !== "string" ||
typeof (org as Record<string, unknown>).name !== "string"
)
) {
throw new Error("INVALID_CLAUDE_ORGS_RESPONSE");
}
return data.map((org) => {
const value = org as Record<string, unknown>;
return { uuid: value.uuid as string, name: value.name as string };
});
}
export function parseClaudeUsageResponse(data: unknown): ClaudeUsageResponse {
if (!data || typeof data !== "object") {
throw new Error("INVALID_CLAUDE_RESPONSE");
}
const value = data as Record<string, unknown>;
if (
!isClaudeWindow(value.five_hour) ||
!isClaudeWindow(value.seven_day) ||
(value.seven_day_sonnet !== undefined &&
value.seven_day_sonnet !== null &&
!isClaudeWindow(value.seven_day_sonnet)) ||
(value.seven_day_opus !== undefined &&
value.seven_day_opus !== null &&
!isClaudeWindow(value.seven_day_opus))
) {
throw new Error("INVALID_CLAUDE_RESPONSE");
}
const toWindow = (window: unknown) => {
const item = window as Record<string, unknown>;
return {
utilization: item.utilization as number,
...(typeof item.resets_at === "string"
? { resets_at: item.resets_at }
: {}),
};
};
return {
five_hour: toWindow(value.five_hour),
seven_day: toWindow(value.seven_day),
...(value.seven_day_sonnet
? { seven_day_sonnet: toWindow(value.seven_day_sonnet) }
: {}),
...(value.seven_day_opus
? { seven_day_opus: toWindow(value.seven_day_opus) }
: {}),
};
}
export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise<ClaudeOrg[]> {
const response = await fetch(`${BASE_URL}/organizations`, {
headers: buildHeaders(auth),
@@ -31,7 +103,8 @@ export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise<ClaudeOrg[]> {
throw new Error(`HTTP_ERROR_${response.status}`);
}
return response.json() as Promise<ClaudeOrg[]>;
const data: unknown = await response.json();
return parseClaudeOrgsResponse(data);
}
export async function fetchClaudeUsage(
@@ -49,5 +122,6 @@ export async function fetchClaudeUsage(
throw new Error(`HTTP_ERROR_${response.status}`);
}
return response.json() as Promise<ClaudeUsageResponse>;
const data: unknown = await response.json();
return parseClaudeUsageResponse(data);
}
+60 -1
View File
@@ -2,6 +2,64 @@ import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
function isNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export function parseCodexUsageResponse(data: unknown): CodexUsageResponse {
if (!data || typeof data !== "object") {
throw new Error("INVALID_CODEX_RESPONSE");
}
const value = data as Record<string, unknown>;
const rateLimit = value.rate_limit as Record<string, unknown> | undefined;
const primary = rateLimit?.primary_window as Record<string, unknown> | undefined;
const secondary = rateLimit?.secondary_window as Record<string, unknown> | undefined;
const credits = value.credits as Record<string, unknown> | undefined;
const validWindow = (window: Record<string, unknown> | undefined) =>
!!window &&
isNumber(window.used_percent) &&
isNumber(window.reset_at) &&
isNumber(window.limit_window_seconds);
if (
typeof value.plan_type !== "string" ||
!validWindow(primary) ||
!validWindow(secondary) ||
!credits ||
typeof credits.has_credits !== "boolean" ||
typeof credits.unlimited !== "boolean" ||
!isNumber(credits.balance)
) {
throw new Error("INVALID_CODEX_RESPONSE");
}
const primaryWindow = primary as Record<string, unknown>;
const secondaryWindow = secondary as Record<string, unknown>;
return {
plan_type: value.plan_type,
rate_limit: {
primary_window: {
used_percent: primaryWindow.used_percent as number,
reset_at: primaryWindow.reset_at as number,
limit_window_seconds: primaryWindow.limit_window_seconds as number,
},
secondary_window: {
used_percent: secondaryWindow.used_percent as number,
reset_at: secondaryWindow.reset_at as number,
limit_window_seconds: secondaryWindow.limit_window_seconds as number,
},
},
credits: {
has_credits: credits.has_credits,
unlimited: credits.unlimited,
balance: credits.balance,
},
};
}
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.accessToken}`,
@@ -22,5 +80,6 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
throw new Error(`HTTP_ERROR_${response.status}`);
}
return response.json() as Promise<CodexUsageResponse>;
const data: unknown = await response.json();
return parseCodexUsageResponse(data);
}
+19
View File
@@ -0,0 +1,19 @@
const CLAUDE_SESSION_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]+$/;
export function validateClaudeSessionKey(value: string): string | null {
const key = value.trim();
if (!key.startsWith("sk-ant-")) {
return "Session key must start with sk-ant-";
}
if (key.length <= 40) {
return "Session key looks too short. Paste the complete cookie value.";
}
if (!CLAUDE_SESSION_KEY_PATTERN.test(key)) {
return "Session key contains unexpected characters. Check the pasted value.";
}
return null;
}
export function isClaudeSessionKeyValid(value: string): boolean {
return validateClaudeSessionKey(value) === null;
}
+23
View File
@@ -0,0 +1,23 @@
export const COLORS = {
codex: "#10a37f",
claude: "#d97706",
danger: "#ef4444",
warning: "#f59e0b",
disabled: "#e5e5e5",
muted: "#a3a3a3",
appBackground: "#090d11",
} as const;
export const PROGRESS_THRESHOLDS = {
warning: 60,
danger: 85,
} as const;
export const COUNTDOWN_INTERVAL_MS = 60_000;
export const RETRY_DELAY_MS = 5_000;
export function getUsageColor(percent: number): string {
if (percent >= PROGRESS_THRESHOLDS.danger) return COLORS.danger;
if (percent >= PROGRESS_THRESHOLDS.warning) return COLORS.warning;
return COLORS.codex;
}
+66 -9
View File
@@ -1,9 +1,13 @@
import * as SecureStore from "expo-secure-store";
import type { ClaudeUsageResponse } from "@/types/claude";
import type { CodexUsageResponse } from "@/types/codex";
const KEYS = {
CODEX_AUTH: "codexbar_codex_auth",
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
CLAUDE_LAST_ACTIVE_ORG: "codexbar_claude_last_active_org",
CODEX_USAGE_CACHE: "codexbar_codex_usage_cache",
CLAUDE_USAGE_CACHE: "codexbar_claude_usage_cache",
NOTIF_DAILY_ENABLED: "notifDailyEnabled",
NOTIF_DAILY_HOUR: "notifDailyHour",
NOTIF_DAILY_MINUTE: "notifDailyMinute",
@@ -12,6 +16,27 @@ const KEYS = {
NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired",
} as const;
export interface UsageCache<T> {
data: T;
lastFetchedAt: number;
}
async function loadJson<T>(key: string): Promise<T | null> {
const raw = await SecureStore.getItemAsync(key);
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
await SecureStore.deleteItemAsync(key);
return null;
}
}
async function saveJson(key: string, value: unknown): Promise<void> {
await SecureStore.setItemAsync(key, JSON.stringify(value));
}
export interface NotifSettings {
dailyEnabled: boolean;
dailyHour: number;
@@ -60,20 +85,46 @@ export async function saveCodexAuth(auth: {
accessToken: string;
accountId?: string;
}): Promise<void> {
await SecureStore.setItemAsync(KEYS.CODEX_AUTH, JSON.stringify(auth));
await saveJson(KEYS.CODEX_AUTH, auth);
}
export async function loadCodexAuth(): Promise<{
accessToken: string;
accountId?: string;
} | null> {
const raw = await SecureStore.getItemAsync(KEYS.CODEX_AUTH);
if (!raw) return null;
return JSON.parse(raw) as { accessToken: string; accountId?: string };
return loadJson(KEYS.CODEX_AUTH);
}
export function loadCodexUsageCache(): Promise<UsageCache<CodexUsageResponse> | null> {
return loadJson(KEYS.CODEX_USAGE_CACHE);
}
export function saveCodexUsageCache(
cache: UsageCache<CodexUsageResponse>
): Promise<void> {
return saveJson(KEYS.CODEX_USAGE_CACHE, cache);
}
export function clearCodexUsageCache(): Promise<void> {
return SecureStore.deleteItemAsync(KEYS.CODEX_USAGE_CACHE);
}
export function loadClaudeUsageCache(): Promise<UsageCache<ClaudeUsageResponse> | null> {
return loadJson(KEYS.CLAUDE_USAGE_CACHE);
}
export function saveClaudeUsageCache(
cache: UsageCache<ClaudeUsageResponse>
): Promise<void> {
return saveJson(KEYS.CLAUDE_USAGE_CACHE, cache);
}
export function clearClaudeUsageCache(): Promise<void> {
return SecureStore.deleteItemAsync(KEYS.CLAUDE_USAGE_CACHE);
}
export async function saveClaudeSessionKey(key: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key);
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key.trim());
}
export async function loadClaudeSessionKey(): Promise<string | null> {
@@ -81,7 +132,7 @@ export async function loadClaudeSessionKey(): Promise<string | null> {
}
export async function saveClaudeLastActiveOrg(orgId: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId);
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId.trim());
}
export async function loadClaudeLastActiveOrg(): Promise<string | null> {
@@ -93,10 +144,16 @@ export async function clearClaudeLastActiveOrg(): Promise<void> {
}
export async function clearCodexAuth(): Promise<void> {
await SecureStore.deleteItemAsync(KEYS.CODEX_AUTH);
await Promise.all([
SecureStore.deleteItemAsync(KEYS.CODEX_AUTH),
clearCodexUsageCache(),
]);
}
export async function clearClaudeSessionKey(): Promise<void> {
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY);
await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
await Promise.all([
SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY),
SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG),
clearClaudeUsageCache(),
]);
}
+31
View File
@@ -19,6 +19,37 @@ export function formatResetCountdown(resetAtSeconds: number): string {
return `resets in ${minutes}m`;
}
export function parseTimeInput(
value: string
): { hour: number; minute: number } | null {
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (hour > 23 || minute > 59) return null;
return { hour, minute };
}
export function formatTime(hour: number, minute: number): string {
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
export function formatLastUpdated(timestamp: number, now = Date.now()): string {
const elapsedSeconds = Math.max(0, Math.floor((now - timestamp) / 1000));
if (elapsedSeconds < 60) return "Updated just now";
const minutes = Math.floor(elapsedSeconds / 60);
if (minutes < 60) return `Updated ${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `Updated ${hours} hr${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
return `Updated ${days} day${days === 1 ? "" : "s"} ago`;
}
export function formatResetCountdownISO(isoString: string): string {
const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000);
return formatResetCountdown(resetAtSeconds);