diff --git a/.changeset/stable-session-reconnect.md b/.changeset/stable-session-reconnect.md new file mode 100644 index 0000000..3070854 --- /dev/null +++ b/.changeset/stable-session-reconnect.md @@ -0,0 +1,10 @@ +--- +"crosscode": patch +--- + +Stabilize the CLI session identity and tunnel URLs so a returning mobile client (e.g. after the PC sleeps or the network drops) can always reconnect instead of being stranded by a freshly generated QR/URL. + +- Persist `sessionToken` (opencode password + QR token) and `projectId` in `~/.crosscode/config.json` so they stay constant across CLI restarts. This makes the managed tunnel URL (`connect.crosscode.site/t/`) and the QR token stable. +- For free, unauthenticated users, use a persistent **named cloudflared tunnel** (`crosscode-`) which keeps a fixed `*.cfargotunnel.com` hostname across restarts and reconnects. Falls back to the ephemeral quick tunnel (URL still changes on restart) when `cloudflared tunnel login` hasn't been run. +- Print the QR code exactly once per identity; tunnel reconnects no longer regenerate it. +- Auto-restart a dropped named cloudflared tunnel without changing its URL. diff --git a/README.md b/README.md index 49e719d..636348f 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,249 @@ # CrossCode -Control your PC's [OpenCode](https://opencode.ai) — the terminal AI coding agent — from your phone, from anywhere in the world. Free, open source, and private: your code never touches CrossCode's servers, because there are none. +Control your PC's [OpenCode](https://opencode.ai) (the terminal AI coding agent) from your phone, from anywhere in the world. Free, open source, and private. Your code never touches CrossCode's servers, because there are none. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![npm](https://img.shields.io/npm/v/crosscode)](https://www.npmjs.com/package/crosscode) [![Node.js >=20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) -## Quick Start +## What is CrossCode? -### 1. On your PC +CrossCode streams a live OpenCode session from your computer to your phone over a secure tunnel. You get real-time chat, streaming responses, inline diffs, tool-call approvals, a Git panel, voice input, push notifications, and more. Your code is never uploaded to a cloud. -Open a terminal in your project directory and run: +There are two ways to connect: + +| Mode | Tunnel | Account needed? | URL | +|---|---|---|---| +| Free | Cloudflare or ngrok | No | Random, changes each session | +| Dedicated | CrossCode Tunnel | Yes (free or paid login) | Stable `*.connect.crosscode.site` | + +We recommend logging in. It gives you a stable, dedicated URL so you don't have to re-scan a new QR code every time you restart. + +## Prerequisites + +- A computer (Windows, macOS, or Linux) with [OpenCode](https://opencode.ai) installed +- Node.js 20 or newer (only needed for the CLI wrapper; OpenCode itself is a standalone binary) +- A phone with the CrossCode app ([Android](https://crosscode.site/download); iOS coming soon) +- For free tunnels: `cloudflared` (the CLI will guide you if it's missing) or `ngrok` + +--- + +## Install OpenCode (by operating system) + +CrossCode needs a running `opencode serve` on your machine. Install OpenCode first. + +### macOS + +```bash +# Recommended (always up to date) +brew install anomalyco/tap/opencode + +# Or the official install script +curl -fsSL https://opencode.ai/install | bash +``` + +### Linux + +```bash +# Debian/Ubuntu or any distro with Homebrew +brew install anomalyco/tap/opencode + +# Arch (stable) +sudo pacman -S opencode + +# Arch (latest from AUR) +paru -S opencode-bin + +# Or the official install script (any distro) +curl -fsSL https://opencode.ai/install | bash +``` + +### Windows + +Windows has two good options. + +**Option A - WSL2 (recommended for the best experience)** + +```powershell +# 1. Install WSL2, then open your WSL terminal and run: +curl -fsSL https://opencode.ai/install | bash +``` + +**Option B - Native Windows** + +```powershell +# Chocolatey +choco install opencode + +# or Scoop +scoop install opencode + +# or npm +npm install -g opencode-ai +``` + +If you use WSL, run CrossCode inside WSL too (see the Windows workflow below). Windows and WSL have separate PATHs and home directories. + +Verify the install on any OS: + +```bash +opencode --version +``` + +--- + +## Install the CrossCode CLI (by operating system) + +The CLI (`crosscode`) does all the setup for you. It starts `opencode serve`, opens a tunnel, and prints a QR code. + +### macOS or Linux + +```bash +# Run instantly (no install needed) +npx crosscode + +# Or install globally +npm install -g crosscode +``` + +### Windows (native) + +```powershell +# Run instantly +npx crosscode + +# Or install globally +npm install -g crosscode +``` + +### Windows (WSL2) + +Run the same commands inside your WSL terminal. Node.js must be installed in WSL: + +```bash +# Install Node.js in WSL if you don't have it +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Then run CrossCode +npx crosscode +``` + +Tip: `npx crosscode` always uses the latest version. If you installed it globally, run `npm update -g crosscode` to upgrade. + +--- + +## Step-by-Step: From Zero to Connected + +Follow these in order. OS-specific install commands are in the sections above. + +### 1. Install OpenCode on your computer +Pick your OS in the Install OpenCode section and run the command. Verify with `opencode --version`. + +### 2. Log in for a dedicated tunnel (recommended) +Logging in gives you a stable, dedicated URL at `*.connect.crosscode.site`. You can reconnect from your phone without re-scanning a fresh QR code every session. + +```bash +npx crosscode login +``` + +1. Your browser opens `https://crosscode.site/login` +2. Log in with your email (one-time code) on the dashboard and copy your API key +3. Paste the API key back into the terminal + +Your credentials are saved to `~/.crosscode/config.json`. Check status anytime with `npx crosscode status`, or clear it with `npx crosscode logout`. + +Skipping login? That's fine. You'll use the free Cloudflare or ngrok tunnel instead. Make sure `cloudflared` or `ngrok` is installed (the CLI will tell you if it's missing and how to install it). Your tunnel URL will change each time you start. + +### 3. Start CrossCode in your project directory +Open a terminal in the folder you want to work in and run: ```bash npx crosscode ``` -This starts `opencode serve`, opens a tunnel, and prints a QR code in your terminal. (Make sure [OpenCode](https://opencode.ai) is installed first.) +This will: +- Start `opencode serve` in the current directory +- Open your tunnel (dedicated if logged in, otherwise Cloudflare or ngrok) +- Print a QR code in your terminal + +Keep this terminal open while you use your phone. + +### 4. Install the mobile app and connect +1. Install CrossCode on your phone ([Android](https://crosscode.site/download); iOS coming soon) +2. Open the app and scan the QR code shown in your terminal +3. You're connected. Start chatting with your agent + +### 5. Stay connected across restarts (optional) +Because the dedicated tunnel gives you a stable URL, you can reopen the app later and reconnect to the same session without scanning again. Just make sure `npx crosscode` is running on your PC. + +That's the whole setup. Your code never leaves your machines. CrossCode only relays the live session traffic. + +--- + +## Tunnel Options + +| Condition | Tunnel Used | +|---|---| +| Logged in (any tier) | CrossCode Tunnel (`*.connect.crosscode.site`) | +| `--ngrok` flag | ngrok | +| `--cloudflared` flag | cloudflared | +| Not logged in, no flag | cloudflared (fallback) | +| CrossCode tunnel fails | cloudflared (automatic fallback) | + +```bash +npx crosscode # auto-selected tunnel (dedicated if logged in) +npx crosscode --cloudflared # force Cloudflare tunnel +npx crosscode --ngrok # force ngrok tunnel +``` + +If you use `--ngrok`, set your auth token first: `ngrok config add-authtoken `. + +### Installing cloudflared (free tier) + +The free tunnel needs `cloudflared`. If it's missing, the CLI will detect it and guide you. You can also install it ahead of time: + +```bash +# macOS or Linux (Homebrew) +brew install cloudflared + +# Debian/Ubuntu +curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o /tmp/cloudflared.deb +sudo dpkg -i /tmp/cloudflared.deb + +# Windows (native) +winget install cloudflare.cloudflared +# or: choco install cloudflared +``` + +--- + +## Interactive Controls -### 2. On your phone +While the tunnel is running: -1. Install the CrossCode app — [Android](https://crosscode.site/download) (iOS coming soon) -2. Open the app and scan the QR code -3. Start chatting with your agent +| Key | Action | +|---|---| +| `l` | Toggle log viewer (last 20 lines from CrossCode, tunnel, and OpenCode) | +| `Ctrl+C` | Graceful shutdown that kills all child processes and exits | -That's it. No account, no configuration, no shared network. +--- ## How It Works -Your phone connects to the OpenCode instance running on your machine through a secure tunnel, and streams everything back in real time — prompts, streaming responses, tool-call approvals, and file diffs. +Your phone connects to the OpenCode instance running on your machine through a secure tunnel. It streams everything back in real time: prompts, streaming responses, tool-call approvals, and file diffs. Your source code stays on your computer. The relay only forwards the encrypted session stream. ## Documentation -- [CLI Reference](docs/cli.md) — all commands, flags, and configuration -- [Development](docs/development.md) — set up and build the project locally -- [Features](docs/features.md) — what CrossCode can do -- [Tunnel Server](docs/tunnel-server/README.md) — self-hosted relay for paid users +- [CLI Reference](docs/cli.md) - all commands, flags, tunnel options, config, and environment variables +- [Development](docs/development.md) - set up and build the project locally +- [Features](docs/features.md) - what CrossCode can do +- [Tunnel Server](docs/tunnel-server/README.md) - self-hosted relay for paid users ## Contributing -Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -MIT — see [LICENSE](LICENSE). \ No newline at end of file +MIT. See [LICENSE](LICENSE). diff --git a/apps/mobile/CHANGELOG.md b/apps/mobile/CHANGELOG.md index 378aabe..704d497 100644 --- a/apps/mobile/CHANGELOG.md +++ b/apps/mobile/CHANGELOG.md @@ -1,3 +1,9 @@ # @crosscode/mobile +## Unreleased + +### Added +- Android home screen widgets (Small / Medium / Large) showing connection status, active session, per-project usage, and a 7-day output-token bar chart. Powered by `react-native-android-widget`. +- Daily usage history tracking (`dailyHistory`) in `opencode-stats.store` to support widget charts. + ## 1.0.0 diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index dafc66b..f8b3ae0 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -10,6 +10,43 @@ module.exports = ({ config }) => { "expo-image", "expo-router", "expo-status-bar", + [ + "react-native-android-widget", + { + widgets: [ + { + name: "SmallWidget", + label: "CrossCode Status", + description: "Connection status and active session", + minWidth: "110dp", + minHeight: "40dp", + targetCellWidth: 2, + targetCellHeight: 1, + resizeMode: "horizontal", + }, + { + name: "MediumWidget", + label: "CrossCode Session", + description: "Session status, today's usage and 7-day chart", + minWidth: "250dp", + minHeight: "110dp", + targetCellWidth: 4, + targetCellHeight: 2, + resizeMode: "horizontal|vertical", + }, + { + name: "LargeWidget", + label: "CrossCode Projects", + description: "Per-project usage with mini charts", + minWidth: "250dp", + minHeight: "180dp", + targetCellWidth: 4, + targetCellHeight: 3, + resizeMode: "horizontal|vertical", + }, + ], + }, + ], [ "expo-build-properties", { diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 77446ff..fa29dc6 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -382,11 +382,13 @@ export default function HomeScreen() { - + router.push("/about")} hitSlop={8} className="active:opacity-60"> + + router.push("/notifications")} className="p-2"> diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 644b7ed..621083c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -19,10 +19,17 @@ import * as SplashScreen from "expo-splash-screen" import { NAV_THEME } from "@/lib/theme" import { useNotificationRouting } from "@/lib/notifications" +import { registerCrossCodeWidgets, refreshWidgets } from "@/lib/widget-task" import { useSettings } from "@/store/settings.store" +import { useSessions } from "@/store/sessions.store" +import { useConnections } from "@/store/connection.store" +import { useOpencodeStats } from "@/store/opencode-stats.store" +import { useQuestions } from "@/store/questions.store" SplashScreen.preventAutoHideAsync() +registerCrossCodeWidgets() + export default function RootLayout() { const router = useRouter() const segments = useSegments() @@ -49,6 +56,28 @@ export default function RootLayout() { } }, [fontsLoaded, hasCompletedOnboarding, router, segments, settingsHydrated]) + React.useEffect(() => { + let timer: ReturnType | undefined + const schedule = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + refreshWidgets() + }, 800) + } + const unsubSessions = useSessions.subscribe(schedule) + const unsubConnections = useConnections.subscribe(schedule) + const unsubStats = useOpencodeStats.subscribe(schedule) + const unsubQuestions = useQuestions.subscribe(schedule) + refreshWidgets() + return () => { + if (timer) clearTimeout(timer) + unsubSessions() + unsubConnections() + unsubStats() + unsubQuestions() + } + }, []) + React.useEffect(() => { if (fontsLoaded && settingsHydrated) { SplashScreen.hideAsync() diff --git a/apps/mobile/app/about.tsx b/apps/mobile/app/about.tsx new file mode 100644 index 0000000..e7ceb01 --- /dev/null +++ b/apps/mobile/app/about.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import { Image, Linking, Pressable, ScrollView, View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +import { Text } from "@/components/ui/text" +import { useColorScheme } from "nativewind" +import { useRouter } from "expo-router" +import { THEME } from "@/lib/theme" + +import ArrowLeft from "lucide-react-native/dist/esm/icons/arrow-left" +import Coffee from "lucide-react-native/dist/esm/icons/coffee" +import Github from "lucide-react-native/dist/esm/icons/github" +import Instagram from "lucide-react-native/dist/esm/icons/instagram" +import Twitter from "lucide-react-native/dist/esm/icons/twitter" +import MessageCircle from "lucide-react-native/dist/esm/icons/message-circle" + +const SUPPORT_URL = "https://buymeacoffee.com/snhsish" +const SOURCE_URL = "https://github.com/snhsish/crosscode" +const CREDIT_URL = "https://sish.work" + +const SOCIAL_LINKS: { key: string; label: string; url: string; Icon: React.ComponentType<{ size: number; color: string }> }[] = [ + { key: "instagram", label: "Instagram", url: "https://instagram.com/snehasish", Icon: Instagram }, + { key: "github", label: "GitHub", url: "https://github.com/snhsish", Icon: Github }, + { key: "x", label: "X", url: "https://x.com/snhsish", Icon: Twitter }, + { key: "discord", label: "Discord", url: "https://discord.gg/K6k6ebkJkx", Icon: MessageCircle }, +] + +export default function AboutScreen() { + const insets = useSafeAreaInsets() + const router = useRouter() + const theme = useColorScheme().colorScheme ?? "light" + const openLink = React.useCallback((url: string) => Linking.openURL(url), []) + + return ( + + + router.back()} + className="p-2 -ml-2 active:opacity-60" + hitSlop={12} + > + + + + + + + + + CrossCode + + An Opencode remote mobile client + + + + + + openLink(SUPPORT_URL)} + className="flex-row items-center gap-4 rounded-xl bg-muted/50 p-4 active:bg-muted/70" + > + + + + + Support project + buymeacoffee.com/snhsish + + + + openLink(SOURCE_URL)} + className="flex-row items-center gap-4 rounded-xl bg-muted/50 p-4 active:bg-muted/70" + > + + + + + GitHub Source + github.com/snhsish/crosscode + + + + + + + Social + + + {SOCIAL_LINKS.map(({ key, label, url, Icon }) => ( + openLink(url)} + className="flex-1 items-center gap-2 rounded-xl bg-muted/50 py-4 active:bg-muted/70" + hitSlop={8} + > + + + + {label} + + ))} + + + + + + Made by{" "} + openLink(CREDIT_URL)} + > + @snhsish + + + + + + ) +} diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index b19ad15..fd03426 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -714,24 +714,55 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const keyExtractor = useCallback((item: Message) => item.id, []) - const workingStartAt = useMemo(() => { - if (!isStreaming) return null - const ordered = [...rawMessages].reverse() - const streamingMsg = ordered.find((m) => m.role === "assistant" && !m.time?.completed) - if (streamingMsg) return streamingMsg.time.created - const lastUser = [...ordered].reverse().find((m) => m.role === "user") - return lastUser?.time?.created ?? null - }, [isStreaming, rawMessages]) + // Compute the current response "turn": all messages after the last user message. + // The group start is anchored to the first assistant message of the turn so the + // timer reflects the entire response rather than resetting per message part. + const turn = useMemo(() => { + const sorted = [...rawMessages].sort( + (a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0) + ) + let lastUserIdx = -1 + for (let i = sorted.length - 1; i >= 0; i--) { + if (sorted[i].role === "user") { + lastUserIdx = i + break + } + } + if (lastUserIdx === -1) return null + + const after = sorted.slice(lastUserIdx + 1) + const assistantMsgs = after.filter((m) => m.role === "assistant") + + if (assistantMsgs.length === 0) { + const startAt = sorted[lastUserIdx].time?.created ?? null + return startAt == null ? null : { startAt, endAt: null as number | null } + } + + const startAt = assistantMsgs[0].time?.created ?? null + if (startAt == null) return null + + let endAt: number | null = null + if (!isStreaming) { + let max = 0 + for (const m of after) { + const t = m.time?.completed ?? m.time?.created ?? 0 + if (t > max) max = t + } + endAt = max || null + } + + return { startAt, endAt } + }, [rawMessages, isStreaming]) // The list is inverted, so the header renders visually below the newest message const StreamingIndicator = useMemo(() => { - if (!isStreaming || workingStartAt == null) return null + if (!turn || turn.startAt == null) return null return ( - + ) - }, [isStreaming, workingStartAt]) + }, [turn]) const ListFooterComponent = useMemo(() => { if (!isLoadingMore) return null @@ -860,9 +891,9 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi )} - {isStreaming && messages.length === 0 && workingStartAt != null && ( + {turn && turn.startAt != null && messages.length === 0 && ( - + )} diff --git a/apps/mobile/components/typing-animation.tsx b/apps/mobile/components/typing-animation.tsx index 6d441c9..fbc3c00 100644 --- a/apps/mobile/components/typing-animation.tsx +++ b/apps/mobile/components/typing-animation.tsx @@ -47,22 +47,25 @@ function formatElapsedTime(totalSeconds: number): string { return `${minutes}m ${seconds}s` } -export function WorkingIndicator({ startedAt }: { startedAt: number }) { +export function WorkingIndicator({ startedAt, endedAt }: { startedAt: number; endedAt?: number | null }) { const [now, setNow] = useState(() => Date.now()) + const done = endedAt != null + const active = done ? endedAt! : now useEffect(() => { + if (done) return setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) - }, [startedAt]) + }, [done, startedAt]) - const totalSeconds = Math.max(0, Math.floor((now - startedAt) / 1000)) + const totalSeconds = Math.max(0, Math.floor((active - startedAt) / 1000)) return ( - + {done ? null : } - Working for {formatElapsedTime(totalSeconds)} + {done ? "Worked for " : "Working for "}{formatElapsedTime(totalSeconds)} ) diff --git a/apps/mobile/lib/widget-data.ts b/apps/mobile/lib/widget-data.ts new file mode 100644 index 0000000..328bb48 --- /dev/null +++ b/apps/mobile/lib/widget-data.ts @@ -0,0 +1,183 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" +import { DAILY_HISTORY_LIMIT, type DailyBucket, todayKey } from "@/store/opencode-stats.store" + +const CONNECTIONS_KEY = "crosscode-connections" +const SESSIONS_KEY = "crosscode-sessions" +const STATS_KEY = "crosscode-opencode-stats" +const QUESTIONS_KEY = "crosscode-questions" + +type Persisted = { state: T } + +interface Connection { + id: string + url: string + name: string + healthy?: boolean | null +} + +interface SessionSummary { + additions: number + deletions: number + files: number +} + +interface Session { + id: string + projectID: string + directory: string + title: string + agent?: string + time: { created: number; updated: number } + summary?: SessionSummary +} + +interface ProjectStats { + projectId: string + projectName: string + responseCount: number + totalInputTokens: number + totalOutputTokens: number + totalCost: number + lastResponseAt: string | null + dailyHistory?: Record +} + +async function readPersisted(key: string): Promise { + try { + const raw = await AsyncStorage.getItem(key) + if (!raw) return undefined + const parsed = JSON.parse(raw) as Persisted + return parsed?.state + } catch { + return undefined + } +} + +function last7Days(): string[] { + const days: string[] = [] + const now = new Date() + for (let i = 6; i >= 0; i--) { + const d = new Date(now) + d.setDate(now.getDate() - i) + days.push(d.toISOString().slice(0, 10)) + } + return days +} + +export interface ProjectWidgetData { + id: string + name: string + responseCount: number + inputTokens: number + outputTokens: number + cost: number + lastResponseAt: string | null + /** Output tokens per day for the trailing 7 days (oldest -> newest). */ + weekOutputTokens: number[] +} + +export interface WidgetData { + connected: boolean + serverName: string + hasSession: boolean + sessionTitle: string + sessionAgent: string + sessionAdditions: number + sessionDeletions: number + sessionFiles: number + sessionUpdatedAt: number | null + pendingQuestions: number + todayInputTokens: number + todayOutputTokens: number + todayCost: number + weekOutputTokens: number[] + projects: ProjectWidgetData[] +} + +export async function buildWidgetData(): Promise { + const [connections, sessions, stats, questions] = await Promise.all([ + readPersisted<{ connections: Connection[]; current: string | null }>(CONNECTIONS_KEY), + readPersisted(SESSIONS_KEY), + readPersisted>(STATS_KEY), + readPersisted<{ questionsBySession: Record }>(QUESTIONS_KEY), + ]) + + const currentId = connections?.current + const current = + connections?.connections.find((c) => c.id === currentId) ?? + connections?.connections[0] + const connected = Boolean(current && current.healthy !== false && current.url) + + const allSessions = sessions ?? [] + const activeSession = allSessions.length + ? [...allSessions].sort((a, b) => b.time.updated - a.time.updated)[0] + : undefined + + const pendingQuestions = questions + ? Object.values(questions.questionsBySession ?? {}).reduce( + (sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), + 0 + ) + : 0 + + const days = last7Days() + const today = todayKey() + const projectsMap = stats ?? {} + const projects: ProjectWidgetData[] = Object.values(projectsMap) + .map((p) => ({ + id: p.projectId, + name: p.projectName, + responseCount: p.responseCount, + inputTokens: p.totalInputTokens, + outputTokens: p.totalOutputTokens, + cost: p.totalCost, + lastResponseAt: p.lastResponseAt, + weekOutputTokens: days.map((d) => p.dailyHistory?.[d]?.outputTokens ?? 0), + })) + .sort((a, b) => (b.lastResponseAt ?? "").localeCompare(a.lastResponseAt ?? "")) + .slice(0, 5) + + const globalHistory: Record = {} + for (const p of Object.values(projectsMap)) { + for (const [day, bucket] of Object.entries(p.dailyHistory ?? {})) { + const agg = globalHistory[day] ?? { + inputTokens: 0, + outputTokens: 0, + cost: 0, + responses: 0, + } + agg.inputTokens += bucket.inputTokens + agg.outputTokens += bucket.outputTokens + agg.cost += bucket.cost + agg.responses += bucket.responses + globalHistory[day] = agg + } + } + + const todayBucket = globalHistory[today] ?? { + inputTokens: 0, + outputTokens: 0, + cost: 0, + responses: 0, + } + + return { + connected: Boolean(connected), + serverName: current?.name ?? "", + hasSession: Boolean(activeSession), + sessionTitle: activeSession?.title ?? "", + sessionAgent: activeSession?.agent ?? "", + sessionAdditions: activeSession?.summary?.additions ?? 0, + sessionDeletions: activeSession?.summary?.deletions ?? 0, + sessionFiles: activeSession?.summary?.files ?? 0, + sessionUpdatedAt: activeSession?.time.updated ?? null, + pendingQuestions, + todayInputTokens: todayBucket.inputTokens, + todayOutputTokens: todayBucket.outputTokens, + todayCost: todayBucket.cost, + weekOutputTokens: days.map((d) => globalHistory[d]?.outputTokens ?? 0), + projects, + } +} + +export const WIDGET_DAILY_HISTORY_LIMIT = DAILY_HISTORY_LIMIT diff --git a/apps/mobile/lib/widget-task.tsx b/apps/mobile/lib/widget-task.tsx new file mode 100644 index 0000000..e2f29d9 --- /dev/null +++ b/apps/mobile/lib/widget-task.tsx @@ -0,0 +1,43 @@ +import { registerWidgetTaskHandler, requestWidgetUpdate } from "react-native-android-widget" +import { buildWidgetData } from "@/lib/widget-data" +import SmallWidget from "@/widgets/SmallWidget" +import MediumWidget from "@/widgets/MediumWidget" +import LargeWidget from "@/widgets/LargeWidget" + +type WidgetData = Awaited> + +const WIDGETS: Record React.ReactElement> = { + SmallWidget, + MediumWidget, + LargeWidget, +} + +let registered = false + +export function registerCrossCodeWidgets() { + if (registered) return + registered = true + + registerWidgetTaskHandler(async ({ widgetInfo, widgetAction, renderWidget }) => { + if (widgetAction === "WIDGET_DELETED") return + + const Component = WIDGETS[widgetInfo.widgetName] + if (!Component) return + + const data = await buildWidgetData() + renderWidget( as never) + }) +} + +export async function refreshWidgets() { + const data = await buildWidgetData() + await Promise.all( + Object.keys(WIDGETS).map((name) => { + const Component = WIDGETS[name] + return requestWidgetUpdate({ + widgetName: name, + renderWidget: () => as never, + }).catch(() => {}) + }) + ) +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a2d5dd9..7839434 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -43,6 +43,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.2", + "react-native-android-widget": "^0.22.1", "react-native-css-interop": "^0.2.6", "react-native-gesture-handler": "^2.32.0", "react-native-marked": "^8.1.0", diff --git a/apps/mobile/store/opencode-stats.store.ts b/apps/mobile/store/opencode-stats.store.ts index 7798c24..e3401e4 100644 --- a/apps/mobile/store/opencode-stats.store.ts +++ b/apps/mobile/store/opencode-stats.store.ts @@ -2,6 +2,15 @@ import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" +export interface DailyBucket { + inputTokens: number + outputTokens: number + cost: number + responses: number +} + +export const DAILY_HISTORY_LIMIT = 30 + export interface ProjectStats { projectId: string projectName: string @@ -11,6 +20,22 @@ export interface ProjectStats { totalCost: number lastResponseAt: string | null firstResponseAt: string | null + /** Per-day usage keyed by YYYY-MM-DD (UTC), capped at DAILY_HISTORY_LIMIT days. */ + dailyHistory: Record +} + +export function todayKey(date = new Date()): string { + return date.toISOString().slice(0, 10) +} + +function pruneHistory(history: Record): Record { + const keys = Object.keys(history).sort() + if (keys.length <= DAILY_HISTORY_LIMIT) return history + const next: Record = {} + for (const key of keys.slice(keys.length - DAILY_HISTORY_LIMIT)) { + next[key] = history[key] + } + return next } type OpencodeStatsStore = { @@ -38,6 +63,23 @@ export const useOpencodeStats = create()( const existing = state.projects[projectId] const now = new Date().toISOString() + const key = todayKey() + const prevBucket = existing?.dailyHistory?.[key] ?? { + inputTokens: 0, + outputTokens: 0, + cost: 0, + responses: 0, + } + const dailyHistory = pruneHistory({ + ...(existing?.dailyHistory ?? {}), + [key]: { + inputTokens: prevBucket.inputTokens + inputTokens, + outputTokens: prevBucket.outputTokens + outputTokens, + cost: prevBucket.cost + cost, + responses: prevBucket.responses + 1, + }, + }) + const updated: ProjectStats = { projectId, projectName, @@ -47,6 +89,7 @@ export const useOpencodeStats = create()( totalCost: (existing?.totalCost ?? 0) + cost, lastResponseAt: now, firstResponseAt: existing?.firstResponseAt ?? now, + dailyHistory, } return { diff --git a/apps/mobile/store/questions.store.ts b/apps/mobile/store/questions.store.ts index 02da087..84047e3 100644 --- a/apps/mobile/store/questions.store.ts +++ b/apps/mobile/store/questions.store.ts @@ -1,4 +1,6 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" export type QuestionOption = { label: string @@ -32,31 +34,39 @@ type QuestionsStore = { clearSessionQuestions: (sessionId: string) => void } -export const useQuestions = create()((set) => ({ - questionsBySession: {}, - - setQuestions: (sessionId, questions) => - set((state) => ({ - questionsBySession: { - ...state.questionsBySession, - [sessionId]: questions, - }, - })), - - removeQuestion: (sessionId, requestId) => - set((state) => ({ - questionsBySession: { - ...state.questionsBySession, - [sessionId]: (state.questionsBySession[sessionId] ?? []).filter( - (q) => q.id !== requestId - ), - }, - })), - - clearSessionQuestions: (sessionId) => - set((state) => { - const next = { ...state.questionsBySession } - delete next[sessionId] - return { questionsBySession: next } +export const useQuestions = create()( + persist( + (set) => ({ + questionsBySession: {}, + + setQuestions: (sessionId, questions) => + set((state) => ({ + questionsBySession: { + ...state.questionsBySession, + [sessionId]: questions, + }, + })), + + removeQuestion: (sessionId, requestId) => + set((state) => ({ + questionsBySession: { + ...state.questionsBySession, + [sessionId]: (state.questionsBySession[sessionId] ?? []).filter( + (q) => q.id !== requestId + ), + }, + })), + + clearSessionQuestions: (sessionId) => + set((state) => { + const next = { ...state.questionsBySession } + delete next[sessionId] + return { questionsBySession: next } + }), }), -})) + { + name: "crosscode-questions", + storage: createJSONStorage(() => AsyncStorage), + } + ) +) diff --git a/apps/mobile/widgets/LargeWidget.tsx b/apps/mobile/widgets/LargeWidget.tsx new file mode 100644 index 0000000..9d5f6f4 --- /dev/null +++ b/apps/mobile/widgets/LargeWidget.tsx @@ -0,0 +1,78 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" +import { BarChart, COLORS, StatusRow, formatCost, formatNumber } from "./shared" +import type { ProjectWidgetData } from "../lib/widget-data" + +function ProjectRow({ project }: { project: ProjectWidgetData }) { + return ( + + + + + + + + + + + ) +} + +export default function LargeWidget(props: WidgetData) { + const projects = props.projects.slice(0, 3) + return ( + + + + + + + + + + + + + + + {projects.length === 0 ? ( + + ) : ( + + {projects.map((p) => ( + + ))} + + )} + + ) +} diff --git a/apps/mobile/widgets/MediumWidget.tsx b/apps/mobile/widgets/MediumWidget.tsx new file mode 100644 index 0000000..e3e8fd4 --- /dev/null +++ b/apps/mobile/widgets/MediumWidget.tsx @@ -0,0 +1,70 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" +import { BarChart, COLORS, StatusRow, formatCost, formatNumber, formatRelativeTime } from "./shared" + +export default function MediumWidget(props: WidgetData) { + return ( + + + + {props.hasSession ? ( + + + + + ) : ( + + )} + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/widgets/SmallWidget.tsx b/apps/mobile/widgets/SmallWidget.tsx new file mode 100644 index 0000000..d734a09 --- /dev/null +++ b/apps/mobile/widgets/SmallWidget.tsx @@ -0,0 +1,39 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" +import { COLORS, StatusRow, formatRelativeTime } from "./shared" + +export default function SmallWidget(props: WidgetData) { + return ( + + + {props.hasSession ? ( + + + + + ) : ( + + )} + + ) +} diff --git a/apps/mobile/widgets/shared.tsx b/apps/mobile/widgets/shared.tsx new file mode 100644 index 0000000..cd7f70f --- /dev/null +++ b/apps/mobile/widgets/shared.tsx @@ -0,0 +1,113 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { ColorProp } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" + +export const COLORS = { + bg: "#0b0b0f", + surface: "#16161d", + border: "#26262f", + text: "#fafafa", + muted: "#a1a1aa", + connected: "#4ade80", + disconnected: "#f87171", + pending: "#fbbf24", + bar: "#7dd3fc", +} as const + +export function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` + return `${Math.round(n)}` +} + +export function formatCost(n: number): string { + if (n <= 0) return "$0.00" + if (n < 1) return `$${n.toFixed(3)}` + return `$${n.toFixed(2)}` +} + +export function formatRelativeTime(ts: number | null): string { + if (!ts) return "" + const diff = Date.now() - ts + const mins = Math.floor(diff / 60000) + if (mins < 1) return "just now" + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +export function BarChart({ + values, + height, + color = COLORS.bar, +}: { + values: number[] + height: number + color?: ColorProp +}) { + const max = Math.max(1, ...values) + return ( + + {values.map((v, i) => { + const barHeight = Math.max(2, Math.round((v / max) * (height - 2))) + return ( + + + + ) + })} + + ) +} + +export function StatusRow({ data }: { data: WidgetData }) { + const label = data.connected + ? data.serverName || "Connected" + : "Not connected" + return ( + + + + {data.pendingQuestions > 0 ? ( + + ) : null} + + ) +} diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index baac483..20d9816 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -13,11 +13,12 @@ import http from "http" import net from "net" import { encodeQrPayload } from "@crosscode/shared" import { onKeypress, cleanupKeypress } from "./keypress" -import { connectTunnel, deriveProjectId } from "./tunnel-client" +import { connectTunnel } from "./tunnel-client" import { handleGitRequest } from "./git-handler" import { waitForOpencodePort } from "./port-detect" const children: import("child_process").ChildProcess[] = [] +let isShuttingDown = false const logDir = join(homedir(), ".crosscode") const configFile = join(logDir, "config.json") const MAX_LOG_SIZE = 1024 * 1024 @@ -118,6 +119,13 @@ type Config = { ngrokToken?: string port?: number tunnelWsUrl?: string + sessionToken?: string + projectId?: string + cloudflaredTunnel?: { + name: string + credentialsPath: string + url: string + } auth?: { email?: string sessionToken?: string @@ -138,6 +146,81 @@ function saveConfig(config: Config) { writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 }) } +// Stable, persistent session identity so the QR/URL stays the same across +// CLI restarts and network blips. Without this the opencode password + QR +// token are randomized every run, making it impossible for a returning +// mobile client to reconnect. +function ensureSessionToken(config: Config): string { + if (!config.sessionToken) { + config.sessionToken = crypto.randomBytes(32).toString("hex") + saveConfig(config) + logCrosscode(`Session token generated (censored: ${censorToken(config.sessionToken)})`) + } else { + debug("session token reused from config") + } + return config.sessionToken +} + +function ensureProjectId(config: Config): string { + if (!config.projectId) { + config.projectId = crypto.randomBytes(4).toString("hex") + saveConfig(config) + logCrosscode(`Project ID generated: ${config.projectId}`) + } else { + debug("project ID reused from config", { projectId: config.projectId }) + } + return config.projectId +} + +const cloudflaredTunnelDir = join(logDir, "cloudflared") +const cfCertPath = join(homedir(), ".cloudflared", "cert.pem") + +// Use a persistent named cloudflared tunnel so the public URL is stable +// (.cfargotunnel.com) across restarts and reconnects, instead of +// the random ephemeral quick tunnel that rotates its URL on every (re)start. +// Returns null when cloudflared isn't logged in yet, falling back to the +// quick tunnel (which still works but changes URL on restart). +async function ensureCloudflaredNamedTunnel(config: Config): Promise<{ name: string; credentialsPath: string; url: string } | null> { + if (!existsSync(cfCertPath)) { + debug("cloudflared not logged in (no cert.pem); falling back to quick tunnel") + return null + } + if (config.cloudflaredTunnel && existsSync(config.cloudflaredTunnel.credentialsPath)) { + return config.cloudflaredTunnel + } + const projectId = ensureProjectId(config) + const name = `crosscode-${projectId}` + const credentialsPath = join(cloudflaredTunnelDir, `${name}.json`) + try { + if (!existsSync(cloudflaredTunnelDir)) mkdirSync(cloudflaredTunnelDir, { recursive: true, mode: 0o700 }) + execFileSync("cloudflared", ["tunnel", "create", "--credentials-file", credentialsPath, name], { stdio: "ignore" }) + } catch (e) { + debug("cloudflared tunnel create failed", { error: (e as Error).message }) + return null + } + let url = "" + try { + const creds = JSON.parse(readFileSync(credentialsPath, "utf-8")) + const id = creds.TunnelID || creds.id + if (id) url = `https://${id}.cfargotunnel.com` + } catch {} + config.cloudflaredTunnel = { name, credentialsPath, url } + saveConfig(config) + logCrosscode(`Cloudflared named tunnel created: ${name} (${url})`) + return config.cloudflaredTunnel +} + +function printTunnelQr(url: string, token: string, opencodePort: number, requestedPort: number) { + const payload = encodeQrPayload({ url, token, v: 1 }) + if (opencodePort !== requestedPort) { + console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) + } + console.log(chalk.cyanBright("\n Scan with CrossCode App:")) + qrcode.generate(payload, { small: true }) + console.log(chalk.grey(`URL: ${url}`)) + console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) +} + const WEB_URL = process.env.CROSSCODE_WEB_URL || "https://crosscode.site" const AUTH_API_URL = process.env.CROSSCODE_AUTH_URL || `${WEB_URL}/api/auth` @@ -431,9 +514,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} if (tunnelProvider === "tunnel") { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - const sessionToken = crypto.randomBytes(32).toString("hex") - logCrosscode(`Session token generated (censored: ${censorToken(sessionToken)})`) - debug("session token generated", { length: sessionToken.length }) + const sessionToken = ensureSessionToken(config) const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { cwd: process.cwd(), @@ -632,7 +713,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Connecting to tunnel server...") - const projectId = deriveProjectId() + const projectId = ensureProjectId(config) logCrosscode(`Project ID: ${projectId}`) debug("connecting to tunnel", { projectId, proxyPort }) @@ -653,24 +734,19 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} config.tunnelWsUrl, (url) => { clearTimeout(tunnelTimeout) + if (tunnelUrl) { + if (tunnelUrl !== url) { + tunnelUrl = url + logCrosscode("Tunnel URL changed: " + tunnelUrl) + console.log(chalk.yellow(`\n Tunnel URL changed: ${tunnelUrl}\n`)) + } + return + } tunnelUrl = url spinner.succeed(chalk.green("Tunnel ready")) logCrosscode("Tunnel ready: " + tunnelUrl) debug("tunnel connected", { tunnelUrl }) - - const payload = encodeQrPayload({ - url: tunnelUrl, - token: sessionToken, - v: 1 - }) - - if (opencodePort !== port) { - console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) - } - console.log(chalk.cyanBright("\n Scan with CrossCode App:")) - qrcode.generate(payload, { small: true }) - console.log(chalk.grey(`URL: ${tunnelUrl}`)) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) }, (err) => { clearTimeout(tunnelTimeout) @@ -701,9 +777,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - const sessionToken = crypto.randomBytes(32).toString("hex") - logCrosscode(`Session token generated (censored: ${censorToken(sessionToken)})`) - debug("session token generated", { length: sessionToken.length }) + const sessionToken = ensureSessionToken(config) const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { cwd: process.cwd(), @@ -765,20 +839,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} spinner.succeed(chalk.green("Tunnel ready")) logCrosscode("ngrok tunnel ready: " + tunnelUrl) debug("ngrok tunnel ready", { tunnelUrl }) - - const payload = encodeQrPayload({ - url: tunnelUrl, - token: sessionToken, - v: 1 - }) - - if (opencodePort !== port) { - console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) - } - console.log(chalk.cyanBright("\n Scan with CrossCode App:")) - qrcode.generate(payload, { small: true }) - console.log(chalk.grey(`URL: ${tunnelUrl}`)) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) } } catch (e) { debug("ngrok API parse error", { error: e instanceof Error ? e.message : String(e) }) @@ -797,9 +858,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} } else if (tunnelProvider === "cloudflared" || tunnelFailed) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - const sessionToken = crypto.randomBytes(32).toString("hex") - logCrosscode(`Session token generated (censored: ${censorToken(sessionToken)})`) - debug("session token generated", { length: sessionToken.length }) + const sessionToken = ensureSessionToken(config) const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { cwd: process.cwd(), @@ -972,64 +1031,81 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} }) }) - proxy.listen(proxyPort, "127.0.0.1", () => { + proxy.listen(proxyPort, "127.0.0.1", async () => { logCrosscode(`SSE proxy started on port ${proxyPort}`) debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") - const cf = spawnCmd("cloudflared", [ - "tunnel", - "--no-autoupdate", - "--config", "/dev/null", - "--url", `http://127.0.0.1:${proxyPort}` - ], { - stdio: ["ignore", "pipe", "pipe"] - }) - - children.push(cf) + const namedTunnel = await ensureCloudflaredNamedTunnel(config) + if (!namedTunnel) { + logCrosscode("Using ephemeral quick tunnel (URL changes on restart). Run `cloudflared tunnel login` once for a stable, persistent URL.") + } - cf.on("spawn", () => { - logCrosscode("cloudflared started (PID: " + cf.pid + ")") - debug("cloudflared spawned", { pid: cf.pid }) - }) - cf.on("error", (err) => { - logCrosscode("cloudflared error: " + err.message) - debug("cloudflared error", { error: err.message }) - }) + function setTunnelUrl(url: string) { + if (tunnelUrl) return + tunnelUrl = url + spinner.succeed(chalk.green("Tunnel ready")) + logCrosscode("Cloudflare tunnel ready: " + tunnelUrl) + debug("cloudflare tunnel ready", { tunnelUrl }) + printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) + } - cf.stdout?.on("data", d => cloudflaredLogStream.write(d)) + function startCloudflared(): import("child_process").ChildProcess { + let cfArgs: string[] + if (namedTunnel) { + const cfgPath = join(cloudflaredTunnelDir, `${namedTunnel.name}.yml`) + writeFileSync(cfgPath, `url: http://127.0.0.1:${proxyPort}\ntunnel: ${namedTunnel.name}\ncredentials-file: ${namedTunnel.credentialsPath}\n`, { mode: 0o600 }) + cfArgs = ["tunnel", "--no-autoupdate", "--config", cfgPath, "run"] + } else { + cfArgs = ["tunnel", "--no-autoupdate", "--config", "/dev/null", "--url", `http://127.0.0.1:${proxyPort}`] + } - cf.stderr?.on("data", (data: Buffer) => { - const text = data.toString() + const cf = spawnCmd("cloudflared", cfArgs, { stdio: ["ignore", "pipe", "pipe"] }) + children.push(cf) - const m = text.match(/https:\/\/[a-zA-Z0-9.-]+\.trycloudflare\.com/) + cf.on("spawn", () => { + logCrosscode("cloudflared started (PID: " + cf.pid + ")") + debug("cloudflared spawned", { pid: cf.pid }) + }) + cf.on("error", (err) => { + logCrosscode("cloudflared error: " + err.message) + debug("cloudflared error", { error: err.message }) + }) - if (m && !tunnelUrl) { - tunnelUrl = m[0] - spinner.succeed(chalk.green("Tunnel ready")) - logCrosscode("Cloudflare tunnel ready: " + tunnelUrl) - debug("cloudflare tunnel ready", { tunnelUrl }) + cf.stdout?.on("data", d => cloudflaredLogStream.write(d)) - const payload = encodeQrPayload({ - url: tunnelUrl, - token: sessionToken, - v: 1 - }) + cf.stderr?.on("data", (data: Buffer) => { + const text = data.toString() + const m = text.match(/https:\/\/[a-zA-Z0-9.-]+\.(trycloudflare|cfargotunnel)\.com/) + if (m && !tunnelUrl) setTunnelUrl(m[0]) + cloudflaredLogStream.write(data) + }) - if (opencodePort !== port) { - console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) + cf.on("exit", (code) => { + logCrosscode("cloudflared exited (code: " + code + ")") + if (isShuttingDown) return + if (namedTunnel) { + logCrosscode("Restarting cloudflared (named tunnel keeps stable URL)") + console.log(chalk.yellow("\n Cloudflare tunnel dropped — reconnecting (URL unchanged)...\n")) + startCloudflared() + } else { + console.log(chalk.red("\n Cloudflare tunnel exited. The session URL may have changed — restart crosscode to reconnect.\n")) } - console.log(chalk.cyanBright("\n Scan with CrossCode App:")) + }) - qrcode.generate(payload, { small: true }) + return cf + } - console.log(chalk.grey(`URL: ${tunnelUrl}`)) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) - } + const cf = startCloudflared() - cloudflaredLogStream.write(data) - }) + if (namedTunnel?.url && !tunnelUrl) { + const urlFallbackTimer = setTimeout(() => { + if (!tunnelUrl) setTunnelUrl(namedTunnel.url) + }, 8000) + cf.on("exit", () => clearTimeout(urlFallbackTimer)) + } }) + }) } @@ -1065,6 +1141,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} } const shutdown = (source?: string) => { + isShuttingDown = true console.log(chalk.yellow("\nShutting down...")) logCrosscode(`Shutting down... (source: ${source || "unknown"})`) debug("shutdown initiated", { source: source || "unknown" }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a2b9ea..f4f04a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: react-native: specifier: 0.86.2 version: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) + react-native-android-widget: + specifier: ^0.22.1 + version: 0.22.1(expo@57.0.11)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) react-native-css-interop: specifier: ^0.2.6 version: 0.2.6(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(tailwindcss@3.4.17) @@ -9635,6 +9638,16 @@ packages: react-is@19.2.8: resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-native-android-widget@0.22.1: + resolution: {integrity: sha512-BBMuqShRrxV23Z09Rn5zNHJaPegCnH3DhhZQgXbhw2GMh45Gez0RbwKTIWHftuW4nLrqWy6esz8XwecUZLiZKA==} + peerDependencies: + expo: '>=54.0.0' + react: '*' + react-native: '*' + peerDependenciesMeta: + expo: + optional: true + react-native-css-interop@0.2.6: resolution: {integrity: sha512-uXad6EWqufupnRjrba2De30tiAx26kLEcwSV3eSLVBwpfYeOnrpDc9k6kHk6SBNbmHHlE5sc9vsfKdVRS9msYg==} engines: {node: '>=18'} @@ -15426,9 +15439,7 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.74.89': optional: true @@ -21719,6 +21730,13 @@ snapshots: react-is@19.2.8: {} + react-native-android-widget@0.22.1(expo@57.0.11)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) + optionalDependencies: + expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react-native-css-interop@0.2.6(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(tailwindcss@3.4.17): dependencies: '@babel/helper-module-imports': 7.29.7