by codex: feat. General Improvements
This commit is contained in:
+76
-2
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user