[skip ci] Initial commit — CodexBar Mobile Expo app

This commit is contained in:
2026-06-24 16:28:28 +02:00
parent 4f32782eaf
commit 0bcd95ee1b
30 changed files with 3562 additions and 141 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Build Android APK
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Android APK
run: eas build --platform android --profile preview --non-interactive --output ./build/codexbar.apk
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: codexbar-android-${{ gitea.sha }}
path: ./build/codexbar.apk
retention-days: 30
+10 -2
View File
@@ -1,7 +1,8 @@
{ {
"expo": { "expo": {
"name": "codexbar.mobile", "name": "codexbar.mobile",
"slug": "codexbar.mobile", "slug": "codexbar-mobile",
"scheme": "codexbar",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
@@ -10,6 +11,7 @@
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"package": "dev.reversed.codexbar",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
@@ -20,6 +22,12 @@
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
} },
"plugins": [
"expo-router",
"expo-status-bar",
"expo-secure-store",
"expo-document-picker"
]
} }
} }
+34
View File
@@ -0,0 +1,34 @@
import { Tabs } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons";
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: "#10a37f",
tabBarStyle: { backgroundColor: "#ffffff" },
headerStyle: { backgroundColor: "#ffffff" },
headerTitleStyle: { fontWeight: "600" },
}}
>
<Tabs.Screen
name="codex"
options={{
title: "Codex",
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="auto-awesome" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="claude"
options={{
title: "Claude",
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="psychology" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
+157
View File
@@ -0,0 +1,157 @@
import {
ScrollView,
View,
Text,
TouchableOpacity,
TextInput,
ActivityIndicator,
RefreshControl,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
import { SectionCard } from "@/components/SectionCard";
import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage";
export default function ClaudeTab() {
const {
sessionKey,
pendingKey,
setPendingKey,
usage,
status,
error,
saveKey,
refresh,
clearKey,
} = useClaudeUsage();
const canSave = pendingKey.trim().startsWith("sk-ant-");
return (
<SafeAreaView className="flex-1 bg-neutral-50 dark:bg-neutral-950" edges={["bottom"]}>
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView
className="flex-1 px-4 pt-4"
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl
refreshing={status === "loading"}
onRefresh={refresh}
tintColor="#d97706"
/>
}
contentContainerStyle={{ paddingBottom: 32 }}
>
{/* Configure */}
<SectionCard title="Configuration">
<ErrorMessage
message={error && (status === "idle" || status === "error") ? error : null}
/>
{sessionKey ? (
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-x-2">
<View className="w-2 h-2 rounded-full bg-green-500" />
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
Configured
</Text>
</View>
<TouchableOpacity
onPress={clearKey}
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
>
<Text className="text-sm text-neutral-600 dark:text-neutral-300">
Clear
</Text>
</TouchableOpacity>
</View>
) : (
<View>
<TextInput
value={pendingKey}
onChangeText={setPendingKey}
placeholder="sk-ant-..."
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-2.5 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-3"
/>
<TouchableOpacity
onPress={saveKey}
disabled={!canSave}
className={`py-3 px-4 rounded-xl ${
canSave ? "bg-amber-500" : "bg-neutral-200 dark:bg-neutral-700"
}`}
>
<Text
className={`text-center font-semibold text-sm ${
canSave ? "text-white" : "text-neutral-400"
}`}
>
Save Session Key
</Text>
</TouchableOpacity>
<Text className="text-xs text-neutral-400 mt-2">
Chrome DevTools Application Cookies claude.ai sessionKey
</Text>
</View>
)}
</SectionCard>
{/* Usage */}
<SectionCard title="Usage">
{status === "idle" && !sessionKey && (
<Text className="text-sm text-neutral-400 text-center py-4">
Enter your Claude session key to see usage
</Text>
)}
{status === "loading" && (
<ActivityIndicator color="#d97706" className="py-4" />
)}
{status === "error" && error && <ErrorMessage message={error} />}
{status === "success" && usage && (
<View>
<UsageStat
label="5-hour window"
percent={usage.five_hour.utilization}
resetAtISO={usage.five_hour.resets_at}
/>
<UsageStat
label="7-day window"
percent={usage.seven_day.utilization}
resetAtISO={usage.seven_day.resets_at}
/>
{(usage.seven_day_sonnet || usage.seven_day_opus) && (
<View className="mt-2 p-3 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
By Model (7-day)
</Text>
{usage.seven_day_sonnet && (
<UsageStat
label="Sonnet"
percent={usage.seven_day_sonnet.utilization}
/>
)}
{usage.seven_day_opus && (
<UsageStat
label="Opus"
percent={usage.seven_day_opus.utilization}
/>
)}
</View>
)}
</View>
)}
</SectionCard>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
+134
View File
@@ -0,0 +1,134 @@
import {
ScrollView,
View,
Text,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useCodexUsage } from "@/hooks/useCodexUsage";
import { SectionCard } from "@/components/SectionCard";
import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage";
import { windowLabel } from "@/lib/timeUtils";
export default function CodexTab() {
const { auth, usage, status, error, importAuthFile, refresh, clearAuth } =
useCodexUsage();
return (
<SafeAreaView className="flex-1 bg-neutral-50 dark:bg-neutral-950" edges={["bottom"]}>
<ScrollView
className="flex-1 px-4 pt-4"
refreshControl={
<RefreshControl
refreshing={status === "loading"}
onRefresh={refresh}
tintColor="#10a37f"
/>
}
contentContainerStyle={{ paddingBottom: 32 }}
>
{/* Configure */}
<SectionCard title="Configuration">
<ErrorMessage message={error && status === "idle" ? error : null} />
{auth ? (
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-x-2">
<View className="w-2 h-2 rounded-full bg-green-500" />
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
Configured
</Text>
{auth.accountId && (
<Text className="text-xs text-neutral-400">
({auth.accountId.slice(0, 8)})
</Text>
)}
</View>
<TouchableOpacity
onPress={clearAuth}
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
>
<Text className="text-sm text-neutral-600 dark:text-neutral-300">
Clear
</Text>
</TouchableOpacity>
</View>
) : (
<View>
<TouchableOpacity
onPress={importAuthFile}
className="flex-row items-center justify-center gap-x-2 py-3 px-4 rounded-xl bg-brand-codex"
>
<Text className="text-white font-semibold text-sm">
Import auth.json
</Text>
</TouchableOpacity>
<Text className="text-xs text-neutral-400 text-center mt-2">
Located at ~/.codex/auth.json
</Text>
</View>
)}
</SectionCard>
{/* Usage */}
<SectionCard title="Usage">
{status === "idle" && !auth && (
<Text className="text-sm text-neutral-400 text-center py-4">
Import your auth.json to see usage
</Text>
)}
{status === "loading" && (
<ActivityIndicator color="#10a37f" className="py-4" />
)}
{status === "error" && error && (
<ErrorMessage message={error} />
)}
{status === "success" && usage && (
<View>
{/* Plan badge */}
<View className="flex-row items-center mb-4">
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
{usage.plan_type}
</Text>
</View>
</View>
<UsageStat
label={`Primary (${windowLabel(usage.rate_limit.primary_window.limit_window_seconds)})`}
percent={usage.rate_limit.primary_window.used_percent}
resetAtSeconds={usage.rate_limit.primary_window.reset_at}
/>
<UsageStat
label={`Secondary (${windowLabel(usage.rate_limit.secondary_window.limit_window_seconds)})`}
percent={usage.rate_limit.secondary_window.used_percent}
resetAtSeconds={usage.rate_limit.secondary_window.reset_at}
/>
{/* Credits */}
<View className="mt-2 p-3 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
Credits
</Text>
{usage.credits.unlimited ? (
<Text className="text-sm font-medium text-green-600 dark:text-green-400">
Unlimited
</Text>
) : usage.credits.has_credits ? (
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
${Number(usage.credits.balance).toFixed(2)} remaining
</Text>
) : (
<Text className="text-sm text-neutral-400">No credits</Text>
)}
</View>
</View>
)}
</SectionCard>
</ScrollView>
</SafeAreaView>
);
}
+13
View File
@@ -0,0 +1,13 @@
import "../global.css";
import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
return (
<SafeAreaProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from "expo-router";
export default function Index() {
return <Redirect href="/(tabs)/codex" />;
}
+9
View File
@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
+29
View File
@@ -0,0 +1,29 @@
import { View, Text } from "react-native";
const ERROR_MESSAGES: Record<string, string> = {
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
INVALID_JSON: "The selected file is not valid JSON.",
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
};
function humanize(code: string): string {
if (code.startsWith("HTTP_ERROR_")) {
return `Unexpected server error (HTTP ${code.replace("HTTP_ERROR_", "")}).`;
}
return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
}
interface ErrorMessageProps {
message: string | null;
}
export function ErrorMessage({ message }: ErrorMessageProps) {
if (!message) return null;
return (
<View className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl px-3 py-2.5 mb-3">
<Text className="text-sm text-red-700 dark:text-red-300">{humanize(message)}</Text>
</View>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { View } from "react-native";
interface ProgressBarProps {
percent: number;
}
function getBarColor(percent: number): string {
if (percent >= 85) return "bg-red-500";
if (percent >= 60) return "bg-yellow-400";
return "bg-green-500";
}
export function ProgressBar({ percent }: ProgressBarProps) {
const clamped = Math.max(0, Math.min(100, percent));
return (
<View className="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-700">
<View
className={`h-2.5 rounded-full ${getBarColor(clamped)}`}
style={{ width: `${clamped}%` }}
/>
</View>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { Text } from "react-native";
import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils";
interface ResetCountdownProps {
resetAtSeconds?: number;
resetAtISO?: string;
}
export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) {
const getLabel = () => {
if (resetAtSeconds !== undefined) return formatResetCountdown(resetAtSeconds);
if (resetAtISO) return formatResetCountdownISO(resetAtISO);
return "";
};
const [label, setLabel] = useState(getLabel);
useEffect(() => {
setLabel(getLabel());
const interval = setInterval(() => setLabel(getLabel()), 60_000);
return () => clearInterval(interval);
}, [resetAtSeconds, resetAtISO]);
if (!label) return null;
return (
<Text className="text-xs text-neutral-500 dark:text-neutral-400">{label}</Text>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { View, Text } from "react-native";
interface SectionCardProps {
title: string;
children: React.ReactNode;
accentClass?: string;
}
export function SectionCard({ title, children, accentClass }: SectionCardProps) {
return (
<View
className={`rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4 mb-4 ${accentClass ?? ""}`}
>
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-500 dark:text-neutral-400 mb-3">
{title}
</Text>
{children}
</View>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { View, Text } from "react-native";
import { ProgressBar } from "./ProgressBar";
import { ResetCountdown } from "./ResetCountdown";
interface UsageStatProps {
label: string;
percent: number;
resetAtSeconds?: number;
resetAtISO?: string;
}
export function UsageStat({ label, percent, resetAtSeconds, resetAtISO }: UsageStatProps) {
return (
<View className="gap-y-1.5 mb-4">
<View className="flex-row justify-between items-center">
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{label}
</Text>
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
{Math.round(percent)}%
</Text>
</View>
<ProgressBar percent={percent} />
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
</View>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 16.0.0"
},
"build": {
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+82
View File
@@ -0,0 +1,82 @@
import { useState, useCallback, useEffect } from "react";
import { fetchClaudeOrgs, fetchClaudeUsage } from "@/lib/api/claudeApi";
import {
loadClaudeSessionKey,
saveClaudeSessionKey,
clearClaudeSessionKey,
} from "@/lib/storage";
import type { ClaudeUsageResponse } from "@/types/claude";
type Status = "idle" | "loading" | "success" | "error";
export function useClaudeUsage() {
const [sessionKey, setSessionKey] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState("");
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadClaudeSessionKey().then((stored) => {
if (stored) setSessionKey(stored);
});
}, []);
const saveKey = useCallback(async () => {
const trimmed = pendingKey.trim();
if (!trimmed.startsWith("sk-ant-")) {
setError("Session key must start with sk-ant-");
return;
}
await saveClaudeSessionKey(trimmed);
setSessionKey(trimmed);
setPendingKey("");
setError(null);
}, [pendingKey]);
const refresh = useCallback(async () => {
if (!sessionKey) return;
setStatus("loading");
setError(null);
try {
const orgs = await fetchClaudeOrgs(sessionKey);
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
const orgUuid = orgs[0].uuid;
const data = await fetchClaudeUsage(sessionKey, orgUuid);
setUsage(data);
setStatus("success");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
setError(msg);
setStatus("error");
if (msg === "TOKEN_EXPIRED") {
await clearClaudeSessionKey();
setSessionKey(null);
}
}
}, [sessionKey]);
useEffect(() => {
if (sessionKey) void refresh();
}, [sessionKey]);
const clearKey = useCallback(async () => {
await clearClaudeSessionKey();
setSessionKey(null);
setUsage(null);
setStatus("idle");
setError(null);
}, []);
return {
sessionKey,
pendingKey,
setPendingKey,
usage,
status,
error,
saveKey,
refresh,
clearKey,
};
}
+65
View File
@@ -0,0 +1,65 @@
import { useState, useCallback, useEffect } from "react";
import { fetchCodexUsage } from "@/lib/api/codexApi";
import { loadCodexAuth, saveCodexAuth, clearCodexAuth } from "@/lib/storage";
import { pickAndReadCodexAuth } from "@/lib/fileReader";
import type { CodexUsageResponse, CodexAuth } from "@/types/codex";
type Status = "idle" | "loading" | "success" | "error";
export function useCodexUsage() {
const [auth, setAuth] = useState<CodexAuth | null>(null);
const [usage, setUsage] = useState<CodexUsageResponse | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadCodexAuth().then((stored) => {
if (stored) setAuth(stored);
});
}, []);
const importAuthFile = useCallback(async () => {
try {
const parsed = await pickAndReadCodexAuth();
await saveCodexAuth(parsed);
setAuth(parsed);
setError(null);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
if (msg !== "PICKER_CANCELLED") setError(msg);
}
}, []);
const refresh = useCallback(async () => {
if (!auth) return;
setStatus("loading");
setError(null);
try {
const data = await fetchCodexUsage(auth);
setUsage(data);
setStatus("success");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
setError(msg);
setStatus("error");
if (msg === "TOKEN_EXPIRED") {
await clearCodexAuth();
setAuth(null);
}
}
}, [auth]);
useEffect(() => {
if (auth) void refresh();
}, [auth]);
const clearAuth = useCallback(async () => {
await clearCodexAuth();
setAuth(null);
setUsage(null);
setStatus("idle");
setError(null);
}, []);
return { auth, usage, status, error, importAuthFile, refresh, clearAuth };
}
+44
View File
@@ -0,0 +1,44 @@
import type { ClaudeOrg, ClaudeUsageResponse } from "@/types/claude";
const BASE_URL = "https://claude.ai/api";
function cookieHeader(sessionKey: string): Record<string, string> {
return {
Cookie: `sessionKey=${sessionKey}`,
"Accept": "application/json",
"Origin": "https://claude.ai",
};
}
export async function fetchClaudeOrgs(sessionKey: string): Promise<ClaudeOrg[]> {
const response = await fetch(`${BASE_URL}/organizations`, {
headers: cookieHeader(sessionKey),
});
if (response.status === 401 || response.status === 403) {
throw new Error("TOKEN_EXPIRED");
}
if (!response.ok) {
throw new Error(`HTTP_ERROR_${response.status}`);
}
return response.json() as Promise<ClaudeOrg[]>;
}
export async function fetchClaudeUsage(
sessionKey: string,
orgUuid: string
): Promise<ClaudeUsageResponse> {
const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, {
headers: cookieHeader(sessionKey),
});
if (response.status === 401 || response.status === 403) {
throw new Error("TOKEN_EXPIRED");
}
if (!response.ok) {
throw new Error(`HTTP_ERROR_${response.status}`);
}
return response.json() as Promise<ClaudeUsageResponse>;
}
+25
View File
@@ -0,0 +1,25 @@
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.accessToken}`,
"Content-Type": "application/json",
};
if (auth.accountId) {
headers["ChatGPT-Account-Id"] = auth.accountId;
}
const response = await fetch(USAGE_URL, { headers });
if (response.status === 401 || response.status === 403) {
throw new Error("TOKEN_EXPIRED");
}
if (!response.ok) {
throw new Error(`HTTP_ERROR_${response.status}`);
}
return response.json() as Promise<CodexUsageResponse>;
}
+34
View File
@@ -0,0 +1,34 @@
import * as DocumentPicker from "expo-document-picker";
import { File } from "expo-file-system/next";
import type { CodexAuth } from "@/types/codex";
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
const result = await DocumentPicker.getDocumentAsync({
type: "application/json",
copyToCacheDirectory: true,
});
if (result.canceled) {
throw new Error("PICKER_CANCELLED");
}
const { uri } = result.assets[0];
const file = new File(uri);
const text = file.text();
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("INVALID_JSON");
}
const accessToken = parsed["accessToken"] as string | undefined;
const accountId = parsed["accountId"] as string | undefined;
if (!accessToken || typeof accessToken !== "string") {
throw new Error("MISSING_ACCESS_TOKEN");
}
return { accessToken, accountId };
}
+38
View File
@@ -0,0 +1,38 @@
import * as SecureStore from "expo-secure-store";
const KEYS = {
CODEX_AUTH: "codexbar_codex_auth",
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
} as const;
export async function saveCodexAuth(auth: {
accessToken: string;
accountId?: string;
}): Promise<void> {
await SecureStore.setItemAsync(KEYS.CODEX_AUTH, JSON.stringify(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 };
}
export async function saveClaudeSessionKey(key: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key);
}
export async function loadClaudeSessionKey(): Promise<string | null> {
return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY);
}
export async function clearCodexAuth(): Promise<void> {
await SecureStore.deleteItemAsync(KEYS.CODEX_AUTH);
}
export async function clearClaudeSessionKey(): Promise<void> {
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY);
}
+25
View File
@@ -0,0 +1,25 @@
export function formatResetCountdown(resetAtSeconds: number): string {
const nowSeconds = Math.floor(Date.now() / 1000);
const diffSeconds = resetAtSeconds - nowSeconds;
if (diffSeconds <= 0) return "resetting now";
if (diffSeconds < 60) return "resets in <1m";
const hours = Math.floor(diffSeconds / 3600);
const minutes = Math.floor((diffSeconds % 3600) / 60);
if (hours > 0 && minutes > 0) return `resets in ${hours}h ${minutes}m`;
if (hours > 0) return `resets in ${hours}h`;
return `resets in ${minutes}m`;
}
export function formatResetCountdownISO(isoString: string): string {
const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000);
return formatResetCountdown(resetAtSeconds);
}
export function windowLabel(limitWindowSeconds: number): string {
if (limitWindowSeconds <= 3600) return "1h window";
if (limitWindowSeconds <= 86400) return "24h window";
return `${Math.round(limitWindowSeconds / 3600)}h window`;
}
+6
View File
@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="nativewind/types" />
declare module "*.css" {
const content: unknown;
export default content;
}
+2599 -135
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -1,15 +1,27 @@
{ {
"name": "codexbar.mobile", "name": "codexbar.mobile",
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "expo-router/entry",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.1.1",
"babel-preset-expo": "~56.0.0",
"expo": "~56.0.12", "expo": "~56.0.12",
"expo-constants": "~56.0.18",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-linking": "~56.0.14",
"expo-router": "~56.2.11",
"expo-secure-store": "~56.0.4",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"nativewind": "^4.2.6",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.85.3" "react-native": "0.85.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"scripts": { "scripts": {
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
"./hooks/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
brand: {
codex: "#10a37f",
claude: "#d97706",
},
},
},
},
plugins: [],
};
+11 -2
View File
@@ -1,6 +1,15 @@
{ {
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"strict": true "strict": true,
} "paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
"nativewind-env.d.ts"
]
} }
+16
View File
@@ -0,0 +1,16 @@
export interface ClaudeOrg {
uuid: string;
name: string;
}
export interface ClaudeWindowUsage {
utilization: number;
resets_at?: string;
}
export interface ClaudeUsageResponse {
five_hour: ClaudeWindowUsage;
seven_day: ClaudeWindowUsage;
seven_day_sonnet?: ClaudeWindowUsage;
seven_day_opus?: ClaudeWindowUsage;
}
+23
View File
@@ -0,0 +1,23 @@
export interface CodexAuth {
accessToken: string;
accountId?: string;
}
export interface UsageWindow {
used_percent: number;
reset_at: number;
limit_window_seconds: number;
}
export interface CodexUsageResponse {
plan_type: string;
rate_limit: {
primary_window: UsageWindow;
secondary_window: UsageWindow;
};
credits: {
has_credits: boolean;
unlimited: boolean;
balance: number;
};
}