diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 00000000000..9e7d88666aa --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,71 @@ +name: Mobile Release + +on: + push: + tags: + - 'mobile-v*' + workflow_dispatch: + +jobs: + android-build: + runs-on: ubuntu-latest + + # Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN + # to call `gh release create`, which requires contents:write. Without this, + # the job builds the APK fine but fails at release time with + # "HTTP 403: Resource not accessible by integration". + permissions: + contents: write + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Expo prebuild + run: npx expo prebuild --platform android --no-install + + - name: Build Android release APK + run: cd android && ./gradlew assembleRelease + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: orca-mobile-apk + path: mobile/android/app/build/outputs/apk/release/*.apk + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/mobile-v') + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF#refs/tags/}" + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Orca Mobile $tag" \ + --prerelease \ + --latest=false \ + --generate-notes \ + android/app/build/outputs/apk/release/*.apk diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 00000000000..95fcff00a08 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,43 @@ +name: Mobile Checks + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'mobile/**' + +jobs: + verify: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: npx tsc --noEmit + + - name: Lint + run: pnpm lint + diff --git a/docs/debug/mobile-fit-debug-status.md b/docs/debug/mobile-fit-debug-status.md new file mode 100644 index 00000000000..8bd9518aa1c --- /dev/null +++ b/docs/debug/mobile-fit-debug-status.md @@ -0,0 +1,203 @@ +# Mobile Phone-Fit Debug Status + +## Architecture Overview + +``` +Mobile Client Server (orca-runtime) Desktop Renderer +───────────── ──────────────────── ──────────────── +subscribeToTerminal(handle) → terminal.subscribe handler → IPC: terminalFitOverrideChanged + sends { client, viewport } calls handleMobileSubscribe() setFitOverride() → banner render + resizes PTY, sets override + serializes scrollback + ← scrollback { cols, rows, + serialized, displayMode } + +switchTab(handle) → terminal.unsubscribe (old) + unsub old, subscribe new handleMobileUnsubscribe() + starts 300ms restore timer + → terminal.subscribe (new) + handleMobileSubscribe() + cancels timer, inline-restores old + resizes new PTY + +toggleDisplayMode(handle) → terminal.setDisplayMode + applyMobileDisplayMode() + ← resized event on stream +``` + +## Key Data Structures (Server) + +- `mobileSubscribers: Map` +- `pendingRestoreTimers: Map` (changed from clientId-keyed to ptyId-keyed) +- `terminalFitOverrides: Map` +- `mobileDisplayModes: Map` + +## Key Data Structures (Mobile Client) + +- `viewportRef: { cols, rows } | null` — measured once from xterm, passed with every subscribe +- `viewportMeasuredRef: boolean` — true after first successful measurement +- `terminalUnsubsRef: Map` — active subscription cleanup closures +- `initializedHandlesRef: Set` — tracks which terminals have been init'd (prevents double-init) +- `subscribeSeqRef: Map` — monotonic counter to ignore stale scrollback + +--- + +## Bug 1: pendingRestoreTimers lost when 2 unsubscribes happen back-to-back + +**Status: FIXED (verification inconclusive — may need more testing)** + +**Root cause**: `pendingRestoreTimers` was keyed by `clientId` (one slot per device). When two terminals were unsubscribed in quick succession, the second timer overwrote the first. + +**Fix applied**: +1. Changed `pendingRestoreTimers` from `Map` to `Map` +2. `handleMobileSubscribe` cancels only the restore timer for the SAME ptyId (re-subscribe case). Other terminals' timers fire normally so their desktop banners clear. +3. `handleMobileSubscribe` skips resize if PTY is already at target phone dims +4. `handleCreateTerminal` now unsubscribes the old active terminal before setting the new one +5. Restore happens via: 300ms timer (tab switch), or `onClientDisconnected` (full disconnect) + +**Bug 8 (banners accumulate on tab switch)**: The original Bug 1 fix was too aggressive — it cancelled ALL pending restore timers for the client, not just the one for the ptyId being re-subscribed. This prevented the 300ms restore timer from firing for the old terminal, so its desktop banner persisted. Fixed by narrowing the cancel scope to only the same ptyId. + +**Files**: `orca-runtime.ts`, `[worktreeId].tsx` + +--- + +## Bug 2: Intermittent blank terminal on tab switch + +**Status: FIX v2 APPLIED — needs testing** + +**Symptom**: After creating a new terminal tab, the original 2 terminals occasionally show blank. Leaving the worktree and re-entering fixes it. + +**Root cause**: The WebView loads xterm.js from CDN, which takes time. Messages sent before `web-ready` queue in `pendingMessagesRef` and flush when ready. The original `handleTerminalWebReady` would unsub+resub ALL initialized terminals (even inactive ones), creating stale server-side subscriptions and disrupting the data stream. + +**Fix v1 (FAILED)**: Gated `subscribeToTerminal` on webReady. This was too aggressive — it prevented subscriptions entirely, so no scrollback arrived, no init was queued, and the terminal stayed blank. + +**Fix v2 (current)**: +1. `subscribeToTerminal` has NO webReady guard — subscriptions start immediately, init messages queue in `pendingMessagesRef`, and flush when `web-ready` fires +2. `handleTerminalWebReady` distinguishes first load vs reload: + - **First load** (`wasAlreadyReady=false`): just marks webReady, triggers viewport measurement for active terminal. Pending messages flush after this callback returns → terminal renders. + - **Reload** (`wasAlreadyReady=true`): unsubscribes and resubscribes to get fresh scrollback (old xterm buffer is gone). Only resubscribes if active. +3. `setTerminalWebViewRef` simplified to just store the ref (no subscription logic) + +**Flow (first load)**: +1. `fetchTerminals` → `subscribeToTerminal(active)` → stream starts +2. Scrollback arrives → `init()` queued in pendingMessages (WebView not ready yet) +3. WebView loads xterm from CDN → `web-ready` fires +4. `handleTerminalWebReady`: marks webReady, triggers viewport measurement (async) +5. `flushPendingMessages()`: sends queued init → terminal renders +6. Viewport measured → resubscribe with dims → server phone-fits → reinit with phone dims + +--- + +## Bug 5: Desktop banner not showing for initial split pane after creating new tab + +**Status: FIXED (verified via CDP + e2e testing)** + +**Root cause**: The bug was caused by the inline restore mechanism in `handleMobileSubscribe`. When mobile switched tabs, the server would restore the previous terminal to desktop dims (sending `desktop-fit` IPC), which cleared the override from `overridesByPtyId`. After the next subscribe, the override was re-set via `mobile-fit` IPC, but the timing was tight and the banner could flicker or fail to appear. + +**Fix**: Removed inline restore from `handleMobileSubscribe` (Bug 1 fix). PTYs now stay at phone dims when the mobile client switches tabs. Overrides persist in `overridesByPtyId` until the mobile client disconnects. This means ALL terminals the mobile client has subscribed to show the banner — which is correct since the mobile client "owns" those terminals. + +**Verification**: E2e testing via CDP (desktop renderer) and agent-device (mobile) confirmed: +- `setFitOverride()` is called correctly via IPC +- `onOverrideChange` fires and triggers re-renders +- `getFitOverrideForPane()` finds overrides when `ptyIdByPaneId` bindings exist +- Banners show correctly for all terminals (1, 2, and 3 tabs) including after creating new tabs +- The earlier diagnostic showed HMR clearing module-level maps was a test artifact, not a production issue + +--- + +## Bug 7: Terminals disappear ("0 terminals") during rapid tab switching + +**Status: FIXED (verified via e2e testing)** + +**Symptom**: During rapid tab switching (3+ toggles in quick succession), all terminals disappear from the mobile UI. The terminal list shows "0 terminals". Leaving the worktree and re-entering fixes it. + +**Root cause**: The periodic `fetchTerminals()` (every 2s) calls with `allowEmptyLoaded: true`. The server can transiently return an empty terminal list during rapid operations. The old code's subscription cleanup loop (`liveHandles` check) ran before the empty guard, unsubscribing all terminals, which then made the empty guard's `terminalUnsubsRef.current.size > 0` check fail. + +**Fix applied**: +1. Added `lastKnownTerminalCountRef` to track the previous non-zero terminal count +2. When the server returns 0 terminals but `lastKnownTerminalCountRef > 0`, skip the FIRST empty response (set ref to 0 and return early) +3. On the NEXT fetch, if still empty, `lastKnownTerminalCountRef` is 0, so the guard doesn't trigger and terminals are cleared normally +4. This gives a ~2s grace period (one polling interval) to filter out transient empty responses +5. The guard runs BEFORE the subscription cleanup loop, preventing premature unsubscription + +**Verification**: Rapid tab switching (8 cycles across 3 terminals in ~1.6s) with 20s wait — terminals survived. Previous code cleared to "0 terminals" within 18s. + +**Files**: `[worktreeId].tsx` + +--- + +## Bug 3: Viewport measurement chicken-and-egg + +**Status: SOLVED (workaround in place)** + +The first subscribe has `viewport=none`. After scrollback init, async measure viewport, resubscribe with dims. Takes 2-3 round-trips. Works reliably. + +--- + +## Bug 4: Desktop terminal focus doesn't follow mobile tab switching + +**Status: FIXED** + +`switchTab` now calls `terminal.focus` RPC. + +--- + +## Bug 6: Claude not launching on new workspace + +**Status: FIXED** + +**Root cause**: Mobile's `NewWorktreeModal` sends `startupCommand` (e.g. `'claude'`) in the `worktree.create` RPC call, but the Zod schema (`WorktreeCreate`) did not include `startupCommand`, so it was silently stripped during validation. The runtime's `createManagedWorktree` never received the startup command, so `args.startup` was always undefined and the activation IPC never included a startup payload. The left pane spawned a plain shell instead of the selected agent. + +**Fix applied**: +1. Added `startupCommand: OptionalString` to the `WorktreeCreate` Zod schema in `src/main/runtime/rpc/methods/worktree.ts` +2. RPC handler maps `params.startupCommand` → `{ command }` and passes to `runtime.createManagedWorktree()` +3. Added `startup?: WorktreeStartupLaunch` to `createManagedWorktree`'s args type in `orca-runtime.ts` + +**Bug 6b: `waitForLeafPtyId` times out due to handle invalidation** + +When a leaf's ptyId changes from null to a real value, `syncWindowGraph` invalidates the old handle (deletes it from `this.handles`). The `waitForLeafPtyId` callback calls `resolveLeafForHandle(handle)` which returns null because the handle no longer exists. The wait never resolves. + +**Fix**: `waitForLeafPtyId` now captures the handle's `tabId` and `leafId` before the handle can be invalidated. The callback falls back to direct `this.leaves.get(getLeafKey(tabId, leafId))` lookup when the handle-based lookup fails. + +**Files**: `src/main/runtime/rpc/methods/worktree.ts`, `src/main/runtime/orca-runtime.ts` + +--- + +## Diagnostic Logging + +All logs prefixed with `[mobile-fit]`. + +### Server-side (main process stdout) +| Location | What it logs | +|----------|-------------| +| `terminal.subscribe` handler | handle, ptyId, client type, viewport | +| `handleMobileSubscribe` | ptyId, mode, viewport, skip reasons, resize details | +| `handleMobileUnsubscribe` | ptyId, subscriber state, wasResized | +| `applyMobileDisplayMode` | ptyId, mode, subscriber state | + +### Mobile client (React Native console) +| Location | What it logs | +|----------|-------------| +| `subscribeToTerminal` | handle, seq, viewport, measured state | +| scrollback handler | cols, rows, displayMode, hasSerialized, alreadyInit | +| resized handler | cols, rows, displayMode, reason | +| `switchTab` | prev/next handle, hasUnsub, hasRef | +| `toggleDisplayMode` | handle, current mode, next mode | +| `setTerminalWebViewRef` | handle, isActive, activeHandle | + +### Desktop renderer (DevTools console) +| Location | What it logs | +|----------|-------------| +| `useIpcEvents.ts` | fitOverrideChanged IPC events received | +| `TerminalPane.tsx` | onOverrideChange callbacks fired | + +--- + +## Priority Order + +1. ~~**Bug 2 (blank terminal)**~~ — **FIXED**. WebView readiness lifecycle corrected. +2. ~~**Duplicate prompt lines**~~ — **FIXED**. Removed inline restore, added alreadyAtTarget skip. +3. ~~**Bug 7 (0 terminals)**~~ — **FIXED**. Consecutive-empty guard with `lastKnownTerminalCountRef`. +4. ~~**Bug 5 (banner missing)**~~ — **FIXED**. Side effect of inline restore removal + verified via CDP. +5. ~~**Bug 1 (timer overwrite)**~~ — **FIXED**. `pendingRestoreTimers` keyed by ptyId, cancel without restore. +6. **Cleanup** — Remove `[mobile-fit]` diagnostic logs, Debug Test button, dead code. diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 00000000000..39a8c8da590 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.expo/ +dist/ +android/ +ios/ +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json new file mode 100644 index 00000000000..90d8894e282 --- /dev/null +++ b/mobile/.oxlintrc.json @@ -0,0 +1,3 @@ +{ + "rules": {} +} diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 00000000000..4d9181b161f --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,172 @@ +# Orca Mobile + +React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone. + +Local development uses two processes: + +- Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port `6768`. +- Expo Metro from `mobile/`. This serves the React Native app on port `8081`. + +## Prerequisites + +- Node.js 24+ +- pnpm +- Xcode and/or Android Studio tooling for simulator or device builds +- Expo Go on your phone, or a development client build when native modules are needed +- Phone and desktop on the same LAN when testing a physical phone + +## Start Desktop Orca + +From the repository root: + +```bash +pnpm install +pnpm dev +``` + +Confirm the mobile RPC server is listening: + +```bash +lsof -nP -iTCP:6768 -sTCP:LISTEN +``` + +Restart `pnpm dev` after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle. + +## Start The Mobile App + +```bash +cd mobile +pnpm install +pnpm start +``` + +Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android. + +For a native dev-client build: + +```bash +pnpm exec expo run:android +pnpm exec expo run:ios +pnpm start --dev-client +``` + +## Pair With Desktop Orca + +1. Open Orca desktop. +2. Go to Settings > Mobile. +3. Scan the pairing QR code from the mobile app. +4. Confirm the mobile host endpoint is `ws://:6768`. + +For the Android emulator, use `ws://10.0.2.2:6768`. For a physical phone, use the desktop LAN IP, for example `ws://192.168.0.179:6768`. + +If the phone has a stale host entry, remove it from the app and pair again. + +## Development Paths + +### Android Phone + +1. Install Expo Go from Google Play +2. Run `pnpm start`, scan QR with Expo Go +3. For native modules: `pnpm exec expo run:android` +4. Run with `pnpm start --dev-client` + +### iOS Simulator + +1. Install Xcode from the App Store +2. Run `pnpm start --ios` to open in iOS Simulator + +## Physical Phone Debugging + +The phone can be inspected through the connected device tooling: + +```bash +orca snapshot --json +orca click --element @e3 --json +orca fill --element @e1 --value "ls" --json +orca screenshot --json +``` + +Use `snapshot` first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs `terminal.subscribe`. + +## Terminal Streaming Repro Without A Phone + +Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs: + +```bash +cd mobile +ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts +``` + +You can pass a worktree selector as the third argument: + +```bash +pnpm exec tsx scripts/test-subscribe.ts "id:" +pnpm exec tsx scripts/test-subscribe.ts "path:/absolute/worktree/path" +pnpm exec tsx scripts/test-subscribe.ts "name:my-worktree" +``` + +The expected result includes: + +```text +streamSawMarker: true +readSawMarker: true +``` + +If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or `TerminalWebView` readiness/queueing path. + +## Terminal Color Repro Without A Phone + +Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run: + +```bash +cd mobile +ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \ + "id:" +``` + +The script captures `terminal.subscribe` snapshots in an A → B → A sequence and writes raw snapshots to `mobile/terminal-color-repro/`. If the two A snapshots have different `sgrColor` counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering. + +## Validation + +Run these checks before committing mobile terminal changes: + +```bash +cd mobile +pnpm exec tsc --noEmit +pnpm lint +cd .. +pnpm typecheck:node +``` + +## Mock Server + +Develop the mobile app without a running Orca desktop instance: + +```bash +pnpm mock-server # starts mock WebSocket server on port 6768 +``` + +Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`. + +## Connecting to Real Orca + +1. Start Orca desktop with WebSocket transport enabled +2. In Orca, go to Settings > Mobile and scan the QR code with this app +3. The QR encodes the connection endpoint, device token, and TLS fingerprint + +## Project Structure + +``` +mobile/ +├── app/ # Expo Router screens (file-based routing) +│ ├── _layout.tsx # Root layout with navigation stack +│ ├── index.tsx # Home screen — paired hosts list +│ └── pair-scan.tsx # QR code scanning screen +├── src/ +│ ├── terminal/ # Terminal WebView and xterm bridge +│ └── transport/ # WebSocket RPC client +├── scripts/ +│ ├── test-subscribe.ts # Desktop streaming repro without a phone +│ └── mock-server.ts # Standalone mock WebSocket server +└── assets/ # App icons and splash screen +``` diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 00000000000..25cc7cf02f6 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,61 @@ +{ + "expo": { + "name": "Orca", + "slug": "orca-mobile", + "version": "0.0.1", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "scheme": "orca", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#111111" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.stably.orca.mobile", + "buildNumber": "2", + "infoPlist": { + "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.", + "NSAppTransportSecurity": { + "NSAllowsLocalNetworking": true + } + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#111111" + }, + "usesCleartextTraffic": true, + "allowBackup": false, + "package": "com.stably.orca.mobile" + }, + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "backgroundColor": "#111111" + } + ], + [ + "expo-camera", + { + "cameraPermission": "Allow Orca to use the camera for QR code scanning." + } + ], + [ + "expo-build-properties", + { + "android": { + "usesCleartextTraffic": true + } + } + ] + ] + } +} diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx new file mode 100644 index 00000000000..742addac1cb --- /dev/null +++ b/mobile/app/_layout.tsx @@ -0,0 +1,73 @@ +import { useCallback } from 'react' +import { View, StyleSheet } from 'react-native' +import { Stack } from 'expo-router' +import { StatusBar } from 'expo-status-bar' +import * as SplashScreen from 'expo-splash-screen' +import * as Notifications from 'expo-notifications' +import { colors } from '../src/theme/mobile-theme' +import { OrcaLogo } from '../src/components/OrcaLogo' + +// Why: keeps the native splash screen visible until the React tree is mounted +// and ready to render. Without this the user sees a blank white/black frame +// between the native splash and the first React paint. +SplashScreen.preventAutoHideAsync() + +// Why: without this, expo-notifications silently drops notifications when +// the app is in the foreground. Setting all three to true makes iOS/Android +// display the banner, play the sound, and show the badge even while the +// app is active. This runs once at module load time before any notification +// is scheduled. +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false + }) +}) + +export default function RootLayout() { + // Why: hide the native splash only once the navigation Stack has been laid + // out — this is the earliest moment the user will see actual app content. + // Previously the splash hid when a placeholder View rendered, leaving a + // grey gap before the real screen appeared. + const onNavigatorLayout = useCallback(async () => { + await SplashScreen.hideAsync() + }, []) + + return ( + + + + + }} + /> + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/app/about.tsx b/mobile/app/about.tsx new file mode 100644 index 00000000000..088c6da211f --- /dev/null +++ b/mobile/app/about.tsx @@ -0,0 +1,145 @@ +import { View, Text, StyleSheet, Pressable, Linking } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, Globe } from 'lucide-react-native' +import Svg, { Path } from 'react-native-svg' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { colors, spacing, typography } from '../src/theme/mobile-theme' + +function GithubIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +function XIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +export default function AboutScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + + return ( + + + router.back()}> + + + About + + + + + Orca + Open-source agent IDE for 100x builders + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://onOrca.dev')} + > + + onOrca.dev + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://github.com/stablyai/orca')} + > + + stablyai/orca + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://x.com/orca_build')} + > + + @orca_build + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.xl + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + brand: { + alignItems: 'center', + paddingVertical: spacing.xl, + marginBottom: spacing.lg + }, + brandName: { + fontSize: 22, + fontWeight: '800', + color: colors.textPrimary, + marginTop: spacing.sm + }, + brandSub: { + fontSize: 13, + color: colors.textMuted, + marginTop: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowValue: { + flex: 1, + textAlign: 'right', + fontSize: typography.bodySize, + color: colors.textSecondary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx new file mode 100644 index 00000000000..72373333dd1 --- /dev/null +++ b/mobile/app/h/[hostId]/index.tsx @@ -0,0 +1,1405 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { + View, + Text, + StyleSheet, + SectionList, + Pressable, + ActivityIndicator, + TextInput +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { + Search, + X, + Pin, + Bell, + GitPullRequest, + SlidersHorizontal, + Layers, + ChevronDown, + ChevronRight, + ChevronLeft, + Plus, + Moon, + Filter, + Check +} from 'lucide-react-native' +import { connect, type RpcClient } from '../../../src/transport/rpc-client' +import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store' +import type { ConnectionState, RpcSuccess } from '../../../src/transport/types' +import { triggerMediumImpact } from '../../../src/platform/haptics' +import { StatusDot } from '../../../src/components/StatusDot' +import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal' +import { AgentSpinner } from '../../../src/components/AgentSpinner' +import { PickerModal, type PickerOption } from '../../../src/components/PickerModal' +import { ActionSheetContent } from '../../../src/components/ActionSheetModal' +import { ConfirmModal } from '../../../src/components/ConfirmModal' +import { BottomDrawer } from '../../../src/components/BottomDrawer' +import { getCachedWorktrees } from '../../../src/cache/worktree-cache' +import { colors, spacing, typography } from '../../../src/theme/mobile-theme' +import { + loadPinnedIds, + savePinnedIds, + loadPreferences, + savePreferences +} from '../../../src/storage/preferences' + +type Worktree = { + worktreeId: string + repo: string + branch: string + displayName: string + liveTerminalCount: number + hasAttachedPty: boolean + preview: string + unread: boolean + lastOutputAt?: number + isPinned: boolean + linkedPR: { number: number; state: string } | null + status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' +} + +type SortMode = 'smart' | 'name' | 'recent' +type _FilterMode = 'all' | 'active' +type GroupMode = 'none' | 'repo' | 'prStatus' + +type FilterState = { + activeOnly: boolean + selectedRepos: Set +} + +const STATUS_LABELS: Record = { + connecting: 'Connecting…', + handshaking: 'Securing…', + connected: 'Connected', + disconnected: 'Disconnected', + reconnecting: 'Reconnecting…', + 'auth-failed': 'Auth failed' +} + +const SORT_OPTIONS: PickerOption[] = [ + { value: 'smart', label: 'Smart', subtitle: 'Unread and active first' }, + { value: 'name', label: 'Name', subtitle: 'Alphabetical by name' }, + { value: 'recent', label: 'Recent', subtitle: 'Most recent output first' } +] + +const GROUP_OPTIONS: PickerOption[] = [ + { value: 'none', label: 'No Grouping' }, + { value: 'repo', label: 'Repository' }, + { value: 'prStatus', label: 'PR Status' } +] + +function getWorktreeStatus(w: Worktree): 'working' | 'active' | 'permission' | 'done' | 'inactive' { + if (w.status) return w.status + if (w.liveTerminalCount > 0) return 'active' + return 'inactive' +} + +// Why: the previous 10-minute lastOutputAt window was too strict — most +// worktrees with idle terminal prompts had no recent output and were excluded. +// Any worktree with live terminals or unread output counts as "active". +function isWorktreeActive(w: Worktree): boolean { + if (w.unread) return true + if (w.status) return w.status !== 'inactive' + if (w.liveTerminalCount > 0) return true + return false +} + +function sortWorktrees(worktrees: Worktree[], mode: SortMode): Worktree[] { + return [...worktrees].sort((a, b) => { + if (mode === 'name') return (a.displayName || a.repo).localeCompare(b.displayName || b.repo) + if (mode === 'recent') return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) + // 'smart' — attention-first + if (a.unread !== b.unread) return a.unread ? -1 : 1 + const aStatus = getWorktreeStatus(a) + const bStatus = getWorktreeStatus(b) + const statusOrder = { permission: 0, working: 1, done: 2, active: 3, inactive: 4 } + if (statusOrder[aStatus] !== statusOrder[bStatus]) + return statusOrder[aStatus] - statusOrder[bStatus] + if ((a.lastOutputAt ?? 0) !== (b.lastOutputAt ?? 0)) + return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) + return (a.displayName || a.repo).localeCompare(b.displayName || b.repo) + }) +} + +function filterWorktrees(worktrees: Worktree[], filters: FilterState, search: string): Worktree[] { + let result = worktrees + if (filters.activeOnly) { + result = result.filter(isWorktreeActive) + } + if (filters.selectedRepos.size > 0) { + result = result.filter((w) => filters.selectedRepos.has(w.repo)) + } + if (search.trim()) { + const q = search.toLowerCase() + result = result.filter( + (w) => + (w.displayName || w.repo).toLowerCase().includes(q) || + w.branch.toLowerCase().includes(q) || + w.repo.toLowerCase().includes(q) + ) + } + return result +} + +type Section = { title: string; icon?: 'pin'; data: Worktree[] } + +// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts. +// no PR/draft/unknown → "In Progress", open → "In Review", merged → "Done", closed → "Closed" +type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' + +const PR_GROUP_LABELS: Record = { + done: 'Done', + 'in-review': 'In Review', + 'in-progress': 'In Progress', + closed: 'Closed' +} + +const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed'] + +function getPRGroupKey(w: Worktree): PRGroupKey { + if (!w.linkedPR) return 'in-progress' + const s = w.linkedPR.state.toLowerCase() + if (s === 'merged') return 'done' + if (s === 'closed') return 'closed' + if (s === 'draft') return 'in-progress' + return 'in-review' +} + +function isWorktreePinned(w: Worktree, localPins: Set): boolean { + return w.isPinned || localPins.has(w.worktreeId) +} + +function buildSections( + worktrees: Worktree[], + sortMode: SortMode, + filters: FilterState, + search: string, + groupMode: GroupMode, + pinnedIds: Set +): Section[] { + const filtered = filterWorktrees(worktrees, filters, search) + const sorted = sortWorktrees(filtered, sortMode) + + const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds)) + const unpinned = sorted.filter((w) => !isWorktreePinned(w, pinnedIds)) + const active = unpinned.filter(isWorktreeActive) + const inactive = unpinned.filter((w) => !isWorktreeActive(w)) + + const sections: Section[] = [] + if (pinned.length > 0) { + sections.push({ title: 'Pinned', icon: 'pin', data: pinned }) + } + + if (groupMode === 'none') { + if (active.length > 0) { + // Why: without explicit grouping, mobile's primary workflow is jumping + // back into running sessions before browsing the full worktree archive. + sections.push({ title: 'Active', data: active }) + } + if (inactive.length > 0) { + sections.push({ title: pinned.length > 0 || active.length > 0 ? 'All' : '', data: inactive }) + } + } else if (groupMode === 'repo') { + const byRepo = new Map() + for (const w of unpinned) { + const key = w.repo || 'Unknown' + const list = byRepo.get(key) + if (list) list.push(w) + else byRepo.set(key, [w]) + } + for (const [repo, items] of byRepo) { + sections.push({ title: repo, data: items }) + } + } else if (groupMode === 'prStatus') { + const byGroup = new Map() + for (const w of unpinned) { + const key = getPRGroupKey(w) + const list = byGroup.get(key) + if (list) list.push(w) + else byGroup.set(key, [w]) + } + for (const groupKey of PR_GROUP_ORDER) { + const items = byGroup.get(groupKey) + if (items && items.length > 0) { + sections.push({ title: PR_GROUP_LABELS[groupKey], data: items }) + } + } + } + + return sections +} + +export default function HostScreen() { + const { hostId, action } = useLocalSearchParams<{ hostId: string; action?: string }>() + const router = useRouter() + const [initialCache] = useState(() => + hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null + ) + const [client, setClient] = useState(null) + const clientRef = useRef(null) + const [connState, setConnState] = useState('disconnected') + const [worktrees, setWorktrees] = useState(initialCache ?? []) + const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null) + const [hostName, setHostName] = useState('') + const [error, setError] = useState('') + const [lastKnownWorktrees, setLastKnownWorktrees] = useState(initialCache ?? []) + const [search, setSearch] = useState('') + const [showSearch, setShowSearch] = useState(false) + const [sortMode, setSortMode] = useState('smart') + const [filters, setFilters] = useState({ + activeOnly: false, + selectedRepos: new Set() + }) + const [groupMode, setGroupMode] = useState('none') + + // Modals + const [showSortPicker, setShowSortPicker] = useState(false) + const [showGroupPicker, setShowGroupPicker] = useState(false) + const [showFilterModal, setShowFilterModal] = useState(false) + const [actionTarget, setActionTarget] = useState(null) + const [confirmDelete, setConfirmDelete] = useState(null) + const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) + const [showNewWorktree, setShowNewWorktree] = useState(false) + const [sleptIds, setSleptIds] = useState>(new Set()) + + // Persisted pin state + const [pinnedIds, setPinnedIds] = useState>(new Set()) + const [_prefsLoaded, setPrefsLoaded] = useState(false) + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) + + useEffect(() => { + if (action === 'newWorktree') setShowNewWorktree(true) + }, [action]) + + // Load persisted pins and preferences + useEffect(() => { + if (!hostId) return + let stale = false + void (async () => { + const [pins, prefs] = await Promise.all([loadPinnedIds(hostId), loadPreferences(hostId)]) + if (stale) return + setPinnedIds(pins) + setSortMode(prefs.sortMode as SortMode) + setFilters({ + activeOnly: prefs.filterMode === 'active', + selectedRepos: new Set(prefs.selectedRepos ?? []) + }) + setGroupMode(prefs.groupMode as GroupMode) + setCollapsedGroups(new Set(prefs.collapsedGroups)) + setPrefsLoaded(true) + })() + return () => { + stale = true + } + }, [hostId]) + + useEffect(() => { + let disposed = false + let rpcClient: RpcClient | null = null + clientRef.current = null + setClient(null) + setConnState('connecting') + setHostName('') + setError('') + // Why: re-seed from the current host's cache on every hostId change. + // The useState initializer only runs on first mount, so if Expo Router + // reuses this screen with a different hostId, we must reset here. + const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null + if (freshCache) { + setWorktrees(freshCache) + setLastKnownWorktrees(freshCache) + setWorktreesLoaded(true) + } else { + setWorktreesLoaded(false) + setWorktrees([]) + setLastKnownWorktrees([]) + } + + // Why: defer the RPC connection until after the navigation animation + // completes. Without this, connect() and loadHosts() block the JS + // thread during mount, delaying the screen transition by ~200-400ms. + // With cached worktrees the user sees content instantly; the live + // connection starts once the animation settles. + const rafId = requestAnimationFrame(() => { + if (disposed) return + void (async () => { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (!host || disposed) { + if (!host && !disposed) setError('Host not found') + return + } + + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => { + if (!disposed) setConnState(state) + }) + if (disposed) { + rpcClient.close() + rpcClient = null + return + } + setHostName(host.name) + clientRef.current = rpcClient + setClient(rpcClient) + + await updateLastConnected(host.id) + })() + }) + + return () => { + disposed = true + cancelAnimationFrame(rafId) + rpcClient?.close() + if (clientRef.current === rpcClient) { + clientRef.current = null + } + } + }, [hostId]) + + const fetchWorktrees = useCallback(async () => { + if (!client || connState !== 'connected') return + const requestClient = client + const requestHostId = hostId + + try { + const response = await requestClient.sendRequest('worktree.ps') + if (clientRef.current !== requestClient || hostId !== requestHostId) return + if (response.ok) { + const result = (response as RpcSuccess).result as { worktrees: Worktree[] } + setWorktrees(result.worktrees) + setLastKnownWorktrees(result.worktrees) + setWorktreesLoaded(true) + + // Clear optimistic sleep overrides once the server confirms the + // worktree is actually inactive (liveTerminalCount dropped to 0). + setSleptIds((prev) => { + if (prev.size === 0) { + return prev + } + const still = new Set() + for (const id of prev) { + const wt = result.worktrees.find((w) => w.worktreeId === id) + if (wt && wt.liveTerminalCount > 0) { + still.add(id) + } + } + return still.size === prev.size ? prev : still + }) + + // Sync local pin state from server so desktop-initiated pins/unpins + // are reflected without relying on stale AsyncStorage. + const serverPinned = new Set( + result.worktrees.filter((w) => w.isPinned).map((w) => w.worktreeId) + ) + setPinnedIds((prev) => { + if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) { + return prev + } + if (hostId) void savePinnedIds(hostId, serverPinned) + return serverPinned + }) + } + } catch { + // Will retry on reconnect + } + }, [client, connState, hostId]) + + useEffect(() => { + if (connState === 'connected') { + void fetchWorktrees() + } + }, [connState, fetchWorktrees]) + + useEffect(() => { + if (connState !== 'connected') return + const interval = setInterval(() => { + void fetchWorktrees() + }, 3000) + return () => clearInterval(interval) + }, [connState, fetchWorktrees]) + + const updateLocalPins = useCallback( + (worktreeId: string, pinned: boolean) => { + setPinnedIds((prev) => { + const next = new Set(prev) + if (pinned) next.add(worktreeId) + else next.delete(worktreeId) + if (hostId) void savePinnedIds(hostId, next) + return next + }) + }, + [hostId] + ) + + const togglePin = useCallback( + (worktreeId: string) => { + const worktree = worktrees.find((w) => w.worktreeId === worktreeId) + const currentlyPinned = worktree + ? isWorktreePinned(worktree, pinnedIds) + : pinnedIds.has(worktreeId) + const newPinned = !currentlyPinned + + setWorktrees((prev) => + prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w)) + ) + setLastKnownWorktrees((prev) => + prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w)) + ) + + updateLocalPins(worktreeId, newPinned) + + if (client) { + client + .sendRequest('worktree.set', { + worktree: `id:${worktreeId}`, + isPinned: newPinned + }) + .catch(() => {}) + } + }, + [client, worktrees, pinnedIds, updateLocalPins] + ) + + const handleDeleteWorktree = useCallback( + async (item: Worktree) => { + if (!client) return + + const removeFromList = (list: Worktree[]) => + list.filter((w) => w.worktreeId !== item.worktreeId) + setWorktrees(removeFromList) + setLastKnownWorktrees(removeFromList) + + try { + const response = await client.sendRequest('worktree.rm', { + worktree: `id:${item.worktreeId}`, + force: true + }) + if (!response.ok) { + setWorktrees((prev) => [...prev, item]) + setLastKnownWorktrees((prev) => [...prev, item]) + } + void fetchWorktrees() + } catch { + setWorktrees((prev) => [...prev, item]) + setLastKnownWorktrees((prev) => [...prev, item]) + } + }, + [client, fetchWorktrees] + ) + + const handleRemoveHost = useCallback(async () => { + if (!hostId) return + await removeHost(hostId) + router.back() + }, [hostId, router]) + + const openWorktreeSession = useCallback( + (item: Worktree) => { + if (client && connState === 'connected') { + void client + .sendRequest('worktree.activate', { + worktree: `id:${item.worktreeId}` + }) + .catch(() => null) + } + router.push( + `/h/${hostId}/session/${encodeURIComponent(item.worktreeId)}?name=${encodeURIComponent(item.displayName || item.repo)}` + ) + }, + [client, connState, hostId, router] + ) + + const handleSortChange = useCallback( + (value: SortMode) => { + setSortMode(value) + if (hostId) void savePreferences(hostId, { sortMode: value }) + }, + [hostId] + ) + + const toggleActiveFilter = useCallback(() => { + setFilters((prev) => { + const next = { ...prev, activeOnly: !prev.activeOnly } + if (hostId) + void savePreferences(hostId, { + filterMode: next.activeOnly ? 'active' : 'all' + }) + return next + }) + }, [hostId]) + + const toggleRepoFilter = useCallback( + (repo: string) => { + setFilters((prev) => { + const next = new Set(prev.selectedRepos) + if (next.has(repo)) next.delete(repo) + else next.add(repo) + const updated = { ...prev, selectedRepos: next } + if (hostId) void savePreferences(hostId, { selectedRepos: [...next] }) + return updated + }) + }, + [hostId] + ) + + const clearFilters = useCallback(() => { + setFilters({ activeOnly: false, selectedRepos: new Set() }) + if (hostId) void savePreferences(hostId, { filterMode: 'all', selectedRepos: [] }) + }, [hostId]) + + const activeFilterCount = useMemo(() => { + let count = 0 + if (filters.activeOnly) count++ + count += filters.selectedRepos.size + return count + }, [filters]) + + const handleGroupChange = useCallback( + (value: GroupMode) => { + setGroupMode(value) + if (hostId) void savePreferences(hostId, { groupMode: value }) + }, + [hostId] + ) + + const displayWorktrees = useMemo(() => { + const base = + connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed' + ? lastKnownWorktrees + : worktrees + if (sleptIds.size === 0) { + return base + } + return base.map((w) => + sleptIds.has(w.worktreeId) + ? { ...w, liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const } + : w + ) + }, [connState, worktrees, lastKnownWorktrees, sleptIds]) + + const uniqueRepos = useMemo(() => { + const repos = new Map() + for (const w of displayWorktrees) { + if (!repos.has(w.repo)) repos.set(w.repo, repoColor(w.repo)) + } + return [...repos.entries()].map(([name, color]) => ({ name, color })) + }, [displayWorktrees]) + + const toggleCollapsed = useCallback( + (title: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev) + if (next.has(title)) next.delete(title) + else next.add(title) + if (hostId) void savePreferences(hostId, { collapsedGroups: [...next] }) + return next + }) + }, + [hostId] + ) + + const rawSections = useMemo( + () => buildSections(displayWorktrees, sortMode, filters, search, groupMode, pinnedIds), + [displayWorktrees, sortMode, filters, search, groupMode, pinnedIds] + ) + + const sections = useMemo( + () => + rawSections.map((s) => ({ + ...s, + data: collapsedGroups.has(s.title) ? [] : s.data + })), + [rawSections, collapsedGroups] + ) + + const isReadOnly = connState === 'auth-failed' + + if (error) { + return ( + + {error} + + ) + } + + return ( + + + + router.back()}> + + + + + + {hostName || 'Host'} + + + {connState !== 'connected' && ( + {STATUS_LABELS[connState]} + )} + + + {/* Filter/sort/group toolbar */} + + 0 && styles.filterChipActive]} + onPress={() => setShowFilterModal(true)} + > + 0 ? colors.textPrimary : colors.textSecondary} + /> + 0 && styles.filterChipTextActive]} + > + Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''} + + + + setShowSortPicker(true)}> + + + {sortMode === 'smart' ? 'Smart' : sortMode === 'name' ? 'Name' : 'Recent'} + + + + setShowGroupPicker(true)}> + + + {groupMode === 'none' ? 'Group' : groupMode === 'repo' ? 'Repo' : 'PR'} + + + + + + setShowNewWorktree(true)} + disabled={connState !== 'connected'} + > + + + + setShowSearch((s) => !s)}> + {showSearch ? ( + + ) : ( + + )} + + + + + {/* Auth failed banner */} + {connState === 'auth-failed' && ( + + + Pairing rejected — re-pair from desktop or remove this host. + + + router.push('/pair-scan')}> + Re-pair + + setConfirmRemoveHost(true)}> + Remove + + + + )} + + {/* Search bar */} + {showSearch && ( + + + + {search.length > 0 && ( + setSearch('')}> + + + )} + + )} + + {/* Loading state */} + {((connState === 'connecting' || connState === 'reconnecting') && + displayWorktrees.length === 0) || + (connState === 'connected' && !worktreesLoaded && displayWorktrees.length === 0) ? ( + + + + ) : null} + + {/* Empty state */} + {connState === 'connected' && worktreesLoaded && sections.length === 0 && ( + + + {search + ? 'No matching worktrees' + : activeFilterCount > 0 + ? 'No worktrees match filters' + : 'No worktrees'} + + + )} + + {/* Worktree list */} + {sections.length > 0 && ( + w.worktreeId} + stickySectionHeadersEnabled={false} + contentContainerStyle={styles.list} + renderSectionHeader={({ section }) => { + if (!section.title) return null + const isCollapsed = collapsedGroups.has(section.title) + const rawSection = rawSections.find((s) => s.title === section.title) + const count = rawSection?.data.length ?? 0 + return ( + toggleCollapsed(section.title)} + > + {isCollapsed ? ( + + ) : ( + + )} + {section.icon === 'pin' && ( + + )} + {section.title} + {count} + + ) + }} + ItemSeparatorComponent={ListSeparator} + renderItem={({ item }) => ( + [styles.worktreeRow, pressed && styles.worktreeRowPressed]} + disabled={isReadOnly} + onPress={() => openWorktreeSession(item)} + onLongPress={() => { + triggerMediumImpact() + setActionTarget(item) + }} + delayLongPress={400} + > + {/* Left indicator */} + + + {item.unread && ( + + )} + + + {/* Main content */} + + + + {item.displayName || item.repo} + + {item.linkedPR && ( + + + #{item.linkedPR.number} + + )} + + + + + {item.repo} + + + {item.branch} + + + {item.preview ? ( + + {item.preview} + + ) : null} + + + {/* Terminal count */} + {item.liveTerminalCount > 0 && ( + {item.liveTerminalCount} + )} + + )} + /> + )} + + {/* Sort picker modal */} + setShowSortPicker(false)} + /> + + {/* Group picker modal */} + setShowGroupPicker(false)} + /> + + {/* Filter modal — matches desktop's Status + Repositories dropdown */} + setShowFilterModal(false)}> + + Filter + {activeFilterCount > 0 && ( + + Clear filters + + )} + + + Status + + + Active only + {filters.activeOnly && } + + + + {uniqueRepos.length > 1 && ( + <> + Repositories + + {uniqueRepos.map((repo, i) => ( + + {i > 0 && } + toggleRepoFilter(repo.name)}> + + + {repo.name} + + {filters.selectedRepos.has(repo.name) && ( + + )} + + + ))} + + + )} + + + {/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */} + { + setConfirmDelete(null) + setActionTarget(null) + }} + > + {confirmDelete ? ( + + + Delete Worktree + + Delete "{confirmDelete.displayName || confirmDelete.repo}" ({confirmDelete.branch})? + + + + [ + styles.confirmBtn, + styles.confirmBtnCancel, + pressed && styles.confirmBtnPressed + ]} + onPress={() => setConfirmDelete(null)} + > + Cancel + + [ + styles.confirmBtn, + styles.confirmBtnDestructive, + pressed && styles.confirmBtnPressed + ]} + onPress={() => { + if (confirmDelete) { + void handleDeleteWorktree(confirmDelete) + } + setConfirmDelete(null) + setActionTarget(null) + }} + > + Delete + + + + ) : ( + { + if (client) { + setSleptIds((prev) => new Set(prev).add(actionTarget.worktreeId)) + void client + .sendRequest('worktree.sleep', { + worktree: `id:${actionTarget.worktreeId}` + }) + .catch(() => null) + } + setActionTarget(null) + } + }, + { + label: isWorktreePinned(actionTarget, pinnedIds) ? 'Unpin' : 'Pin', + onPress: () => { + togglePin(actionTarget.worktreeId) + setActionTarget(null) + } + }, + { + label: 'Delete', + destructive: true, + onPress: () => setConfirmDelete(actionTarget) + } + ] + : [] + } + /> + )} + + + {/* Host remove confirmation */} + void handleRemoveHost()} + onCancel={() => setConfirmRemoveHost(false)} + /> + + { + void fetchWorktrees() + const params = new URLSearchParams({ name: worktreeName, created: '1' }) + router.push(`/h/${hostId}/session/${encodeURIComponent(worktreeId)}?${params.toString()}`) + }} + onClose={() => setShowNewWorktree(false)} + /> + + ) +} + +function ListSeparator() { + return +} + +function repoColor(name: string): string { + const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] + let hash = 0 + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0 + return palette[Math.abs(hash) % palette.length]! +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topChrome: { + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + statusBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 34, + paddingTop: spacing.xs, + paddingHorizontal: spacing.lg + }, + backButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, + hostIdentity: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + minWidth: 0, + marginRight: spacing.md + }, + hostNameText: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + statusText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + authBanner: { + backgroundColor: colors.bgPanel, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + authBannerText: { + color: colors.statusRed, + fontSize: 13, + marginBottom: spacing.sm + }, + authActions: { + flexDirection: 'row', + gap: spacing.lg + }, + authAction: { + paddingVertical: spacing.xs + }, + authActionText: { + color: colors.accentBlue, + fontSize: 13, + fontWeight: '600' + }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + gap: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + filterChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + filterChipActive: { + borderColor: colors.textSecondary, + backgroundColor: colors.bgRaised + }, + filterChipText: { + fontSize: 12, + color: colors.textSecondary + }, + filterChipTextActive: { + color: colors.textPrimary + }, + sortButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + groupButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + sortLabel: { + fontSize: 12, + color: colors.textSecondary + }, + toolbarSpacer: { + flex: 1 + }, + newButton: { + padding: spacing.xs + }, + searchToggle: { + padding: spacing.xs + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + gap: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + searchInput: { + flex: 1, + color: colors.textPrimary, + fontSize: 13, + paddingVertical: 2 + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize + }, + list: { + paddingBottom: spacing.lg + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xs + }, + sectionIcon: { + marginRight: spacing.xs + }, + sectionTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + sectionCount: { + fontSize: 11, + color: colors.textMuted, + marginLeft: spacing.xs + }, + separator: { + height: 1, + backgroundColor: colors.borderSubtle, + marginLeft: spacing.lg + 24, + marginRight: spacing.lg + }, + worktreeRow: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: spacing.sm + 2, + paddingHorizontal: spacing.lg + }, + worktreeRowPressed: { + backgroundColor: colors.bgRaised + }, + indicatorCol: { + width: 20, + alignItems: 'center', + paddingTop: 6, + marginRight: spacing.sm, + gap: 4 + }, + unreadBell: { + marginTop: 2 + }, + worktreeMain: { + flex: 1, + marginRight: spacing.sm + }, + worktreeNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + worktreeName: { + fontSize: 14, + fontWeight: '600', + color: colors.textPrimary, + flexShrink: 1 + }, + textReadOnly: { + opacity: 0.5 + }, + prBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 3, + backgroundColor: colors.bgRaised, + paddingHorizontal: 5, + paddingVertical: 1, + borderRadius: 4 + }, + prNumber: { + fontSize: 10, + color: colors.textSecondary + }, + worktreeMetaRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + gap: spacing.xs + }, + repoDot: { + width: 6, + height: 6, + borderRadius: 3 + }, + repoName: { + fontSize: 11, + color: colors.textSecondary, + maxWidth: 100 + }, + branchName: { + fontSize: 11, + color: colors.textMuted, + fontFamily: typography.monoFamily, + flexShrink: 1 + }, + worktreePreview: { + fontSize: 11, + color: colors.textMuted, + fontFamily: typography.monoFamily, + marginTop: 2 + }, + terminalCount: { + fontSize: typography.metaSize, + color: colors.textMuted, + minWidth: 16, + textAlign: 'right', + paddingTop: 3 + }, + filterModalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + filterModalTitle: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + clearFiltersText: { + fontSize: 13, + color: colors.textSecondary + }, + filterSectionLabel: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + filterGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden', + marginBottom: spacing.md + }, + filterRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2, + gap: spacing.sm + }, + filterRowText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + filterSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + filterRepoDot: { + width: 8, + height: 8, + borderRadius: 4 + }, + confirmContent: { + paddingBottom: spacing.lg + }, + confirmTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + confirmMessage: { + fontSize: typography.bodySize, + color: colors.textSecondary, + marginTop: spacing.xs, + lineHeight: 20 + }, + confirmButtons: { + flexDirection: 'row', + gap: spacing.sm + }, + confirmBtn: { + flex: 1, + paddingVertical: spacing.sm + 2, + borderRadius: 10, + alignItems: 'center' + }, + confirmBtnCancel: { + backgroundColor: colors.bgPanel + }, + confirmBtnDestructive: { + backgroundColor: colors.statusRed + }, + confirmBtnPressed: { + opacity: 0.7 + }, + confirmBtnCancelText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textSecondary + }, + confirmBtnDestructiveText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: '#fff' + } +}) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx new file mode 100644 index 00000000000..320dcfadf2e --- /dev/null +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -0,0 +1,1248 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + Pressable, + KeyboardAvoidingView, + Platform, + ActivityIndicator +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { ArrowUp, ChevronLeft, Monitor, Plus, Smartphone } from 'lucide-react-native' +import { connect, type RpcClient } from '../../../../src/transport/rpc-client' +import { loadHosts } from '../../../../src/transport/host-store' +import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types' +import { triggerMediumImpact } from '../../../../src/platform/haptics' +import { + TerminalWebView, + type TerminalWebViewHandle +} from '../../../../src/terminal/TerminalWebView' +import { StatusDot } from '../../../../src/components/StatusDot' +import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' +import { TextInputModal } from '../../../../src/components/TextInputModal' +import { + CustomKeyModal, + loadCustomKeys, + type CustomKey +} from '../../../../src/components/CustomKeyModal' +import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' + +type Terminal = { + handle: string + title: string + isActive: boolean +} + +type TerminalCreateResult = { + terminal: { + handle: string + title: string | null + } +} + +type MobileDisplayMode = 'auto' | 'phone' | 'desktop' + +type AccessoryKey = { label: string; bytes: string; accessibilityLabel?: string } + +const ACCESSORY_KEYS: AccessoryKey[] = [ + { label: 'Esc', bytes: '\x1b' }, + { label: 'Tab', bytes: '\t' }, + { label: '↑', bytes: '\x1b[A' }, + { label: '↓', bytes: '\x1b[B' }, + { label: '←', bytes: '\x1b[D' }, + { label: '→', bytes: '\x1b[C' }, + { label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, + { label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, + { label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, + { label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, + { label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, + { label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, + { label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, + { label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, + { label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } +] + +const STATUS_LABELS: Record = { + connecting: 'Connecting', + handshaking: 'Securing', + connected: 'Connected', + disconnected: 'Disconnected', + reconnecting: 'Reconnecting', + 'auth-failed': 'Auth failed' +} + +function TerminalPaneView({ + handle, + active, + onRef, + onWebReady +}: { + handle: string + active: boolean + onRef: (handle: string, ref: TerminalWebViewHandle | null) => void + onWebReady: (handle: string) => void +}) { + const setRef = useCallback( + (ref: TerminalWebViewHandle | null) => { + onRef(handle, ref) + }, + [handle, onRef] + ) + + return ( + + onWebReady(handle)} + /> + + ) +} + +export default function SessionScreen() { + const { + hostId, + worktreeId, + name: worktreeName, + created + } = useLocalSearchParams<{ + hostId: string + worktreeId: string + name?: string + created?: string + }>() + const router = useRouter() + const [client, setClient] = useState(null) + const [connState, setConnState] = useState('disconnected') + const [terminals, setTerminals] = useState([]) + const [terminalsLoaded, setTerminalsLoaded] = useState(false) + const [input, setInput] = useState('') + const [activeHandle, setActiveHandle] = useState(null) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState('') + const [actionTarget, setActionTarget] = useState(null) + const [renameTarget, setRenameTarget] = useState(null) + const [customKeys, setCustomKeys] = useState([]) + const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) + const [deleteKeyTarget, setDeleteKeyTarget] = useState(null) + // Why: server-authoritative display mode per terminal. The runtime is the + // single source of truth — this state is populated from subscribe responses. + const [terminalModes, setTerminalModes] = useState>(new Map()) + const deviceTokenRef = useRef(null) + const clientRef = useRef(null) + // Why: measured once from TerminalWebView on mount, then passed with every + // subscribe call so the server can auto-fit the PTY to phone dimensions. + const viewportRef = useRef<{ cols: number; rows: number } | null>(null) + const viewportMeasuredRef = useRef(false) + const terminalRefs = useRef>(new Map()) + const terminalUnsubsRef = useRef void>>(new Map()) + const subscribingHandlesRef = useRef>(new Set()) + const initializedHandlesRef = useRef>(new Set()) + // Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews + // (opacity:0) may have delayed JS execution on iOS. We must not subscribe + // until the WebView has fired web-ready, otherwise init() messages queue + // and may not render reliably. + const webReadyHandlesRef = useRef>(new Set()) + const activeHandleRef = useRef(null) + const subscribeSeqRef = useRef>(new Map()) + const sendingRef = useRef(false) + // Why: tracks the pixel height of the terminal frame so measureFitDimensions + // can use the exact container height instead of relying on window.innerHeight, + // which can overstate the visible area due to layout timing. + const terminalFrameHeightRef = useRef(0) + + const canSend = connState === 'connected' && activeHandle != null + + const getTerminalRef = useCallback((handle: string | null) => { + return handle ? terminalRefs.current.get(handle) : undefined + }, []) + + const unsubscribeTerminal = useCallback((handle: string) => { + terminalUnsubsRef.current.get(handle)?.() + terminalUnsubsRef.current.delete(handle) + subscribingHandlesRef.current.delete(handle) + subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + }, []) + + const clearTerminalCache = useCallback(() => { + for (const unsub of terminalUnsubsRef.current.values()) { + unsub() + } + terminalUnsubsRef.current.clear() + subscribingHandlesRef.current.clear() + initializedHandlesRef.current.clear() + webReadyHandlesRef.current.clear() + subscribeSeqRef.current.clear() + for (const term of terminalRefs.current.values()) { + term.clear() + } + }, []) + + // Why: measures the phone viewport once from the first available TerminalWebView. + // The viewport dims are passed with every subscribe call so the server can + // auto-fit the PTY without a separate RPC round-trip. + const measureViewportOnce = useCallback( + async (handle: string) => { + if (viewportMeasuredRef.current) return + const dims = await getTerminalRef(handle)?.measureFitDimensions( + terminalFrameHeightRef.current || undefined + ) + if (dims) { + viewportRef.current = dims + viewportMeasuredRef.current = true + } + }, + [getTerminalRef] + ) + + const subscribeToTerminal = useCallback( + (handle: string) => { + if (!client) return + if (terminalUnsubsRef.current.has(handle)) return + if (subscribingHandlesRef.current.has(handle)) return + if (!getTerminalRef(handle)) { + return + } + + subscribingHandlesRef.current.add(handle) + const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 + subscribeSeqRef.current.set(handle, seq) + + console.log( + `[mobile-fit] subscribeToTerminal handle=${handle} seq=${seq} viewport=${viewportRef.current ? `${viewportRef.current.cols}x${viewportRef.current.rows}` : 'none'} measured=${viewportMeasuredRef.current}` + ) + + // Why: server handles auto-fit on subscribe — no terminal.focus call needed. + // The viewport is embedded in the subscribe params so the server resizes + // the PTY before serializing scrollback. This eliminates the focus→safeFit + // race and the measure→resize→resubscribe pipeline. + const unsub = client.subscribe( + 'terminal.subscribe', + { + terminal: handle, + client: { id: deviceTokenRef.current!, type: 'mobile' as const }, + viewport: viewportRef.current ?? undefined + }, + (result) => { + if (subscribeSeqRef.current.get(handle) !== seq) return + const data = result as Record + if (data.type === 'scrollback') { + console.log( + `[mobile-fit] scrollback handle=${handle} cols=${data.cols} rows=${data.rows} displayMode=${data.displayMode} hasSerialized=${!!data.serialized} alreadyInit=${initializedHandlesRef.current.has(handle)}` + ) + if (initializedHandlesRef.current.has(handle)) return + const cols = (data.cols as number) || 80 + const rows = (data.rows as number) || 24 + const initialData = + typeof data.serialized === 'string' && data.serialized.length > 0 + ? data.serialized + : '' + getTerminalRef(handle)?.init(cols, rows, initialData) + initializedHandlesRef.current.add(handle) + if (data.displayMode) { + setTerminalModes((prev) => + new Map(prev).set(handle, data.displayMode as MobileDisplayMode) + ) + } + // Why: viewport measurement needs xterm to be initialized (cell + // dimensions come from the renderer). On the first subscribe the + // WebView hasn't loaded yet, so viewportRef is null and the server + // can't auto-fit. After the first init we can measure, then + // resubscribe so the server gets the viewport and phone-fits. + if (!viewportMeasuredRef.current) { + void (async () => { + const dims = await getTerminalRef(handle)?.measureFitDimensions( + terminalFrameHeightRef.current || undefined + ) + if (dims && !viewportMeasuredRef.current) { + viewportRef.current = dims + viewportMeasuredRef.current = true + unsubscribeTerminal(handle) + initializedHandlesRef.current.delete(handle) + subscribeToTerminal(handle) + } + })() + } + } else if (data.type === 'data') { + getTerminalRef(handle)?.write(data.chunk as string) + } else if (data.type === 'resized') { + console.log( + `[mobile-fit] resized handle=${handle} cols=${data.cols} rows=${data.rows} displayMode=${data.displayMode} reason=${(data as Record).reason}` + ) + // Why: inline resize event — the server changed the PTY dimensions + // (mode toggle or desktop restore). Reinitialize xterm at the new + // dims with fresh scrollback. No resubscribe needed. + const cols = (data.cols as number) || 80 + const rows = (data.rows as number) || 24 + const serialized = + typeof data.serialized === 'string' && data.serialized.length > 0 + ? data.serialized + : '' + getTerminalRef(handle)?.init(cols, rows, serialized) + if (data.displayMode) { + setTerminalModes((prev) => + new Map(prev).set(handle, data.displayMode as MobileDisplayMode) + ) + } + setTimeout(() => getTerminalRef(handle)?.resetZoom(), 200) + } + } + ) + + if (subscribeSeqRef.current.get(handle) === seq) { + terminalUnsubsRef.current.set(handle, unsub) + } else { + unsub() + } + subscribingHandlesRef.current.delete(handle) + }, + [client, getTerminalRef] + ) + + // Why: toggles between phone and desktop mode via server RPC. The server + // handles the actual resize and emits a 'resized' event on the existing + // subscription stream — no client-side state tracking needed. + const toggleInFlightRef = useRef>(new Set()) + const toggleDisplayMode = useCallback( + async (handle: string) => { + if (!client) return + if (toggleInFlightRef.current.has(handle)) return + const current = terminalModes.get(handle) ?? 'auto' + const next: MobileDisplayMode = current === 'auto' || current === 'phone' ? 'desktop' : 'auto' + toggleInFlightRef.current.add(handle) + try { + await client.sendRequest('terminal.setDisplayMode', { terminal: handle, mode: next }) + } catch { + // Mode change failed — server state unchanged, UI stays in sync. + } finally { + toggleInFlightRef.current.delete(handle) + } + }, + [client, terminalModes] + ) + + const lastKnownTerminalCountRef = useRef(0) + + const fetchTerminals = useCallback( + async (opts: { allowEmptyLoaded?: boolean } = {}) => { + if (!client) return + const allowEmptyLoaded = opts.allowEmptyLoaded ?? true + + try { + const response = await client.sendRequest('terminal.list', { + worktree: `id:${worktreeId}` + }) + if (response.ok) { + const result = (response as RpcSuccess).result as { terminals: Terminal[] } + console.log( + `[mobile-fit] fetchTerminals count=${result.terminals.length} allowEmpty=${allowEmptyLoaded} activeHandle=${activeHandleRef.current} lastKnown=${lastKnownTerminalCountRef.current}` + ) + + if (result.terminals.length === 0 && !allowEmptyLoaded) { + return + } + // Why: protect against transient empty responses from the server + // during rapid tab switching or RPC timing. If we previously had + // terminals and the server now says 0, require a second consecutive + // empty to confirm. This prevents the UI from flashing empty during + // rapid interactions while still allowing genuine cleanup. + if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) { + lastKnownTerminalCountRef.current = 0 + console.log( + `[mobile-fit] fetchTerminals SKIP first empty — will clear on next fetch if still empty` + ) + return + } + + const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) + for (const handle of Array.from(terminalUnsubsRef.current.keys())) { + if (!liveHandles.has(handle)) { + unsubscribeTerminal(handle) + terminalRefs.current.delete(handle) + initializedHandlesRef.current.delete(handle) + } + } + lastKnownTerminalCountRef.current = result.terminals.length + const current = activeHandleRef.current + + setTerminals(result.terminals) + setTerminalsLoaded(true) + + if (!current || !result.terminals.some((t) => t.handle === current)) { + const active = result.terminals.find((t) => t.isActive) ?? result.terminals[0] + if (active) { + activeHandleRef.current = active.handle + setActiveHandle(active.handle) + subscribeToTerminal(active.handle) + } else { + activeHandleRef.current = null + setActiveHandle(null) + } + } + } + } catch { + // Failed to list terminals + } + }, + [client, worktreeId, subscribeToTerminal, unsubscribeTerminal] + ) + + useEffect(() => { + let disposed = false + let rpcClient: RpcClient | null = null + + void (async () => { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (!host || disposed) return + + deviceTokenRef.current = host.deviceToken + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) + if (disposed) { + rpcClient.close() + return + } + setClient(rpcClient) + clientRef.current = rpcClient + })() + + return () => { + disposed = true + clearTerminalCache() + rpcClient?.close() + if (clientRef.current === rpcClient) { + clientRef.current = null + } + } + }, [clearTerminalCache, hostId]) + + useEffect(() => { + void loadCustomKeys().then(setCustomKeys) + }, []) + + useEffect(() => { + if (hostId && worktreeId) { + void AsyncStorage.setItem( + 'orca:last-visited-worktree', + JSON.stringify({ hostId, worktreeId }) + ) + } + }, [hostId, worktreeId]) + + const handleDeleteCustomKey = useCallback( + async (key: CustomKey) => { + const updated = customKeys.filter((k) => k.id !== key.id) + setCustomKeys(updated) + await AsyncStorage.setItem('orca:custom-accessory-keys', JSON.stringify(updated)) + }, + [customKeys] + ) + + useEffect(() => { + clearTerminalCache() + activeHandleRef.current = null + setActiveHandle(null) + setTerminals([]) + }, [clearTerminalCache, worktreeId]) + + useEffect(() => { + if (connState !== 'connected') return + // Why: on reconnect the RPC client auto-resends terminal.subscribe, + // creating new server-side handlers. Clear local subscription state + // so subscribeToTerminal's guards don't block fresh subscriptions, + // and clear xterm buffers so the new scrollback snapshot replaces + // stale content (including data that arrived while disconnected). + clearTerminalCache() + setTerminalsLoaded(false) + let disposed = false + const timers: ReturnType[] = [] + function addTimer(fn: () => void, ms: number) { + if (disposed) return + timers.push(setTimeout(fn, ms)) + } + void (async () => { + if (client && created !== '1') { + await client + .sendRequest('worktree.activate', { + worktree: `id:${worktreeId}` + }) + .catch(() => null) + } + if (disposed) return + await fetchTerminals({ allowEmptyLoaded: false }) + if (disposed) return + addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750) + addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500) + if (client && created === '1') { + addTimer(() => { + if (activeHandleRef.current) return + void (async () => { + await client + .sendRequest('worktree.activate', { + worktree: `id:${worktreeId}` + }) + .catch(() => null) + if (disposed) return + await fetchTerminals({ allowEmptyLoaded: true }) + addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750) + })() + }, 1800) + } + })() + return () => { + disposed = true + for (const t of timers) clearTimeout(t) + } + }, [client, connState, created, fetchTerminals, worktreeId]) + + useEffect(() => { + if (connState !== 'connected') return + const interval = setInterval(() => { + void fetchTerminals() + }, 2000) + return () => clearInterval(interval) + }, [connState, fetchTerminals]) + + // Why: unsubscribe the old terminal so the server restores its desktop dims + // (clearing the phone-fit banner), then subscribe the new terminal with the + // measured viewport so the server phone-fits it. Also call terminal.focus + // so the desktop renderer follows the mobile user's active terminal. + const switchTab = useCallback( + (handle: string) => { + const prev = activeHandleRef.current + console.log( + `[mobile-fit] switchTab prev=${prev} next=${handle} hasUnsub=${terminalUnsubsRef.current.has(handle)} hasRef=${!!terminalRefs.current.get(handle)}` + ) + activeHandleRef.current = handle + setActiveHandle(handle) + if (prev && prev !== handle) { + unsubscribeTerminal(prev) + initializedHandlesRef.current.delete(prev) + } + // Force a fresh subscribe even if eagerly subscribed without viewport + if (terminalUnsubsRef.current.has(handle)) { + unsubscribeTerminal(handle) + initializedHandlesRef.current.delete(handle) + } + subscribeToTerminal(handle) + if (client) { + void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {}) + } + }, + [client, subscribeToTerminal, unsubscribeTerminal] + ) + + // Why: just store the ref. Subscription is deferred to handleTerminalWebReady + // which fires after the WebView has loaded xterm.js and is ready to process + // init messages. This prevents the blank terminal race where init() was + // queued before the WebView loaded. + const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => { + if (ref) { + terminalRefs.current.set(handle, ref) + console.log( + `[mobile-fit] setTerminalWebViewRef handle=${handle} isActive=${handle === activeHandleRef.current} webReady=${webReadyHandlesRef.current.has(handle)}` + ) + } else { + terminalRefs.current.delete(handle) + } + }, []) + + const handleTerminalWebReady = useCallback( + (handle: string) => { + const wasAlreadyReady = webReadyHandlesRef.current.has(handle) + webReadyHandlesRef.current.add(handle) + console.log( + `[mobile-fit] handleTerminalWebReady handle=${handle} isActive=${handle === activeHandleRef.current} wasAlreadyReady=${wasAlreadyReady} wasInitialized=${initializedHandlesRef.current.has(handle)}` + ) + if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) { + // Why: the native WebView reloaded (Metro hot reload or Android + // process churn). The old xterm buffer is gone, so force a fresh + // scrollback snapshot. Only resubscribe if this is a reload — on + // first load the subscription is already running and pendingMessages + // will flush the queued init after this callback returns. + unsubscribeTerminal(handle) + initializedHandlesRef.current.delete(handle) + if (handle === activeHandleRef.current) { + subscribeToTerminal(handle) + } + return + } + // Why: on first web-ready, the initial subscribeToTerminal call from + // fetchTerminals may have been skipped (reason=no-ref, WebView wasn't + // mounted yet). Now that the WebView is ready, subscribe if this is the + // active terminal and no subscription is running. + if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) { + void measureViewportOnce(handle) + subscribeToTerminal(handle) + } + }, + [measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + ) + + async function handleSend() { + if (!client || !activeHandle || sendingRef.current) return + sendingRef.current = true + + const text = input + setInput('') + + try { + await client.sendRequest('terminal.send', { + terminal: activeHandle, + text, + enter: true + }) + } catch { + setInput(text) + } finally { + sendingRef.current = false + } + } + + async function handleAccessoryKey(bytes: string) { + if (!client || !activeHandle || !canSend) return + + try { + await client.sendRequest('terminal.send', { + terminal: activeHandle, + text: bytes, + enter: false + }) + } catch { + // Transient failure + } + } + + async function handleCreateTerminal() { + if (!client || creating) return + + setCreating(true) + setCreateError('') + + try { + const response = await client.sendRequest('terminal.create', { + worktree: `id:${worktreeId}` + }) + if (response.ok) { + const result = (response as RpcSuccess).result as TerminalCreateResult + const created = result.terminal + // Why: unsubscribe the old active terminal so the server restores its + // desktop dims. Without this, the old terminal's mobile subscription + // stays alive and its restore timer is never set. + const prev = activeHandleRef.current + if (prev) { + unsubscribeTerminal(prev) + initializedHandlesRef.current.delete(prev) + } + activeHandleRef.current = created.handle + setActiveHandle(created.handle) + setTerminals((prev) => [ + ...prev, + { handle: created.handle, title: created.title || 'Terminal', isActive: true } + ]) + subscribeToTerminal(created.handle) + setTimeout(() => void fetchTerminals(), 500) + } else { + setCreateError('Failed to create terminal') + } + } catch { + setCreateError('Failed to create terminal') + } finally { + setCreating(false) + } + } + + async function handleRenameTerminal(value: string) { + if (!client || !renameTarget) return + const target = renameTarget + setRenameTarget(null) + + try { + const title = value.trim() + const response = await client.sendRequest('terminal.rename', { + terminal: target.handle, + title + }) + if (response.ok) { + setTerminals((prev) => + prev.map((terminal) => + terminal.handle === target.handle + ? { ...terminal, title: title || 'Terminal' } + : terminal + ) + ) + setTimeout(() => void fetchTerminals(), 300) + } + } catch { + // Rename failed — refresh will restore the server title. + } + } + + async function handleCloseTerminal(target: Terminal) { + if (!client) return + + try { + const response = await client.sendRequest('terminal.close', { + terminal: target.handle + }) + if (response.ok) { + unsubscribeTerminal(target.handle) + terminalRefs.current.delete(target.handle) + initializedHandlesRef.current.delete(target.handle) + const next = terminals.filter((terminal) => terminal.handle !== target.handle) + setTerminals(next) + if (activeHandleRef.current === target.handle) { + const replacement = next[0] ?? null + activeHandleRef.current = replacement?.handle ?? null + setActiveHandle(replacement?.handle ?? null) + if (replacement) { + subscribeToTerminal(replacement.handle) + } + } + setTimeout(() => void fetchTerminals(), 300) + } + } catch { + // Close failed — keep the local tab list unchanged. + } + } + + const isPhoneMode = (handle: string | null): boolean => { + if (!handle) return false + const mode = terminalModes.get(handle) + return mode === 'auto' || mode === 'phone' || mode === undefined + } + + const showLoadingState = connState === 'connected' && !terminalsLoaded + const showEmptyState = + connState === 'connected' && terminalsLoaded && terminals.length === 0 && !activeHandle + const terminalSummary = + connState === 'connected' + ? !terminalsLoaded + ? 'Loading terminals' + : terminals.length === 1 + ? '1 terminal' + : `${terminals.length} terminals` + : STATUS_LABELS[connState] + + return ( + + + + + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => router.back()} + hitSlop={8} + accessibilityLabel="Back to worktrees" + > + + + + + + {worktreeName || 'Terminal'} + + + + + {terminalSummary} + + + + + + {terminals.length > 0 && ( + + + {terminals.map((t) => ( + switchTab(t.handle)} + onLongPress={() => { + triggerMediumImpact() + setActionTarget(t) + }} + delayLongPress={400} + > + + {t.title || 'Terminal'} + + + ))} + [ + styles.newTerminalButton, + pressed && styles.newTerminalButtonPressed, + (creating || connState !== 'connected') && styles.newTerminalButtonDisabled + ]} + disabled={creating || connState !== 'connected'} + onPress={() => void handleCreateTerminal()} + accessibilityLabel="New terminal" + > + + + + + )} + + + {showLoadingState ? ( + + + + ) : showEmptyState ? ( + + No terminals in this session + {createError ? {createError} : null} + void handleCreateTerminal()} + > + + {creating ? 'Creating…' : 'Create Terminal'} + + + + ) : ( + { + terminalFrameHeightRef.current = e.nativeEvent.layout.height + }} + > + {terminals.map((terminal) => ( + + ))} + + )} + + {/* Accessory keys */} + + + [ + styles.accessoryKey, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={() => { + if (activeHandle) { + void toggleDisplayMode(activeHandle) + } + }} + accessibilityLabel={ + isPhoneMode(activeHandle) ? 'Switch to desktop mode' : 'Switch to phone mode' + } + > + {isPhoneMode(activeHandle) ? ( + + ) : ( + + )} + + {ACCESSORY_KEYS.map((key) => ( + [ + styles.accessoryKey, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={() => void handleAccessoryKey(key.bytes)} + accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`} + > + + {key.label} + + + ))} + {customKeys.map((key) => ( + [ + styles.accessoryKey, + styles.customAccessoryKey, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={() => void handleAccessoryKey(key.bytes)} + onLongPress={() => { + triggerMediumImpact() + setDeleteKeyTarget(key) + }} + delayLongPress={400} + accessibilityLabel={`Send ${key.label}`} + > + + {key.label} + + + ))} + [styles.accessoryKey, pressed && styles.accessoryKeyPressed]} + onPress={() => setShowCustomKeyModal(true)} + accessibilityLabel="Add custom shortcut" + > + + + + + + {/* Input bar */} + + void handleSend()} + /> + void handleSend()} + accessibilityLabel="Send command" + > + + + + + + { + const target = actionTarget + setActionTarget(null) + if (target) { + void toggleDisplayMode(target.handle) + } + } + } + ] + : []), + { + label: 'Rename', + onPress: () => { + const target = actionTarget + setActionTarget(null) + if (target) { + setRenameTarget(target) + } + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + const target = actionTarget + setActionTarget(null) + if (target) { + void handleCloseTerminal(target) + } + } + } + ]} + onClose={() => setActionTarget(null)} + /> + void handleRenameTerminal(value)} + onCancel={() => setRenameTarget(null)} + /> + setShowCustomKeyModal(false)} + onKeysChanged={setCustomKeys} + /> + { + if (deleteKeyTarget) { + void handleDeleteCustomKey(deleteKeyTarget) + } + setDeleteKeyTarget(null) + } + } + ]} + onClose={() => setDeleteKeyTarget(null)} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + kavInner: { + flex: 1 + }, + sessionChrome: { + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + sessionTopBar: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + sessionTitleBlock: { + flex: 1, + minWidth: 0 + }, + sessionTitle: { + color: colors.textPrimary, + fontSize: 14, + fontWeight: '600' + }, + sessionMetaRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2 + }, + sessionMetaText: { + flexShrink: 1, + color: colors.textSecondary, + fontSize: typography.metaSize + }, + tabBar: { + flexDirection: 'row', + alignItems: 'center', + borderTopWidth: 1, + borderTopColor: colors.borderSubtle + }, + tabScroll: { + flex: 1, + maxHeight: 36 + }, + tabContent: { + paddingLeft: spacing.sm, + paddingRight: spacing.sm + }, + tab: { + width: 128, + maxWidth: 128, + minHeight: 36, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + tabActive: { + borderBottomColor: colors.accentBlue + }, + tabText: { + maxWidth: '100%', + color: colors.textSecondary, + fontSize: 13 + }, + tabTextActive: { + color: colors.textPrimary + }, + newTerminalButton: { + width: 40, + height: 36, + alignItems: 'center', + justifyContent: 'center', + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + newTerminalButtonPressed: { + backgroundColor: colors.bgRaised + }, + newTerminalButtonDisabled: { + opacity: 0.45 + }, + terminalFrame: { + flex: 1, + minHeight: 0, + position: 'relative', + overflow: 'hidden' + }, + terminalPane: { + ...StyleSheet.absoluteFillObject + }, + terminalPaneHidden: { + opacity: 0 + }, + terminalWebView: { + flex: 1 + }, + emptyState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + marginBottom: spacing.lg + }, + createError: { + color: colors.statusRed, + fontSize: 13, + marginBottom: spacing.sm + }, + createButton: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button + }, + createButtonDisabled: { + opacity: 0.5 + }, + createButtonText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + accessoryBar: { + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + accessoryContent: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + gap: spacing.xs + }, + accessoryKey: { + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs, + borderRadius: radii.button, + minWidth: 36, + alignItems: 'center' + }, + accessoryKeyPressed: { + backgroundColor: colors.borderSubtle + }, + customAccessoryKey: { + borderWidth: 1, + borderColor: colors.borderSubtle + }, + accessoryKeyDisabled: { + opacity: 0.35 + }, + accessoryKeyText: { + color: colors.textSecondary, + fontSize: 12, + fontFamily: typography.monoFamily + }, + accessoryKeyTextDisabled: { + color: colors.textMuted + }, + inputBar: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + textInput: { + flex: 1, + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 14, + fontFamily: typography.monoFamily, + marginRight: spacing.sm + }, + sendButton: { + backgroundColor: colors.bgRaised, + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center' + }, + sendButtonDisabled: { + opacity: 0.35 + } +}) diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx new file mode 100644 index 00000000000..59bbf7eeaf4 --- /dev/null +++ b/mobile/app/h/_layout.tsx @@ -0,0 +1,16 @@ +import { Stack } from 'expo-router' +import { colors } from '../../src/theme/mobile-theme' + +export default function HostGroupLayout() { + return ( + + + + + ) +} diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx new file mode 100644 index 00000000000..046e8ddb01a --- /dev/null +++ b/mobile/app/index.tsx @@ -0,0 +1,947 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from 'react' +import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useRouter, useFocusEffect } from 'expo-router' +import { + Monitor, + QrCode, + Settings, + Bot, + Clock, + GitPullRequest, + ChevronRight, + Terminal, + Plus +} from 'lucide-react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { loadHosts, removeHost, renameHost } from '../src/transport/host-store' +import { connect, type RpcClient } from '../src/transport/rpc-client' +import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' +import type { ConnectionState, HostProfile } from '../src/transport/types' +import { triggerMediumImpact } from '../src/platform/haptics' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { TextInputModal } from '../src/components/TextInputModal' +import { ActionSheetModal } from '../src/components/ActionSheetModal' +import { ConfirmModal } from '../src/components/ConfirmModal' +import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache' +import { colors, spacing, radii } from '../src/theme/mobile-theme' + +function endpointLabel(endpoint: string): string { + try { + const url = new URL(endpoint) + return `${url.hostname}${url.port ? `:${url.port}` : ''}` + } catch { + return endpoint + } +} + +const STATUS_LABELS: Record = { + connected: 'Connected', + connecting: 'Connecting…', + disconnected: 'Disconnected', + reconnecting: 'Reconnecting…', + handshaking: 'Connecting…', + 'auth-failed': 'Auth failed' +} + +type StatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null +} + +type WorktreeSummary = { + worktreeId: string + repo: string + branch: string + displayName: string + liveTerminalCount: number + status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' +} + +type HostWorktreeInfo = { + hostId: string + totalWorktrees: number + activeCount: number + lastActiveWorktree: WorktreeSummary | null +} + +function formatDuration(ms: number): string { + const totalMinutes = Math.floor(ms / 60_000) + const totalHours = Math.floor(totalMinutes / 60) + const days = Math.floor(totalHours / 24) + const hours = totalHours % 24 + if (days > 0) return `${days}d ${hours}h` + const minutes = totalMinutes % 60 + if (totalHours > 0) return `${totalHours}h ${minutes}m` + return `${totalMinutes}m` +} + +function fetchStats( + client: RpcClient, + setStats: (s: StatsSummary) => void, + disposed: () => boolean +) { + client + .sendRequest('stats.summary') + .then((response) => { + if (disposed()) return + if (response.ok) { + setStats(response.result as StatsSummary) + } + }) + .catch(() => {}) +} + +function fetchWorktreeInfo( + client: RpcClient, + hostId: string, + setInfo: ( + updater: (prev: Record) => Record + ) => void, + disposed: () => boolean +) { + const markLoaded = () => { + setInfo((prev) => ({ + ...prev, + [hostId]: { + hostId, + totalWorktrees: 0, + activeCount: 0, + lastActiveWorktree: null + } + })) + } + + client + .sendRequest('worktree.ps') + .then((response) => { + if (disposed()) return + if (response.ok) { + const result = response.result as { worktrees: WorktreeSummary[] } + const worktrees = result.worktrees ?? [] + setCachedWorktrees(hostId, worktrees) + const activeStatuses = new Set(['working', 'active', 'permission']) + const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status)) + const lastActive = active.length > 0 ? active[0] : (worktrees[0] ?? null) + setInfo((prev) => ({ + ...prev, + [hostId]: { + hostId, + totalWorktrees: worktrees.length, + activeCount: active.length, + lastActiveWorktree: lastActive + } + })) + } else { + markLoaded() + } + }) + .catch(() => { + if (!disposed()) markLoaded() + }) +} + +// Why: repo names get a stable color derived from hashing, matching the +// host detail page's colored dots for visual consistency. +const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] +function repoColor(name: string): string { + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = (hash * 31 + name.charCodeAt(i)) | 0 + } + return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length] +} + +export default function HomeScreen() { + const router = useRouter() + const [hosts, setHosts] = useState([]) + const [actionTarget, setActionTarget] = useState(null) + const [renameTarget, setRenameTarget] = useState(null) + const [confirmRemove, setConfirmRemove] = useState(null) + const [hostStates, setHostStates] = useState>({}) + const [stats, setStats] = useState(null) + const [worktreeInfo, setWorktreeInfo] = useState>({}) + const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( + null + ) + const clientsRef = useRef>([]) + + useFocusEffect( + useCallback(() => { + let stale = false + void loadHosts().then((h) => { + if (!stale) setHosts(h) + }) + void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => { + if (stale || !raw) return + try { + setLastVisited(JSON.parse(raw)) + } catch {} + }) + for (const entry of clientsRef.current) { + if (entry.client.getState() === 'connected') { + fetchStats(entry.client, setStats, () => stale) + fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + } + } + return () => { + stale = true + } + }, []) + ) + + const sortedHosts = useMemo( + () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), + [hosts] + ) + + useEffect(() => { + let disposed = false + const notifCleanups: Array<() => void> = [] + const entries = hosts.flatMap((host) => { + if (!host.publicKeyB64 || !host.deviceToken) { + setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' })) + return [] + } + setHostStates((prev) => ({ + ...prev, + [host.id]: prev[host.id] ?? 'connecting' + })) + let client: ReturnType + try { + client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => { + if (disposed) return + setHostStates((prev) => ({ ...prev, [host.id]: state })) + }) + } catch { + setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' })) + return [] + } + + let unsubNotif: (() => void) | null = null + let statsFetched = false + const unsubState = client.onStateChange((state) => { + if (state === 'connected') { + if (!unsubNotif) { + unsubNotif = subscribeToDesktopNotifications(client) + } + if (!statsFetched) { + statsFetched = true + fetchStats(client, setStats, () => disposed) + fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed) + } + } else if (unsubNotif) { + unsubNotif() + unsubNotif = null + } + }) + notifCleanups.push(() => { + unsubState() + unsubNotif?.() + }) + + return [{ hostId: host.id, client }] + }) + + clientsRef.current = entries + + return () => { + disposed = true + clientsRef.current = [] + for (const cleanup of notifCleanups) cleanup() + for (const entry of entries) entry.client.close() + } + }, [hosts]) + + // Why: prefer the worktree the user last opened on this device so the + // "Resume" card reflects their mobile session history, not just the + // desktop's most-recently-outputting worktree. + const resumeWorktree = useMemo(() => { + if (lastVisited && hostStates[lastVisited.hostId] === 'connected') { + const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null + const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId) + if (match) return { hostId: lastVisited.hostId, worktree: match } + } + for (const host of sortedHosts) { + if (hostStates[host.id] !== 'connected') continue + const info = worktreeInfo[host.id] + if (info?.lastActiveWorktree) { + return { hostId: host.id, worktree: info.lastActiveWorktree } + } + } + return null + }, [sortedHosts, hostStates, worktreeInfo, lastVisited]) + + const resumeLoading = useMemo( + () => + sortedHosts.some((host) => { + const state = hostStates[host.id] ?? 'connecting' + return ( + state === 'connecting' || + state === 'handshaking' || + state === 'reconnecting' || + (state === 'connected' && !worktreeInfo[host.id]) + ) + }), + [sortedHosts, hostStates, worktreeInfo] + ) + + async function handleRename(newName: string) { + if (!renameTarget) return + try { + await renameHost(renameTarget.id, newName) + setRenameTarget(null) + setHosts(await loadHosts()) + } catch { + setRenameTarget(null) + } + } + + async function handleRemove() { + if (!confirmRemove) return + try { + await removeHost(confirmRemove.id) + setConfirmRemove(null) + setHosts(await loadHosts()) + } catch { + setConfirmRemove(null) + } + } + + return ( + + {/* ─── Top bar ─── */} + + + + + + Orca + + [styles.iconButton, pressed && styles.iconButtonPressed]} + onPress={() => router.push('/settings')} + > + + + + + {hosts.length === 0 ? ( + /* ─── Empty state: onboarding ─── */ + + + Connect your desktop + + Pair with Orca on your computer to monitor worktrees, watch agents work, and manage + terminals — all from your phone. + + router.push('/pair-scan')}> + + Scan Pairing Code + + + + + How it works + {ONBOARDING_STEPS.map((step, i) => ( + 0 && styles.stepRowBorder]}> + + {i + 1} + + + {step.title} + {step.desc} + + + ))} + + + ) : ( + /* ─── Populated state ─── */ + h.id} + contentContainerStyle={styles.list} + ListHeaderComponent={ + + + Welcome back + + + {stats && ( + + + + + + + {stats.totalAgentsSpawned.toLocaleString()} + + Agents spawned + + + + + + {formatDuration(stats.totalAgentTimeMs)} + Agent time + + + + + + {stats.totalPRsCreated.toLocaleString()} + PRs created + + + )} + + Desktops + + } + ItemSeparatorComponent={CardGap} + renderItem={({ item }) => { + const state = hostStates[item.id] ?? 'connecting' + const connected = state === 'connected' + const info = worktreeInfo[item.id] + return ( + [styles.hostCard, pressed && styles.hostCardPressed]} + onPress={() => router.push(`/h/${item.id}`)} + onLongPress={() => { + triggerMediumImpact() + setActionTarget(item) + }} + delayLongPress={400} + > + + + + + + {item.name} + + + + + {STATUS_LABELS[state]} + {connected && info + ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` + : ''} + + + + + + ) + }} + ListFooterComponent={ + + {/* ─── Resume card ─── */} + {resumeWorktree ? ( + <> + Resume + [styles.resumeCard, pressed && styles.hostCardPressed]} + onPress={() => + router.push( + `/h/${resumeWorktree.hostId}/session/${encodeURIComponent(resumeWorktree.worktree.worktreeId)}` + ) + } + > + + + + + + {resumeWorktree.worktree.displayName} + + + + + {resumeWorktree.worktree.repo} + {' · '} + {resumeWorktree.worktree.branch} + + + + + + + ) : hosts.length > 0 && resumeLoading ? ( + <> + Resume + + + + + + + + + ) : null} + + {/* ─── Quick actions ─── */} + Quick Actions + + [styles.quickAction, pressed && styles.hostCardPressed]} + onPress={() => router.push('/pair-scan')} + > + + + + Pair Desktop + + [styles.quickAction, pressed && styles.hostCardPressed]} + onPress={() => { + const connectedHost = sortedHosts.find((h) => hostStates[h.id] === 'connected') + if (connectedHost) { + router.push(`/h/${connectedHost.id}?action=newWorktree`) + } + }} + > + + + + New Worktree + + + + } + /> + )} + + {/* ─── Action sheets (shared by both states) ─── */} + { + const host = actionTarget + setActionTarget(null) + if (host) setRenameTarget(host) + } + }, + { + label: 'Remove', + destructive: true, + onPress: () => { + const host = actionTarget + setActionTarget(null) + if (host) setConfirmRemove(host) + } + } + ]} + onClose={() => setActionTarget(null)} + /> + + void handleRename(name)} + onCancel={() => setRenameTarget(null)} + /> + + void handleRemove()} + onCancel={() => setConfirmRemove(null)} + /> + + ) +} + +function CardGap() { + return +} + +const ONBOARDING_STEPS = [ + { + title: 'Open Orca desktop', + desc: 'Go to Settings → Mobile and generate a pairing QR code.' + }, + { + title: 'Scan the code', + desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.' + }, + { + title: "You're connected", + desc: 'Your desktop will appear here. Everything is encrypted end-to-end.' + } +] + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + + /* ─── Top bar ─── */ + topBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + paddingBottom: spacing.md + }, + brandLockup: { + flexDirection: 'row', + alignItems: 'center', + minWidth: 0 + }, + logoMark: { + marginRight: spacing.sm + }, + brandName: { + color: colors.textPrimary, + fontSize: 17, + fontWeight: '700' + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + iconButtonPressed: { + backgroundColor: colors.bgRaised + }, + + /* ─── Hero / greeting ─── */ + hero: { + paddingTop: spacing.md, + paddingBottom: spacing.lg + }, + heroTitle: { + color: colors.textPrimary, + fontSize: 26, + fontWeight: '800', + letterSpacing: -0.3 + }, + + /* ─── Stat cards ─── */ + statsRow: { + flexDirection: 'row', + gap: 10, + marginBottom: spacing.xl + }, + statCard: { + flex: 1, + backgroundColor: 'rgba(26,26,26,0.6)', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: 10, + padding: spacing.md + }, + statIcon: { + width: 30, + height: 30, + borderRadius: 7, + backgroundColor: 'rgba(255,255,255,0.04)', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 10 + }, + statValue: { + color: colors.textPrimary, + fontSize: 20, + fontWeight: '700', + letterSpacing: -0.3 + }, + statLabel: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '500', + marginTop: 3 + }, + + /* ─── Section heading ─── */ + sectionHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginBottom: spacing.sm, + paddingHorizontal: spacing.xs + }, + + /* ─── List ─── */ + list: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xl + }, + cardGap: { + height: spacing.sm + }, + + /* ─── Host cards ─── */ + hostCard: { + flexDirection: 'row', + alignItems: 'center', + paddingLeft: spacing.md, + paddingRight: spacing.md, + paddingVertical: 14, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + hostCardPressed: { + backgroundColor: colors.bgRaised + }, + hostIcon: { + width: 46, + height: 46, + borderRadius: 13, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + marginRight: 14, + position: 'relative' + }, + hostMain: { + flex: 1, + minWidth: 0, + marginRight: spacing.sm + }, + hostName: { + color: colors.textPrimary, + fontSize: 15, + fontWeight: '600', + lineHeight: 20 + }, + hostMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 3 + }, + hostMetaItem: { + fontSize: 12, + color: colors.textSecondary + }, + hostMetaDot: { + width: 3, + height: 3, + borderRadius: 1.5, + backgroundColor: colors.textMuted, + marginHorizontal: 8 + }, + statusDot: { + width: 7, + height: 7, + borderRadius: 3.5 + }, + + /* ─── Resume card ─── */ + resumeCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + paddingLeft: spacing.md, + paddingRight: spacing.md, + paddingVertical: 14 + }, + resumeIcon: { + width: 46, + height: 46, + borderRadius: 13, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + marginRight: 14 + }, + resumeMain: { + flex: 1, + minWidth: 0 + }, + resumeTitle: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + resumeSub: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 3 + }, + repoDot: { + width: 7, + height: 7, + borderRadius: 3.5 + }, + resumeSubText: { + fontSize: 12, + color: colors.textSecondary, + flex: 1 + }, + + /* ─── Skeleton ─── */ + skeletonBlock: { + backgroundColor: colors.bgRaised, + opacity: 0.5 + }, + skeletonLine: { + height: 12, + borderRadius: 4, + backgroundColor: colors.bgRaised, + opacity: 0.5 + }, + + /* ─── Quick actions ─── */ + quickActions: { + flexDirection: 'row', + gap: spacing.sm + }, + quickAction: { + flex: 1, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + padding: spacing.lg, + alignItems: 'center', + gap: 10 + }, + quickActionIcon: { + width: 40, + height: 40, + borderRadius: 12, + backgroundColor: 'rgba(255,255,255,0.04)', + alignItems: 'center', + justifyContent: 'center' + }, + quickActionLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + textAlign: 'center' + }, + + /* ─── Empty state ─── */ + emptyContainer: { + flex: 1 + }, + emptyGreeting: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm + }, + emptyHero: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + paddingBottom: 40 + }, + emptyTitle: { + fontSize: 22, + fontWeight: '700', + color: colors.textPrimary, + textAlign: 'center', + marginBottom: 10 + }, + emptyBody: { + fontSize: 15, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + marginBottom: 32 + }, + primaryButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: colors.textPrimary, + paddingHorizontal: 28, + paddingVertical: 14, + borderRadius: radii.card + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: 15, + fontWeight: '700' + }, + + /* ─── Onboarding steps ─── */ + stepsSection: { + paddingHorizontal: spacing.xl + }, + stepRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 14, + paddingVertical: spacing.lg + }, + stepRowBorder: { + borderTopWidth: 1, + borderTopColor: colors.borderSubtle + }, + stepNum: { + width: 28, + height: 28, + borderRadius: 8, + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: colors.borderSubtle, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1 + }, + stepNumText: { + fontSize: 12, + fontWeight: '700', + color: colors.textSecondary + }, + stepText: { + flex: 1 + }, + stepTitle: { + fontSize: 14, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 3 + }, + stepDesc: { + fontSize: 12, + color: colors.textMuted, + lineHeight: 17 + } +}) diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx new file mode 100644 index 00000000000..7b0fdf9cd43 --- /dev/null +++ b/mobile/app/notifications.tsx @@ -0,0 +1,109 @@ +import { useState, useCallback } from 'react' +import { View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter, useFocusEffect } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { + loadPushNotificationsEnabled, + savePushNotificationsEnabled +} from '../src/storage/preferences' +import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' + +export default function NotificationsScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [pushEnabled, setPushEnabled] = useState(true) + + useFocusEffect( + useCallback(() => { + void loadPushNotificationsEnabled().then(setPushEnabled) + }, []) + ) + + const togglePush = async (value: boolean) => { + if (value) { + const granted = await ensureNotificationPermissions() + if (!granted) return + } + setPushEnabled(value) + await savePushNotificationsEnabled(value) + } + + return ( + + + router.back()}> + + + Notifications + + + + + Push Notifications + void togglePush(v)} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + + Receive notifications when an agent task completes on your desktop. + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.xl + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + hint: { + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18, + paddingHorizontal: spacing.md + 2, + paddingBottom: spacing.md + } +}) diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx new file mode 100644 index 00000000000..ca522e7ddae --- /dev/null +++ b/mobile/app/pair-scan.tsx @@ -0,0 +1,307 @@ +import { useState, useRef, useCallback } from 'react' +import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { CameraView, useCameraPermissions } from 'expo-camera' +import { useRouter } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { decodePairingUrl } from '../src/transport/pairing' +import { connect } from '../src/transport/rpc-client' +import { saveHost, getNextHostName } from '../src/transport/host-store' +import type { PairingOffer } from '../src/transport/types' +import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' + +function Step({ number, text }: { number: number; text: string }) { + return ( + + + {number} + + {text} + + ) +} + +export default function PairScanScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [permission, requestPermission] = useCameraPermissions() + const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') + const [errorMessage, setErrorMessage] = useState('') + const processingRef = useRef(false) + + const handleBarCodeScanned = useCallback( + ({ data }: { data: string }) => { + if (processingRef.current) return + processingRef.current = true + + const offer = decodePairingUrl(data) + if (!offer) { + setStatus('error') + setErrorMessage('Not a valid Orca QR code') + processingRef.current = false + return + } + + void testAndSave(offer) + }, + [router] + ) + + async function testAndSave(offer: PairingOffer) { + setStatus('connecting') + let client: ReturnType | null = null + + try { + client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64) + const response = await client.sendRequest('status.get') + client.close() + client = null + + if (!response.ok) { + if (response.error.code === 'unauthorized') { + setStatus('error') + setErrorMessage('Authentication failed — token may be expired') + processingRef.current = false + return + } + setStatus('error') + setErrorMessage(`Server error: ${response.error.message}`) + processingRef.current = false + return + } + + const hostId = `host-${Date.now()}` + const hostName = await getNextHostName() + + await saveHost({ + id: hostId, + name: hostName, + endpoint: offer.endpoint, + deviceToken: offer.deviceToken, + publicKeyB64: offer.publicKeyB64, + lastConnected: Date.now() + }) + + router.replace(`/h/${hostId}`) + } catch { + setStatus('error') + setErrorMessage('Cannot connect — check that your computer is on the same network') + processingRef.current = false + } finally { + client?.close() + } + } + + function retry() { + setStatus('scanning') + setErrorMessage('') + processingRef.current = false + } + + const containerPadding = { paddingTop: insets.top + spacing.sm } + + if (!permission) { + return ( + + + + ) + } + + if (!permission.granted) { + return ( + + router.back()}> + + + + Camera Permission + + Orca needs camera access to scan the pairing QR code from your desktop. + + + Grant Camera Access + + + + ) + } + + return ( + + router.back()}> + + + + + + + + + + {status === 'scanning' && ( + + + + + + + + + + )} + + {status === 'connecting' && ( + + + Connecting… + + )} + + {status === 'error' && ( + + {errorMessage} + + Try Again + + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm + }, + steps: { + gap: spacing.sm, + marginBottom: spacing.lg, + marginLeft: 7 + }, + step: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stepBadge: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + stepNumber: { + fontSize: 12, + fontWeight: '700', + color: colors.textSecondary + }, + stepText: { + fontSize: typography.bodySize, + color: colors.textSecondary + }, + cameraWrap: { + flex: 1, + borderRadius: radii.camera, + overflow: 'hidden' + }, + camera: { + ...StyleSheet.absoluteFillObject + }, + reticle: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center' + }, + corner: { + position: 'absolute', + width: 28, + height: 28, + borderColor: 'rgba(255,255,255,0.7)' + }, + cornerTL: { + top: '30%', + left: '20%', + borderTopWidth: 2.5, + borderLeftWidth: 2.5, + borderTopLeftRadius: 6 + }, + cornerTR: { + top: '30%', + right: '20%', + borderTopWidth: 2.5, + borderRightWidth: 2.5, + borderTopRightRadius: 6 + }, + cornerBL: { + bottom: '30%', + left: '20%', + borderBottomWidth: 2.5, + borderLeftWidth: 2.5, + borderBottomLeftRadius: 6 + }, + cornerBR: { + bottom: '30%', + right: '20%', + borderBottomWidth: 2.5, + borderRightWidth: 2.5, + borderBottomRightRadius: 6 + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + title: { + fontSize: typography.titleSize, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: spacing.sm + }, + subtitle: { + fontSize: typography.bodySize, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.xl, + lineHeight: 20 + }, + connectingText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + marginTop: spacing.lg + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + textAlign: 'center', + marginBottom: spacing.xl, + lineHeight: 20 + }, + primaryButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx new file mode 100644 index 00000000000..171bd004853 --- /dev/null +++ b/mobile/app/settings.tsx @@ -0,0 +1,102 @@ +import { View, Text, StyleSheet, Pressable } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, ChevronRight, Info, Bell, Wrench } from 'lucide-react-native' +import { colors, spacing, typography } from '../src/theme/mobile-theme' + +export default function SettingsScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + + return ( + + + router.back()}> + + + Settings + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/notifications')} + > + + Notifications + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/troubleshoot')} + > + + Troubleshooting + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/about')} + > + + About + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.xl + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx new file mode 100644 index 00000000000..be800b74c31 --- /dev/null +++ b/mobile/app/troubleshoot.tsx @@ -0,0 +1,455 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { + View, + Text, + StyleSheet, + Pressable, + ScrollView, + ActivityIndicator, + Platform +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { + ChevronLeft, + ChevronDown, + ChevronUp, + WifiOff, + Shield, + Monitor, + Clock, + Globe, + Activity, + CheckCircle2, + XCircle, + AlertTriangle +} from 'lucide-react-native' +import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { loadHosts } from '../src/transport/host-store' + +type DiagnosticStatus = 'idle' | 'running' | 'done' + +type CheckResult = { + label: string + status: 'pass' | 'fail' | 'warn' + detail: string +} + +type TroubleshootSection = { + id: string + icon: React.ReactNode + title: string + steps: string[] +} + +const sections: TroubleshootSection[] = [ + { + id: 'wifi', + icon: , + title: 'Different WiFi Networks', + steps: [ + 'Both devices must be on the same local network.', + 'Ethernet and WiFi must share the same subnet.', + 'Try reconnecting WiFi on both devices.' + ] + }, + { + id: 'firewall', + icon: , + title: 'Firewall Blocking Port 6768', + steps: [ + 'macOS: System Settings → Network → Firewall — allow Orca.', + 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', + 'Linux: sudo ufw allow 6768', + 'Corporate/school networks may block P2P — try a personal hotspot.' + ] + }, + { + id: 'desktop', + icon: , + title: 'Desktop App Not Running', + steps: [ + 'Orca must be open on your desktop to accept connections.', + 'Try restarting Orca — the companion server starts on launch.', + 'After an update, you may need to re-pair via QR code.' + ] + }, + { + id: 'timeout', + icon: , + title: 'Connection Timeout', + steps: [ + 'Check WiFi signal strength on your phone.', + 'Go back to the host list and tap your host to retry.', + 'Restart both apps if timeouts persist.' + ] + }, + { + id: 'vpn', + icon: , + title: 'VPN Interference', + steps: [ + 'VPNs can route local traffic through a remote server.', + 'Disable the VPN or enable split tunneling / "Allow LAN".' + ] + } +] + +function StatusIcon({ status }: { status: CheckResult['status'] }) { + switch (status) { + case 'pass': + return + case 'fail': + return + case 'warn': + return + } +} + +export default function TroubleshootScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [expandedId, setExpandedId] = useState(null) + const [diagnosticStatus, setDiagnosticStatus] = useState('idle') + const [checks, setChecks] = useState([]) + const abortRef = useRef(false) + + useEffect(() => { + return () => { + abortRef.current = true + } + }, []) + + const toggleSection = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)) + }, []) + + const runDiagnostics = useCallback(async () => { + abortRef.current = false + setDiagnosticStatus('running') + setChecks([]) + + const results: CheckResult[] = [] + + try { + const hosts = await loadHosts() + results.push( + hosts.length > 0 + ? { label: 'Paired hosts', status: 'pass', detail: `${hosts.length} paired` } + : { label: 'Paired hosts', status: 'fail', detail: 'None — scan a QR to pair' } + ) + } catch { + results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' }) + } + + if (abortRef.current) return + setChecks([...results]) + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', { + signal: controller.signal + }) + clearTimeout(timeout) + results.push( + resp.ok + ? { label: 'Internet', status: 'pass', detail: 'Connected' } + : { label: 'Internet', status: 'warn', detail: 'Unexpected response' } + ) + } catch { + results.push({ label: 'Internet', status: 'fail', detail: 'No connection' }) + } + + if (abortRef.current) return + setChecks([...results]) + + try { + const hosts = await loadHosts() + for (const host of hosts) { + if (abortRef.current) return + const reachable = await testHostReachability(host.endpoint) + results.push({ + label: host.name, + status: reachable ? 'pass' : 'fail', + detail: reachable + ? `Reachable at ${formatEndpoint(host.endpoint)}` + : `Cannot reach ${formatEndpoint(host.endpoint)}` + }) + setChecks([...results]) + } + } catch { + results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' }) + } + + if (abortRef.current) return + + results.push({ + label: 'Platform', + status: 'pass', + detail: `${Platform.OS} ${Platform.Version ?? ''}` + }) + + setChecks([...results]) + setDiagnosticStatus('done') + }, []) + + return ( + + + router.back()}> + + + Troubleshooting + + + + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed, + diagnosticStatus === 'running' && styles.diagnosticButtonDisabled + ]} + onPress={runDiagnostics} + disabled={diagnosticStatus === 'running'} + > + {diagnosticStatus === 'running' ? ( + + ) : ( + + )} + + {diagnosticStatus === 'running' + ? 'Running…' + : diagnosticStatus === 'done' + ? 'Run again' + : 'Run diagnostics'} + + + + {checks.length > 0 && ( + + {checks.map((check, i) => ( + + {i > 0 && } + + + {check.label} + + {check.detail} + + + + ))} + + )} + + Common issues + + + {sections.map((section, i) => ( + + {i > 0 && } + [styles.accordionHeader, pressed && styles.rowPressed]} + onPress={() => toggleSection(section.id)} + > + {section.icon} + {section.title} + {expandedId === section.id ? ( + + ) : ( + + )} + + {expandedId === section.id && ( + + {section.steps.map((step, j) => ( + + + {step} + + ))} + + )} + + ))} + + + + + + ) +} + +// Why: WebSocket reachability is tested by opening a connection and waiting for +// the server to respond with any frame, or timing out after 4 seconds. +// We don't complete the E2EE handshake — just verify the endpoint is listening. +async function testHostReachability(endpoint: string): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + ws.close() + resolve(false) + }, 4000) + + const ws = new WebSocket(endpoint) + + ws.onopen = () => { + clearTimeout(timeout) + ws.close() + resolve(true) + } + + ws.onerror = () => { + clearTimeout(timeout) + resolve(false) + } + }) +} + +function formatEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint) + return url.host + } catch { + return endpoint + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + scroll: { + flex: 1 + }, + scrollContent: { + paddingBottom: spacing.xl + }, + diagnosticButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: 10, + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + marginBottom: spacing.lg + }, + diagnosticButtonPressed: { + opacity: 0.7 + }, + diagnosticButtonDisabled: { + opacity: 0.5 + }, + diagnosticButtonLabel: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textPrimary + }, + checkRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm + 2, + paddingHorizontal: spacing.md + 2 + }, + checkLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + checkDetail: { + flex: 1, + textAlign: 'right', + fontSize: typography.metaSize, + color: colors.textMuted + }, + checkDetailFail: { + color: colors.statusRed + }, + sectionHeading: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: spacing.sm, + marginTop: spacing.sm, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden', + marginBottom: spacing.lg + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + accordionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + accordionTitle: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + accordionBody: { + paddingHorizontal: spacing.md + 2, + paddingBottom: spacing.md, + gap: spacing.xs + 2 + }, + stepRow: { + flexDirection: 'row', + gap: spacing.sm + }, + bullet: { + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + }, + stepText: { + flex: 1, + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/assets/adaptive-icon.png b/mobile/assets/adaptive-icon.png new file mode 100644 index 00000000000..0e8fe0ed19d Binary files /dev/null and b/mobile/assets/adaptive-icon.png differ diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png new file mode 100644 index 00000000000..5c95faf868d Binary files /dev/null and b/mobile/assets/favicon.png differ diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png new file mode 100644 index 00000000000..d056b1f01e9 Binary files /dev/null and b/mobile/assets/icon.png differ diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png new file mode 100644 index 00000000000..0943070d71e Binary files /dev/null and b/mobile/assets/splash-icon.png differ diff --git a/mobile/mock-homepage.html b/mobile/mock-homepage.html new file mode 100644 index 00000000000..65f51a3dc8b --- /dev/null +++ b/mobile/mock-homepage.html @@ -0,0 +1,808 @@ + + + + + +Orca Mobile – Homepage Redesign + + + + +
+ + + +
+ + +
+
+
+ + Orca +
+
+ +
+
Welcome back
+
+ +
+
+ +
294
+
Agents
+
+
+ +
1d 14h
+
Agent time
+
+
+ +
277
+
PRs
+
+
+ +
Desktops
+
+
+
+ +
+
+
+
+
Host 1
+
+
Connected
+
+
+ +
+
+ +
+
+ +
+
+
Pair another desktop
+
Scan a QR code from Orca desktop
+
+
+
+ +
+ +
+ + Settings +
+
+ + +
+
+
+ + Orca +
+
+ +
+
+ +
+ +
Welcome back
+
+ + +
+
+
+ +
+
294
+
Agents
+
+
+
+ +
+
1d 14h
+
Agent time
+
+
+
+ +
+
277
+
PRs
+
+
+ + +
Desktops
+
+
+
+ +
+
+
+
Host 1
+
+ 12 worktrees +
+ 3 active +
+
+
+ +
+
+ +
+
+ +
+
+
+
Work Laptop
+
+ Disconnected +
+ Last seen 2h ago +
+
+
+ +
+
+
+ + +
Resume
+
+
+ + +
+
+
fix-auth-middleware
+
+ + orca  ·  feat/auth-v2 +
+
+
+ +
+
+ + +
Quick Actions
+ + + +
Recent Activity
+
+
+
+
+
+
Agent completed: fix login validation
+
Host 1 · 12 min ago
+
+
+
+
+
+
PR #284 merged: update auth middleware
+
Host 1 · 1h ago
+
+
+
+
+
+
Agent started: refactor payment flow
+
Host 1 · 2h ago
+
+
+
+
+ +
+
+ + +
+
+
+ + Orca +
+
+ +
+
+ +
+ +
Welcome to Orca
+
+ +
+
+
+ + + + + + +
+
Connect your desktop
+
+ Pair with Orca on your computer to monitor worktrees, watch agents work, and manage terminals — all from your phone. +
+ +
+ +
+
How it works
+
+
1
+
+
Open Orca desktop
+
Go to Settings → Mobile and generate a pairing QR code.
+
+
+
+
2
+
+
Scan the code
+
Tap the button above to open the scanner. Point at the QR code on your screen.
+
+
+
+
3
+
+
You're connected
+
Your desktop will appear here. Everything is encrypted end-to-end.
+
+
+
+
+
+ + + + diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 00000000000..6006461c802 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,53 @@ +{ + "name": "orca-mobile", + "version": "0.0.1", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "lint": "oxlint", + "format": "oxfmt --write .", + "mock-server": "npx tsx scripts/mock-server.ts" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "expo": "^55.0.17", + "expo-build-properties": "^55.0.13", + "expo-camera": "^55.0.16", + "expo-constants": "^55.0.15", + "expo-crypto": "^55.0.14", + "expo-haptics": "^55.0.14", + "expo-linking": "^55.0.14", + "expo-notifications": "^55.0.21", + "expo-router": "^55.0.13", + "expo-splash-screen": "^55.0.19", + "expo-status-bar": "^55.0.5", + "lucide-react-native": "^1.11.0", + "react": "^19.2.0", + "react-native": "^0.83.6", + "react-native-gesture-handler": "^2.30.1", + "react-native-reanimated": "^4.2.1", + "react-native-safe-area-context": "^5.6.2", + "react-native-screens": "^4.23.0", + "react-native-svg": "^15.15.4", + "react-native-web": "^0.21.2", + "react-native-webview": "^13.16.1", + "react-native-worklets": "^0.7.4", + "tweetnacl": "^1.0.3", + "ws": "^8.18.0", + "zod": "^4.3.6", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-native": "^0.73.0", + "@types/ws": "^8.18.1", + "expo-module-scripts": "^55.0.2", + "oxfmt": "^0.47.0", + "oxlint": "^1.62.0", + "tsx": "^4.19.4", + "typescript": "^5.9.3" + } +} diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml new file mode 100644 index 00000000000..11f0e80157f --- /dev/null +++ b/mobile/pnpm-lock.yaml @@ -0,0 +1,12571 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@react-native-async-storage/async-storage': + specifier: ^2.2.0 + version: 2.2.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo: + specifier: ^55.0.17 + version: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-build-properties: + specifier: ^55.0.13 + version: 55.0.13(expo@55.0.17) + expo-camera: + specifier: ^55.0.16 + version: 55.0.16(@types/emscripten@1.41.5)(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-constants: + specifier: ^55.0.15 + version: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-crypto: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.17) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.17) + expo-linking: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-notifications: + specifier: ^55.0.21 + version: 55.0.21(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-router: + specifier: ^55.0.13 + version: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f) + expo-splash-screen: + specifier: ^55.0.19 + version: 55.0.19(expo@55.0.17)(typescript@5.9.3) + expo-status-bar: + specifier: ^55.0.5 + version: 55.0.5(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + lucide-react-native: + specifier: ^1.11.0 + version: 1.11.0(react-native-svg@15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-native: + specifier: ^0.83.6 + version: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-gesture-handler: + specifier: ^2.30.1 + version: 2.30.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-reanimated: + specifier: ^4.2.1 + version: 4.2.1(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: + specifier: ^5.6.2 + version: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: + specifier: ^4.23.0 + version: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-svg: + specifier: ^15.15.4 + version: 15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-web: + specifier: ^0.21.2 + version: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + react-native-webview: + specifier: ^13.16.1 + version: 13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-worklets: + specifier: ^0.7.4 + version: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + ws: + specifier: ^8.18.0 + version: 8.20.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-native': + specifier: ^0.73.0 + version: 0.73.0(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + expo-module-scripts: + specifier: ^55.0.2 + version: 55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(eslint@9.39.4)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(prettier@2.8.8)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + oxfmt: + specifier: ^0.47.0 + version: 0.47.0 + oxlint: + specifier: ^1.62.0 + version: 1.62.0 + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/cli@7.28.6': + resolution: {integrity: sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==} + engines: {node: '>=6.9.0'} + hasBin: true + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': + resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': + resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': + resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': + resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6': + resolution: {integrity: sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.27.1': + resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.28.6': + resolution: {integrity: sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.28.6': + resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.27.1': + resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.27.1': + resolution: {integrity: sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.4': + resolution: {integrity: sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.28.6': + resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.27.1': + resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.27.1': + resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.28.6': + resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.28.6': + resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.28.6': + resolution: {integrity: sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.27.1': + resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.27.1': + resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.0': + resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.27.1': + resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.27.1': + resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1': + resolution: {integrity: sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.27.1': + resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.27.1': + resolution: {integrity: sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.27.1': + resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.28.6': + resolution: {integrity: sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.27.1': + resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.27.1': + resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.27.1': + resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.28.6': + resolution: {integrity: sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6': + resolution: {integrity: sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.2': + resolution: {integrity: sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@expo-google-fonts/material-symbols@0.4.34': + resolution: {integrity: sha512-PdwETUhvu1gHF1e8eIyEHnBJLq/dRNoTrT5yhsGUfGyRxH5pbm54dF3+QPknxwMKj0M1trN7PSelYz+yzlt3lA==} + + '@expo/cli@55.0.26': + resolution: {integrity: sha512-Ud9gpeGMF5RIL42LXvCw3k3mWK8rf/P2wu+Yrzz9Do1kcFKZeT9Vy2D/xukjdr/Xw+ELba87ThOot17GsPiWjw==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@55.0.8': + resolution: {integrity: sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==} + + '@expo/config-types@55.0.5': + resolution: {integrity: sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==} + + '@expo/config@55.0.15': + resolution: {integrity: sha512-lHc0ELIQ8126jYOMZpLv3WIuvordW98jFg5aT/J1/12n2ycuXu01XLZkJsdw0avO34cusUYb1It+MvY8JiMduA==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@55.0.2': + resolution: {integrity: sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@55.0.5': + resolution: {integrity: sha512-lt3uxYOCk3wmWvtOOvsC35CKGbDAOx5C2EaY8SH1JVSfBzqmF8Cs0Xp1MPxncDPMyxpMiWx5SvvV/iLF1rJU4A==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.1.1': + resolution: {integrity: sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==} + engines: {node: '>=20.12.0'} + + '@expo/fingerprint@0.16.6': + resolution: {integrity: sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==} + hasBin: true + + '@expo/image-utils@0.8.13': + resolution: {integrity: sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==} + + '@expo/json-file@10.0.13': + resolution: {integrity: sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==} + + '@expo/local-build-cache-provider@55.0.11': + resolution: {integrity: sha512-rJ4RTCrkeKaXaido/bVyhl90ZRtVTOEbj59F1PWVjIEIVgjdlfc1J3VD9v7hEsbf/+8Tbr/PgvWhT6Visi5sLQ==} + + '@expo/log-box@55.0.11': + resolution: {integrity: sha512-JQHFLWkskIbJi6cxYMjErx8lQqfFJilDQLKmdTO3m3YkdmN9GE/CrzjOfVlCG0DGEGZJ90br0pGKvGPdXNsHKw==} + peerDependencies: + '@expo/dom-webview': ^55.0.5 + expo: '*' + react: '*' + react-native: '*' + + '@expo/metro-config@55.0.17': + resolution: {integrity: sha512-o11VyNoRDXv0T5320D9cH+nSsrR/OMHTjtysKLIfDlidsBswDk1DMApPv9Kw0/gluArCSnbx8JC1G0Yh2Y4P3g==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-runtime@55.0.10': + resolution: {integrity: sha512-7v+ldTvMWRa1ml83Jel9W2f8qT/NZZWrlHaEjf29nb72JTEO50+Xac9PWLo+X3LCDAAuyYuBGKYXOJwfqxV0fQ==} + peerDependencies: + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@55.1.0': + resolution: {integrity: sha512-bb/LOncsz9KiP6cHmMy0MCDG1COZOn+k+pRpDrvJUmxLdOOuniJSYyCc/Dgv1bR9E/6YR+fh3EXGg9MUrVNy4Q==} + + '@expo/npm-proofread@1.0.1': + resolution: {integrity: sha512-yDyBlNIgg+rKoKCOVM9ZyAtgqRx6gHw+JtmZTrYYW4auAfwS6kE/iInmCpVXPqRy0OhJHgXj6q5PCgWELLVMeg==} + hasBin: true + + '@expo/osascript@2.4.2': + resolution: {integrity: sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==} + engines: {node: '>=12'} + + '@expo/package-manager@1.10.4': + resolution: {integrity: sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ==} + + '@expo/plist@0.5.2': + resolution: {integrity: sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==} + + '@expo/prebuild-config@55.0.16': + resolution: {integrity: sha512-o4EAVgDGk1lISirtMD8hciO2vyMp7cWlPdfTtjjd5AXSfODVYDIDhygXrfvVQHmJXAztVqPUTKJT+BYOsVkYGQ==} + peerDependencies: + expo: '*' + + '@expo/require-utils@55.0.4': + resolution: {integrity: sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@55.0.15': + resolution: {integrity: sha512-6LksYO4Pg13qroL138KfUebt/x/EO07zVhdyT/nTgcxnpn6CS4ecTl3DciSKhxbaH+0BVLdANkxYeGdp43TMwQ==} + peerDependencies: + '@expo/metro-runtime': ^55.0.10 + expo: '*' + expo-constants: ^55.0.15 + expo-font: ^55.0.6 + expo-router: '*' + expo-server: ^55.0.8 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@55.0.3': + resolution: {integrity: sha512-l9KHVjTo6MvoeyvwNr6AjckGJm8NIcqZ3QSAh51cWozXW9v2AUjyCyqYtFtyntLWRZ0x/ByYJishpQo4ZQq45Q==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.7.2': + resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/vector-icons@15.1.1': + resolution: {integrity: sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==} + peerDependencies: + expo-font: '>=14.0.4' + react: '*' + react-native: '*' + + '@expo/ws-tunnel@1.0.6': + resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + + '@expo/xcpretty@4.4.3': + resolution: {integrity: sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==} + hasBin: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/diff-sequences@30.3.0': + resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} + + '@oxfmt/binding-android-arm-eabi@0.47.0': + resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.47.0': + resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.47.0': + resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.47.0': + resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.47.0': + resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': + resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.47.0': + resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.47.0': + resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.47.0': + resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.47.0': + resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.47.0': + resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.47.0': + resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.47.0': + resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.47.0': + resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.47.0': + resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.47.0': + resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.47.0': + resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.47.0': + resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.47.0': + resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.62.0': + resolution: {integrity: sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.62.0': + resolution: {integrity: sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.62.0': + resolution: {integrity: sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.62.0': + resolution: {integrity: sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.62.0': + resolution: {integrity: sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.62.0': + resolution: {integrity: sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.62.0': + resolution: {integrity: sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.62.0': + resolution: {integrity: sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.62.0': + resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.62.0': + resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.62.0': + resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.62.0': + resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.62.0': + resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.62.0': + resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.62.0': + resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.62.0': + resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.62.0': + resolution: {integrity: sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.62.0': + resolution: {integrity: sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.62.0': + resolution: {integrity: sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native-async-storage/async-storage@2.2.0': + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + + '@react-native/assets-registry@0.83.6': + resolution: {integrity: sha512-iljb4ue1yWJ3EhySz7EjV6CzSVrI2uNtR8BI2jzP5+QS5E4Cl3fdIJRmVwDEx1pu8uE97PGEusGRHnoaZ9Q3jg==} + engines: {node: '>= 20.19.4'} + + '@react-native/assets-registry@0.85.2': + resolution: {integrity: sha512-kauC/oPaxklU4Y+u9gBfCBJm51qX6WBZq4xx0USCdimtp+G8+554kpygfSWIjoqCJa2o06bWxBEjesiuCv+LzA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-plugin-codegen@0.83.6': + resolution: {integrity: sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.85.2': + resolution: {integrity: sha512-5Dqn08kRTUIxPLYju9hExI0cR1ESX+P5tEv5yv0q0UZcisRTw0VB8iUWDIph2LdY1i5Dc8PIvuaWMRNCw3vnKg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-preset@0.83.6': + resolution: {integrity: sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/babel-preset@0.85.2': + resolution: {integrity: sha512-7d2yW23eKkVt0FbbnZLxqO7KybGLtQXOuvvcO1NUOYGtjzVh6ihNKn0TIHrhSNpMyHwYLDoiiuj95wLtcg3IwQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.83.6': + resolution: {integrity: sha512-doB/Pq6Cf6IjF3wlQXTIiZOnsX9X8mEEk+CdGfyuCwZjWrf7IB8KaZEXXckJmfUcIwvJ9u/a72ZoTTCIoxAc9A==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.85.2': + resolution: {integrity: sha512-XCginmxh0//++EXVOEJHBVZxHla294FzLCFF6jXwAUjvXVhqyIKyxhABfz+r4OOmaiuWk4Rtd4arqdAzeHeprg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.83.6': + resolution: {integrity: sha512-Mko6mywoHYJmpBnjwAC95vQWaUUh//71knFadH0BrhHDq2m7i/IrpLwcQsPAy8855ucXflBs5zQyGTpNbPBAaw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': '*' + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/community-cli-plugin@0.85.2': + resolution: {integrity: sha512-3KLgSg1kHvBpr93zMaQhvfYTgnCw7yZRED+3J4dMcYjfSjtD0Wf8SofU6uBmAw9JaVYvP43lpdwUpI4p0+ABsg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': 0.85.2 + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.83.6': + resolution: {integrity: sha512-TyWXEpAjVundrc87fPWg91piOUg75+X9iutcfDe7cO3NrAEYCsl7Z09rKHuiAGkxfG9/rFD13dPsYIixUFkSFA==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-frontend@0.85.2': + resolution: {integrity: sha512-j+0b9H5f5hGTLQxHIhJU/b/W6ijuxJF+ZTLHB0se2kzUBNxFKd7DkIc6753qk3CJdiv55vxG3XDgmlpbHxOpmA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/debugger-shell@0.83.6': + resolution: {integrity: sha512-684TJMBCU0l0ZjJWzrnK0HH+ERaM9KLyxyArE1k7BrP+gVl4X9GO0Pi94RoInOxvW/nyV65sOU6Ip1F3ygS0cg==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-shell@0.85.2': + resolution: {integrity: sha512-r5BkhqPMfg3LmaZS5zadHmBNVH5h4bhSpv4BEPGfK4gat9HABAMzUzybi+2wpgU3SoHxnyKGdExEJvoqVcjeRg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/dev-middleware@0.83.6': + resolution: {integrity: sha512-22xoddLTelpcVnF385SNH2hdP7X2av5pu7yRl/WnM5jBznbcl0+M9Ce94cj+WVeomsoUF/vlfuB0Ooy+RMlRiA==} + engines: {node: '>= 20.19.4'} + + '@react-native/dev-middleware@0.85.2': + resolution: {integrity: sha512-3J+NaDUg+QEfDeLAUzgaWhpaxEg78g+KwbydlDCewh2G6WnHpsty8XooruxNHzyAsqVWywZMrzmbn78Ctc1O9Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/gradle-plugin@0.83.6': + resolution: {integrity: sha512-5prXv7WWR1RgZ/kWGZP+mi7/y/IE2ymfOHIZO5Pv14tMOmRAcQSgSYogcRmOiWw5mJs2K0UFeMiQD49ZO9oCug==} + engines: {node: '>= 20.19.4'} + + '@react-native/gradle-plugin@0.85.2': + resolution: {integrity: sha512-YXBOLeAqFrv7XwUeBPTKZeOV1FIxn4AW7UAEitScf3ibC8bu8+6NpJu4HWgbNQHg7vDbbTZVbcOl8EwGxsSq2w==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/js-polyfills@0.83.6': + resolution: {integrity: sha512-VSev0LV2i5X0ibduHBSLqKj0YU2F+waCgjl2uvaGHMGCSV1ZRKNFX/vJFqvLwjvdzLbkAZoFT1Rg7k7jDv44UA==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.85.2': + resolution: {integrity: sha512-esGEAmKVM40DV/yVmNljCKZTIeUo7qXqc+Hwffkv3TG+b3E24xyFovHrbP98gGxZr2ZsEyx+2sKLdXF5asY5nw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/metro-babel-transformer@0.85.2': + resolution: {integrity: sha512-lU9XOGahpHvQff30H5lnvh9RYbVwC1zpSHpl84E+7BD2zj0FvW+pD7MBh7CWbmbWmegjtAb+U/2bokXcDVA+jA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.85.2': + resolution: {integrity: sha512-YkTIMfTPeyMUrtpQo/7zd3oybVYJCfTp8626PqoakOvEiWi9PxsUpZ8j44a5GFtOIq8Nc6WWVBiFRn/6qdi1uQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.83.6': + resolution: {integrity: sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw==} + + '@react-native/normalize-colors@0.85.2': + resolution: {integrity: sha512-svuOLtjbFGXDdHsriHXuND5FgHg7XlkOXCbH/8+X4t76YLH6qSTffSIQQrKLDL5mn4EFU+Oh/PNO0/FfpnTOTg==} + + '@react-native/virtualized-lists@0.83.6': + resolution: {integrity: sha512-gNSFXeb4P7qHtauLvl+zESroULIyX6Ltpvau3dhwy/QmfanBv0KUcrIU/7aVXxtWcXgp+54oWJyu2LIrsZ9+LQ==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native/virtualized-lists@0.85.2': + resolution: {integrity: sha512-wmVKpAlcr+UB0L5SpbrV865EdleUP7I5+X+48e1aRsQK8q+wsTRBXeUwWVip/1l+HZwlZFeO8iOILJ16VRu0Cw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: 0.85.2 + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-navigation/bottom-tabs@7.15.10': + resolution: {integrity: sha512-Ao/yYlrpr0cwYYGxt9FDMQk+tTSHNm4WTaszyhroINLdoEMuKH19k1tGFdYbRBKHJx1UIH8kD+EZTYW1w6LL3Q==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/core@7.17.2': + resolution: {integrity: sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==} + peerDependencies: + react: '>= 18.2.0' + + '@react-navigation/elements@2.9.15': + resolution: {integrity: sha512-cyz/pPiyyC6gaTVLsGFc1g0MYgrmuCFqklAWGXMWPscr5YU3ui94vPI4vnZwcsEy0T758TQWLzmS5XudZeRKcA==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + + '@react-navigation/native-stack@7.14.12': + resolution: {integrity: sha512-dUfpkrVeVKKV8iqXsmoUp3Rv0iH3YaB3eZwScru/FlcqAp/r3/qA6zEXkGX9hZK+/ziWAPFrf1frBSNbgOYSFQ==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/native@7.2.2': + resolution: {integrity: sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + + '@react-navigation/routers@7.5.3': + resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@testing-library/react-native@13.3.3': + resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} + engines: {node: '>=18'} + peerDependencies: + jest: '>=29.0.0' + react: '>=18.2.0' + react-native: '>=0.71' + react-test-renderer: '>=18.2.0' + peerDependenciesMeta: + jest: + optional: true + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@tsconfig/node18@18.2.6': + resolution: {integrity: sha512-eAWQzAjPj18tKnDzmWstz4OyWewLUNBm9tdoN9LayzoboRktYx3Enk1ZXPmThj55L7c4VWYq/Bzq0A51znZfhw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/react-native@0.73.0': + resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} + deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed. + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-dynamic-import-node@2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.32.0: + resolution: {integrity: sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==} + + babel-plugin-syntax-hermes-parser@0.32.1: + resolution: {integrity: sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==} + + babel-plugin-syntax-hermes-parser@0.33.3: + resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@55.0.18: + resolution: {integrity: sha512-zmDwKxCFBTe4e/jQXuITRUZlbl8HTZOhsUlwcHGjwEUB0lKQfRdaSYXZckQ+jMOBC34MrOl3Cs7/6F6vNbj5Pw==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^55.0.14 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + barcode-detector@3.1.2: + resolution: {integrity: sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.23: + resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + char-regex@2.0.2: + resolution: {integrity: sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==} + engines: {node: '>=12.20'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.2.0: + resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} + + chromium-edge-launcher@0.3.0: + resolution: {integrity: sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dnssd-advertise@1.1.4: + resolution: {integrity: sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-config-universe@15.0.3: + resolution: {integrity: sha512-fUMsNXp7GJBu7Sz9PXFBbXhkiixdQ5sbnViFIBbk6ORAfeokczJ+eVv5HQ2gwxPQdbfJarpkO9WZDtxIvJnEGw==} + peerDependencies: + eslint: '>=8.10' + prettier: '>=3' + peerDependenciesMeta: + prettier: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-es@3.0.1: + resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=4.19.1' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-node@11.1.0: + resolution: {integrity: sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=5.16.0' + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-utils@2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + + eslint-visitor-keys@1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + expo-application@55.0.14: + resolution: {integrity: sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==} + peerDependencies: + expo: '*' + + expo-asset@55.0.16: + resolution: {integrity: sha512-5IJyfJtYqvKGg04NKGQWiCIoK/fULDL9m15mXPPyfabD1jsToVj2hnWmo1r2SWNNmMwtQxi6jTpcGwVo2nLDxg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-build-properties@55.0.13: + resolution: {integrity: sha512-UYZhUKyh7YQhbJdkBvo68WUQ7fOtZeSV7F8kfYkjEiN/ADRHG0WfEIiddvGfi9cH/5iwpptv/+Lu5cx6uvfegA==} + peerDependencies: + expo: '*' + + expo-camera@55.0.16: + resolution: {integrity: sha512-9c6FGrLVwMLyQ08wqwkH7DXAC8Oj1VD0LXM4hFu62Nuq2f2zIAZwsXxG7J5ex+HHHAGyligGGi6VJWuiib9qNg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-constants@55.0.15: + resolution: {integrity: sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-crypto@55.0.14: + resolution: {integrity: sha512-TfAADBGZNNv9OOmdKFJCz54wDj87ufxtzQNSY+Roycpm8e5tuCnDIL7EjqUOmNTGH99Jj8ftPGFt4KGG2Ii2fg==} + peerDependencies: + expo: '*' + + expo-file-system@55.0.17: + resolution: {integrity: sha512-d27K1cagUOt2BwxwPka9KW8Znu5kN1tnairozCzzCRZviZFtWnBxwFuJ3KU6MAbav/9UhSMkp5Ve/oZ+SR0UgQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@55.0.6: + resolution: {integrity: sha512-x9czUA3UQWjIwa0ZUEs/eWJNqB4mAue/m4ltESlNPLZhHL0nWWqIfsyHmklTLFH7mVfcHSJvew6k+pR2FE1zVw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@55.0.10: + resolution: {integrity: sha512-5kL/jATvgJWdrqPdxixrECJqD2l8cfQ4ALr1DK7qi9XkyI97ejXvUjB2VsfEePNy3Fg+/VwzA3n3L7Nv3tAPkw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + + expo-image@55.0.9: + resolution: {integrity: sha512-+NVgWv+tr7a6EpBEaIIVVp+XfruRA2JL5xOxvd6ajvFGdH0rOhagwX1m1piAII6w7sh6uAnBr8X+fDZsav7B2w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-keep-awake@55.0.6: + resolution: {integrity: sha512-acJjeHqkNxMVckEcJhGQeIksqqsarscSHJtT559bNgyiM4r14dViQ66su7bb6qDVeBt0K7z3glXI1dHVck1Zgg==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@55.0.14: + resolution: {integrity: sha512-ZSqOvJyEquf04M5/ZpQo2diK9QRnNrzgqZo7p8gzxaPPHxP6IyUJnmcd12qT+dTxnRTVmUpxFQVHHWbvwPNIwQ==} + peerDependencies: + react: '*' + react-native: '*' + + expo-module-scripts@55.0.2: + resolution: {integrity: sha512-gtFQzs6wduZxYo4/1udcVLANDvQbVUVFjK1xJjgtZuUBlbJ+xFjQg5wOsQaffUDO3kqrG+DRXt1ihSxK72Wqkg==} + hasBin: true + + expo-modules-autolinking@55.0.18: + resolution: {integrity: sha512-olGTCWYkwVPj/momcgnF+z8MTzurGNFjopqPztQ4F53UkGPJnOFEuaM2/z4KbZtKbwHqeBv34OA5hxZP8uLdaQ==} + hasBin: true + + expo-modules-core@55.0.23: + resolution: {integrity: sha512-IGWT5N9MoV4zgWyrv686bElnKhzhE7E6pSazhaBNh3vgViAah5nnAz2o5h5YoUMR2B+ZTdHumRbGHN6gHLgwPA==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: ^0.7.4 || ^0.8.0 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-notifications@55.0.21: + resolution: {integrity: sha512-nZKbvfjCoKFTZU435ihXcCCqCF3uCY1kcD5e2Tm2GMDLSt7HTiLZ1CQFGgckg/6QYIfMKhLFdHfOQHAEGakQug==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-router@55.0.13: + resolution: {integrity: sha512-cIBR5RmQtbr+b535mlbMhmm7lweVZXFtjzJOgJTutoxIApRztl816kFRFNesnVyqQ0LZrEU0a6vqa3i0wdlRQw==} + peerDependencies: + '@expo/log-box': 55.0.11 + '@expo/metro-runtime': ^55.0.10 + '@react-navigation/drawer': ^7.9.4 + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^55.0.15 + expo-linking: ^55.0.14 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: '*' + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@react-navigation/drawer': + optional: true + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-server@55.0.8: + resolution: {integrity: sha512-AoV5TKuO4biSzrhe/OVLyInfTT0pV9/OOc/g/oVq5vmCjL8SaSYTkES8PLt+67Tm7VqX+Dn0+kSx1nQcjEKaPw==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@55.0.19: + resolution: {integrity: sha512-l8BWI/inLJW46Ojz5NgwvaM8LftrdXeFfZBUXhAoZxg44Qo2xKY76s0S1h3WIxWXT4sRKwK8YQzGr4k+zHubxQ==} + peerDependencies: + expo: '*' + + expo-status-bar@55.0.5: + resolution: {integrity: sha512-qb0c3rJO2b7CC0gUVGi1JYp92oLenWdYGyk8l4YQs6U+uaXUTPv6aaFa3KkT2HON10re3AxxPNJci8rsz6kPxg==} + peerDependencies: + react: '*' + react-native: '*' + + expo-symbols@55.0.7: + resolution: {integrity: sha512-y4ALLbncSGQzhFLw1PaIBbO39xzaw3ie249HmK6zK/WLJYfw4Z/9UU4iPKO3KCE4FyCKIzd+yRsvzvlri23YrQ==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo@55.0.17: + resolution: {integrity: sha512-yVF2phiPw5XgOCedC/oQaL3j0XbwzsBLst3JiAF8bi9aFlxLOVvuDEM8BDg3E09XGSLaGCAclY4q5L+sFerXlQ==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-native: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-readdir-recursive@1.1.0: + resolution: {integrity: sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hermes-compiler@0.14.1: + resolution: {integrity: sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==} + + hermes-compiler@250829098.0.10: + resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} + + hermes-estree@0.32.0: + resolution: {integrity: sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==} + + hermes-estree@0.32.1: + resolution: {integrity: sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==} + + hermes-estree@0.33.3: + resolution: {integrity: sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + + hermes-parser@0.32.0: + resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + + hermes-parser@0.32.1: + resolution: {integrity: sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==} + + hermes-parser@0.33.3: + resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.3.0: + resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-expo@55.0.16: + resolution: {integrity: sha512-bOvrTNyDaiaoTz9GhvnXib9v9rjX9PTJFvvoqRMRKEg4MoHghG82E7YF+pH71EWSXTaibQ07F46GS+fcUxTWEg==} + hasBin: true + peerDependencies: + expo: '*' + react-native: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + react-server-dom-webpack: + optional: true + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.3.0: + resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watch-select-projects@2.0.0: + resolution: {integrity: sha512-j00nW4dXc2NiCW6znXgFLF9g8PJ0zP25cpQ1xRro/HU2GBfZQFZD0SoXnAlaoKkIY4MlfTMkKGbNXFpvCdjl1w==} + + jest-watch-typeahead@2.2.1: + resolution: {integrity: sha512-jYpYmUnTzysmVnwq49TAxlmtOAwp8QIqvZyoofQFn8fiWhEDZj33ZXzg3JA4nGnzWFm1hbWf3ADpteUokvXgFA==} + engines: {node: ^14.17.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: ^27.0.0 || ^28.0.0 || ^29.0.0 + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react-native@1.11.0: + resolution: {integrity: sha512-/dEt+/noOpsvgH1dbMHHSv+f8+npB1BJSKCqK9v9yvHbssN3OJJJXrih6N1prJGG3vj0e1zKiVLtet+OKYmiXg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: '*' + react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.83.6: + resolution: {integrity: sha512-1AnuazBpzY3meRMr04WUw14kRBkV0W3Ez+AA75FAeNpRyWNN5S3M3PHLUbZw7IXq7ZeOzceyRsHStaFrnWd+8w==} + engines: {node: '>=20.19.4'} + + metro-babel-transformer@0.84.3: + resolution: {integrity: sha512-svAA+yMLpeMiGcz/jKJs4oHpIGEx4nBqNEJ5AGj4CYIg1efvK+A0TjR6tgIuc6tKO5e8JmN/1lglpN2+f3/z/w==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.83.6: + resolution: {integrity: sha512-5gdK4PVpgNOHi7xCGrgesNP1AuOA2TiPqpcirGXZi4RLLzX1VMowpkgTVtBfpQQCqWoosQF9yrSo9/KDQg1eBg==} + engines: {node: '>=20.19.4'} + + metro-cache-key@0.84.3: + resolution: {integrity: sha512-TnSL1Fdvrw+2glTdBSRmA5TL8l/i16ECjsrUdf3E5HncA+sNx8KcwDG8r+3ct1UhfYcusJypzZqTN55FZZcwGg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.83.6: + resolution: {integrity: sha512-DpvZE32feNkqfZkI4Fic7YI/Kw8QP9wdl1rC4YKPrA77wQbI9vXbxjmfkCT/EGwBTFOPKqvIXo+H3BNe93YyiQ==} + engines: {node: '>=20.19.4'} + + metro-cache@0.84.3: + resolution: {integrity: sha512-0QElxwLaHqLZf+Xqio8QrjVbuXP/8sJfQBGSPiITlKDVXrVLefuzYVSH9Sj+QL6lrPj2gYZd/iwQh1yZuVKnLA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.83.6: + resolution: {integrity: sha512-G5622400uNtnAMlppEA5zkFAZltEf7DSGhOu09BkisCxOlVMWfdosD/oPyh4f2YVQsc1MBYyp4w6OzbExTYarg==} + engines: {node: '>=20.19.4'} + + metro-config@0.84.3: + resolution: {integrity: sha512-JmCzZWOETR+O22q8oPBWyQppx3roU9EbkbGzD8Gf1jukQ4b5T1fTzqqHruu6K4sTiNq5zVQySmKF6bp4kVARew==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.83.6: + resolution: {integrity: sha512-l+yQ2fuIgR//wszUlMrrAa9+Z+kbKazd0QOh0VQY7jC4ghb7yZBBSla/UMYRBZZ6fPg9IM+wD3+h+37a5f9etw==} + engines: {node: '>=20.19.4'} + + metro-core@0.84.3: + resolution: {integrity: sha512-cc0pvAa80ai1nDmqqz0P59a+0ZqCZ/YHU/3jEekZL6spFnYDfX8iDLdn9FR6kX+67rmzKxHNrbrSRFLX2AYocw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.83.6: + resolution: {integrity: sha512-Jg3oN604C7GWbQwFAUXt8KsbMXeKfsxbZ5HFy4XFM3ggTS+ja9QgUmq9B613kgXv3G4M6rwiI6cvh9TRly4x3w==} + engines: {node: '>=20.19.4'} + + metro-file-map@0.84.3: + resolution: {integrity: sha512-1cL4m4Jv1yRUt9RJExZQLfccscdlMNOcRG6LHLtmJhf3BG9j3MujPVc7CIpKYdFl+KUl+sdjge6oO3+meKCHQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.83.6: + resolution: {integrity: sha512-Vx3/Ne9Q+EIEDLfKzZUOtn/rxSNa/QjlYxc42nvK4Mg8mB6XUgd3LXX5ZZVq7lzQgehgEqLrbgShJPGfeF8PnQ==} + engines: {node: '>=20.19.4'} + + metro-minify-terser@0.84.3: + resolution: {integrity: sha512-3ofrG2OQyJbO9RNhCfOcl8QU7EE2WrSsnN5dFkuZaJO5+4Imujr9bUXmspeNlXRsOVk0F/rVRbEFH98lFSCkBQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.83.6: + resolution: {integrity: sha512-lAwR/FsT1uJ5iCt4AIsN3boKfJ88aN8bjvDT5FwBS0tKeKw4/sbdSTWlFxc7W/MUTN5RekJ3nQkJRIWsvs28tA==} + engines: {node: '>=20.19.4'} + + metro-resolver@0.84.3: + resolution: {integrity: sha512-pjEzGDtoM8DTHAIPK/9u9ZxszEiuRohYUVImWvgbnB91V4gqYJpQcoEYUugf2NIm1lrX5HNu0OvNqWmPBnGYjA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.83.6: + resolution: {integrity: sha512-WQPua1G2VgYbwRn6vSKxOhTX7CFbSf/JdUu6Nd8bZnPXckOf7HQ2y51NXNQHoEsiuawathrkzL8pBhv+zgZFmg==} + engines: {node: '>=20.19.4'} + + metro-runtime@0.84.3: + resolution: {integrity: sha512-o7HLRfMyVk9N2dUZ9VjQfB6xxUItL9Pi9WcqxURE7MEKOH6wbGt9/E92YdYLluTOtkzYAEVfdC6h6lcxqA+hMQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.83.6: + resolution: {integrity: sha512-AqJbOMMpeyyM4iNI91pchqDIszzNuuHApEhg6OABqZ+9mjLEqzcIEQ/fboZ7x74fNU5DBd2K36FdUQYPqlGClA==} + engines: {node: '>=20.19.4'} + + metro-source-map@0.84.3: + resolution: {integrity: sha512-jS48CeSzw78M8y6VE0f9uy3lVmfbOS677j2VCxnlmlYmnahcXuC6IhoN9K6LynNvos9517yUadcfgioju38xYQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.83.6: + resolution: {integrity: sha512-4nvkmv9T7ozhprlPwk/+xm0SVPsxly5kYyMHdNaOlFemFz4df9BanvD46Ac6OISu/4Idinzfk2KVb++6OfzPAQ==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro-symbolicate@0.84.3: + resolution: {integrity: sha512-J9Tpo8NCycYrozRvBIUyOwGAu4xkawOsAppmTscFiaegK0WvuDGwIM53GbzVSnytCHjVAF0io5GQxpkrKTuc7g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.83.6: + resolution: {integrity: sha512-V+zoY2Ul0v0BW6IokJkTud3raXmDdbdwkUQ/5eiSoy0jKuKMhrDjdH+H5buCS5iiJdNbykOn69Eip+Sqymkodg==} + engines: {node: '>=20.19.4'} + + metro-transform-plugins@0.84.3: + resolution: {integrity: sha512-8S3baq2XhBaafHEH5Q8sJW6tmzsEJk80qKc3RU/nZV1MsnYq94RdjTUR6AyKjQd6Rfsk1BtBxhtiNnk7mgslCg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.83.6: + resolution: {integrity: sha512-G5kDJ/P0ZTIf57t3iyAd5qIXbj2Wb1j7WtIDh82uTFQHe2Mq2SO9aXG9j1wI+kxZlIe58Z22XEXIKMl89z0ibQ==} + engines: {node: '>=20.19.4'} + + metro-transform-worker@0.84.3: + resolution: {integrity: sha512-Wjba7PyYktNRsHbPmkx2J2UX32rAzcDXjCu49zPHeF/viJlYJhwRaNePQcHaCRqQ+kmgQT4ThprsnJfDj71ZMA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.83.6: + resolution: {integrity: sha512-pbdndsAZ2F/ceopDdhVbttpa/hfLzXPJ/husc+QvQ33R0D9UXJKzTn5+OzOXx4bpQNtAKF2bY88cCI3Zl44xDQ==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro@0.84.3: + resolution: {integrity: sha512-1h3lbVrE6hGf1e/764HfhPGg/bGrWMJDDh7G2rc4gFYZboVuI40BlG/y+UhtbhQDNlO/csMvrcnK0YrTlHUVew==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multitars@1.0.0: + resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + ob1@0.83.6: + resolution: {integrity: sha512-m/xZYkwcjo6UqLMrUICEB3iHk7Bjt3RSR7KXMi6Y1MO/kGkPhoRmfUDF6KAan3rLAZ7ABRqnQyKUTwaqZgUV4w==} + engines: {node: '>=20.19.4'} + + ob1@0.84.3: + resolution: {integrity: sha512-J7554Ef8bzmKaDY365Afq6PF+qtdnY/d5PKUQFrsKlZHV/N3OGZewVrvDrQDyX5V5NJjTpcAKtlrFZcDr+HvpQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxfmt@0.47.0: + resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint@1.62.0: + resolution: {integrity: sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.18.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.3.0: + resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.5: + resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} + + react-native-gesture-handler@2.30.1: + resolution: {integrity: sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.2.1: + resolution: {integrity: sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.2.1: + resolution: {integrity: sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: '>=0.7.0' + + react-native-safe-area-context@5.6.2: + resolution: {integrity: sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.23.0: + resolution: {integrity: sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-svg@15.15.4: + resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-webview@13.16.1: + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-worklets@0.7.4: + resolution: {integrity: sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==} + peerDependencies: + '@babel/core': '*' + react: '*' + react-native: '*' + + react-native@0.83.6: + resolution: {integrity: sha512-H513+8VzviNFXOdPnStRzX9S3/jiJGg++QZ1zd+ROyAvBEKqFqKUPHH0d82y3QyRPct5qKjdOa7J6vNehCvXYA==} + engines: {node: '>= 20.19.4'} + hasBin: true + peerDependencies: + '@types/react': ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-native@0.85.2: + resolution: {integrity: sha512-GFWEPwLYirfj5X8gMtXOWtqX0cqUEURRHETZfFk37VCa4++izrKvGvv24anvuyulXV87NAhVkfNw93rLg3HByw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + peerDependencies: + '@react-native/jest-preset': 0.85.2 + '@types/react': ^19.1.1 + react: ^19.2.3 + peerDependenciesMeta: + '@react-native/jest-preset': + optional: true + '@types/react': + optional: true + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-test-renderer@19.2.0: + resolution: {integrity: sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ==} + peerDependencies: + react: ^19.2.0 + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpp@3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@2.0.0: + resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} + engines: {node: '>=6'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + + stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-length@5.0.1: + resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} + engines: {node: '>=12.20'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-jest@29.0.5: + resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url-minimum@0.1.1: + resolution: {integrity: sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zxing-wasm@3.0.2: + resolution: {integrity: sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + +snapshots: + + '@babel/cli@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@jridgewell/trace-mapping': 0.3.31 + commander: 6.2.1 + convert-source-map: 2.0.0 + fs-readdir-recursive: 1.1.0 + glob: 7.2.3 + make-dir: 2.1.0 + slash: 2.0.0 + optionalDependencies: + '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3 + chokidar: 3.6.0 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-env@7.29.2(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.0 + esutils: 2.0.3 + + '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@expo-google-fonts/material-symbols@0.4.34': {} + + '@expo/cli@55.0.26(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/devcert': 1.2.1 + '@expo/env': 2.1.1 + '@expo/image-utils': 0.8.13(typescript@5.9.3) + '@expo/json-file': 10.0.13 + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro': 55.1.0 + '@expo/metro-config': 55.0.17(expo@55.0.17)(typescript@5.9.3) + '@expo/osascript': 2.4.2 + '@expo/package-manager': 1.10.4 + '@expo/plist': 0.5.2 + '@expo/prebuild-config': 55.0.16(expo@55.0.17)(typescript@5.9.3) + '@expo/require-utils': 55.0.4(typescript@5.9.3) + '@expo/router-server': 55.0.15(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo-server@55.0.8)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.3 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.3 + '@react-native/dev-middleware': 0.83.6 + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.4 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-server: 55.0.8 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.0 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.4 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + semver: 7.7.4 + send: 0.19.2 + slugify: 1.6.9 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.20.0 + zod: 3.25.76 + optionalDependencies: + expo-router: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@55.0.8': + dependencies: + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.13 + '@expo/plist': 0.5.2 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.7.4 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/config-types@55.0.5': {} + + '@expo/config@55.0.15(typescript@5.9.3)': + dependencies: + '@expo/config-plugins': 55.0.8 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.13 + '@expo/require-utils': 55.0.4(typescript@5.9.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.7.4 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/devcert@1.2.1': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/devtools@55.0.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@expo/dom-webview@55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@expo/env@2.1.1': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/fingerprint@0.16.6': + dependencies: + '@expo/env': 2.1.1 + '@expo/spawn-async': 1.7.2 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.5 + resolve-from: 5.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.8.13(typescript@5.9.3)': + dependencies: + '@expo/require-utils': 55.0.4(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/json-file@10.0.13': + dependencies: + '@babel/code-frame': 7.29.0 + json5: 2.2.3 + + '@expo/local-build-cache-provider@55.0.11(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.15(typescript@5.9.3) + chalk: 4.1.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/log-box@55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@expo/dom-webview': 55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + anser: 1.4.10 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + stacktrace-parser: 0.1.11 + + '@expo/metro-config@55.0.17(expo@55.0.17)(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/env': 2.1.1 + '@expo/json-file': 10.0.13 + '@expo/metro': 55.1.0 + '@expo/spawn-async': 1.7.2 + browserslist: 4.28.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.32.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.4.49 + resolve-from: 5.0.0 + optionalDependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + anser: 1.4.10 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + pretty-format: 29.7.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.5(react@19.2.0) + transitivePeerDependencies: + - '@expo/dom-webview' + + '@expo/metro@55.1.0': + dependencies: + metro: 0.83.6 + metro-babel-transformer: 0.83.6 + metro-cache: 0.83.6 + metro-cache-key: 0.83.6 + metro-config: 0.83.6 + metro-core: 0.83.6 + metro-file-map: 0.83.6 + metro-minify-terser: 0.83.6 + metro-resolver: 0.83.6 + metro-runtime: 0.83.6 + metro-source-map: 0.83.6 + metro-symbolicate: 0.83.6 + metro-transform-plugins: 0.83.6 + metro-transform-worker: 0.83.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/npm-proofread@1.0.1': + dependencies: + semver: 5.7.2 + + '@expo/osascript@2.4.2': + dependencies: + '@expo/spawn-async': 1.7.2 + + '@expo/package-manager@1.10.4': + dependencies: + '@expo/json-file': 10.0.13 + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.5.2': + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@55.0.16(expo@55.0.17)(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/config-types': 55.0.5 + '@expo/image-utils': 0.8.13(typescript@5.9.3) + '@expo/json-file': 10.0.13 + '@react-native/normalize-colors': 0.83.6 + debug: 4.4.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@55.0.4(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@55.0.15(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo-server@55.0.8)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + debug: 4.4.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-server: 55.0.8 + react: 19.2.0 + optionalDependencies: + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-router: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f) + react-dom: 19.2.5(react@19.2.0) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@55.0.3': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.7.2': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/vector-icons@15.1.1(expo-font@55.0.6)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@expo/ws-tunnel@1.0.6': {} + + '@expo/xcpretty@4.4.3': + dependencies: + '@babel/code-frame': 7.29.0 + chalk: 4.1.2 + js-yaml: 4.1.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@25.6.0) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.3.0': {} + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 25.6.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 25.6.0 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.49 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.6.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + optional: true + + '@oxfmt/binding-android-arm-eabi@0.47.0': + optional: true + + '@oxfmt/binding-android-arm64@0.47.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.47.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.47.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.47.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.47.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.47.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.47.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.47.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.47.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.47.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.62.0': + optional: true + + '@oxlint/binding-android-arm64@1.62.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.62.0': + optional: true + + '@oxlint/binding-darwin-x64@1.62.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.62.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.62.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.62.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.62.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.62.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.62.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.62.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.62.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.62.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.62.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@react-native-async-storage/async-storage@2.2.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))': + dependencies: + merge-options: 3.0.4 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@react-native/assets-registry@0.83.6': {} + + '@react-native/assets-registry@0.85.2': {} + + '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.83.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-plugin-codegen@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + optional: true + + '@react-native/babel-preset@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.32.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/babel-preset@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + optional: true + + '@react-native/codegen@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + glob: 7.2.3 + hermes-parser: 0.32.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/codegen@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + hermes-parser: 0.33.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.16 + yargs: 17.7.2 + + '@react-native/community-cli-plugin@0.83.6(@react-native/metro-config@0.85.2(@babel/core@7.29.0))': + dependencies: + '@react-native/dev-middleware': 0.83.6 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.83.6 + metro-config: 0.83.6 + metro-core: 0.83.6 + semver: 7.7.4 + optionalDependencies: + '@react-native/metro-config': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/community-cli-plugin@0.85.2(@react-native/metro-config@0.85.2(@babel/core@7.29.0))': + dependencies: + '@react-native/dev-middleware': 0.85.2 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.84.3 + metro-config: 0.84.3 + metro-core: 0.84.3 + semver: 7.7.4 + optionalDependencies: + '@react-native/metro-config': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.83.6': {} + + '@react-native/debugger-frontend@0.85.2': {} + + '@react-native/debugger-shell@0.83.6': + dependencies: + cross-spawn: 7.0.6 + fb-dotslash: 0.5.8 + + '@react-native/debugger-shell@0.85.2': + dependencies: + cross-spawn: 7.0.6 + debug: 4.4.3 + fb-dotslash: 0.5.8 + transitivePeerDependencies: + - supports-color + + '@react-native/dev-middleware@0.83.6': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.83.6 + '@react-native/debugger-shell': 0.83.6 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/dev-middleware@0.85.2': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.85.2 + '@react-native/debugger-shell': 0.85.2 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.3.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.83.6': {} + + '@react-native/gradle-plugin@0.85.2': {} + + '@react-native/js-polyfills@0.83.6': {} + + '@react-native/js-polyfills@0.85.2': {} + + '@react-native/metro-babel-transformer@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@react-native/babel-preset': 0.85.2(@babel/core@7.29.0) + hermes-parser: 0.33.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@react-native/metro-config@0.85.2(@babel/core@7.29.0)': + dependencies: + '@react-native/js-polyfills': 0.85.2 + '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.0) + metro-config: 0.84.3 + metro-runtime: 0.84.3 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - supports-color + - utf-8-validate + optional: true + + '@react-native/normalize-colors@0.74.89': {} + + '@react-native/normalize-colors@0.83.6': {} + + '@react-native/normalize-colors@0.85.2': {} + + '@react-native/virtualized-lists@0.83.6(@types/react@19.2.14)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@react-native/virtualized-lists@0.85.2(@types/react@19.2.14)(react-native@0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.0 + react-native: 0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@react-navigation/bottom-tabs@7.15.10(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + sf-symbols-typescript: 2.2.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/core@7.17.2(react@19.2.0)': + dependencies: + '@react-navigation/routers': 7.5.3 + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-is: 19.2.5 + use-latest-callback: 0.2.6(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + + '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + use-latest-callback: 0.2.6(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + + '@react-navigation/native-stack@7.14.12(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/core': 7.17.2(react@19.2.0) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + use-latest-callback: 0.2.6(react@19.2.0) + + '@react-navigation/routers@7.5.3': + dependencies: + nanoid: 3.3.11 + + '@rtsao/scc@1.1.0': {} + + '@sinclair/typebox@0.27.10': {} + + '@sinclair/typebox@0.34.49': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + jest-matcher-utils: 30.3.0 + picocolors: 1.1.1 + pretty-format: 30.3.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-test-renderer: 19.2.0(react@19.2.0) + redent: 3.0.0 + optionalDependencies: + jest: 29.7.0(@types/node@25.6.0) + + '@tootallnate/once@2.0.0': {} + + '@tsconfig/node18@18.2.6': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/emscripten@1.41.5': {} + + '@types/estree@1.0.8': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 25.6.0 + + '@types/hammerjs@2.0.46': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 25.6.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/react-native@0.73.0(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react-native: 0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/jest-preset' + - '@react-native/metro-config' + - '@types/react' + - bufferutil + - react + - supports-color + - utf-8-validate + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/stack-utils@2.0.3': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.0': {} + + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': {} + + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.16.0 + acorn-walk: 8.3.5 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@6.2.1: {} + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asap@2.0.6: {} + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-dynamic-import-node@2.3.3: + dependencies: + object.assign: 4.1.7 + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.32.0: + dependencies: + hermes-parser: 0.32.0 + + babel-plugin-syntax-hermes-parser@0.32.1: + dependencies: + hermes-parser: 0.32.1 + + babel-plugin-syntax-hermes-parser@0.33.3: + dependencies: + hermes-parser: 0.33.3 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + dependencies: + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-expo@55.0.18(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.17)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': 0.83.6(@babel/core@7.29.0) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.32.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.29.2 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + badgin@1.2.3: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + barcode-detector@3.1.2(@types/emscripten@1.41.5): + dependencies: + zxing-wasm: 3.0.2(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.23: {} + + better-opn@3.0.2: + dependencies: + open: 8.4.2 + + big-integer@1.6.52: {} + + binary-extensions@2.3.0: + optional: true + + boolbase@1.0.0: {} + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.344 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001791: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + char-regex@2.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + optional: true + + chrome-launcher@0.15.2: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.2.0: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.3.0: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@6.2.1: {} + + commander@7.2.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + convert-source-map@2.0.0: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + create-jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.2.3: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decode-uri-component@0.2.2: {} + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@2.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + detect-node-es@1.1.0: {} + + diff-sequences@29.6.3: {} + + dnssd-advertise@1.1.4: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.344: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-compat-utils@0.5.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + semver: 7.7.4 + + eslint-config-prettier@9.1.2(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-config-universe@15.0.3(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-config-prettier: 9.1.2(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + eslint-plugin-node: 11.1.0(eslint@9.39.4) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) + eslint-plugin-react: 7.37.5(eslint@9.39.4) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) + globals: 16.5.0 + optionalDependencies: + prettier: 2.8.8 + transitivePeerDependencies: + - '@types/eslint' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + - typescript + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + transitivePeerDependencies: + - supports-color + + eslint-plugin-es-x@7.8.0(eslint@9.39.4): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + eslint: 9.39.4 + eslint-compat-utils: 0.5.1(eslint@9.39.4) + + eslint-plugin-es@3.0.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + eslint-utils: 2.1.0 + regexpp: 3.2.0 + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + hasown: 2.0.3 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-n@17.24.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + enhanced-resolve: 5.21.0 + eslint: 9.39.4 + eslint-plugin-es-x: 7.8.0(eslint@9.39.4) + get-tsconfig: 4.14.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.7.4 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-node@11.1.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + eslint-plugin-es: 3.0.1(eslint@9.39.4) + eslint-utils: 2.1.0 + ignore: 5.3.2 + minimatch: 3.1.5 + resolve: 1.22.12 + semver: 6.3.1 + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8): + dependencies: + eslint: 9.39.4 + prettier: 2.8.8 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 + optionalDependencies: + eslint-config-prettier: 9.1.2(eslint@9.39.4) + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-plugin-react@7.37.5(eslint@9.39.4): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4 + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-utils@2.1.0: + dependencies: + eslint-visitor-keys: 1.3.0 + + eslint-visitor-keys@1.3.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expo-application@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-asset@55.0.16(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.13(typescript@5.9.3) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + - typescript + + expo-build-properties@55.0.13(expo@55.0.17): + dependencies: + '@expo/schema-utils': 55.0.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + + expo-camera@55.0.16(@types/emscripten@1.41.5)(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + barcode-detector: 3.1.2(@types/emscripten@1.41.5) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@types/emscripten' + + expo-constants@55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + '@expo/env': 2.1.1 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + + expo-crypto@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-file-system@55.0.17(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + expo-font@55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + fontfaceobserver: 2.3.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + expo-glass-effect@55.0.10(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + expo-haptics@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-image@55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + sf-symbols-typescript: 2.2.0 + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + + expo-keep-awake@55.0.6(expo@55.0.17)(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + + expo-linking@55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - expo + - supports-color + + expo-module-scripts@55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(eslint@9.39.4)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(prettier@2.8.8)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/cli': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/preset-env': 7.29.2(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@expo/npm-proofread': 1.0.1 + '@expo/spawn-async': 1.7.2 + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + '@tsconfig/node18': 18.2.6 + '@types/jest': 29.5.14 + babel-plugin-dynamic-import-node: 2.3.3 + babel-preset-expo: 55.0.18(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.17)(react-refresh@0.14.2) + commander: 12.1.0 + eslint-config-universe: 15.0.3(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) + glob: 13.0.6 + jest-expo: 55.0.16(@babel/core@7.29.0)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + jest-snapshot-prettier: prettier@2.8.8 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.6.0)) + resolve-workspace-root: 2.0.1 + ts-jest: 29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@25.6.0))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@babel/core' + - '@babel/runtime' + - '@jest/types' + - '@types/eslint' + - babel-jest + - bufferutil + - canvas + - esbuild + - eslint + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - expo + - expo-widgets + - jest + - prettier + - react + - react-native + - react-refresh + - react-server-dom-webpack + - react-test-renderer + - supports-color + - utf-8-validate + + expo-modules-autolinking@55.0.18(typescript@5.9.3): + dependencies: + '@expo/require-utils': 55.0.4(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@55.0.23(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + + expo-notifications@55.0.21(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.13(typescript@5.9.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-application: 55.0.14(expo@55.0.17) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + - typescript + + expo-router@55.0.13(121105c3b042d5e83ab3c1d3b84ed55f): + dependencies: + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.3 + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@react-navigation/bottom-tabs': 7.15.10(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-glass-effect: 55.0.10(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-image: 55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-linking: 55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-server: 55.0.8 + expo-symbols: 55.0.7(expo-font@55.0.6)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-fast-compare: 3.2.2 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.2.0) + vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + optionalDependencies: + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + react-dom: 19.2.5(react@19.2.0) + react-native-gesture-handler: 2.30.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-reanimated: 4.2.1(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - expo-font + - supports-color + + expo-server@55.0.8: {} + + expo-splash-screen@55.0.19(expo@55.0.17)(typescript@5.9.3): + dependencies: + '@expo/prebuild-config': 55.0.16(expo@55.0.17)(typescript@5.9.3) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + expo-status-bar@55.0.5(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + + expo-symbols@55.0.7(expo-font@55.0.6)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.34 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + sf-symbols-typescript: 2.2.0 + + expo@55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + '@expo/cli': 55.0.26(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/devtools': 55.0.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/fingerprint': 0.16.6 + '@expo/local-build-cache-provider': 55.0.11(typescript@5.9.3) + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro': 55.1.0 + '@expo/metro-config': 55.0.17(expo@55.0.17)(typescript@5.9.3) + '@expo/vector-icons': 15.1.1(expo-font@55.0.6)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@ungap/structured-clone': 1.3.0 + babel-preset-expo: 55.0.18(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.17)(react-refresh@0.14.2) + expo-asset: 55.0.16(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-file-system: 55.0.17(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-keep-awake: 55.0.6(expo@55.0.17)(react@19.2.0) + expo-modules-autolinking: 55.0.18(typescript@5.9.3) + expo-modules-core: 55.0.23(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + pretty-format: 29.7.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.1 + optionalDependencies: + '@expo/dom-webview': 55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-webview: 13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-dom + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + exponential-backoff@3.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-nodeshim@0.4.10: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fresh@0.5.2: {} + + fs-readdir-recursive@1.1.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + getenv@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hermes-compiler@0.14.1: {} + + hermes-compiler@250829098.0.10: {} + + hermes-estree@0.32.0: {} + + hermes-estree@0.32.1: {} + + hermes-estree@0.33.3: {} + + hermes-estree@0.35.0: {} + + hermes-parser@0.32.0: + dependencies: + hermes-estree: 0.32.0 + + hermes-parser@0.32.1: + dependencies: + hermes-estree: 0.32.1 + + hermes-parser@0.33.3: + dependencies: + hermes-estree: 0.33.3 + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + hyphenate-style-name@1.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + optional: true + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.6.0) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@25.6.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.3.0: + dependencies: + '@jest/diff-sequences': 30.3.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.3.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-expo@55.0.16(@babel/core@7.29.0)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/json-file': 10.0.13 + '@jest/create-cache-key-function': 29.7.0 + '@jest/globals': 29.7.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + jest-environment-jsdom: 29.7.0 + jest-snapshot: 29.7.0 + jest-watch-select-projects: 2.0.0 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.6.0)) + json5: 2.2.3 + lodash: 4.18.1 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-test-renderer: 19.2.0(react@19.2.0) + server-only: 0.0.1 + stacktrace-js: 2.0.2 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - canvas + - jest + - react + - supports-color + - typescript + - utf-8-validate + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 25.6.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.3.0 + pretty-format: 30.3.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watch-select-projects@2.0.0: + dependencies: + ansi-escapes: 4.3.2 + chalk: 3.0.0 + prompts: 2.4.2 + + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.6.0)): + dependencies: + ansi-escapes: 6.2.1 + chalk: 4.1.2 + jest: 29.7.0(@types/node@25.6.0) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 25.6.0 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.6.0) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jimp-compact@0.16.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.16.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.20.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + lan-network@0.2.1: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.3.5: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react-native@1.11.0(react-native-svg@15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-svg: 15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + memoize-one@5.2.1: {} + + memoize-one@6.0.0: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.83.6: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.83.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-babel-transformer@0.84.3: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache-key@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.83.6: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.83.6 + transitivePeerDependencies: + - supports-color + + metro-cache@0.84.3: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.3 + transitivePeerDependencies: + - supports-color + + metro-config@0.83.6: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.83.6 + metro-cache: 0.83.6 + metro-core: 0.83.6 + metro-runtime: 0.83.6 + yaml: 2.8.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-config@0.84.3: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.3 + metro-cache: 0.84.3 + metro-core: 0.84.3 + metro-runtime: 0.84.3 + yaml: 2.8.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.83.6 + + metro-core@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.3 + + metro-file-map@0.83.6: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-file-map@0.84.3: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.2 + + metro-minify-terser@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.2 + + metro-resolver@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-resolver@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.83.6: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.3: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.83.6: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.83.6 + nullthrows: 1.1.1 + ob1: 0.83.6 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-source-map@0.84.3: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.3 + nullthrows: 1.1.1 + ob1: 0.84.3 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.83.6 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.3 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.83.6: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.83.6: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.83.6 + metro-babel-transformer: 0.83.6 + metro-cache: 0.83.6 + metro-cache-key: 0.83.6 + metro-minify-terser: 0.83.6 + metro-source-map: 0.83.6 + metro-transform-plugins: 0.83.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-transform-worker@0.84.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.84.3 + metro-babel-transformer: 0.84.3 + metro-cache: 0.84.3 + metro-cache-key: 0.84.3 + metro-minify-terser: 0.84.3 + metro-source-map: 0.84.3 + metro-transform-plugins: 0.84.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.83.6: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.83.6 + metro-cache: 0.83.6 + metro-cache-key: 0.83.6 + metro-config: 0.83.6 + metro-core: 0.83.6 + metro-file-map: 0.83.6 + metro-resolver: 0.83.6 + metro-runtime: 0.83.6 + metro-source-map: 0.83.6 + metro-symbolicate: 0.83.6 + metro-transform-plugins: 0.83.6 + metro-transform-worker: 0.83.6 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.3: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.3 + metro-cache: 0.84.3 + metro-cache-key: 0.84.3 + metro-config: 0.84.3 + metro-core: 0.84.3 + metro-file-map: 0.84.3 + metro-resolver: 0.84.3 + metro-runtime: 0.84.3 + metro-source-map: 0.84.3 + metro-symbolicate: 0.84.3 + metro-transform-plugins: 0.84.3 + metro-transform-worker: 0.84.3 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multitars@1.0.0: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-forge@1.4.0: {} + + node-int64@0.4.0: {} + + node-releases@2.0.38: {} + + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.7.4 + validate-npm-package-name: 5.0.1 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + + nwsapi@2.2.23: {} + + ob1@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + + ob1@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxfmt@0.47.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.47.0 + '@oxfmt/binding-android-arm64': 0.47.0 + '@oxfmt/binding-darwin-arm64': 0.47.0 + '@oxfmt/binding-darwin-x64': 0.47.0 + '@oxfmt/binding-freebsd-x64': 0.47.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.47.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.47.0 + '@oxfmt/binding-linux-arm64-gnu': 0.47.0 + '@oxfmt/binding-linux-arm64-musl': 0.47.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.47.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.47.0 + '@oxfmt/binding-linux-riscv64-musl': 0.47.0 + '@oxfmt/binding-linux-s390x-gnu': 0.47.0 + '@oxfmt/binding-linux-x64-gnu': 0.47.0 + '@oxfmt/binding-linux-x64-musl': 0.47.0 + '@oxfmt/binding-openharmony-arm64': 0.47.0 + '@oxfmt/binding-win32-arm64-msvc': 0.47.0 + '@oxfmt/binding-win32-ia32-msvc': 0.47.0 + '@oxfmt/binding-win32-x64-msvc': 0.47.0 + + oxlint@1.62.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.62.0 + '@oxlint/binding-android-arm64': 1.62.0 + '@oxlint/binding-darwin-arm64': 1.62.0 + '@oxlint/binding-darwin-x64': 1.62.0 + '@oxlint/binding-freebsd-x64': 1.62.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.62.0 + '@oxlint/binding-linux-arm-musleabihf': 1.62.0 + '@oxlint/binding-linux-arm64-gnu': 1.62.0 + '@oxlint/binding-linux-arm64-musl': 1.62.0 + '@oxlint/binding-linux-ppc64-gnu': 1.62.0 + '@oxlint/binding-linux-riscv64-gnu': 1.62.0 + '@oxlint/binding-linux-riscv64-musl': 1.62.0 + '@oxlint/binding-linux-s390x-gnu': 1.62.0 + '@oxlint/binding-linux-x64-gnu': 1.62.0 + '@oxlint/binding-linux-x64-musl': 1.62.0 + '@oxlint/binding-openharmony-arm64': 1.62.0 + '@oxlint/binding-win32-arm64-msvc': 1.62.0 + '@oxlint/binding-win32-ia32-msvc': 1.62.0 + '@oxlint/binding-win32-x64-msvc': 1.62.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.5 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@3.4.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.49: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@2.8.8: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.3.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + querystringify@2.2.0: {} + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + range-parser@1.2.1: {} + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.8.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.2.5(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.0): + dependencies: + react: 19.2.0 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.2.5: {} + + react-native-gesture-handler@2.30.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@egjs/hammerjs': 2.0.17 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-is-edge-to-edge@1.2.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-is-edge-to-edge@1.3.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-reanimated@4.2.1(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + semver: 7.7.3 + + react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-freeze: 1.0.4(react@19.2.0) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + warn-once: 0.1.1 + + react-native-svg@15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + warn-once: 0.1.1 + + react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/runtime': 7.29.2 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + + react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) + convert-source-map: 2.0.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.83.6 + '@react-native/codegen': 0.83.6(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.83.6(@react-native/metro-config@0.85.2(@babel/core@7.29.0)) + '@react-native/gradle-plugin': 0.83.6 + '@react-native/js-polyfills': 0.83.6 + '@react-native/normalize-colors': 0.83.6 + '@react-native/virtualized-lists': 0.83.6(@types/react@19.2.14)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + hermes-compiler: 0.14.1 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.6 + metro-source-map: 0.83.6 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-native@0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0): + dependencies: + '@react-native/assets-registry': 0.85.2 + '@react-native/codegen': 0.85.2(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.85.2(@react-native/metro-config@0.85.2(@babel/core@7.29.0)) + '@react-native/gradle-plugin': 0.85.2 + '@react-native/js-polyfills': 0.85.2 + '@react-native/normalize-colors': 0.85.2 + '@react-native/virtualized-lists': 0.85.2(@types/react@19.2.14)(react-native@0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.33.3 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.10 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.3 + metro-source-map: 0.84.3 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.16 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-refresh@0.14.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-test-renderer@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + react-is: 19.2.5 + scheduler: 0.27.0 + + react@19.2.0: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + optional: true + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpp@3.2.0: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.1: + dependencies: + jsesc: 3.1.0 + + require-directory@2.1.1: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + semver@7.7.3: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slash@2.0.0: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + + slugify@1.6.9: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.6: {} + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + split-on-first@1.1.0: {} + + sprintf-js@1.0.3: {} + + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackframe@1.3.4: {} + + stacktrace-gps@3.1.2: + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + + stacktrace-js@2.0.2: + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-buffers@2.2.0: {} + + strict-uri-encode@2.0.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-length@5.0.1: + dependencies: + char-regex: 2.0.2 + strip-ansi: 7.2.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + structured-headers@0.4.1: {} + + styleq@0.1.3: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tagged-tag@1.0.0: {} + + tapable@2.3.3: {} + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.46.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + throat@5.0.0: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toqr@0.1.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@0.0.3: {} + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-jest@29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@25.6.0))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + tweetnacl@1.0.3: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.3: {} + + ua-parser-js@1.0.41: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.19.2: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + universalify@0.2.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-latest-callback@0.2.6(react@19.2.0): + dependencies: + react: 19.2.0 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.0): + dependencies: + react: 19.2.0 + + utils-merge@1.0.1: {} + + uuid@7.0.3: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vlq@1.0.1: {} + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-fetch@3.6.20: {} + + whatwg-mimetype@3.0.0: {} + + whatwg-url-minimum@0.1.1: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@7.5.10: {} + + ws@8.20.0: {} + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml-name-validator@4.0.0: {} + + xml2js@0.6.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.8.3: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} + + zod@4.3.6: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + + zxing-wasm@3.0.2(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.6.0 diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts new file mode 100644 index 00000000000..14c7ab2c3db --- /dev/null +++ b/mobile/scripts/mock-server.ts @@ -0,0 +1,321 @@ +#!/usr/bin/env npx tsx +// Why: standalone mock WebSocket server for developing the mobile app without +// a running Orca desktop instance. Responds to the same RPC methods the real +// runtime exposes, with realistic fake data. Supports E2EE handshake. +import { WebSocketServer, type WebSocket } from 'ws' +import nacl from 'tweetnacl' + +const PORT = Number(process.env.PORT) || 6768 +const AUTH_TOKEN = 'mock-device-token' + +// Why: generate a persistent server keypair for this mock session. +// The public key is printed at startup so it can be used in pairing QR data. +const serverKeyPair = nacl.box.keyPair() +const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64') + +type E2EEState = { + sharedKey: Uint8Array + deviceToken: string | null + authenticated: boolean +} + +function deriveSharedKey(ourSecret: Uint8Array, peerPublic: Uint8Array): Uint8Array { + return nacl.box.before(peerPublic, ourSecret) +} + +function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const msg = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(msg, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return Buffer.from(bundle).toString('base64') +} + +function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) return null + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + if (!plaintext) return null + return new TextDecoder().decode(plaintext) +} + +const FAKE_WORKTREES = [ + { + worktreeId: 'repo-1::/home/user/projects/acme-api', + repoId: 'repo-1', + repo: 'acme-api', + path: '/home/user/projects/acme-api', + branch: 'feature/auth-refactor', + linkedIssue: 42, + unread: true, + liveTerminalCount: 2, + hasAttachedPty: true, + lastOutputAt: Date.now() - 5000, + preview: '$ claude "refactor the auth module"' + }, + { + worktreeId: 'repo-1::/home/user/projects/acme-web', + repoId: 'repo-1', + repo: 'acme-web', + path: '/home/user/projects/acme-web', + branch: 'main', + linkedIssue: null, + unread: false, + liveTerminalCount: 1, + hasAttachedPty: true, + lastOutputAt: Date.now() - 60000, + preview: '$ npm test\nAll tests passed.' + } +] + +const FAKE_TERMINALS = [ + { + handle: 'term-1', + worktreeId: 'repo-1::/home/user/projects/acme-api', + title: 'Claude — auth refactor', + isActive: true, + hasRunningProcess: true + }, + { + handle: 'term-2', + worktreeId: 'repo-1::/home/user/projects/acme-api', + title: 'zsh', + isActive: false, + hasRunningProcess: false + } +] + +const FAKE_SCROLLBACK = [ + '$ claude "refactor the auth module to use JWT tokens"', + '', + '⏳ Working on it...', + '', + "I'll refactor the auth module. Here's my plan:", + '1. Replace session-based auth with JWT', + '2. Add token refresh endpoint', + '3. Update middleware', + '', + 'Let me start by reading the current auth module...', + '' +].join('\n') + +const STREAMING_CHUNKS = [ + 'Reading src/auth/middleware.ts...\n', + 'Reading src/auth/session.ts...\n', + '\nI see the current implementation uses express-session.\n', + "I'll replace it with jsonwebtoken.\n", + '\nUpdating src/auth/middleware.ts...\n' +] + +type RpcRequest = { + id: string + method: string + deviceToken?: string + params?: Record +} + +type RpcResponse = { + id: string + ok: boolean + result?: unknown + error?: { code: string; message: string } + streaming?: true + _meta: { runtimeId: string } +} + +function success(id: string, result: unknown, streaming?: boolean): RpcResponse { + const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } } + if (streaming) { + resp.streaming = true + } + return resp +} + +function error(id: string, code: string, message: string): RpcResponse { + return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'mock-runtime' } } +} + +function handleRequest( + request: RpcRequest, + send: (response: RpcResponse) => void, + ws: WebSocket +): void { + switch (request.method) { + case 'status.get': + send( + success(request.id, { + runtimeId: 'mock-runtime', + graphStatus: 'ready', + windowCount: 1, + tabCount: 2, + terminalCount: 2 + }) + ) + break + + case 'worktree.ps': + send( + success(request.id, { + worktrees: FAKE_WORKTREES, + totalCount: FAKE_WORKTREES.length, + truncated: false + }) + ) + break + + case 'terminal.list': + send( + success(request.id, { + terminals: FAKE_TERMINALS, + totalCount: FAKE_TERMINALS.length, + truncated: false + }) + ) + break + + case 'terminal.subscribe': { + send(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) + + let chunkIndex = 0 + const interval = setInterval(() => { + if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { + clearInterval(interval) + if (ws.readyState === ws.OPEN) { + send(success(request.id, { type: 'end' })) + } + return + } + send(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) + chunkIndex++ + }, 500) + break + } + + case 'terminal.send': + send(success(request.id, { send: { handle: 'term-1', ok: true } })) + break + + case 'terminal.unsubscribe': + send(success(request.id, { unsubscribed: true })) + break + + default: + send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`)) + } +} + +const wss = new WebSocketServer({ port: PORT }) + +// Why: each connection goes through an E2EE handshake before any RPC traffic. +// The first message must be e2ee_hello (plaintext), then all subsequent +// messages are encrypted with the derived shared key. +const connectionState = new Map() + +wss.on('connection', (ws) => { + console.log('[mock] Client connected — waiting for e2ee_hello') + + ws.on('message', (data) => { + const msg = typeof data === 'string' ? data : data.toString('utf-8') + const e2ee = connectionState.get(ws) + + if (!e2ee) { + // Handshake phase — expect e2ee_hello + let hello: { type?: string; publicKeyB64?: string } + try { + hello = JSON.parse(msg) + } catch { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid JSON' })) + ws.close() + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Expected e2ee_hello' })) + ws.close() + return + } + + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid public key' })) + ws.close() + return + } + + const sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + connectionState.set(ws, { sharedKey, deviceToken: null, authenticated: false }) + + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + console.log('[mock] E2EE key exchange complete — waiting for encrypted auth') + return + } + + // Post-handshake — decrypt, handle, encrypt reply + const plaintext = e2eeDecrypt(msg, e2ee.sharedKey) + if (plaintext === null) { + console.log('[mock] Decryption failed — dropping message') + return + } + + let request: RpcRequest + try { + request = JSON.parse(plaintext) as RpcRequest + } catch { + const encrypted = e2eeEncrypt( + JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')), + e2ee.sharedKey + ) + ws.send(encrypted) + return + } + + if (!e2ee.authenticated) { + const auth = request as unknown as { type?: string; deviceToken?: string } + if (auth.type !== 'e2ee_auth' || auth.deviceToken !== AUTH_TOKEN) { + ws.send( + e2eeEncrypt( + JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } }), + e2ee.sharedKey + ) + ) + ws.close() + return + } + e2ee.deviceToken = auth.deviceToken + e2ee.authenticated = true + ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), e2ee.sharedKey)) + console.log('[mock] E2EE authentication complete') + return + } + + console.log(`[mock] ${request.method} (id: ${request.id})`) + handleRequest( + request, + (response) => { + if (ws.readyState === ws.OPEN) { + ws.send(e2eeEncrypt(JSON.stringify(response), e2ee.sharedKey)) + } + }, + ws + ) + }) + + ws.on('close', () => { + connectionState.delete(ws) + console.log('[mock] Client disconnected') + }) + + ws.on('error', () => { + connectionState.delete(ws) + ws.close() + }) +}) + +console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`) +console.log(`[mock] Auth token: ${AUTH_TOKEN}`) +console.log(`[mock] Server public key (base64): ${serverPublicKeyB64}`) +console.log(`[mock] E2EE enabled — clients must send e2ee_hello before RPC`) diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts new file mode 100644 index 00000000000..89f0bb55d56 --- /dev/null +++ b/mobile/scripts/repro-terminal-colors.ts @@ -0,0 +1,362 @@ +/** + * Minimal mobile terminal color repro. + * + * Captures terminal.subscribe snapshots in the same sequence that tab switching + * uses: terminal A, terminal B, terminal A again. The report tells us whether + * ANSI SGR color attributes disappeared in the desktop serialized scrollback + * or are still present and therefore being lost during mobile WebView replay. + * + * Usage: + * ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \ + * pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB] + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const token = process.argv[2] +const serverPublicKeyB64 = process.argv[3] +const worktreeSelector = process.argv[4] +const explicitHandleA = process.argv[5] +const explicitHandleB = process.argv[6] +const ESC = String.fromCharCode(27) + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type Snapshot = { + label: string + handle: string + cols: number | null + rows: number | null + serialized: string + lines: string +} + +if (!token || !serverPublicKeyB64 || !worktreeSelector) { + console.error( + 'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]' + ) + process.exit(1) +} + +let reqId = 0 +const pending = new Map() +const streamListeners = new Map) => void>() +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `color-repro-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send(ws: WebSocket, method: string, params?: unknown): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + streamListeners.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, 10_000) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatError(response: RpcResponse): string { + return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) +} + +async function listHandles( + ws: WebSocket +): Promise> { + const response = await send(ws, 'terminal.list', { worktree: worktreeSelector }) + if (!response.ok) { + throw new Error(`terminal.list failed: ${formatError(response)}`) + } + return ( + (response.result?.terminals ?? []) as Array<{ handle: string; title?: string | null }> + ).map((terminal) => ({ + handle: terminal.handle, + title: terminal.title ?? null + })) +} + +async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise { + const terminals = await listHandles(ws) + const existing = terminals.find((terminal) => terminal.handle !== handleA) + if (existing) return existing.handle + + const created = await send(ws, 'terminal.create', { + worktree: worktreeSelector, + title: 'color-repro-switch-target' + }) + if (!created.ok) { + throw new Error(`terminal.create failed: ${formatError(created)}`) + } + const handle = (created.result?.terminal as { handle?: string } | undefined)?.handle + if (!handle) { + throw new Error(`terminal.create returned no handle: ${formatError(created)}`) + } + return handle +} + +async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise { + const id = nextId() + const snapshot = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + streamListeners.delete(id) + reject(new Error(`Timed out waiting for scrollback snapshot ${label}`)) + }, 10_000) + + pending.set(id, { + method: 'terminal.subscribe', + resolve: () => {}, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + streamListeners.set(id, (result) => { + if (result.type !== 'scrollback') return + clearTimeout(timeout) + pending.delete(id) + streamListeners.delete(id) + const serialized = typeof result.serialized === 'string' ? result.serialized : '' + const rawLines = result.lines + const lines = Array.isArray(rawLines) ? rawLines.join('\n') : String(rawLines ?? '') + resolve({ + label, + handle, + cols: typeof result.cols === 'number' ? result.cols : null, + rows: typeof result.rows === 'number' ? result.rows : null, + serialized, + lines + }) + }) + + sendRaw(ws, { + id, + deviceToken: token, + method: 'terminal.subscribe', + params: { terminal: handle } + }) + }) + + await send(ws, 'terminal.unsubscribe', { subscriptionId: handle }).catch(() => null) + return snapshot +} + +function countMatches(value: string, pattern: RegExp): number { + return value.match(pattern)?.length ?? 0 +} + +function summarize(snapshot: Snapshot): Record { + const data = snapshot.serialized + return { + label: snapshot.label, + handle: snapshot.handle, + cols: snapshot.cols, + rows: snapshot.rows, + serializedBytes: Buffer.byteLength(data), + sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')), + sgrColor: countMatches( + data, + new RegExp( + `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`, + 'g' + ) + ), + sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')), + altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no', + containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no', + containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no' + } +} + +function saveSnapshots(snapshots: Snapshot[]): string { + const dir = join(process.cwd(), 'terminal-color-repro') + mkdirSync(dir, { recursive: true }) + for (const snapshot of snapshots) { + const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-') + writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines) + writeFileSync( + join(dir, `${base}.escaped.txt`), + JSON.stringify(snapshot.serialized || snapshot.lines) + ) + } + writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2)) + return dir +} + +async function run(ws: WebSocket): Promise { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: toBase64(clientKeys.publicKey) + }) + ) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const terminals = await listHandles(ws) + if (terminals.length === 0 && !explicitHandleA) { + throw new Error('No terminals found. Open a Claude Code terminal first, then rerun.') + } + + const handleA = explicitHandleA ?? terminals[0]!.handle + const handleB = explicitHandleB ?? (await ensureSecondHandle(ws, handleA)) + + console.log(`A: ${handleA}`) + console.log(`B: ${handleB}`) + + const firstA = await captureSnapshot(ws, 'a-before-switch', handleA) + const firstB = await captureSnapshot(ws, 'b-during-switch', handleB) + const secondA = await captureSnapshot(ws, 'a-after-switch', handleA) + const snapshots = [firstA, firstB, secondA] + const dir = saveSnapshots(snapshots) + + console.table(snapshots.map(summarize)) + console.log(`saved: ${dir}`) + if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) { + console.log( + 'color SGR count changed between A snapshots: serialization/state changed on desktop' + ) + } else { + console.log('A snapshots have the same color SGR count: likely mobile replay/render path') + } +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) + if (!plaintext) return + + const response = JSON.parse(plaintext) as RpcResponse + const result = response.result + const streamListener = streamListeners.get(response.id) + if (streamListener && response.ok && (response.streaming || result?.type === 'scrollback')) { + streamListener(result ?? {}) + return + } + + const request = pending.get(response.id) + if (!request || request.method === 'terminal.subscribe') return + pending.delete(response.id) + request.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + process.exit(1) +}) diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts new file mode 100644 index 00000000000..bc1ab769564 --- /dev/null +++ b/mobile/scripts/repro-worktree-startup-stream.ts @@ -0,0 +1,367 @@ +/** + * Captures the mobile WebSocket stream during worktree startup. + * + * Usage: + * pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand] + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const USER_DATA = + process.env.ORCA_USER_DATA ?? `${process.env.HOME}/Library/Application Support/orca-dev` +const repoSelector = process.argv[2] +const worktreeName = process.argv[3] +const startupCommand = process.argv[4] || 'claude' +const ESC = String.fromCharCode(27) + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type TerminalInfo = { + handle: string + title: string | null +} + +type Capture = { + handle: string + title: string | null + scrollback: Record | null + chunks: string[] +} + +if (!repoSelector || !worktreeName) { + console.error( + 'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]' + ) + process.exit(1) +} + +function readJson(path: string): T { + if (!existsSync(path)) { + throw new Error(`Missing ${path}`) + } + return JSON.parse(readFileSync(path, 'utf8')) as T +} + +const devices = readJson>(join(USER_DATA, 'orca-devices.json')) +const token = devices[0]?.token +const keypair = readJson<{ publicKeyB64: string }>(join(USER_DATA, 'orca-e2ee-keypair.json')) + +if (!token || !keypair.publicKeyB64) { + throw new Error(`Missing mobile token or E2EE public key in ${USER_DATA}`) +} + +let reqId = 0 +const pending = new Map() +const streamListeners = new Map) => void>() +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(keypair.publicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `startup-repro-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send( + ws: WebSocket, + method: string, + params?: unknown, + timeoutMs = 30_000 +): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, timeoutMs) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatError(response: RpcResponse): string { + return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) +} + +function stripAnsi(value: string): string { + return ( + value + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, '\n') + ) +} + +function normalizePreview(value: string): string { + return stripAnsi(value) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .join('\n') +} + +async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + const response = await send(ws, 'terminal.list', { worktree: worktreeId }) + if (!response.ok) { + throw new Error(`terminal.list failed: ${formatError(response)}`) + } + const terminals = (response.result?.terminals ?? []) as Array<{ + handle?: string + title?: string | null + }> + const handles = terminals + .filter((terminal): terminal is { handle: string; title?: string | null } => + Boolean(terminal.handle) + ) + .map((terminal) => ({ handle: terminal.handle, title: terminal.title ?? null })) + if (handles.length > 0) { + return handles + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('Timed out waiting for startup terminals') +} + +async function subscribe(ws: WebSocket, capture: Capture): Promise { + const id = nextId() + streamListeners.set(id, (result) => { + if (result.type === 'scrollback') { + capture.scrollback = result + return + } + if (result.type === 'data' && typeof result.chunk === 'string') { + capture.chunks.push(result.chunk) + } + }) + sendRaw(ws, { + id, + deviceToken: token, + method: 'terminal.subscribe', + params: { terminal: capture.handle } + }) +} + +function summarize(capture: Capture): Record { + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + const live = capture.chunks.join('') + const serializedPreview = normalizePreview(serialized) + const livePreview = normalizePreview(live) + return { + handle: capture.handle, + title: capture.title, + cols: capture.scrollback?.cols ?? null, + rows: capture.scrollback?.rows ?? null, + serializedBytes: Buffer.byteLength(serialized), + liveBytes: Buffer.byteLength(live), + liveChunks: capture.chunks.length, + serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + livePreviewContainedInSerialized: + livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), + serializedPreview, + livePreview + } +} + +function save(captures: Capture[], worktreeName: string): string { + const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) + mkdirSync(dir, { recursive: true }) + for (const capture of captures) { + const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) + writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) + writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) + } + writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2)) + return dir +} + +async function run(ws: WebSocket): Promise { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: toBase64(clientKeys.publicKey) + }) + ) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const created = await send( + ws, + 'worktree.create', + { repo: repoSelector, name: worktreeName, startupCommand }, + 120_000 + ) + if (!created.ok) { + throw new Error(`worktree.create failed: ${formatError(created)}`) + } + const worktree = created.result?.worktree as { id?: string; path?: string } | undefined + if (!worktree?.id) { + throw new Error(`worktree.create returned no worktree id: ${formatError(created)}`) + } + + console.log(`worktree: ${worktree.id}`) + const terminals = await waitForTerminals(ws, worktree.id) + console.log(`terminals: ${terminals.map((terminal) => terminal.handle).join(', ')}`) + const captures = terminals.map((terminal) => ({ + handle: terminal.handle, + title: terminal.title, + scrollback: null, + chunks: [] + })) + for (const capture of captures) { + await subscribe(ws, capture) + } + + await new Promise((resolve) => setTimeout(resolve, 15_000)) + for (const capture of captures) { + await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null) + } + const dir = save(captures, worktreeName) + console.table(captures.map(summarize)) + console.log(`saved: ${dir}`) +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) + if (!plaintext) return + + const response = JSON.parse(plaintext) as RpcResponse + const result = response.result + const streamListener = streamListeners.get(response.id) + if (streamListener && response.ok && (response.streaming || result?.type)) { + streamListener(result ?? {}) + return + } + + const request = pending.get(response.id) + if (!request || request.method === 'terminal.subscribe') return + pending.delete(response.id) + request.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + process.exit(1) +}) diff --git a/mobile/scripts/test-subscribe.ts b/mobile/scripts/test-subscribe.ts new file mode 100644 index 00000000000..6fa8ffe4b42 --- /dev/null +++ b/mobile/scripts/test-subscribe.ts @@ -0,0 +1,324 @@ +/** + * Lightweight terminal streaming repro for the mobile WebSocket RPC. + * + * Usage: + * pnpm exec tsx scripts/test-subscribe.ts [worktreeSelector] + * + * Example: + * pnpm exec tsx scripts/test-subscribe.ts "$TOKEN" "$SERVER_PUBLIC_KEY" \ + * "id:repo-id::/path/to/worktree" + */ +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const token = process.argv[2] +const serverPublicKeyB64 = process.argv[3] +const worktreeSelector = process.argv[4] +const marker = `MOBILE_STREAM_${Date.now()}` + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } + _meta?: { runtimeId: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +if (!token || !serverPublicKeyB64) { + console.error( + 'Usage: pnpm exec tsx scripts/test-subscribe.ts [worktreeSelector]' + ) + process.exit(1) +} + +let reqId = 0 +const pending = new Map() +let streamSawMarker = false +let readSawMarker = false +let activeHandle: string | null = null +let runtimeId = '' +let scrollbackCols: number | null = null +let scrollbackRows: number | null = null +let serializedLength = 0 +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `test-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send(ws: WebSocket, method: string, params?: unknown): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, 10_000) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatResponse(response: RpcResponse): string { + return JSON.stringify(response.result ?? response.error ?? response).slice(0, 1000) +} + +async function chooseWorktree(ws: WebSocket): Promise { + if (worktreeSelector) return worktreeSelector + + const response = await send(ws, 'worktree.ps') + if (!response.ok) { + throw new Error(`worktree.ps failed: ${formatResponse(response)}`) + } + + const worktrees = (response.result?.worktrees ?? []) as Array<{ + worktreeId: string + liveTerminalCount: number + branch: string + path: string + }> + const selected = worktrees.find((w) => w.liveTerminalCount > 0) ?? worktrees[0] + if (!selected) { + throw new Error('No worktrees returned by worktree.ps') + } + + console.log(`worktree: ${selected.branch || '(no branch)'} ${selected.path}`) + return `id:${selected.worktreeId}` +} + +async function chooseTerminal(ws: WebSocket, worktree: string): Promise { + const list = await send(ws, 'terminal.list', { worktree }) + if (!list.ok) { + throw new Error(`terminal.list failed: ${formatResponse(list)}`) + } + + const terminals = (list.result?.terminals ?? []) as Array<{ + handle: string + title: string + preview: string + lastOutputAt: number | null + }> + + if (terminals.length > 0) { + const selected = terminals[0]! + console.log(`terminal: ${selected.title || selected.handle} ${selected.handle}`) + return selected.handle + } + + const created = await send(ws, 'terminal.create', { + worktree, + title: 'mobile-stream-repro' + }) + if (!created.ok) { + throw new Error(`terminal.create failed: ${formatResponse(created)}`) + } + + const terminal = created.result?.terminal as { handle?: string; title?: string } | undefined + if (!terminal?.handle) { + throw new Error(`terminal.create returned no handle: ${formatResponse(created)}`) + } + console.log(`terminal: ${terminal.title || terminal.handle} ${terminal.handle}`) + return terminal.handle +} + +async function run(ws: WebSocket): Promise { + console.log(`connected: ${WS_URL}`) + ws.send(JSON.stringify({ type: 'e2ee_hello', publicKeyB64: toBase64(clientKeys.publicKey) })) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const worktree = await chooseWorktree(ws) + const handle = await chooseTerminal(ws, worktree) + activeHandle = handle + + void send(ws, 'terminal.subscribe', { terminal: handle }).catch((error) => { + console.error(`subscribe failed: ${error.message}`) + }) + + await new Promise((resolve) => setTimeout(resolve, 500)) + + const sendResponse = await send(ws, 'terminal.send', { + terminal: handle, + text: `echo ${marker}`, + enter: true + }) + if (!sendResponse.ok) { + throw new Error(`terminal.send failed: ${formatResponse(sendResponse)}`) + } + console.log(`sent marker: ${marker}`) + + const deadline = Date.now() + 5_000 + while (Date.now() < deadline && !streamSawMarker && !readSawMarker) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const read = await send(ws, 'terminal.read', { terminal: handle }) + if (!read.ok) { + throw new Error(`terminal.read failed: ${formatResponse(read)}`) + } + const terminal = read.result?.terminal as { tail?: string[]; preview?: string } | undefined + const text = [...(terminal?.tail ?? []), terminal?.preview ?? ''].join('\n') + if (text.includes(marker)) { + readSawMarker = true + } + } + + console.log(`runtime: ${runtimeId || '(unknown)'}`) + console.log(`scrollbackCols: ${scrollbackCols ?? '(none)'}`) + console.log(`scrollbackRows: ${scrollbackRows ?? '(none)'}`) + console.log(`serializedLength: ${serializedLength}`) + console.log(`streamSawMarker: ${streamSawMarker}`) + console.log(`readSawMarker: ${readSawMarker}`) + + if (!streamSawMarker && !readSawMarker) { + throw new Error('terminal output did not reach stream or terminal.read') + } +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const plaintext = decrypt(data.toString()) + if (!plaintext) return + const response = JSON.parse(plaintext) as RpcResponse + if (response._meta?.runtimeId) { + runtimeId = response._meta.runtimeId + } + + const result = response.result + if (response.streaming && result?.type === 'data') { + const chunk = typeof result.chunk === 'string' ? result.chunk : '' + if (chunk.includes(marker)) { + streamSawMarker = true + } + } + + if (response.streaming && result?.type === 'scrollback') { + const lines = Array.isArray(result.lines) ? result.lines.join('\n') : String(result.lines ?? '') + scrollbackCols = typeof result.cols === 'number' ? result.cols : null + scrollbackRows = typeof result.rows === 'number' ? result.rows : null + serializedLength = typeof result.serialized === 'string' ? result.serialized.length : 0 + if (lines.includes(marker)) { + streamSawMarker = true + } + if (typeof result.serialized === 'string' && result.serialized.includes(marker)) { + streamSawMarker = true + } + } + + const pendingRequest = pending.get(response.id) + if (!pendingRequest) { + return + } + + if (response.streaming && pendingRequest.method === 'terminal.subscribe') { + return + } + + pending.delete(response.id) + pendingRequest.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + if (activeHandle) { + console.error(`active terminal: ${activeHandle}`) + } + process.exit(1) +}) diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts new file mode 100644 index 00000000000..9654dd357ad --- /dev/null +++ b/mobile/src/cache/worktree-cache.ts @@ -0,0 +1,35 @@ +// Why: module-level cache lets the home screen pre-populate worktree data +// so the host detail page can render instantly on navigation instead of +// waiting for a fresh RPC connection + fetch cycle. + +type CachedWorktrees = { + worktrees: unknown[] + at: number +} + +const cache = new Map() + +const MAX_AGE_MS = 30_000 +const MAX_ENTRIES = 20 + +export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { + // Why: Map.set on an existing key does not move it to the end of iteration + // order. Delete first so the re-inserted key becomes the newest entry, + // giving us true LRU eviction when the cap is hit. + cache.delete(hostId) + cache.set(hostId, { worktrees, at: Date.now() }) + if (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest) cache.delete(oldest) + } +} + +export function getCachedWorktrees(hostId: string): unknown[] | null { + const entry = cache.get(hostId) + if (!entry) return null + if (Date.now() - entry.at > MAX_AGE_MS) { + cache.delete(hostId) + return null + } + return entry.worktrees +} diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx new file mode 100644 index 00000000000..a68b08ea637 --- /dev/null +++ b/mobile/src/components/ActionSheetModal.tsx @@ -0,0 +1,133 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type ActionSheetAction = { + label: string + icon?: LucideIcon + destructive?: boolean + skipAutoClose?: boolean + onPress: () => void +} + +type Props = { + visible: boolean + title?: string + message?: string + actions: ActionSheetAction[] + onClose: () => void +} + +function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon): LucideIcon { + if (icon) return icon + if (destructive || /delete|remove/i.test(label)) return Trash2 + return Edit3 +} + +type ContentProps = { + title?: string + message?: string + actions: ActionSheetAction[] + onClose?: () => void +} + +export function ActionSheetContent({ title, message, actions, onClose }: ContentProps) { + return ( + <> + {(title || message) && ( + + {title ? ( + + {title} + + ) : null} + {message ? {message} : null} + + )} + + + {actions.map((action, i) => { + const Icon = iconForAction(action.label, action.destructive, action.icon) + return ( + + {i > 0 && } + [styles.action, pressed && styles.actionPressed]} + onPress={() => { + action.onPress() + if (!action.skipAutoClose && onClose) { + onClose() + } + }} + > + + + {action.label} + + + + ) + })} + + + ) +} + +export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) { + return ( + + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + message: { + fontSize: 12, + color: colors.textMuted, + marginTop: 2 + }, + actionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + action: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + actionPressed: { + backgroundColor: colors.bgRaised + }, + actionText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + actionTextDestructive: { + color: colors.statusRed + } +}) diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx new file mode 100644 index 00000000000..734cb8bbc6d --- /dev/null +++ b/mobile/src/components/AgentSpinner.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react' +import { Animated, Easing, StyleSheet, View } from 'react-native' +import { colors } from '../theme/mobile-theme' + +type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' + +const STATUS_COLORS: Record = { + working: colors.statusGreen, + active: colors.statusGreen, + permission: colors.statusRed, + done: '#38bdf8', + inactive: '#666666' +} + +export function AgentSpinner({ status }: { status: WorktreeStatus }) { + const spinValue = useRef(new Animated.Value(0)).current + + useEffect(() => { + if (status === 'working') { + const animation = Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true + }) + ) + animation.start() + return () => animation.stop() + } + spinValue.setValue(0) + }, [status, spinValue]) + + const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive + + if (status === 'working') { + const rotate = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'] + }) + return ( + + ) + } + + return +} + +const styles = StyleSheet.create({ + dot: { + width: 8, + height: 8, + borderRadius: 4 + }, + spinner: { + width: 10, + height: 10, + borderRadius: 5, + borderWidth: 2, + borderTopColor: 'transparent' + } +}) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx new file mode 100644 index 00000000000..2698353fb92 --- /dev/null +++ b/mobile/src/components/BottomDrawer.tsx @@ -0,0 +1,252 @@ +import { type ReactNode, useCallback, useEffect, useState } from 'react' +import { + View, + Pressable, + StyleSheet, + Platform, + useWindowDimensions, + ScrollView, + Keyboard, + BackHandler +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + withTiming, + runOnJS, + interpolate, + Extrapolation +} from 'react-native-reanimated' +import { colors, spacing } from '../theme/mobile-theme' + +const DISMISS_THRESHOLD = 80 +const SPRING_CONFIG = { damping: 28, stiffness: 400 } +// Why: negative translateY (pulling up) is damped with a rubber-band factor +// so the drawer resists upward dragging — a subtle polish touch that signals +// the drawer cannot expand further. +const RUBBER_BAND_FACTOR = 0.25 +const SHOW_DURATION = 180 +const HIDE_DURATION = 150 + +type Props = { + visible: boolean + onClose: () => void + children: ReactNode +} + +export function BottomDrawer({ visible, onClose, children }: Props) { + const [mounted, setMounted] = useState(visible) + const translateY = useSharedValue(0) + const progress = useSharedValue(0) + const keyboardOffset = useSharedValue(0) + const { height: screenHeight } = useWindowDimensions() + const insets = useSafeAreaInsets() + + useEffect(() => { + if (visible) { + setMounted(true) + } + }, [visible]) + + useEffect(() => { + if (!mounted) return + + if (visible) { + translateY.value = 0 + progress.value = withTiming(1, { duration: SHOW_DURATION }) + } else { + Keyboard.dismiss() + progress.value = withTiming(0, { duration: HIDE_DURATION }, (finished) => { + if (finished) { + runOnJS(setMounted)(false) + } + }) + } + }, [mounted, visible]) + + // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable + // inside Modal (iOS ignores KAV; Android needs adjustNothing for + // useAnimatedKeyboard). Keyboard event listeners work on both platforms + // and give us the exact height to shift the drawer by. + useEffect(() => { + if (!visible) return + + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + + const onShow = Keyboard.addListener(showEvent, (e) => { + const height = e.endCoordinates.height - insets.bottom + keyboardOffset.value = withTiming(Math.max(height, 0), { duration: e.duration || 250 }) + }) + const onHide = Keyboard.addListener(hideEvent, (e) => { + keyboardOffset.value = withTiming(0, { duration: e.duration || 250 }) + }) + + return () => { + onShow.remove() + onHide.remove() + keyboardOffset.value = 0 + } + }, [visible, insets.bottom]) + + useEffect(() => { + if (!visible) return + + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + onClose() + return true + }) + return () => sub.remove() + }, [visible, onClose]) + + const dismiss = useCallback(() => { + onClose() + }, [onClose]) + + const panGesture = Gesture.Pan() + .onUpdate((e) => { + if (e.translationY > 0) { + translateY.value = e.translationY + } else { + translateY.value = e.translationY * RUBBER_BAND_FACTOR + } + }) + .onEnd((e) => { + if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) { + const velocity = Math.max(e.velocityY, 800) + const remaining = screenHeight - e.translationY + const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300) + translateY.value = withTiming(screenHeight, { duration }) + progress.value = withTiming(0, { duration }, () => { + runOnJS(dismiss)() + }) + } else { + translateY.value = withSpring(0, SPRING_CONFIG) + } + }) + + const drawerStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: + interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) + + translateY.value - + keyboardOffset.value + } + ] + })) + + const backdropStyle = useAnimatedStyle(() => { + const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP) + return { opacity: progress.value * dragFade } + }) + + const pointerStyle = useAnimatedStyle( + () => + ({ + pointerEvents: progress.value > 0 ? 'auto' : 'none' + }) as { pointerEvents: 'auto' | 'none' } + ) + + // Why: hidden drawers can contain auto-focused inputs; keeping them mounted + // lets Android open the keyboard even when the drawer is offscreen. + if (!mounted) return null + + return ( + + + + + + + + + + + + + + + {children} + + + + + + + ) +} + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 1000 + }, + root: { + flex: 1 + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)' + }, + anchor: { + flex: 1, + justifyContent: 'flex-end' + }, + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 + }, + android: { elevation: 8 } + }) + }, + handle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + opacity: 0.4 + }, + handleHitArea: { + alignItems: 'center', + paddingTop: spacing.sm, + paddingBottom: spacing.md + }, + bottomExtension: { + position: 'absolute', + bottom: -500, + left: 0, + right: 0, + height: 500, + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/src/components/ConfirmModal.tsx b/mobile/src/components/ConfirmModal.tsx new file mode 100644 index 00000000000..a393593b5cf --- /dev/null +++ b/mobile/src/components/ConfirmModal.tsx @@ -0,0 +1,111 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +type Props = { + visible: boolean + title: string + message?: string + confirmLabel?: string + cancelLabel?: string + destructive?: boolean + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmModal({ + visible, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + destructive = false, + onConfirm, + onCancel +}: Props) { + return ( + + + {title} + {message ? {message} : null} + + + [styles.button, styles.cancelButton, pressed && styles.pressed]} + onPress={onCancel} + > + {cancelLabel} + + [ + styles.button, + destructive ? styles.destructiveButton : styles.confirmButton, + pressed && styles.pressed + ]} + onPress={() => { + onConfirm() + onCancel() + }} + > + + {confirmLabel} + + + + + ) +} + +const styles = StyleSheet.create({ + content: { + paddingBottom: spacing.lg + }, + title: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + message: { + fontSize: typography.bodySize, + color: colors.textSecondary, + marginTop: spacing.xs, + lineHeight: 20 + }, + buttons: { + flexDirection: 'row', + gap: spacing.sm + }, + button: { + flex: 1, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + cancelButton: { + backgroundColor: colors.bgPanel + }, + confirmButton: { + backgroundColor: colors.textPrimary + }, + destructiveButton: { + backgroundColor: colors.statusRed + }, + pressed: { + opacity: 0.7 + }, + cancelText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textSecondary + }, + confirmText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.bgBase + }, + destructiveText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: '#fff' + } +}) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx new file mode 100644 index 00000000000..ba264548dde --- /dev/null +++ b/mobile/src/components/CustomKeyModal.tsx @@ -0,0 +1,338 @@ +import { useState, useEffect, useCallback } from 'react' +import { View, Text, Pressable, TextInput, StyleSheet, ScrollView, Switch } from 'react-native' +import { ChevronLeft } from 'lucide-react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +const STORAGE_KEY = 'orca:custom-accessory-keys' + +export type CustomKey = { + id: string + label: string + bytes: string + enter: boolean +} + +type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro' + +const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + +function ctrlBytes(letter: string): string { + return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64) +} + +function altBytes(letter: string): string { + return `\x1b${letter.toLowerCase()}` +} + +type Props = { + visible: boolean + onClose: () => void + onKeysChanged: (keys: CustomKey[]) => void +} + +export async function loadCustomKeys(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as CustomKey[]) : [] + } catch { + return [] + } +} + +async function saveCustomKeys(keys: CustomKey[]): Promise { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys)) +} + +export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { + const [step, setStep] = useState('choose-type') + const [macroLabel, setMacroLabel] = useState('') + const [macroText, setMacroText] = useState('') + const [macroEnter, setMacroEnter] = useState(true) + + useEffect(() => { + if (visible) { + setStep('choose-type') + setMacroLabel('') + setMacroText('') + setMacroEnter(true) + } + }, [visible]) + + const addKey = useCallback( + async (key: Omit) => { + const existing = await loadCustomKeys() + const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` } + const updated = [...existing, newKey] + await saveCustomKeys(updated) + onKeysChanged(updated) + onClose() + }, + [onClose, onKeysChanged] + ) + + const handleCtrlKey = useCallback( + (letter: string) => { + void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleAltKey = useCallback( + (letter: string) => { + void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleMacroSave = useCallback(() => { + const label = macroLabel.trim() || macroText.trim().slice(0, 12) + const text = macroText + if (!label || !text) return + const bytes = macroEnter ? `${text}\r` : text + void addKey({ label, bytes, enter: false }) + }, [addKey, macroLabel, macroText, macroEnter]) + + const showBack = step !== 'choose-type' + + return ( + + + {showBack ? ( + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => setStep('choose-type')} + accessibilityLabel="Back" + > + + + ) : ( + + )} + + {step === 'choose-type' && 'Add Shortcut'} + {step === 'pick-ctrl' && 'Ctrl + Key'} + {step === 'pick-alt' && 'Alt + Key'} + {step === 'text-macro' && 'Text Macro'} + + + + + {step === 'choose-type' && ( + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-ctrl')} + > + Ctrl + Key + Control character shortcuts + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-alt')} + > + Alt + Key + Alt/Option key combos + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('text-macro')} + > + Text Macro + Send custom text command + + + )} + + {(step === 'pick-ctrl' || step === 'pick-alt') && ( + + + {ALPHA_KEYS.map((letter) => ( + [styles.keyCell, pressed && styles.keyCellPressed]} + onPress={() => + step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter) + } + > + {letter} + + ))} + + + )} + + {step === 'text-macro' && ( + + + Label + + Command + + + Press Enter + + + + Add Shortcut + + + + )} + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: spacing.sm + }, + backButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center' + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + backSpacer: { + width: 30 + }, + title: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + textAlign: 'center' + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary, + marginBottom: 1 + }, + rowHint: { + fontSize: 12, + color: colors.textMuted + }, + keyGridScroll: { + maxHeight: 240 + }, + keyGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + justifyContent: 'center', + padding: spacing.md + }, + keyCell: { + width: 42, + height: 38, + borderRadius: radii.button, + backgroundColor: colors.bgBase, + alignItems: 'center', + justifyContent: 'center' + }, + keyCellPressed: { + backgroundColor: colors.bgRaised + }, + keyCellText: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + fontFamily: typography.monoFamily + }, + macroForm: { + padding: spacing.md, + gap: spacing.sm + }, + fieldLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary + }, + fieldInput: { + backgroundColor: colors.bgBase, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 14, + fontFamily: typography.monoFamily, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs + }, + switchLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + saveButton: { + backgroundColor: colors.textPrimary, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + saveButtonDisabled: { + opacity: 0.5 + }, + saveButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx new file mode 100644 index 00000000000..63d00cf8b0a --- /dev/null +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -0,0 +1,756 @@ +import { useState, useEffect } from 'react' +import { + View, + Text, + TextInput, + Pressable, + Switch, + StyleSheet, + Platform, + ActivityIndicator, + Image +} from 'react-native' +import { ChevronDown, ChevronUp, Check, Terminal } from 'lucide-react-native' +import Svg, { Path, G } from 'react-native-svg' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +type Repo = { + id: string + displayName: string + path: string +} + +type AgentOption = { + id: string + label: string + faviconDomain?: string +} + +// Why: matches the AGENT_CATALOG ordering and faviconDomain values from +// src/renderer/src/lib/agent-catalog.tsx so mobile uses the same icon sources. +const AGENT_OPTIONS: AgentOption[] = [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' }, + { id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' }, + { id: 'pi', label: 'Pi' }, + { id: 'gemini', label: 'Gemini', faviconDomain: 'gemini.google.com' }, + { id: 'aider', label: 'Aider' }, + { id: 'goose', label: 'Goose', faviconDomain: 'goose-docs.ai' }, + { id: 'amp', label: 'Amp', faviconDomain: 'ampcode.com' }, + { id: 'kilo', label: 'Kilocode', faviconDomain: 'kilo.ai' }, + { id: 'kiro', label: 'Kiro', faviconDomain: 'kiro.dev' }, + { id: 'crush', label: 'Charm', faviconDomain: 'charm.sh' }, + { id: 'aug', label: 'Auggie', faviconDomain: 'augmentcode.com' }, + { id: 'cline', label: 'Cline', faviconDomain: 'cline.bot' }, + { id: 'codebuff', label: 'Codebuff', faviconDomain: 'codebuff.com' }, + { id: 'continue', label: 'Continue', faviconDomain: 'continue.dev' }, + { id: 'cursor', label: 'Cursor', faviconDomain: 'cursor.com' }, + { id: 'droid', label: 'Droid', faviconDomain: 'factory.ai' }, + { id: 'kimi', label: 'Kimi', faviconDomain: 'moonshot.cn' }, + { id: 'mistral-vibe', label: 'Mistral Vibe', faviconDomain: 'mistral.ai' }, + { id: 'qwen-code', label: 'Qwen Code', faviconDomain: 'qwenlm.github.io' }, + { id: 'rovo', label: 'Rovo Dev', faviconDomain: 'atlassian.com' }, + { id: 'hermes', label: 'Hermes', faviconDomain: 'nousresearch.com' } +] + +const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' } +const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL] + +// Why: mirrors launchCmd from src/shared/tui-agent-config.ts so terminal.create +// gets the correct binary name for each agent. +const AGENT_COMMANDS: Record = { + claude: 'claude', + codex: 'codex', + copilot: 'copilot', + opencode: 'opencode', + pi: 'pi', + gemini: 'gemini', + aider: 'aider', + goose: 'goose', + amp: 'amp', + kilo: 'kilo', + kiro: 'kiro', + crush: 'crush', + aug: 'auggie', + cline: 'cline', + codebuff: 'codebuff', + continue: 'continue', + cursor: 'cursor-agent', + droid: 'droid', + kimi: 'kimi', + 'mistral-vibe': 'mistral-vibe', + 'qwen-code': 'qwen-code', + rovo: 'rovo', + hermes: 'hermes' +} + +// ── Agent icons ───────────────────────────────────────────────────── +// SVG paths sourced from the desktop codebase: +// Claude & OpenAI: src/renderer/src/components/status-bar/icons.tsx +// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx +// Agents with a faviconDomain use Google's favicon service (same as desktop). + +function ClaudeIcon({ size = 16 }: { size?: number }) { + return ( + + + + ) +} + +function OpenAIIcon({ size = 16 }: { size?: number }) { + return ( + + + + ) +} + +function PiIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ) +} + +function AiderIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + + ) +} + +function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) { + return ( + + ) +} + +function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number }) { + return ( + + + {letter} + + + ) +} + +function AgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) { + if (agentId === 'claude') return + if (agentId === 'codex') return + if (agentId === 'pi') return + if (agentId === 'aider') return + if (agentId === '__blank__') return + + const agent = AGENT_OPTIONS.find((a) => a.id === agentId) + if (agent?.faviconDomain) { + return + } + const label = agent?.label ?? agentId + return +} + +// ── Picker sub-modal ──────────────────────────────────────────────── +// Why: inline dropdowns with position:absolute + ScrollView have persistent +// touch-conflict issues in React Native. A separate modal for the picker +// list is the standard mobile pattern — it scrolls reliably and feels native. + +function PickerListModal({ + visible, + title, + items, + selectedId, + onSelect, + onClose, + renderIcon +}: { + visible: boolean + title: string + items: T[] + selectedId: string + onSelect: (item: T) => void + onClose: () => void + renderIcon?: (item: T) => React.ReactNode +}) { + return ( + + + {title} + + + {items.map((item, index) => { + const selected = item.id === selectedId + return ( + + {index > 0 && } + [styles.pickerItem, pressed && styles.pickerItemPressed]} + onPress={() => { + onSelect(item) + onClose() + }} + > + {renderIcon?.(item)} + + {item.label} + + {selected && } + + + ) + })} + + + ) +} + +// ── Main modal ────────────────────────────────────────────────────── + +type Props = { + visible: boolean + client: RpcClient | null + onCreated: (worktreeId: string, name: string) => void + onClose: () => void +} + +export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) { + const [repos, setRepos] = useState([]) + const [selectedRepo, setSelectedRepo] = useState(null) + const [showRepoPicker, setShowRepoPicker] = useState(false) + const [selectedAgent, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) + const [showAgentPicker, setShowAgentPicker] = useState(false) + const [name, setName] = useState('') + const [note, setNote] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [setupCommand, setSetupCommand] = useState(null) + const [setupSource, setSetupSource] = useState(null) + const [runSetup, setRunSetup] = useState(true) + const [creating, setCreating] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!visible) { + setShowRepoPicker(false) + setShowAgentPicker(false) + return + } + if (!client) return + let stale = false + setName('') + setNote('') + setShowAdvanced(false) + setSetupCommand(null) + setSetupSource(null) + setRunSetup(true) + setError('') + setCreating(false) + setShowRepoPicker(false) + setShowAgentPicker(false) + setSelectedAgent(AGENT_OPTIONS[0]!) + setLoading(true) + + void (async () => { + try { + const response = await client.sendRequest('repo.list') + if (stale) return + if (response.ok) { + const result = (response as RpcSuccess).result as { repos: Repo[] } + setRepos(result.repos) + if (result.repos.length === 1) { + setSelectedRepo(result.repos[0]!) + } else { + setSelectedRepo(null) + } + } + } catch { + if (!stale) setRepos([]) + } finally { + if (!stale) setLoading(false) + } + })() + return () => { + stale = true + } + }, [visible, client]) + + useEffect(() => { + if (!client || !selectedRepo) { + setSetupCommand(null) + setSetupSource(null) + return + } + let stale = false + void (async () => { + try { + const response = await client.sendRequest('repo.hooks', { + repo: `id:${selectedRepo.id}` + }) + if (stale) return + if (response.ok) { + const result = (response as RpcSuccess).result as { + hooks: { scripts: { setup?: string } } | null + source: string | null + setupRunPolicy: string + } + const cmd = result.hooks?.scripts.setup ?? null + setSetupCommand(cmd) + setSetupSource(result.source) + setRunSetup(result.setupRunPolicy !== 'skip-by-default') + } + } catch { + if (!stale) { + setSetupCommand(null) + setSetupSource(null) + } + } + })() + return () => { + stale = true + } + }, [client, selectedRepo]) + + async function handleCreate() { + if (!client || !selectedRepo) return + setCreating(true) + setError('') + + try { + const command = + selectedAgent.id !== '__blank__' ? AGENT_COMMANDS[selectedAgent.id] : undefined + + const params: Record = { + repo: `id:${selectedRepo.id}`, + startupCommand: command, + setupDecision: runSetup ? 'inherit' : 'skip' + } + if (name.trim()) params.name = name.trim() + if (note.trim()) params.comment = note.trim() + + const response = await client.sendRequest('worktree.create', params) + + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + const worktreeId = result.worktree.id + + onClose() + onCreated(worktreeId, name.trim() || 'New workspace') + } else { + setError(response.error.message) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to create workspace') + } finally { + setCreating(false) + } + } + + const canCreate = selectedRepo != null && !creating + + return ( + <> + + + Create Workspace + + Pick a repository and agent to spin up a new workspace. + + + + {loading ? ( + + + + ) : repos.length === 0 ? ( + + No repositories found + + ) : ( + <> + + Repository + setShowRepoPicker(true)}> + + {selectedRepo?.displayName ?? 'Select a repository'} + + + + + + + + Workspace Name [Optional] + + { + setName(t) + setError('') + }} + placeholder="Workspace name" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + autoFocus={repos.length <= 1} + returnKeyType="done" + onSubmitEditing={() => { + if (canCreate) void handleCreate() + }} + /> + + + + Agent + setShowAgentPicker(true)}> + + + {selectedAgent.label} + + + + + + setShowAdvanced(!showAdvanced)}> + Advanced + {showAdvanced ? ( + + ) : ( + + )} + + + {showAdvanced && ( + <> + + Note + + + + {setupCommand ? ( + + + Setup script + {setupSource && ( + + + {setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'} + + + )} + + + + Run setup command + + + + {setupCommand} + + + + ) : null} + + )} + + {error ? {error} : null} + + + void handleCreate()} + > + {creating ? ( + + ) : ( + Create Workspace + )} + + + + )} + + + {/* Sub-modals for pickers — rendered outside the main modal so they + layer on top and scroll without touch conflicts. */} + ({ id: r.id, label: r.displayName, _repo: r }))} + selectedId={selectedRepo?.id ?? ''} + onSelect={(item) => setSelectedRepo((item as { _repo: Repo })._repo)} + onClose={() => setShowRepoPicker(false)} + /> + + setSelectedAgent(agent)} + onClose={() => setShowAgentPicker(false)} + renderIcon={(agent) => } + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + loadingContainer: { + paddingVertical: spacing.xl, + alignItems: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + fieldButtonText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + fieldButtonPlaceholder: { + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + error: { + color: colors.statusRed, + fontSize: 13, + marginBottom: spacing.md + }, + advancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + marginBottom: spacing.xs + }, + advancedText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textSecondary + }, + setupHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.xs + }, + sourceBadge: { + backgroundColor: colors.bgRaised, + borderRadius: 4, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2 + }, + sourceBadgeText: { + fontSize: 10, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5 + }, + setupBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md + }, + setupToggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + setupToggleLabel: { + fontSize: 13, + color: colors.textSecondary + }, + setupSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + }, + setupCommandBlock: { + backgroundColor: colors.bgBase, + borderRadius: 6, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.sm + }, + setupCommand: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: spacing.sm + }, + createButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + minWidth: 160, + alignItems: 'center' + }, + createButtonDisabled: { + opacity: 0.4 + }, + createText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + letterIcon: { + alignItems: 'center', + justifyContent: 'center' + }, + letterIconText: { + fontWeight: '700' + }, + + // Picker sub-modal styles + pickerHeader: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + pickerTitle: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + pickerGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + pickerSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + pickerList: { + flexGrow: 0 + }, + pickerItem: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + pickerItemPressed: { + backgroundColor: colors.bgRaised + }, + pickerItemText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + pickerItemTextSelected: { + fontWeight: '600' + } +}) diff --git a/mobile/src/components/OrcaLogo.tsx b/mobile/src/components/OrcaLogo.tsx new file mode 100644 index 00000000000..d05e4f2a968 --- /dev/null +++ b/mobile/src/components/OrcaLogo.tsx @@ -0,0 +1,23 @@ +import Svg, { G, Path, Defs } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' + +type Props = { + size?: number + color?: string +} + +export function OrcaLogo({ size = 24, color = colors.textPrimary }: Props) { + const aspectRatio = 318.6 / 202.67 + const width = size * aspectRatio + return ( + + + + + + + ) +} diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx new file mode 100644 index 00000000000..eecc653e8da --- /dev/null +++ b/mobile/src/components/PickerModal.tsx @@ -0,0 +1,108 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type PickerOption = { + value: T + label: string + subtitle?: string +} + +type Props = { + visible: boolean + title: string + options: PickerOption[] + selected: T + onSelect: (value: T) => void + onClose: () => void +} + +export function PickerModal({ + visible, + title, + options, + selected, + onSelect, + onClose +}: Props) { + return ( + + + {title} + + + + {options.map((opt, i) => { + const isSelected = opt.value === selected + return ( + + {i > 0 && } + [styles.row, pressed && styles.rowPressed]} + onPress={() => { + onSelect(opt.value) + onClose() + }} + > + + + {opt.label} + + {opt.subtitle ? {opt.subtitle} : null} + + {isSelected && } + + + ) + })} + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + rowLabelSelected: { + fontWeight: '600' + }, + rowSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + } +}) diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx new file mode 100644 index 00000000000..63777890d70 --- /dev/null +++ b/mobile/src/components/StatusDot.tsx @@ -0,0 +1,25 @@ +import { View, StyleSheet } from 'react-native' +import { colors } from '../theme/mobile-theme' +import type { ConnectionState } from '../transport/types' + +const stateColors: Record = { + connected: colors.statusGreen, + connecting: colors.statusAmber, + handshaking: colors.statusAmber, + reconnecting: colors.statusAmber, + disconnected: colors.statusRed, + 'auth-failed': colors.statusRed +} + +export function StatusDot({ state }: { state: ConnectionState }) { + return +} + +const styles = StyleSheet.create({ + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8 + } +}) diff --git a/mobile/src/components/TextInputModal.tsx b/mobile/src/components/TextInputModal.tsx new file mode 100644 index 00000000000..70a9f323f56 --- /dev/null +++ b/mobile/src/components/TextInputModal.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect } from 'react' +import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +type Props = { + visible: boolean + title: string + message?: string + defaultValue?: string + placeholder?: string + onSubmit: (value: string) => void + onCancel: () => void +} + +export function TextInputModal({ + visible, + title, + message, + defaultValue = '', + placeholder, + onSubmit, + onCancel +}: Props) { + const [value, setValue] = useState(defaultValue) + + useEffect(() => { + if (visible) setValue(defaultValue) + }, [visible, defaultValue]) + + function handleSubmit() { + if (value.trim()) { + onSubmit(value.trim()) + } + } + + return ( + + + {title} + {message ? {message} : null} + + + + + + + + + + [styles.cancelButton, pressed && styles.buttonPressed]} + onPress={onCancel} + > + Cancel + + [ + styles.submitButton, + pressed && styles.buttonPressed, + !value.trim() && styles.submitButtonDisabled + ]} + disabled={!value.trim()} + onPress={handleSubmit} + > + Save + + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + message: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + inputWrap: { + padding: spacing.md + }, + input: { + backgroundColor: colors.bgBase, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.md + }, + cancelButton: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + submitButton: { + backgroundColor: colors.accentBlue, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + buttonPressed: { + opacity: 0.7 + }, + submitButtonDisabled: { + opacity: 0.4 + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + submitText: { + color: '#fff', + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts new file mode 100644 index 00000000000..287de38a0ec --- /dev/null +++ b/mobile/src/notifications/mobile-notifications.ts @@ -0,0 +1,111 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import { loadPushNotificationsEnabled } from '../storage/preferences' + +type NotificationEvent = { + type: 'notification' + source: 'agent-task-complete' | 'terminal-bell' | 'test' + title: string + body: string + worktreeId?: string +} + +type SubscribeResult = { + type: 'ready' + subscriptionId: string +} + +let permissionGranted: boolean | null = null + +// Why: permissions must be requested before scheduling any local notification. +// Cache the result so we only prompt once per app session. +export async function ensureNotificationPermissions(): Promise { + if (permissionGranted !== null) return permissionGranted + + const { status: existingStatus } = await Notifications.getPermissionsAsync() + if (existingStatus === 'granted') { + permissionGranted = true + return true + } + + const { status } = await Notifications.requestPermissionsAsync() + permissionGranted = status === 'granted' + return permissionGranted +} + +function configureNotificationChannel(): void { + if (Platform.OS === 'android') { + void Notifications.setNotificationChannelAsync('orca-desktop', { + name: 'Desktop Notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250], + lightColor: '#6366f1' + }) + } +} + +async function showLocalNotification(event: NotificationEvent): Promise { + const enabled = await loadPushNotificationsEnabled() + if (!enabled) return + + const granted = await ensureNotificationPermissions() + if (!granted) return + + await Notifications.scheduleNotificationAsync({ + content: { + title: event.title, + body: event.body, + data: { source: event.source, worktreeId: event.worktreeId }, + ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) + }, + trigger: null + }) +} + +// Why: each host connection gets its own notification subscription. When the +// connection drops, the unsubscribe function cleans up the streaming RPC. +// Returns an unsubscribe function. +export function subscribeToDesktopNotifications(client: RpcClient): () => void { + configureNotificationChannel() + + let subscriptionId: string | null = null + let disposed = false + function unsubscribeServer(id: string) { + if (client.getState() === 'connected') { + client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {}) + } + } + + const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => { + const event = data as NotificationEvent | SubscribeResult | { type: 'end' } + if (event.type === 'ready') { + subscriptionId = (event as SubscribeResult).subscriptionId + if (disposed) { + unsubscribeServer(subscriptionId) + unsubscribeStream() + } + return + } + if (event.type === 'end') { + if (disposed) unsubscribeStream() + return + } + if (disposed) return + if (event.type === 'notification') { + void showLocalNotification(event as NotificationEvent) + } + }) + + return () => { + disposed = true + // Why: the client may already be closed when this cleanup runs (component + // unmount races with disconnect). sendRequest rejects immediately on a + // closed client — swallow it since server-side cleanup happens via + // connection-close anyway. + if (subscriptionId) { + unsubscribeStream() + unsubscribeServer(subscriptionId) + } + } +} diff --git a/mobile/src/platform/haptics.ts b/mobile/src/platform/haptics.ts new file mode 100644 index 00000000000..72bf2443a82 --- /dev/null +++ b/mobile/src/platform/haptics.ts @@ -0,0 +1,13 @@ +import { Platform } from 'react-native' +import * as Haptics from 'expo-haptics' + +export function triggerMediumImpact(): void { + if (Platform.OS === 'android') { + // Why: Android's Vibrator API (used by impactAsync) is unreliable for haptic + // feedback. performAndroidHapticsAsync uses the native HapticFeedbackConstants + // API which works without VIBRATE permission and feels more natural. + void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Long_Press).catch(() => {}) + } else { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}) + } +} diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts new file mode 100644 index 00000000000..efd4dc75c9b --- /dev/null +++ b/mobile/src/storage/preferences.ts @@ -0,0 +1,88 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const PINS_PREFIX = 'orca:pins:' +const PREFS_PREFIX = 'orca:prefs:' +const NOTIF_KEY = 'orca:pushNotificationsEnabled' + +export async function loadPushNotificationsEnabled(): Promise { + try { + const raw = await AsyncStorage.getItem(NOTIF_KEY) + if (raw === null) return true + return raw === 'true' + } catch { + return true + } +} + +export async function savePushNotificationsEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(NOTIF_KEY, String(enabled)) +} + +export type HostPreferences = { + sortMode: string + filterMode: string + groupMode: string + collapsedGroups: string[] + selectedRepos: string[] +} + +const DEFAULT_PREFS: HostPreferences = { + sortMode: 'smart', + filterMode: 'all', + groupMode: 'none', + collapsedGroups: [], + selectedRepos: [] +} +const SORT_MODES = new Set(['smart', 'recent', 'name']) +const FILTER_MODES = new Set(['all', 'active']) +const GROUP_MODES = new Set(['none', 'repo', 'prStatus']) + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +function allowedString(value: unknown, allowed: Set, fallback: string): string { + return typeof value === 'string' && allowed.has(value) ? value : fallback +} + +export async function loadPinnedIds(hostId: string): Promise> { + try { + const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId) + if (!raw) return new Set() + return new Set(stringArray(JSON.parse(raw))) + } catch { + return new Set() + } +} + +export async function savePinnedIds(hostId: string, ids: Set): Promise { + await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids])) +} + +export async function loadPreferences(hostId: string): Promise { + try { + const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId) + if (!raw) return DEFAULT_PREFS + const parsed = JSON.parse(raw) as Partial + return { + sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode), + filterMode: allowedString(parsed.filterMode, FILTER_MODES, DEFAULT_PREFS.filterMode), + groupMode: allowedString(parsed.groupMode, GROUP_MODES, DEFAULT_PREFS.groupMode), + collapsedGroups: stringArray(parsed.collapsedGroups), + selectedRepos: stringArray(parsed.selectedRepos) + } + } catch { + return DEFAULT_PREFS + } +} + +export async function savePreferences( + hostId: string, + prefs: Partial +): Promise { + const current = await loadPreferences(hostId) + const merged = { ...current, ...prefs } + await AsyncStorage.setItem(PREFS_PREFIX + hostId, JSON.stringify(merged)) +} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx new file mode 100644 index 00000000000..22a93bcc7da --- /dev/null +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -0,0 +1,633 @@ +import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react' +import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native' +import { WebView } from 'react-native-webview' +import type { WebViewMessageEvent } from 'react-native-webview' +import { colors } from '../theme/mobile-theme' + +export type TerminalWebViewHandle = { + write: (data: string) => void + init: (cols: number, rows: number, initialData?: string) => void + clear: () => void + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + resetZoom: () => void +} + +type Props = { + style?: StyleProp + onWebReady?: () => void +} + +type TerminalMessage = + | { type: 'write'; id?: number; data: string } + | { type: 'init'; id?: number; cols: number; rows: number; initialData?: string } + | { type: 'clear'; id?: number } + | { type: 'measure'; id?: number; containerHeight?: number } + | { type: 'reset-zoom'; id?: number } + +// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor +// positioning designed for the desktop's terminal dimensions (~150+ cols). +// We initialize xterm at the desktop's exact cols/rows so those escape codes +// render correctly, then use a measured CSS transform: scale() to fit the +// canvas into the phone viewport. The scale is computed after xterm opens +// by measuring the rendered surface width, not hardcoded, so it adapts to +// any terminal column count (80, 150, 200+). All touch gestures (scroll, +// pinch-to-zoom, pan) are handled by custom JS rather than native WebView +// behavior, so they work correctly with the CSS scale transform. +const XTERM_HTML = ` + + + + + + + + +
+
+
+ + + +` + +export const TerminalWebView = forwardRef(function TerminalWebView( + { style, onWebReady }, + ref +) { + const webViewRef = useRef(null) + const isWebReadyRef = useRef(false) + const pendingMessagesRef = useRef([]) + const messageIdRef = useRef(0) + const measureResolveRef = useRef< + ((result: { cols: number; rows: number } | null) => void) | null + >(null) + + const sendToWebView = useCallback((msg: TerminalMessage) => { + messageIdRef.current += 1 + webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current })) + }, []) + + const flushPendingMessages = useCallback(() => { + const pending = pendingMessagesRef.current + pendingMessagesRef.current = [] + for (const msg of pending) { + sendToWebView(msg) + } + }, [sendToWebView]) + + const postMessage = useCallback( + (msg: TerminalMessage) => { + if (!isWebReadyRef.current) { + pendingMessagesRef.current.push(msg) + return + } + sendToWebView(msg) + }, + [sendToWebView] + ) + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let msg: Record + try { + msg = JSON.parse(event.nativeEvent.data) as Record + } catch { + return + } + + if (msg.type === 'web-ready') { + isWebReadyRef.current = true + onWebReady?.() + flushPendingMessages() + } else if (msg.type === 'measure-result') { + const resolve = measureResolveRef.current + measureResolveRef.current = null + if (resolve) { + const cols = typeof msg.cols === 'number' ? msg.cols : null + const rows = typeof msg.rows === 'number' ? msg.rows : null + resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null) + } + } + }, + [flushPendingMessages, onWebReady] + ) + + const handleLoadStart = useCallback(() => { + isWebReadyRef.current = false + }, []) + + useImperativeHandle( + ref, + () => ({ + write(data: string) { + postMessage({ type: 'write', data }) + }, + init(cols: number, rows: number, initialData?: string) { + postMessage({ type: 'init', cols, rows, initialData }) + }, + clear() { + postMessage({ type: 'clear' }) + }, + measureFitDimensions( + containerHeight?: number + ): Promise<{ cols: number; rows: number } | null> { + if (!isWebReadyRef.current) return Promise.resolve(null) + return new Promise((resolve) => { + measureResolveRef.current?.(null) + measureResolveRef.current = resolve + sendToWebView({ type: 'measure', containerHeight }) + // Why: if the WebView doesn't respond within 2s (e.g., xterm + // failed to load), resolve null so the caller can disable + // Fit to Phone rather than hanging indefinitely. + setTimeout(() => { + if (measureResolveRef.current === resolve) { + measureResolveRef.current = null + resolve(null) + } + }, 2000) + }) + }, + resetZoom() { + postMessage({ type: 'reset-zoom' }) + } + }), + [postMessage, sendToWebView] + ) + + return ( + + ) +}) + +const styles = StyleSheet.create({ + webview: { + flex: 1, + backgroundColor: colors.terminalBg + } +}) diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts new file mode 100644 index 00000000000..d0f80c671e0 --- /dev/null +++ b/mobile/src/theme/mobile-theme.ts @@ -0,0 +1,45 @@ +// Orca mobile design tokens — matches desktop graphite/dark palette. +// All screen files should import from here instead of using inline hex values. + +export const colors = { + bgBase: '#111111', + bgPanel: '#1a1a1a', + bgRaised: '#242424', + borderSubtle: '#2a2a2a', + + textPrimary: '#e0e0e0', + textSecondary: '#888888', + textMuted: '#555555', + + accentBlue: '#3b82f6', + + statusGreen: '#22c55e', + statusAmber: '#f59e0b', + statusRed: '#ef4444', + + // Terminal WebView background (Tokyonight) — separate from app chrome + terminalBg: '#1a1b26' +} as const + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24 +} as const + +export const radii = { + row: 6, + card: 14, + button: 6, + input: 6, + camera: 8 +} as const + +export const typography = { + titleSize: 18, + bodySize: 14, + metaSize: 12, + monoFamily: 'monospace' as const +} as const diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts new file mode 100644 index 00000000000..87d599ec077 --- /dev/null +++ b/mobile/src/transport/e2ee.ts @@ -0,0 +1,91 @@ +// Why: E2EE primitives for the mobile side. Uses tweetnacl for Curve25519 ECDH +// key exchange and XSalsa20-Poly1305 authenticated encryption. Wire format is +// base64([24-byte nonce][ciphertext]) over WebSocket text frames. +import nacl from 'tweetnacl' +import * as ExpoCrypto from 'expo-crypto' + +// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues, +// which tweetnacl requires. expo-crypto provides a native secure RNG +// that works in Expo Go without a custom dev build. +nacl.setPRNG((_x: Uint8Array, n: number) => { + const bytes = ExpoCrypto.getRandomBytes(n) + _x.set(bytes) +}) + +// Why: tweetnacl uses `instanceof Uint8Array` checks internally. On Hermes, +// values from native modules (expo-crypto), TextEncoder, or typed array +// operations can return objects that fail this check despite being +// functionally identical. Wrapping with `new Uint8Array(x)` guarantees +// the correct prototype chain. +function u8(x: Uint8Array): Uint8Array { + return new Uint8Array(x) +} + +export function generateKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } { + const kp = nacl.box.keyPair() + return { publicKey: u8(kp.publicKey), secretKey: u8(kp.secretKey) } +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return u8(nacl.box.before(u8(peerPublicKey), u8(ourSecretKey))) +} + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!) + } + return btoa(binary) +} + +function base64ToUint8(b64: string): Uint8Array { + const binary = atob(b64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +export function publicKeyFromBase64(b64: string): Uint8Array { + const key = base64ToUint8(b64) + if (key.length !== 32) { + throw new Error( + `Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."` + ) + } + return key +} + +export function publicKeyToBase64(key: Uint8Array): string { + return uint8ToBase64(key) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = u8(nacl.randomBytes(nacl.box.nonceLength)) + const messageBytes = u8(new TextEncoder().encode(plaintext)) + const ciphertext = nacl.box.after(messageBytes, nonce, u8(sharedKey)) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return uint8ToBase64(bundle) +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = base64ToUint8(encrypted) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = u8(bundle.subarray(0, nacl.box.nonceLength)) + const ciphertext = u8(bundle.subarray(nacl.box.nonceLength)) + const plaintext = nacl.box.open.after(ciphertext, nonce, u8(sharedKey)) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts new file mode 100644 index 00000000000..7f35fb742a2 --- /dev/null +++ b/mobile/src/transport/host-store.ts @@ -0,0 +1,66 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { HostProfileSchema, type HostProfile } from './types' + +const STORAGE_KEY = 'orca:hosts' + +export async function loadHosts(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((item) => { + const result = HostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return [] + } +} + +export async function saveHost(host: HostProfile): Promise { + const hosts = await loadHosts() + const index = hosts.findIndex((h) => h.id === host.id) + if (index >= 0) { + hosts[index] = host + } else { + hosts.push(host) + } + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) +} + +export async function removeHost(hostId: string): Promise { + const hosts = await loadHosts() + const filtered = hosts.filter((h) => h.id !== hostId) + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) +} + +export async function renameHost(hostId: string, newName: string): Promise { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (host) { + host.name = newName + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + } +} + +export async function getNextHostName(): Promise { + const hosts = await loadHosts() + const existingNumbers = hosts + .map((h) => { + const match = h.name.match(/^Host (\d+)$/) + return match ? parseInt(match[1]!, 10) : 0 + }) + .filter((n) => n > 0) + const next = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1 + return `Host ${next}` +} + +export async function updateLastConnected(hostId: string): Promise { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (host) { + host.lastConnected = Date.now() + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + } +} diff --git a/mobile/src/transport/index.ts b/mobile/src/transport/index.ts new file mode 100644 index 00000000000..8aacca59eff --- /dev/null +++ b/mobile/src/transport/index.ts @@ -0,0 +1,19 @@ +export { connect, type RpcClient } from './rpc-client' +export { + loadHosts, + saveHost, + removeHost, + renameHost, + getNextHostName, + updateLastConnected +} from './host-store' +export type { + RpcRequest, + RpcResponse, + RpcSuccess, + RpcFailure, + ConnectionState, + HostProfile, + PairingOffer +} from './types' +export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types' diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts new file mode 100644 index 00000000000..ada3e2715eb --- /dev/null +++ b/mobile/src/transport/pairing.ts @@ -0,0 +1,16 @@ +import { PairingOfferSchema, type PairingOffer } from './types' + +export function decodePairingUrl(url: string): PairingOffer | null { + try { + const hashIndex = url.indexOf('#') + if (!url.startsWith('orca://pair') || hashIndex === -1) return null + + const base64url = url.slice(hashIndex + 1) + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const json = atob(base64) + const parsed = JSON.parse(json) + return PairingOfferSchema.parse(parsed) + } catch { + return null + } +} diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts new file mode 100644 index 00000000000..10b51ab19d2 --- /dev/null +++ b/mobile/src/transport/rpc-client.ts @@ -0,0 +1,371 @@ +import type { RpcResponse, RpcSuccess, ConnectionState } from './types' +import { + generateKeyPair, + deriveSharedKey, + publicKeyFromBase64, + publicKeyToBase64, + encrypt, + decrypt +} from './e2ee' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type StreamingListener = (result: unknown) => void + +type StreamRequest = { + method: string + params: unknown + listener: StreamingListener +} + +export type RpcClient = { + sendRequest: (method: string, params?: unknown) => Promise + subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void + getState: () => ConnectionState + onStateChange: (listener: (state: ConnectionState) => void) => () => void + close: () => void +} + +const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] +const REQUEST_TIMEOUT_MS = 30_000 +const HANDSHAKE_TIMEOUT_MS = 5_000 + +export function connect( + endpoint: string, + deviceToken: string, + serverPublicKeyB64: string, + onStateChange?: (state: ConnectionState) => void +): RpcClient { + let ws: WebSocket | null = null + let state: ConnectionState = 'disconnected' + let requestCounter = 0 + let reconnectAttempt = 0 + let reconnectTimer: ReturnType | null = null + let handshakeTimer: ReturnType | null = null + let intentionallyClosed = false + + // Why: fresh ephemeral keypair per connection provides forward secrecy. + // The shared key is derived from our ephemeral secret + server's static public key. + let sharedKey: Uint8Array | null = null + const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64) + + const pending = new Map() + const streamListeners = new Map() + const stateListeners = new Set<(state: ConnectionState) => void>() + const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [] + + if (onStateChange) { + stateListeners.add(onStateChange) + } + + function setState(next: ConnectionState) { + if (state === next) return + state = next + if (next === 'connected') { + for (const w of connectWaiters.splice(0)) w.resolve() + } else if (next === 'disconnected' || next === 'auth-failed') { + const reason = + next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed' + for (const w of connectWaiters.splice(0)) w.reject(new Error(reason)) + } + for (const listener of stateListeners) { + listener(next) + } + } + + function waitForConnected(): Promise { + if (state === 'connected') return Promise.resolve() + if (intentionallyClosed) return Promise.reject(new Error('Client closed')) + return new Promise((resolve, reject) => { + connectWaiters.push({ resolve, reject }) + }) + } + + function nextId(): string { + return `rpc-${++requestCounter}-${Date.now()}` + } + + function openConnection() { + if (intentionallyClosed) return + + setState('connecting') + sharedKey = null + + ws = new WebSocket(endpoint) + + ws.onopen = () => { + reconnectAttempt = 0 + setState('handshaking') + + // Why: generate a fresh ephemeral keypair for each connection. + // This provides forward secrecy — compromising one session's key + // doesn't compromise past or future sessions. + const ephemeral = generateKeyPair() + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ephemeral.publicKey) + }) + ws?.send(hello) + + sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey) + + handshakeTimer = setTimeout(() => { + handshakeTimer = null + ws?.close() + }, HANDSHAKE_TIMEOUT_MS) + } + + ws.onmessage = (event) => { + const raw = typeof event.data === 'string' ? event.data : String(event.data) + + // Why: during handshaking, e2ee_ready is plaintext because it precedes + // encrypted auth; e2ee_authenticated/e2ee_error are encrypted. + if (state === 'handshaking') { + try { + const msg = JSON.parse(raw) + if (msg.type === 'e2ee_ready') { + sendEncrypted({ type: 'e2ee_auth', deviceToken }) + return + } + } catch { + // Not plaintext JSON — fall through and try encrypted handshake messages. + } + + if (!sharedKey || sharedKey.length !== 32) { + return + } + + const plaintext = decrypt(raw, sharedKey) + if (plaintext === null) { + return + } + + try { + const msg = JSON.parse(plaintext) + if (msg.type === 'e2ee_authenticated') { + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + setState('connected') + for (const [id, stream] of streamListeners) { + sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params }) + } + } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { + intentionallyClosed = true + ws?.close() + ws = null + setState('auth-failed') + rejectAllPending('Unauthorized — pairing may be revoked') + } + } catch { + // Not JSON — ignore during handshake. + } + return + } + + // Why: guard against decrypt with an invalid key — sharedKey can be null + // after destroy() or if a message arrives during a reconnect race. + if (!sharedKey || sharedKey.length !== 32) { + return + } + + const plaintext = decrypt(raw, sharedKey) + if (plaintext === null) { + return + } + + let response: RpcResponse + try { + response = JSON.parse(plaintext) + } catch { + return + } + + // Why: auth failure is distinct from transient disconnect — retrying + // with a rejected token causes infinite reconnect churn. + if (!response.ok && response.error.code === 'unauthorized') { + intentionallyClosed = true + ws?.close() + ws = null + setState('auth-failed') + rejectAllPending('Unauthorized — pairing may be revoked') + return + } + + const isStreaming = response.ok && (response as RpcSuccess).streaming === true + + if (isStreaming) { + const stream = streamListeners.get(response.id) + if (stream && response.ok) { + stream.listener((response as RpcSuccess).result) + } + return + } + + if (response.ok) { + const result = (response as RpcSuccess).result as Record | null + if (result && result.type === 'end') { + const stream = streamListeners.get(response.id) + if (stream) { + stream.listener(result) + streamListeners.delete(response.id) + return + } + } + if (result && result.type === 'scrollback') { + const stream = streamListeners.get(response.id) + if (stream) { + stream.listener(result) + return + } + } + } + + const req = pending.get(response.id) + if (req) { + pending.delete(response.id) + req.resolve(response) + } + } + + ws.onclose = () => { + ws = null + sharedKey = null + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + if (intentionallyClosed) { + setState('disconnected') + rejectAllPending('Connection closed') + return + } + rejectAllPending('Connection interrupted') + setState('reconnecting') + scheduleReconnect() + } + + ws.onerror = () => { + // onclose will fire after this + } + } + + function scheduleReconnect() { + const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ + reconnectTimer = setTimeout(() => { + reconnectTimer = null + openConnection() + }, delay) + } + + function rejectAllPending(reason: string) { + const error = new Error(reason) + for (const [id, req] of pending) { + pending.delete(id) + queueMicrotask(() => req.reject(error)) + } + } + + function sendEncrypted(request: unknown): boolean { + if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { + ws.send(encrypt(JSON.stringify(request), sharedKey)) + return true + } + return false + } + + openConnection() + + return { + async sendRequest(method: string, params?: unknown): Promise { + await waitForConnected() + + return new Promise((resolve, reject) => { + const id = nextId() + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Request timed out: ${method}`)) + }, REQUEST_TIMEOUT_MS) + + pending.set(id, { + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + + if (!sendEncrypted({ id, deviceToken, method, params })) { + pending.delete(id) + clearTimeout(timeout) + reject(new Error('Connection interrupted')) + } + }) + }, + + subscribe(method: string, params: unknown, onData: StreamingListener): () => void { + const id = nextId() + streamListeners.set(id, { method, params, listener: onData }) + + if (state === 'connected') { + sendEncrypted({ id, deviceToken, method, params }) + } + + return () => { + const stream = streamListeners.get(id) + streamListeners.delete(id) + if ( + stream?.method === 'terminal.subscribe' && + stream.params && + typeof stream.params === 'object' && + typeof (stream.params as { terminal?: unknown }).terminal === 'string' + ) { + sendEncrypted({ + id: nextId(), + deviceToken, + method: 'terminal.unsubscribe', + // Why: the runtime keys terminal subscription cleanup by terminal + // handle, not by the RPC request id used to open the stream. + params: { subscriptionId: (stream.params as { terminal: string }).terminal } + }) + } + } + }, + + getState(): ConnectionState { + return state + }, + + onStateChange(listener: (state: ConnectionState) => void): () => void { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }, + + close() { + intentionallyClosed = true + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + if (ws) { + ws.close() + ws = null + } + sharedKey = null + setState('disconnected') + rejectAllPending('Client closed') + } + } +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts new file mode 100644 index 00000000000..c73489a9751 --- /dev/null +++ b/mobile/src/transport/types.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' + +export type RpcRequest = { + id: string + deviceToken: string + method: string + params?: unknown +} + +export type RpcSuccess = { + id: string + ok: true + result: unknown + streaming?: true + _meta: { runtimeId: string } +} + +export type RpcFailure = { + id: string + ok: false + error: { code: string; message: string; data?: unknown } + _meta: { runtimeId: string } +} + +export type RpcResponse = RpcSuccess | RpcFailure + +export const PAIRING_OFFER_VERSION = 2 + +export const PairingOfferSchema = z.object({ + v: z.literal(PAIRING_OFFER_VERSION), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1) +}) + +export type PairingOffer = z.infer + +export type ConnectionState = + | 'connecting' + | 'handshaking' + | 'connected' + | 'disconnected' + | 'reconnecting' + | 'auth-failed' + +export type HostProfile = { + id: string + name: string + endpoint: string + deviceToken: string + publicKeyB64: string + lastConnected: number +} + +export const HostProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().finite() +}) diff --git a/mobile/terminal-output-streaming-findings.md b/mobile/terminal-output-streaming-findings.md new file mode 100644 index 00000000000..5f9aac2b23a --- /dev/null +++ b/mobile/terminal-output-streaming-findings.md @@ -0,0 +1,56 @@ +# Mobile Terminal Output Streaming Findings + +Date: 2026-04-28 + +## Scope + +The investigation was limited to `mobile/`. Server-side files under `src/main/` were read for context only and were not modified. + +## Verified Findings + +- The phone can connect to `ws://192.168.0.179:6768`, list worktrees, list terminals, and call `terminal.send`. +- The phone's saved host token is valid; a direct WebSocket probe from the desktop using the same token can call `worktree.ps`, `terminal.list`, `terminal.subscribe`, `terminal.send`, and `terminal.read`. +- `TerminalWebView` can render text when the React Native side writes to it after xterm initializes. A temporary marker written after `init()` appeared visibly in the WebView. +- Messages posted to the WebView before its page installs message handlers can be dropped. The mobile fix queues `init`, `write`, and `clear` until the WebView reports `web-ready`. +- The WebView also has an internal queue for writes that arrive after `web-ready` but before xterm finishes `init()`. +- The selected physical-phone test worktree was `refs/heads/tasks-improvements` at `/Users/jinwoohong/orca/workspaces/orca/pr-1172-review`. +- For that test worktree, `terminal.subscribe` produced an initial `scrollback` event with an empty `lines` array and no serialized buffer. +- Sending commands to that test terminal returned `ok:true`, but a direct WebSocket `terminal.read` for the same handle still returned an empty tail and no live `data` chunks were observed. +- Creating a fresh terminal with `terminal.create` in that same worktree also returned a writable handle, but `terminal.send` followed by delayed `terminal.read` still returned an empty tail. +- Adding a mobile-side `terminal.read` fallback did not make the current physical-phone test terminal display output, because the direct WebSocket `terminal.read` response for `mobile-output-test` remained `tail: []` and `lastOutputAt: null` after `echo hi`. +- Calling `terminal.show` before `terminal.send` on `mobile-output-test` returned a connected/writable terminal with a `ptyId`, but a delayed `terminal.read` still returned an empty tail. +- Root cause found after building a no-phone repro: daemon-backed PTYs were forwarding provider data to the desktop renderer, but not into `runtime.onPtyData`. The runtime tail buffer and `terminal.subscribe` listeners are fed by `runtime.onPtyData`, so the phone saw accepted sends with no output. +- Fix: `src/main/ipc/pty.ts` now forwards `provider.onData` into `runtime.onPtyData` for non-`LocalPtyProvider` providers. Local PTYs already use the LocalPtyProvider configure hook, so the guard avoids duplicate local output. +- After restarting Electron, `pnpm exec tsx mobile/scripts/test-subscribe.ts ` passed: both `streamSawMarker` and `readSawMarker` were `true`. +- After the backend fix, the physical phone rendered the repro marker in `TerminalWebView`. +- Because of the empty tail/no live stream on that specific desktop terminal, the physical `ls` test did not prove the WebView display path after real PTY output. + +## Changes Made + +- `mobile/src/terminal/TerminalWebView.tsx` + - Added a native-side queue so WebView messages are not sent until the HTML reports `web-ready`. + - Added a WebView-side queue so writes wait until xterm finishes `init()`. +- `mobile/src/transport/rpc-client.ts` + - Stored full stream request metadata so active streams can be replayed after reconnect. + - Avoided sending a subscription before the socket reaches `connected`. + - Fixed `terminal.unsubscribe` to use the terminal handle as `subscriptionId`, matching runtime cleanup behavior. +- `mobile/app/h/[hostId]/session/[worktreeId].tsx` + - Clears and resubscribes when the worktree changes. + - Tracks the active terminal handle in a ref so stale handles are replaced when `terminal.list` changes. + - Routes scrollback and live data into `TerminalWebView`. + +## Useful Commands + +```bash +agent-device devices --json +agent-device snapshot --platform android --serial R3CX105QXRH --json +agent-device fill @e33 "ls" --platform android --serial R3CX105QXRH --json +agent-device click @e34 --platform android --serial R3CX105QXRH --json +agent-device screenshot /tmp/mobile-terminal.png --platform android --serial R3CX105QXRH --json +orca terminal send --terminal term_926b8898-f843-461a-acd2-482f741327ad --text r --json +orca terminal read --terminal term_926b8898-f843-461a-acd2-482f741327ad --json +``` + +## Next Debug Boundary + +Do not repeat WebView readiness tests unless the WebView changes again; the WebView can render post-init writes. For future regressions, run the no-phone repro first. If it fails, debug the WebSocket/runtime/PTY bridge before touching the mobile UI. If it passes but the phone is blank, debug `TerminalWebView` or session-screen routing. diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 00000000000..c07b7787f55 --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/package.json b/package.json index 0f2c873ea14..a20272b3ea6 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "node-pty": "^1.1.0", "pdfjs-dist": "^5.6.205", "posthog-node": "^5.33.0", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", @@ -108,6 +109,7 @@ "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "tweetnacl": "^1.0.3", "ws": "^8.20.0", "zod": "^4.3.6", "zustand": "^5.0.12" @@ -120,6 +122,7 @@ "@tailwindcss/vite": "^4.2.2", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ssh2": "^1.15.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1eaf072407f..7f0204d0552 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: posthog-node: specifier: ^5.33.0 version: 5.33.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -217,6 +220,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 ws: specifier: ^8.20.0 version: 8.20.0 @@ -248,6 +254,9 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -2837,6 +2846,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3242,6 +3254,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} @@ -3326,6 +3342,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3640,6 +3659,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3722,6 +3745,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -4020,6 +4046,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4655,6 +4685,10 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -5175,10 +5209,18 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-map@7.0.4: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} @@ -5187,6 +5229,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5221,6 +5267,10 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -5289,6 +5339,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -5404,6 +5458,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -5554,6 +5613,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -5685,6 +5747,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -6020,6 +6085,9 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -6273,6 +6341,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6332,6 +6403,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6351,10 +6425,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -8774,6 +8856,10 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.5.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -9238,6 +9324,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} @@ -9309,6 +9397,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9623,6 +9717,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -9689,6 +9785,8 @@ snapshots: diff@8.0.3: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -10128,6 +10226,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -10765,6 +10868,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash-es@4.18.1: {} lodash.escaperegexp@4.1.2: {} @@ -11590,10 +11697,18 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-map@7.0.4: {} p-retry@4.6.2: @@ -11601,6 +11716,8 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -11638,6 +11755,8 @@ snapshots: path-data-parser@0.1.0: {} + path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -11692,6 +11811,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: {} + pngjs@7.0.0: {} points-on-curve@0.2.0: {} @@ -11845,6 +11966,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -12116,6 +12243,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -12279,6 +12408,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3): @@ -12664,6 +12795,8 @@ snapshots: tweetnacl@0.14.5: {} + tweetnacl@1.0.3: {} + type-fest@0.13.1: optional: true @@ -12878,6 +13011,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -12930,6 +13065,8 @@ snapshots: xmlbuilder@15.1.1: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -12940,8 +13077,27 @@ snapshots: yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/skills/mobile-fit-debug/SKILL.md b/skills/mobile-fit-debug/SKILL.md new file mode 100644 index 00000000000..63535e24172 --- /dev/null +++ b/skills/mobile-fit-debug/SKILL.md @@ -0,0 +1,182 @@ +--- +name: mobile-fit-debug +description: Debug mobile terminal display-mode toggle and PTY resize issues. Use when investigating why a terminal restores to the wrong width after toggling between phone and desktop mode on mobile, or when PTY sizes are corrupted across tab switches. +--- + +# Mobile Fit Debug + +Use this skill when debugging mobile terminal display-mode toggle issues — wrong restore width, corrupted previousCols, stale PTY sizes, or collateral safeFit cascades. + +## Architecture Overview + +The mobile display-mode toggle involves **3 processes** communicating asynchronously: + +``` +Mobile App (React Native) Server (Electron main) Desktop Renderer (Electron renderer) +───────────────────────── ────────────────────── ──────────────────────────────────── +terminal.setDisplayMode ──RPC──► applyMobileDisplayMode + ├─ resize PTY (authoritative) + ├─ suppressResizesForMs(500) + ├─ terminalFitOverrideChanged ──IPC──► setOverrideTick (React re-render) + │ ├─ safeFit on ALL panes + │ └─ pty:resize IPC ──────► SUPPRESSED + └─ notifyTerminalResize ──stream──► mobile client updates +``` + +## Key Files + +| File | Role | +|------|------| +| `src/main/runtime/orca-runtime.ts` | Server-side state: `mobileSubscribers`, `mobileDisplayModes`, `lastRendererSizes`, suppress window. Core handlers: `handleMobileSubscribe`, `handleMobileUnsubscribe`, `applyMobileDisplayMode`, `onExternalPtyResize` | +| `src/main/ipc/pty.ts` | `pty:resize` IPC handler. Checks `isResizeSuppressed()` before processing. Calls `onExternalPtyResize` to update server state | +| `src/main/runtime/rpc/methods/terminal.ts` | RPC methods: `terminal.subscribe` (mobile subscription lifecycle), `terminal.setDisplayMode`, `terminal.getDisplayMode` | +| `src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts` | Desktop renderer's override cache. `setFitOverride('desktop-fit')` clears the override. `notifyChange` fires `onOverrideChange` listeners | +| `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` | `safeFit()` — measures pane DOM and resizes xterm. When a mobile-fit override exists, uses override dims instead of measuring | +| `src/renderer/src/components/terminal-pane/TerminalPane.tsx` | `onOverrideChange` handler triggers `setOverrideTick` re-render cascade. This is what causes safeFit to run on ALL panes | +| `mobile/app/h/[hostId]/session/[worktreeId].tsx` | Mobile session screen — tab switching, `toggleDisplayMode`, terminal subscription management | +| `src/main/runtime/mobile-subscribe-integration.test.ts` | Integration tests for the full subscribe/unsubscribe/toggle lifecycle | + +## Adding Debug Logging + +Production code has no debug logging. To investigate issues, temporarily add file-based logging to the key files. Here's a pattern that works well: + +### 1. Add mfLog helper to each file you want to instrument + +```typescript +// In orca-runtime.ts (no prefix): +import { appendFileSync } from 'fs' +function mfLog(msg: string): void { + try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] ${msg}\n`) } catch {} +} + +// In pty.ts (prefix [pty-ipc]): +function mfLog(msg: string): void { + try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [pty-ipc] ${msg}\n`) } catch {} +} + +// In terminal.ts RPC methods (prefix [rpc]): +function mfLog(msg: string): void { + try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [rpc] ${msg}\n`) } catch {} +} +``` + +### 2. Useful log points + +**orca-runtime.ts — `handleMobileSubscribe`:** +```typescript +mfLog(`handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport?.cols}x${viewport?.rows}`) +mfLog(` existing.prev=${existing?.previousCols}x${existing?.previousRows} currentSize=${currentSize?.cols}x${currentSize?.rows} rendererSize=${rendererSize?.cols}x${rendererSize?.rows}`) +mfLog(` → previousCols=${previousCols}`) +``` + +**orca-runtime.ts — `applyMobileDisplayMode`:** +```typescript +mfLog(`applyMobileDisplayMode ptyId=${ptyId} mode=${mode} sub.prev=${subscriber?.previousCols}x${subscriber?.previousRows}`) +mfLog(` DESKTOP RESTORE: previousCols=${previousCols}`) +``` + +**orca-runtime.ts — `onExternalPtyResize`:** +```typescript +mfLog(`onExternalPtyResize ptyId=${ptyId} cols=${cols} rows=${rows}`) +``` + +**pty.ts — `pty:resize` handler:** +```typescript +if (runtime?.isResizeSuppressed()) { + mfLog(`pty:resize SUPPRESSED id=${args.id} cols=${args.cols} rows=${args.rows}`) + return +} +mfLog(`pty:resize id=${args.id} cols=${args.cols} rows=${args.rows}`) +``` + +**terminal.ts — `terminal.subscribe`:** +```typescript +mfLog(`\n========== MOBILE SUBSCRIBE ==========`) +mfLog(`terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId} client=${params.client?.type}`) +``` + +### 3. Reading the log + +```bash +> /tmp/mobile-fit-debug.log # clear +# ... reproduce issue on mobile ... +cat /tmp/mobile-fit-debug.log # read +``` + +### 4. Remember to remove logging before committing + +Remove all `mfLog` functions, calls, and `appendFileSync` imports. + +## What to Look For in Logs + +**Wrong previousCols captured:** +``` +handleMobileSubscribe ptyId=...@@abc123 ... + existing.prev=null currentSize=105x72 rendererSize=214x72 <-- rendererSize is WRONG + → previousCols=214 <-- captured wrong value +``` +This means `lastRendererSizes` was polluted by a collateral safeFit cascade. + +**Suppress window too short:** +``` +[pty-ipc] pty:resize id=...@@abc123 cols=105 rows=72 <-- NOT suppressed, arrived after window +``` +If you see unsuppressed `pty:resize` events for terminals that weren't the one being toggled, the 500ms suppress window may be too short. Check the timestamp gap between `applyMobileDisplayMode` and the stale resize. + +**Suppress working correctly:** +``` +[pty-ipc] pty:resize SUPPRESSED id=...@@abc123 cols=214 rows=72 +``` + +**Collateral cascade pattern** (the root cause of most bugs): +``` +applyMobileDisplayMode ptyId=...@@split mode=desktop + DESKTOP RESTORE: previousCols=105 +[pty-ipc] pty:resize SUPPRESSED id=...@@split cols=105 <-- redundant, suppressed OK +[pty-ipc] pty:resize SUPPRESSED id=...@@other cols=214 <-- collateral, suppressed OK +``` +If the collateral line says `pty:resize id=` instead of `SUPPRESSED`, the cascade leaked through. + +## Known Gotchas + +### 1. The Collateral safeFit Cascade +When `terminalFitOverrideChanged` fires for ONE pane, the desktop renderer's `setOverrideTick` React state change triggers a re-render that runs `safeFit` on ALL panes across ALL tabs. Background-tab panes get measured at wrong widths because they may not be visible or may be in different split configurations than the active tab. + +### 2. Cascade Timing is ~360ms +The full cascade path: server sends IPC -> desktop renderer receives -> React re-render -> requestAnimationFrame -> DOM measurement -> `pty:resize` IPC back to server. This takes ~360ms, which is why the suppress window must be >360ms (currently 500ms). + +### 3. lastRendererSizes Persistence +`lastRendererSizes` stores every `pty:resize` from the desktop renderer. Stale values persist across mobile subscribe/unsubscribe cycles. The `lastRendererSizes.delete(ptyId)` in desktop restore is critical — without it, a stale 214 from a previous cascade gets used as `previousCols` on the next subscribe. + +### 4. Daemon Doesn't Hot-Reload +The daemon process (`out/main/daemon-entry.js`) loads compiled code once at startup. Changes to `orca-runtime.ts` or `pty.ts` require: +1. `npx electron-vite build --outDir out` (or wait for watcher) +2. **Restart the dev server** (`pnpm dev`) — the daemon subprocess won't pick up new builds + +Verify your code is live: `grep "your_unique_string" out/main/index.js` + +### 5. previousCols Priority Chain +```typescript +previousCols = existing?.previousCols ?? rendererSize?.cols ?? currentSize?.cols +``` +- `existing?.previousCols` — re-subscribe case (tab switch back), most trusted +- `rendererSize?.cols` — from `lastRendererSizes`, the desktop renderer's last `pty:resize` +- `currentSize?.cols` — from `getTerminalSize()`, the server-side PTY size + +If `rendererSize` is polluted (e.g. 214 for a split pane), it takes priority over the correct `currentSize`. That's why clearing `lastRendererSizes` on restore is essential. + +### 6. agent-device for Automated Testing +Use `/opt/homebrew/bin/agent-device` to automate mobile UI testing: +```bash +agent-device snapshot # accessibility tree +agent-device click @e45 # click element by ref +agent-device screenshot --out /tmp/s.png # capture screenshot +agent-device wait 2 # wait 2 seconds +``` +Typical test flow: click tab -> click "Switch to desktop mode" -> wait 3s -> click "Switch to phone mode" -> switch tabs -> repeat -> read `/tmp/mobile-fit-debug.log`. + +## Running Tests + +```bash +npx vitest run src/main/runtime/mobile-subscribe-integration.test.ts +``` diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 74102e01e05..2064b937887 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -35,10 +35,12 @@ function writeMetadata( JSON.stringify({ runtimeId: 'runtime-1', pid, - transport: { - kind: 'unix', - endpoint - }, + transports: [ + { + kind: 'unix', + endpoint + } + ], authToken, startedAt: 1 }), diff --git a/src/cli/runtime/metadata.ts b/src/cli/runtime/metadata.ts index cd690ab0f14..df93adad8b5 100644 --- a/src/cli/runtime/metadata.ts +++ b/src/cli/runtime/metadata.ts @@ -1,14 +1,18 @@ import { homedir } from 'os' import { join } from 'path' import { readFileSync } from 'fs' -import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { + findTransport, + getRuntimeMetadataPath, + type RuntimeMetadata +} from '../../shared/runtime-bootstrap' import { RuntimeClientError } from './types' export function readMetadata(userDataPath: string): RuntimeMetadata { const metadataPath = getRuntimeMetadataPath(userDataPath) try { const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null - if (!metadata?.transport || !metadata.authToken) { + if (!metadata || !findTransport(metadata, 'unix', 'named-pipe') || !metadata.authToken) { throw new RuntimeClientError( 'runtime_unavailable', `Orca runtime metadata is incomplete at ${metadataPath}` diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 00daf4bfec6..3a0f445a40f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -7,7 +7,7 @@ export async function getCliStatus( userDataPath: string ): Promise> { const metadata = tryReadMetadata(userDataPath) - if (!metadata?.transport || !metadata.authToken) { + if (!metadata?.transports?.length || !metadata.authToken) { return buildCliStatusResponse({ app: { running: false, diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 7209a274c0f..ca8ca746274 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,6 +1,6 @@ import { createConnection } from 'net' import { randomUUID } from 'crypto' -import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' +import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' import { RuntimeClientError, type RuntimeRpcResponse } from './types' @@ -11,7 +11,17 @@ export async function sendRequest( timeoutMs: number ): Promise> { return await new Promise((resolve, reject) => { - const socket = createConnection(getTransportEndpoint(metadata.transport!)) + const transport = findTransport(metadata, 'unix', 'named-pipe') + if (!transport) { + reject( + new RuntimeClientError( + 'runtime_unavailable', + 'No compatible transport found in Orca runtime metadata.' + ) + ) + return + } + const socket = createConnection(transport.endpoint) let buffer = '' let settled = false const requestId = randomUUID() @@ -159,7 +169,3 @@ export async function sendRequest( }) }) } - -function getTransportEndpoint(transport: RuntimeTransportMetadata): string { - return transport.endpoint -} diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index 37a89caea3d..8475a0cbca8 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -273,7 +273,7 @@ describe('Browser automation pipeline (integration)', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath)! - endpoint = metadata.transport!.endpoint + endpoint = metadata.transports[0]!.endpoint authToken = metadata.authToken! }) @@ -521,7 +521,7 @@ describe('Browser automation pipeline (integration)', () => { await server2.start() const metadata2 = readRuntimeMetadata(userDataPath2)! - const res = await sendRequest(metadata2.transport!.endpoint, { + const res = await sendRequest(metadata2.transports[0]!.endpoint, { id: 'req_no_tab', authToken: metadata2.authToken, method: 'browser.snapshot' diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index ed16d9ab18d..65d304f71b6 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -94,6 +94,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, + experimentalMobile: false, experimentalSidekick: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index d3673d56681..cff5f956d75 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -88,6 +88,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, + experimentalMobile: false, experimentalSidekick: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 6491e435de5..12645ec1e0a 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -9,6 +9,10 @@ export type HeadlessEmulatorOptions = { scrollback?: number } +export type HeadlessSnapshotOptions = { + scrollbackRows?: number +} + const DEFAULT_SCROLLBACK = 5000 function parseFileUriPath(uri: string): string | null { @@ -87,10 +91,10 @@ export class HeadlessEmulator { this.terminal.resize(cols, rows) } - getSnapshot(): TerminalSnapshot { + getSnapshot(opts: HeadlessSnapshotOptions = {}): TerminalSnapshot { const modes = this.getModes() return { - snapshotAnsi: this.serializer.serialize(), + snapshotAnsi: this.serializer.serialize({ scrollback: opts.scrollbackRows }), scrollbackAnsi: '', rehydrateSequences: this.buildRehydrateSequences(modes), cwd: this.cwd, diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index f7a2e021fdb..1bccaf63263 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -134,7 +134,11 @@ if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER}" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + # Why: zsh precmd fires before zle switches the PTY into line-editing mode, + # so writing startup input there can be echoed once outside the prompt. + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = `# Orca daemon bash shell-ready wrapper diff --git a/src/main/index.ts b/src/main/index.ts index 650ea14045b..ca89b89659b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { setAppRuntimeFlags } from './ipc/app' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' +import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry } from './telemetry/client' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' @@ -498,10 +499,18 @@ app.whenReady().then(async () => { } } }) + // Why: E2E tests launch parallel Electron instances that would all race to + // bind the default fixed port, crashing on EADDRINUSE. Port 0 lets the OS + // assign a random available port per instance while still exercising the + // full WebSocket startup path. + const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) runtimeRpc = new OrcaRuntimeRpcServer({ runtime, - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + enableWebSocket: true, + ...(isE2E ? { wsPort: 0 } : {}) }) + registerMobileHandlers(runtimeRpc) // Why: the persistent-terminal daemon is always started. If it fails, the // LocalPtyProvider (initialized at module load in ipc/pty.ts) remains as the diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts new file mode 100644 index 00000000000..c7ee87d07ee --- /dev/null +++ b/src/main/ipc/mobile.ts @@ -0,0 +1,125 @@ +import { ipcMain } from 'electron' +import { networkInterfaces } from 'os' +import QRCode from 'qrcode' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' + +export type NetworkInterface = { + name: string + address: string +} + +// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't +// connectable from a mobile device. We enumerate all non-internal IPv4 +// addresses so the user can choose which one to advertise in the QR code +// (e.g. LAN vs Tailscale). +function getNetworkInterfaces(): NetworkInterface[] { + const result: NetworkInterface[] = [] + const interfaces = networkInterfaces() + for (const [name, addrs] of Object.entries(interfaces)) { + if (!addrs) { + continue + } + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + result.push({ name, address: addr.address }) + } + } + } + return result +} + +function getLanAddress(): string | null { + const ifaces = getNetworkInterfaces() + return ifaces.length > 0 ? ifaces[0]!.address : null +} + +// Why: the mobile IPC handlers provide the renderer with QR code pairing data, +// device management, and WebSocket readiness status. They depend on the +// OrcaRuntimeRpcServer because it owns the device registry and TLS state. + +export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { + ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({ + interfaces: getNetworkInterfaces() + })) + + ipcMain.handle('mobile:getPairingQR', async (_event, args?: { address?: string }) => { + const rawEndpoint = rpcServer.getWebSocketEndpoint() + const registry = rpcServer.getDeviceRegistry() + if (!rawEndpoint || !registry) { + return { available: false as const } + } + + // Why: allow the caller to specify which network interface address to + // embed in the QR code. This supports overlay networks (Tailscale, + // ZeroTier) where the default LAN IP isn't reachable from the phone. + const ip = args?.address ?? getLanAddress() + if (!ip) { + return { available: false as const } + } + const endpoint = rawEndpoint.replace('0.0.0.0', ip) + + const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`) + + const publicKeyB64 = rpcServer.getE2EEPublicKey() + if (!publicKeyB64) { + return { available: false as const } + } + + const url = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64 + }) + + const qrDataUrl = await QRCode.toDataURL(url, { + errorCorrectionLevel: 'M', + margin: 2, + width: 256 + }) + + return { + available: true as const, + qrDataUrl, + endpoint, + deviceId: device.deviceId + } + }) + + ipcMain.handle('mobile:listDevices', () => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { devices: [] } + } + // Why: devices with lastSeenAt === 0 were created during QR generation + // but never actually scanned/connected. Showing them as "paired" is + // misleading, so we filter them out. + return { + devices: registry + .listDevices() + .filter((d) => d.lastSeenAt > 0) + .map((d) => ({ + deviceId: d.deviceId, + name: d.name, + pairedAt: d.pairedAt, + lastSeenAt: d.lastSeenAt + })) + } + }) + + ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { revoked: false } + } + return { revoked: registry.removeDevice(args.deviceId) } + }) + + ipcMain.handle('mobile:isWebSocketReady', () => { + return { + ready: rpcServer.getWebSocketEndpoint() !== null, + endpoint: rpcServer.getWebSocketEndpoint() + } + }) +} diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index eb69159bba8..c8f2856e5e8 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron' import type { Store } from '../persistence' import type { NotificationDispatchRequest, NotificationDispatchResult } from '../../shared/types' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' const NOTIFICATION_COOLDOWN_MS = 5000 @@ -11,7 +12,7 @@ const NOTIFICATION_COOLDOWN_MS = 5000 // strong reference until the notification is clicked or closed. const activeNotifications = new Set() -export function registerNotificationHandlers(store: Store): void { +export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { const recentNotifications = new Map() ipcMain.removeHandler('notifications:openSystemSettings') @@ -28,6 +29,21 @@ export function registerNotificationHandlers(store: Store): void { ipcMain.handle( 'notifications:dispatch', (_event, args: NotificationDispatchRequest): NotificationDispatchResult => { + // Why: mobile push is independent of desktop notification guards. + // The user's phone should receive the notification even when the desktop + // window is focused (suppressWhenFocused), Electron notifications aren't + // supported, or the desktop is in cooldown. The mobile client decides + // independently whether to show based on its own app state. + if (runtime) { + const opts = buildNotificationOptions(args) + runtime.dispatchMobileNotification({ + source: args.source, + title: opts.title, + body: opts.body, + worktreeId: args.worktreeId + }) + } + if (!Notification.isSupported()) { return { delivered: false, reason: 'not-supported' } } @@ -119,6 +135,7 @@ export function registerNotificationHandlers(store: Store): void { } notification.show() + return { delivered: true } } ) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4519c99ac12..b8962fc823d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4,6 +4,7 @@ foreground-process inspection, and renderer IPC stay behind a single audited boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'path' +import { randomUUID } from 'crypto' import { type BrowserWindow, ipcMain, app } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -36,6 +37,9 @@ const sshProviders = new Map() // post-spawn operations to the correct provider without the renderer needing // to track connectionId per-PTY. const ptyOwnership = new Map() +// Why: mobile clients must mirror desktop PTY geometry even when the renderer +// cannot provide an xterm snapshot yet, such as immediately after tab creation. +const ptySizes = new Map() // Why: the agent-hooks server caches per-paneKey state (last prompt, last // tool) that otherwise grows unbounded as panes come and go. Track the // spawn-time paneKey so clearProviderPtyState can clear that cache on PTY @@ -284,6 +288,7 @@ export function clearProviderPtyState(id: string): void { // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) + ptySizes.delete(id) // Why: drop the memory-collector registration so a dead PTY does not keep // trying to resolve its (now-dead) pid on every snapshot. Safe no-op for // PTYs that were never registered (SSH-owned). @@ -436,10 +441,15 @@ export function registerPtyHandlers( // Why: LocalPtyProvider routes data to the runtime via configure().onData, // but daemon-backed providers don't have configure(). Without this, daemon // PTY data never reaches the runtime's tail buffer, so terminal.read returns - // empty and agent-detection from raw data never fires. + // empty and agent-detection from raw data never fires. Runtime tails also + // power mobile read/stream, so they must be notified regardless of window + // state. const isLocalProvider = localProvider instanceof LocalPtyProvider localDataUnsub = localProvider.onData((payload) => { + if (!isLocalProvider) { + runtime?.onPtyData(payload.id, payload.data, Date.now()) + } if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window // is gone. Without this, macOS app re-activation leaks orphaned timers @@ -451,9 +461,6 @@ export function registerPtyHandlers( pendingData.clear() return } - if (!isLocalProvider) { - runtime?.onPtyData(payload.id, payload.data, Date.now()) - } const existing = pendingData.get(payload.id) pendingData.set(payload.id, existing ? existing + payload.data : payload.data) if (!flushTimer) { @@ -484,6 +491,54 @@ export function registerPtyHandlers( bindProviderListeners() rebindProviderListeners = bindProviderListeners + function requestSerializedBuffer( + ptyId: string + ): Promise<{ data: string; cols: number; rows: number } | null> { + if (mainWindow.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + return new Promise((resolve) => { + const cleanup = (): void => { + clearTimeout(timeout) + ipcMain.removeListener('pty:serializeBuffer:response', onResponse) + } + + const timeout = setTimeout(() => { + cleanup() + resolve(null) + }, 750) + + const onResponse = ( + _event: Electron.IpcMainEvent, + args: { + requestId?: string + snapshot?: { data?: unknown; cols?: unknown; rows?: unknown } | null + } + ): void => { + if (args.requestId !== requestId) { + return + } + cleanup() + const snapshot = args.snapshot + if ( + snapshot && + typeof snapshot.data === 'string' && + typeof snapshot.cols === 'number' && + typeof snapshot.rows === 'number' + ) { + resolve({ data: snapshot.data, cols: snapshot.cols, rows: snapshot.rows }) + } else { + resolve(null) + } + } + + ipcMain.on('pty:serializeBuffer:response', onResponse) + mainWindow.webContents.send('pty:serializeBuffer:request', { requestId, ptyId }) + }) + } + // Kill orphaned PTY processes from previous page loads when the renderer reloads. // Why: only applies to LocalPtyProvider where PTYs live in the Electron main // process and can become orphaned on page reload. Daemon-backed sessions @@ -535,6 +590,28 @@ export function registerPtyHandlers( } catch { return null } + }, + listProcesses: async () => { + const providerSessions = await Promise.all([ + localProvider.listProcesses(), + ...Array.from(sshProviders.values(), (provider) => provider.listProcesses().catch(() => [])) + ]) + return providerSessions.flat() + }, + serializeBuffer: (ptyId) => { + // Why: mobile xterm must start from the desktop xterm's exact screen + // state and dimensions before live TUI chunks can render correctly. + return requestSerializedBuffer(ptyId) + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null, + resize: (ptyId, cols, rows) => { + try { + ptySizes.set(ptyId, { cols, rows }) + getProviderForPty(ptyId).resize(ptyId, cols, rows) + return true + } catch { + return false + } } }) @@ -691,6 +768,12 @@ export function registerPtyHandlers( if (effectiveShellOverride !== undefined) { spawnOptions.shellOverride = effectiveShellOverride } + if (effectiveSessionId !== undefined) { + // Why: daemon PTYs can emit prompt/startup bytes before spawn() + // resolves. Runtime headless snapshots need the real pane geometry + // for those early bytes; otherwise they default to 80x24 and wrap TUIs. + ptySizes.set(effectiveSessionId, { cols: args.cols, rows: args.rows }) + } if (process.platform === 'win32' && !args.connectionId) { // Why: the renderer only models PowerShell as one shell family. Thread // the persisted implementation choice through spawnOptions so both the @@ -704,16 +787,11 @@ export function registerPtyHandlers( try { result = await provider.spawn(spawnOptions) } catch (err) { + if (effectiveSessionId !== undefined) { + ptySizes.delete(effectiveSessionId) + } // Why: when buildPtyHostEnv materialized a Pi overlay for this id - // but provider.spawn failed, the overlay would leak. Sweep per-PTY - // state for the minted id so it isn't orphaned. Safe to call even - // when no overlay was created (clearProviderPtyState is a no-op in - // that case). - // - // Only clean up when we MINTED the id in this request. Caller-supplied - // ids may correspond to existing PTYs whose state (OpenCode hooks, Pi - // overlay, agent-hook pane caches) we MUST NOT clear on a retry/attach - // failure. + // but provider.spawn failed, the overlay would leak. if (isMintedSessionId && effectiveSessionId !== undefined) { clearProviderPtyState(effectiveSessionId) } @@ -723,6 +801,14 @@ export function registerPtyHandlers( if (preAllocatedHandle) { runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) } + ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) + if ( + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ) { + runtime?.registerPty(result.id, args.worktreeId) + } if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -789,7 +875,17 @@ export function registerPtyHandlers( // empty acknowledgement message back to the renderer. ipcMain.removeAllListeners('pty:resize') ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: after a desktop-fit override change, the desktop renderer's + // re-render cascade runs safeFit on ALL panes (not just the affected + // one). Background-tab panes get measured at full-width (214) instead + // of their correct split width. Suppressing ALL pty:resize during + // this window prevents the cascade from corrupting PTY dimensions. + if (runtime?.isResizeSuppressed()) { + return + } + ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) getProviderForPty(args.id).resize(args.id, args.cols, args.rows) + runtime?.onExternalPtyResize(args.id, args.cols, args.rows) }) // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 916c8e97859..b1d7cda9b9e 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -251,7 +251,7 @@ describe('registerCoreHandlers', () => { expect(registerFeedbackHandlersMock).toHaveBeenCalled() expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats) expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store) - expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store) + expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime) expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 704358fc214..273a679d4ff 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -80,7 +80,7 @@ export function registerCoreHandlers( registerExportHandlers() registerStatsHandlers(stats) registerMemoryHandlers(store) - registerNotificationHandlers(store) + registerNotificationHandlers(store, runtime) registerDeveloperPermissionHandlers() registerSettingsHandlers(store) registerTelemetryHandlers() diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 1460d6c9599..3931869c7bb 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -20,4 +20,35 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle('runtime:getStatus', (): RuntimeStatus => { return runtime.getStatus() }) + + ipcMain.removeHandler('runtime:getTerminalFitOverrides') + ipcMain.handle( + 'runtime:getTerminalFitOverrides', + (): { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] => { + const overrides = runtime.getAllTerminalFitOverrides() + return Array.from(overrides.entries()).map(([ptyId, override]) => ({ + ptyId, + ...override + })) + } + ) + + // Why: the desktop "Restore" button sets the display mode to 'desktop' and + // applies it, which restores the PTY to its original dimensions and emits + // a 'resized' event to any active mobile subscriber. This uses the same + // code path as the mobile toggle button (terminal.setDisplayMode RPC). + ipcMain.removeHandler('runtime:restoreTerminalFit') + ipcMain.handle('runtime:restoreTerminalFit', (_event, args: { ptyId: string }) => { + const override = runtime.getTerminalFitOverride(args.ptyId) + if (!override) { + return { restored: false } + } + try { + runtime.setMobileDisplayMode(args.ptyId, 'desktop') + runtime.applyMobileDisplayMode(args.ptyId) + return { restored: true } + } catch { + return { restored: false } + } + }) } diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 7ad3c09b902..b15d8f59bbc 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -210,13 +210,15 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path -# Why: emit OSC 133;A only after the user's startup hooks finish so Orca knows -# the prompt is actually ready for a long startup command paste. +# Why: zsh precmd runs before the prompt is drawn and before zle owns input, +# which can double-echo startup commands. line-init fires when zle is ready. if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "\\033]133;A\\007" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = getBashShellReadyRcfileContent() diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts new file mode 100644 index 00000000000..fed25b62212 --- /dev/null +++ b/src/main/runtime/device-registry.ts @@ -0,0 +1,83 @@ +// Why: per-device tokens replace the shared runtime auth token for WebSocket +// (mobile) connections. Each paired device gets its own revocable token so +// compromising one device doesn't expose others. The registry is a simple +// JSON file with hardened permissions matching the runtime metadata pattern. +import { randomBytes, randomUUID } from 'crypto' +import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { join } from 'path' + +const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' + +export type DeviceEntry = { + deviceId: string + name: string + token: string + pairedAt: number + lastSeenAt: number +} + +export class DeviceRegistry { + private readonly registryPath: string + private devices: DeviceEntry[] = [] + + constructor(userDataPath: string) { + this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME) + this.load() + } + + addDevice(name: string): DeviceEntry { + const entry: DeviceEntry = { + deviceId: randomUUID(), + name, + token: randomBytes(24).toString('hex'), + pairedAt: Date.now(), + lastSeenAt: 0 + } + this.devices.push(entry) + this.save() + return entry + } + + removeDevice(deviceId: string): boolean { + const before = this.devices.length + this.devices = this.devices.filter((d) => d.deviceId !== deviceId) + if (this.devices.length < before) { + this.save() + return true + } + return false + } + + listDevices(): readonly DeviceEntry[] { + return this.devices + } + + validateToken(token: string): DeviceEntry | null { + return this.devices.find((d) => d.token === token) ?? null + } + + updateLastSeen(deviceId: string): void { + const device = this.devices.find((d) => d.deviceId === deviceId) + if (device) { + device.lastSeenAt = Date.now() + this.save() + } + } + + private load(): void { + if (!existsSync(this.registryPath)) { + this.devices = [] + return + } + try { + this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + } catch { + this.devices = [] + } + } + + private save(): void { + writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 }) + chmodSync(this.registryPath, 0o600) + } +} diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts new file mode 100644 index 00000000000..de0373ebafe --- /dev/null +++ b/src/main/runtime/e2ee-keypair.ts @@ -0,0 +1,50 @@ +// Why: the E2EE keypair enables application-layer encryption between mobile +// and desktop over plain ws://. The public key is embedded in the QR pairing +// offer so the mobile client can derive a shared secret via ECDH. +import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { join } from 'path' +import nacl from 'tweetnacl' + +const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' +const KEYPAIR_VERSION = 1 + +type KeypairFile = { + v: number + publicKeyB64: string + secretKeyB64: string +} + +export type E2EEKeypair = { + publicKey: Uint8Array + secretKey: Uint8Array + publicKeyB64: string +} + +export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { + const filePath = join(userDataPath, KEYPAIR_FILENAME) + + if (existsSync(filePath)) { + try { + const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) + if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { + const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) + const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64')) + if (publicKey.length === 32 && secretKey.length === 32) { + return { publicKey, secretKey, publicKeyB64: raw.publicKeyB64 } + } + } + } catch { + // Malformed file — regenerate below. + } + } + + const keypair = nacl.box.keyPair() + const publicKeyB64 = Buffer.from(keypair.publicKey).toString('base64') + const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64') + + const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 } + writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') + chmodSync(filePath, 0o600) + + return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 } +} diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts new file mode 100644 index 00000000000..763b7d8cbf9 --- /dev/null +++ b/src/main/runtime/fit-override-integration.test.ts @@ -0,0 +1,275 @@ +/** + * Integration test for the mobile-fit override flow. + * Tests the full lifecycle: mobile-fit → restore → verify PTY resized. + */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]) +})) + +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +describe('fit override integration', () => { + it('full lifecycle: fit → getSize → restore → verify PTY dims', () => { + const runtime = new OrcaRuntimeService(store) + const currentSize = { cols: 150, rows: 40 } + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + currentSize.cols = cols + currentSize.rows = rows + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: () => ({ ...currentSize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + notifications.push({ ptyId, mode, cols, rows }) + } + }) + + // Simulate a synced leaf (mounted desktop pane) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + console.log('=== Step 1: Initial state ===') + console.log('PTY size:', currentSize) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + + console.log('\n=== Step 2: Mobile fit to 45x20 ===') + const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + console.log('Fit result:', fitResult) + console.log('PTY size after fit:', currentSize) + console.log('Override:', runtime.getTerminalFitOverride('pty-1')) + expect(currentSize).toEqual({ cols: 45, rows: 20 }) + expect(fitResult.previousCols).toBe(150) + expect(fitResult.previousRows).toBe(40) + + console.log('\n=== Step 3: Desktop Restore (via IPC handler path) ===') + // Simulate what runtime:restoreTerminalFit IPC handler does + const override = runtime.getTerminalFitOverride('pty-1') + expect(override).not.toBeNull() + const restoreResult = runtime.resizeForClient('pty-1', 'restore', override!.clientId) + console.log('Restore result:', restoreResult) + console.log('PTY size after restore:', currentSize) + console.log('Override after restore:', runtime.getTerminalFitOverride('pty-1')) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + expect(restoreResult.mode).toBe('desktop-fit') + + console.log('\n=== Step 4: Verify all resizes ===') + console.log('All resize calls:', resizes) + expect(resizes).toEqual([ + { ptyId: 'pty-1', cols: 45, rows: 20 }, + { ptyId: 'pty-1', cols: 150, rows: 40 } + ]) + + console.log('\n=== Step 5: Verify notifications ===') + console.log('All notifications:', notifications) + expect(notifications).toEqual([ + { ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }, + { ptyId: 'pty-1', mode: 'desktop-fit', cols: 150, rows: 40 } + ]) + + console.log('\n=== Step 6: Mobile restore via RPC path ===') + // Re-fit, then restore via the mobile RPC handler path + runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + expect(currentSize).toEqual({ cols: 45, rows: 20 }) + + // This is what terminal.resizeForClient RPC handler does + const mobileRestore = runtime.resizeForClient('pty-1', 'restore', 'client-phone') + console.log('Mobile restore result:', mobileRestore) + console.log('PTY size after mobile restore:', currentSize) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + }) + + it('restore resizes PTY even with mounted leaf (the bug fix)', () => { + const runtime = new OrcaRuntimeService(store) + let ptySize = { cols: 120, rows: 35 } + const resizes: string[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySize = { cols, rows } + resizes.push(`${ptyId}:${cols}x${rows}`) + return true + }, + getSize: () => ({ ...ptySize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn() + }) + + // Synced leaf = mounted desktop pane + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + // Mobile fit + runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18) + expect(ptySize).toEqual({ cols: 42, rows: 18 }) + + // Restore — THIS is the critical assertion. + // Before the fix, mounted leaves skipped the PTY resize. + runtime.resizeForClient('pty-1', 'restore', 'phone-a') + expect(ptySize).toEqual({ cols: 120, rows: 35 }) + expect(resizes).toEqual(['pty-1:42x18', 'pty-1:120x35']) + }) + + it('disconnect auto-restore also resizes PTY', () => { + const runtime = new OrcaRuntimeService(store) + let ptySize = { cols: 100, rows: 30 } + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (_ptyId, cols, rows) => { + ptySize = { cols, rows } + return true + }, + getSize: () => ({ ...ptySize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn() + }) + + runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20) + expect(ptySize).toEqual({ cols: 45, rows: 20 }) + + // Simulate WS disconnect + runtime.onClientDisconnected('phone-disconnect') + expect(ptySize).toEqual({ cols: 100, rows: 30 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + }) +}) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts new file mode 100644 index 00000000000..f5eed223526 --- /dev/null +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -0,0 +1,471 @@ +/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */ +/** + * Integration tests for the server-authoritative mobile subscribe lifecycle. + * Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode, + * debounced restore, inline restore on timer cancel, and cleanup paths. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]) +})) + +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function createRuntime() { + const runtime = new OrcaRuntimeService(store) + const ptySizes = new Map() + ptySizes.set('pty-1', { cols: 150, rows: 40 }) + ptySizes.set('pty-2', { cols: 120, rows: 35 }) + ptySizes.set('pty-3', { cols: 100, rows: 30 }) + + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySizes.set(ptyId, { cols, rows }) + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + notifications.push({ ptyId, mode, cols, rows }) + } + }) + + return { runtime, ptySizes, resizes, notifications } +} + +describe('mobile subscribe integration', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('handleMobileSubscribe resizes PTY to phone dims', () => { + const { runtime, ptySizes, resizes, notifications } = createRuntime() + + const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + expect(result).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(resizes).toEqual([{ ptyId: 'pty-1', cols: 45, rows: 20 }]) + expect(notifications).toEqual([{ ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }]) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true) + }) + + it('handleMobileSubscribe skips resize when mode is desktop', () => { + const { runtime, ptySizes, resizes } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'desktop') + + const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + expect(result).toBe(false) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizes).toEqual([]) + }) + + it('handleMobileSubscribe skips resize when no viewport provided', () => { + const { runtime, ptySizes, resizes } = createRuntime() + + const result = runtime.handleMobileSubscribe('pty-1', 'client-a') + + expect(result).toBe(false) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizes).toEqual([]) + }) + + it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Not yet restored + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + vi.advanceTimersByTime(300) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('handleMobileUnsubscribe does not restore in phone mode', () => { + const { runtime, ptySizes } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'phone') + // In phone mode, handleMobileSubscribe still resizes because mode is 'phone' + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + vi.advanceTimersByTime(1000) + // Still at phone dims — no restore for phone mode + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + // TODO: inline restore on re-subscribe not yet implemented + it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // Re-subscribe to a different terminal before the timer fires + vi.advanceTimersByTime(100) + runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + // pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 }) + + // Advancing past the 300ms debounce should not cause a second restore + vi.advanceTimersByTime(300) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // TODO: inline restore on re-subscribe not yet implemented + it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', () => { + const { runtime, ptySizes } = createRuntime() + + // Subscribe to A + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + // pty-1 should be restored inline (not waiting for timer) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 }) + + // Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore + runtime.handleMobileUnsubscribe('pty-2', 'client-a') + runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 }) + + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) + + // Verify final state + vi.advanceTimersByTime(1000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) + }) + + it('preserves previousDims across re-subscribes to same terminal', () => { + const { runtime, ptySizes } = createRuntime() + + // First subscribe at desktop 150x40 + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Re-subscribe to the same terminal (e.g., after reconnect) + // The PTY is already at 45x20, but previousDims should still be 150x40 + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Unsubscribe and let restore fire + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + vi.advanceTimersByTime(300) + + // Should restore to original desktop dims, not 45x20 + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('clamps viewport to valid range', () => { + const { runtime, ptySizes } = createRuntime() + + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 }) + // Should clamp to minimum 20x8 + expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 }) + }) + + describe('display mode', () => { + it('defaults to auto', () => { + const { runtime } = createRuntime() + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + }) + + it('set/get round-trip', () => { + const { runtime } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'phone') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('phone') + + runtime.setMobileDisplayMode('pty-1', 'desktop') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop') + + // Setting to 'auto' deletes the entry (same as default) + runtime.setMobileDisplayMode('pty-1', 'auto') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + }) + }) + + describe('applyMobileDisplayMode', () => { + it('desktop mode restores PTY when currently phone-fitted', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + const resizeEvents: unknown[] = [] + runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event)) + + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizeEvents).toHaveLength(1) + expect(resizeEvents[0]).toMatchObject({ + cols: 150, + rows: 40, + displayMode: 'desktop', + reason: 'mode-change' + }) + }) + + it('auto mode re-fits PTY when subscriber exists and not phone-fitted', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Switch to desktop (restores to 150x40) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + const resizeEvents: unknown[] = [] + runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event)) + + // Switch back to auto (should re-fit to phone dims) + runtime.setMobileDisplayMode('pty-1', 'auto') + runtime.applyMobileDisplayMode('pty-1') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(resizeEvents).toHaveLength(1) + expect(resizeEvents[0]).toMatchObject({ + displayMode: 'auto', + reason: 'mode-change' + }) + }) + }) + + describe('cleanup paths', () => { + it('onClientDisconnected restores all PTYs immediately (no debounce)', () => { + const { runtime, ptySizes } = createRuntime() + + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + runtime.onClientDisconnected('client-a') + + // Both PTYs restored immediately + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false) + }) + + it('onClientDisconnected cancels pending restore timers', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Timer is pending + + runtime.onClientDisconnected('client-a') + // Timer should be cancelled, PTY already restored by disconnect handler + + vi.advanceTimersByTime(1000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('onPtyExit cleans up mobileSubscribers and pending timers', () => { + const { runtime } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Timer pending for pty-1 + + runtime.onPtyExit('pty-1', 0) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + + // Timer should have been cancelled — no crash from resizing a dead PTY + vi.advanceTimersByTime(1000) + }) + + it('onPtyExit does not cancel timers for other PTYs', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // pty-2 exits — should not affect pty-1's pending restore + runtime.onPtyExit('pty-2', 0) + + vi.advanceTimersByTime(300) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + }) + + describe('resize listener system', () => { + it('subscribe/unsubscribe lifecycle', () => { + const { runtime } = createRuntime() + const events: unknown[] = [] + const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e)) + + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + + expect(events.length).toBeGreaterThan(0) + + const countBefore = events.length + unsubscribe() + + // After unsubscribe, no more events + runtime.setMobileDisplayMode('pty-1', 'auto') + runtime.applyMobileDisplayMode('pty-1') + expect(events.length).toBe(countBefore) + }) + }) + + describe('onExternalPtyResize', () => { + it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Toggle to desktop — restores to previousCols (150x40) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Simulate desktop renderer's safeFit correcting to split-pane width + runtime.onExternalPtyResize('pty-1', 105, 40) + + // Toggle back to auto — should capture previousCols=105 (not 150) + runtime.setMobileDisplayMode('pty-1', 'auto') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Toggle to desktop again — should restore to 105 (the corrected value) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) + }) + + it('uses lastRendererSize for previousCols on first subscribe', () => { + const { runtime, ptySizes } = createRuntime() + + // Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105 + ptySizes.set('pty-1', { cols: 214, rows: 72 }) + runtime.onExternalPtyResize('pty-1', 105, 40) + + // First mobile subscribe — should use rendererSize (105) not ptySizes (214) + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Toggle to desktop — should restore to 105, not 214 + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) + }) + + it('does not update previousCols when PTY is phone-fitted', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // PTY is phone-fitted (wasResizedToPhone=true) — external resize should not + // overwrite previousCols with phone dims + runtime.onExternalPtyResize('pty-1', 45, 20) + + // Toggle to desktop — should still restore to original 150x40 + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + }) + + describe('backward compatibility', () => { + it('old resizeForClient still works alongside new system', () => { + const { runtime, ptySizes } = createRuntime() + + // Old flow: explicit resizeForClient + const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(fitResult.mode).toBe('mobile-fit') + + // Old flow: restore + const restoreResult = runtime.resizeForClient('pty-1', 'restore', 'client-old') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(restoreResult.mode).toBe('desktop-fit') + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 463b778b709..3fb54c5a637 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/types' import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' -import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { + createSetupRunnerScript, + getEffectiveHooks, + runHook, + shouldRunSetupForCreate +} from '../hooks' import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' @@ -39,7 +44,12 @@ vi.mock('../git/worktree', () => ({ vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), - runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), + shouldRunSetupForCreate: vi + .fn() + .mockImplementation((_repo: never, decision: string) => decision === 'run'), + getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'), + hasHooksFile: vi.fn().mockReturnValue(false) })) vi.mock('../ipc/worktree-logic', async (importOriginal) => { @@ -77,6 +87,8 @@ afterEach(() => { vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(runHook).mockReset() + vi.mocked(shouldRunSetupForCreate).mockReset() + vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() @@ -156,6 +168,7 @@ const store = { ...meta }) as never, removeWorktreeMeta: () => {}, + getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: '/tmp/workspaces', nestWorkspaces: false, @@ -763,7 +776,11 @@ describe('OrcaRuntimeService', () => { repo: 'repo', path: '/tmp/worktree-a', branch: 'feature/foo', + displayName: 'foo', linkedIssue: 123, + linkedPR: null, + isPinned: false, + status: 'active', unread: false, liveTerminalCount: 1, hasAttachedPty: true, @@ -926,7 +943,9 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn() }) runtime.attachWindow(1) @@ -1001,7 +1020,9 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn() }) runtime.attachWindow(1) @@ -1092,7 +1113,9 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn() }) computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-worktree') @@ -1150,6 +1173,7 @@ describe('OrcaRuntimeService', () => { return nextMeta }, removeWorktreeMeta: () => {}, + getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: 'C:\\workspaces', nestWorkspaces: false, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3d2492bf173..be4210e28f7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,7 +14,12 @@ import { join } from 'path' import { rm } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' -import type { CreateWorktreeResult, Repo } from '../../shared/types' +import type { + CreateWorktreeResult, + Repo, + StatsSummary, + WorktreeStartupLaunch +} from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import type { RuntimeGraphStatus, @@ -32,6 +37,7 @@ import type { RuntimeTerminalWait, RuntimeTerminalWaitCondition, RuntimeWorktreePsSummary, + RuntimeWorktreeStatus, RuntimeTerminalShow, RuntimeTerminalSummary, RuntimeSyncedLeaf, @@ -89,7 +95,14 @@ import { getRecentDriftSubjects } from '../git/repo' import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' -import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { + createSetupRunnerScript, + getEffectiveHooks, + getEffectiveSetupRunPolicy, + hasHooksFile, + runHook, + shouldRunSetupForCreate +} from '../hooks' import { REPO_COLORS } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import type { Store } from '../persistence' @@ -107,6 +120,7 @@ import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import type { IPtyProvider } from '../providers/types' @@ -119,6 +133,8 @@ type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] + getGitHubCache: Store['getGitHubCache'] + getWorkspaceSession?: Store['getWorkspaceSession'] getSettings(): { workspaceDir: string nestWorkspaces: boolean @@ -142,16 +158,42 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { lastAgentStatus: AgentStatus | null } +type RuntimePtyWorktreeRecord = { + ptyId: string + worktreeId: string + connected: boolean + lastOutputAt: number | null + tailBuffer: string[] + tailPartialLine: string + tailTruncated: boolean + tailLinesTotal: number + preview: string +} + +type RuntimeHeadlessTerminal = { + emulator: HeadlessEmulator + writeChain: Promise +} + type RuntimePtyController = { write(ptyId: string, data: string): boolean kill(ptyId: string): boolean getForegroundProcess(ptyId: string): Promise + resize?(ptyId: string, cols: number, rows: number): boolean + listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + serializeBuffer?(ptyId: string): Promise<{ data: string; cols: number; rows: number } | null> + getSize?(ptyId: string): { cols: number; rows: number } | null } type RuntimeNotifier = { worktreesChanged(repoId: string): void reposChanged(): void - activateWorktree(repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup']): void + activateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch + ): void createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void splitTerminal( tabId: string, @@ -161,6 +203,13 @@ type RuntimeNotifier = { renameTerminal(tabId: string, title: string | null): void focusTerminal(tabId: string, worktreeId: string): void closeTerminal(tabId: string, paneRuntimeId?: number): void + sleepWorktree(worktreeId: string): void + terminalFitOverrideChanged( + ptyId: string, + mode: 'mobile-fit' | 'desktop-fit', + cols: number, + rows: number + ): void } type TerminalHandleRecord = { @@ -222,6 +271,13 @@ type ResolvedWorktreeCache = { worktrees: ResolvedWorktree[] } +export type MobileNotificationEvent = { + source: 'agent-task-complete' | 'terminal-bell' | 'test' + title: string + body: string + worktreeId?: string +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -244,6 +300,97 @@ export class OrcaRuntimeService { private agentDetector: AgentDetector | null = null private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() + // Why: mobile clients subscribe to terminal output via terminal.subscribe. + // These listeners fire on every onPtyData call, enabling real-time streaming + // without polling. Keyed by ptyId for O(1) lookup per data event. + private dataListeners = new Map void>>() + // Why: mobile clients need to know when the desktop restores a terminal + // from mobile-fit so they can update their UI. These listeners are + // invoked from resizeForClient and onClientDisconnected/onPtyExit. + private fitOverrideListeners = new Map< + string, + Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> + >() + private subscriptionCleanups = new Map void>() + // Why: mobile clients subscribe to desktop notifications via + // notifications.subscribe. This set enables fan-out — each connected + // mobile client gets its own listener, and dispatchMobileNotification + // iterates them all. Listeners are cleaned up via subscriptionCleanups. + private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() + private ptysById = new Map() + private headlessTerminals = new Map() + // Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because + // handles can be reissued while the PTY identity is stable. In-memory only — + // a stale phone override should not survive an app restart. + private terminalFitOverrides = new Map< + string, + { + mode: 'mobile-fit' + cols: number + rows: number + previousCols: number | null + previousRows: number | null + updatedAt: number + clientId: string + } + >() + + // Why: server-authoritative display mode per terminal. 'auto' (default) means + // phone-fit when mobile subscribes, desktop otherwise. 'phone'/'desktop' lock + // the mode regardless of subscriber state. In-memory only — modes reset on restart. + private mobileDisplayModes = new Map() + + // Why: tracks active mobile subscriber per PTY so the runtime can restore + // desktop dimensions on unsubscribe and prevent orphaned overrides during + // rapid tab switches. Keyed by ptyId (single mobile client per terminal). + private mobileSubscribers = new Map< + string, + { + clientId: string + viewport: { cols: number; rows: number } | null + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + } + >() + + // Why: tracks the last PTY size set by the desktop renderer (via pty:resize + // IPC). Unlike ptySizes (which is overwritten by server-side phone-fit + // resizes), this map preserves the actual pane geometry. Used as the + // preferred source for previousCols so desktop restore uses the correct + // split-pane width instead of a stale full-width value. + private lastRendererSizes = new Map() + + // Why: when a desktop-fit override change fires, the desktop renderer's + // re-render cascade (triggered by setOverrideTick) runs safeFit on ALL + // panes — not just the affected one. Background tab panes get measured at + // full-width (214) instead of their correct split width (105). The stale + // pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes. + // This global window suppresses ALL pty:resize for 200ms after any + // desktop-fit notification. The server has already set the correct PTY + // size via ptyController.resize(), so desktop renderer resizes during + // this window are redundant (for the restored pane) or wrong (collateral). + private resizeSuppressedUntil = 0 + + // Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab + // switches don't cause unnecessary resize thrashing. Keyed by clientId + // Why: keyed by ptyId so each PTY gets its own independent restore timer. + // The old clientId-keyed design lost timers when two PTYs were unsubscribed + // back-to-back (only the last timer survived). + private pendingRestoreTimers = new Map< + string, + { timer: ReturnType; clientId: string } + >() + + // Why: inline resize events replace the unsubscribe→resubscribe pattern. + // Listeners are notified when mode changes or desktop restores, allowing + // the subscribe stream to emit a 'resized' event with fresh scrollback. + private resizeListeners = new Map< + string, + Set<(event: { cols: number; rows: number; displayMode: string; reason: string }) => void> + >() + + private stats: StatsCollector | null = null // Why (§3.3 + §7.1): the renderer-create path and coordinator // `probeWorktreeDrift` share this cache so a create that already fetched // `origin` within the last 30s does not re-fetch during dispatch, and @@ -270,6 +417,7 @@ export class OrcaRuntimeService { ) { this.store = store if (stats) { + this.stats = stats this.agentDetector = new AgentDetector(stats) } // Why: the daemon adapter is installed via `setLocalPtyProvider()` during @@ -285,6 +433,10 @@ export class OrcaRuntimeService { return this.getLocalProviderFn ? this.getLocalProviderFn() : null } + getStatsSummary(): StatsSummary | null { + return this.stats?.getSummary() ?? null + } + // Why: lazy initialization — the DB path depends on Electron's userData // which may not be finalized until after app.ready. Also allows unit tests // to inject an in-memory DB without touching the filesystem. @@ -387,6 +539,14 @@ export class OrcaRuntimeService { lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null }) + if (leaf.ptyId) { + this.recordPtyWorktree(leaf.ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, + preview: existing?.ptyId === leaf.ptyId ? existing.preview : '' + }) + } + if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { this.invalidateLeafHandle(leafKey) } @@ -465,6 +625,10 @@ export class OrcaRuntimeService { } onPtySpawned(ptyId: string): void { + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.connected = true + } for (const leaf of this.leaves.values()) { if (leaf.ptyId === ptyId) { leaf.connected = true @@ -474,10 +638,15 @@ export class OrcaRuntimeService { } } + registerPty(ptyId: string, worktreeId: string): void { + this.recordPtyWorktree(ptyId, worktreeId, { connected: true }) + } + onPtyData(ptyId: string, data: string, at: number): void { // Agent detection runs on raw data before leaf processing, since the // tail buffer logic normalizes away the OSC sequences we need. this.agentDetector?.onData(ptyId, data, at) + this.trackHeadlessTerminalData(ptyId, data) // Why: extract OSC title from raw PTY data before tail-buffer processing // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) @@ -486,10 +655,27 @@ export class OrcaRuntimeService { const oscTitle = extractLastOscTitle(data) const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.connected = true + pty.lastOutputAt = at + const nextTail = appendToTailBuffer(pty.tailBuffer, pty.tailPartialLine, data) + pty.tailBuffer = nextTail.lines + pty.tailPartialLine = nextTail.partialLine + pty.tailTruncated = pty.tailTruncated || nextTail.truncated + pty.tailLinesTotal += nextTail.newCompleteLines + pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) + } + for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { continue } + this.recordPtyWorktree(ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: pty?.lastOutputAt ?? at, + preview: pty?.preview ?? leaf.preview + }) leaf.connected = true leaf.writable = this.graphStatus === 'ready' leaf.lastOutputAt = at @@ -515,10 +701,394 @@ export class OrcaRuntimeService { } } } + + const listeners = this.dataListeners.get(ptyId) + if (listeners) { + for (const listener of listeners) { + listener(data) + } + } + } + + subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void { + let listeners = this.dataListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.dataListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.dataListeners.delete(ptyId) + } + } + } + + subscribeToFitOverrideChanges( + ptyId: string, + listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void + ): () => void { + let listeners = this.fitOverrideListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.fitOverrideListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.fitOverrideListeners.delete(ptyId) + } + } + } + + private notifyFitOverrideListeners( + ptyId: string, + mode: 'mobile-fit' | 'desktop-fit', + cols: number, + rows: number + ): void { + const listeners = this.fitOverrideListeners.get(ptyId) + if (!listeners) { + return + } + for (const listener of listeners) { + listener({ mode, cols, rows }) + } + } + + serializeTerminalBuffer( + ptyId: string + ): Promise<{ data: string; cols: number; rows: number } | null> { + return this.serializeTerminalBufferFromAvailableState(ptyId) + } + + getTerminalSize(ptyId: string): { cols: number; rows: number } | null { + return this.ptyController?.getSize?.(ptyId) ?? null + } + + private trackHeadlessTerminalData(ptyId: string, data: string): void { + const state = this.getOrCreateHeadlessTerminal(ptyId) + state.writeChain = state.writeChain + .then(() => state.emulator.write(data)) + .catch(() => { + // Best-effort state tracking; live streaming must continue even if + // xterm rejects a malformed or raced write during shutdown. + }) + } + + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { + const existing = this.headlessTerminals.get(ptyId) + if (existing) { + return existing + } + const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + return state + } + + private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { + this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows) + } + + private async serializeTerminalBufferFromAvailableState( + ptyId: string + ): Promise<{ data: string; cols: number; rows: number } | null> { + const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId) + if (headlessSnapshot) { + return headlessSnapshot + } + + let rendererSnapshot: { data: string; cols: number; rows: number } | null = null + try { + rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId) ?? + Promise.resolve(null)) + } catch { + // Why: mobile scrollback should not depend on a mounted renderer pane. + // If renderer serialization races reload/unmount, the runtime snapshot + // below can still preserve colored terminal state. + } + if (rendererSnapshot && rendererSnapshot.data.length > 0) { + return rendererSnapshot + } + return rendererSnapshot + } + + private async serializeHeadlessTerminalBuffer( + ptyId: string + ): Promise<{ data: string; cols: number; rows: number } | null> { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return null + } + await state.writeChain + // Why: terminal.subscribe needs the current visible screen, not the full + // launch history. Full scrollback plus a normal-buffer TUI can replay the + // shell prompt and active TUI frame together, which looks duplicated. + const snapshot = state.emulator.getSnapshot({ scrollbackRows: 0 }) + const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi + return data.length > 0 ? { data, cols: snapshot.cols, rows: snapshot.rows } : null + } + + private disposeHeadlessTerminal(ptyId: string): void { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + this.headlessTerminals.delete(ptyId) + state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) + } + + resolveLeafForHandle(handle: string): { ptyId: string | null } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (record.tabId.startsWith('pty:')) { + return { ptyId: record.ptyId } + } + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf) { + return null + } + return { ptyId: leaf.ptyId } + } + + registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void { + // Why: mobile clients reconnect frequently (phone lock, network switch). + // The RPC client re-sends terminal.subscribe on reconnect, creating a new + // handler before the old one is cleaned up. Without this, the old data + // listener leaks in dataListeners and duplicates every PTY data event. + const existing = this.subscriptionCleanups.get(subscriptionId) + if (existing) { + existing() + } + this.subscriptionCleanups.set(subscriptionId, cleanup) + } + + cleanupSubscription(subscriptionId: string): void { + const cleanup = this.subscriptionCleanups.get(subscriptionId) + if (cleanup) { + this.subscriptionCleanups.delete(subscriptionId) + cleanup() + } + } + + // Why: mobile clients subscribe via notifications.subscribe streaming RPC. + // Each subscriber gets its own listener. Returns an unsubscribe function + // that the subscription cleanup mechanism calls on disconnect. + onNotificationDispatched(listener: (event: MobileNotificationEvent) => void): () => void { + this.notificationListeners.add(listener) + return () => { + this.notificationListeners.delete(listener) + } + } + + getMobileNotificationListenerCount(): number { + return this.notificationListeners.size + } + + dispatchMobileNotification(event: MobileNotificationEvent): void { + for (const listener of this.notificationListeners) { + listener(event) + } + } + + // ─── Mobile Fit Override Management ───────────────────────── + + resizeForClient( + ptyId: string, + mode: 'mobile-fit' | 'restore', + clientId: string, + cols?: number, + rows?: number + ): { + cols: number + rows: number + previousCols: number | null + previousRows: number | null + mode: 'mobile-fit' | 'desktop-fit' + } { + if (mode === 'mobile-fit') { + if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { + throw new Error('invalid_dimensions') + } + const clampedCols = Math.max(20, Math.min(240, Math.round(cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(rows))) + + const currentSize = this.getTerminalSize(ptyId) + const existing = this.terminalFitOverrides.get(ptyId) + // Why: preserve the original desktop size from before any mobile-fit, + // so restore returns to the right dimensions even after multiple re-fits. + const previousCols = existing?.previousCols ?? currentSize?.cols ?? null + const previousRows = existing?.previousRows ?? currentSize?.rows ?? null + + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + updatedAt: Date.now(), + clientId + }) + + const resized = this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) + if (!resized) { + this.terminalFitOverrides.delete(ptyId) + throw new Error('resize_failed') + } + this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) + + console.log( + `[mobile-fit] handleMobileSubscribe notifier=${!!this.notifier} ptyId=${ptyId} mode=mobile-fit cols=${clampedCols} rows=${clampedRows}` + ) + this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) + + return { + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + mode: 'mobile-fit' + } + } + + // restore mode + const override = this.terminalFitOverrides.get(ptyId) + if (!override) { + throw new Error('no_active_override') + } + // Why: only the owning client can restore, preventing one phone from + // undoing another phone's active fit. + if (override.clientId !== clientId) { + throw new Error('not_override_owner') + } + + const { previousCols: prevCols, previousRows: prevRows } = override + this.terminalFitOverrides.delete(ptyId) + + // Why: always resize the PTY back to pre-fit dimensions immediately, + // even for mounted leaves. Relying solely on the renderer chain + // (IPC notification → safeFit → fitAddon.fit → onResize → transport.resize) + // is fragile — any async gap leaves the PTY at phone dims while xterm + // looks correct, causing text to wrap at the wrong column. The renderer + // will still run safeFit and may send a second resize with the exact + // current pane geometry, which is harmless (SIGWINCH is idempotent). + if (prevCols != null && prevRows != null) { + this.ptyController?.resize?.(ptyId, prevCols, prevRows) + this.resizeHeadlessTerminal(ptyId, prevCols, prevRows) + } + + // Why: send the restored dimensions so the renderer can fall back to a + // direct terminal.resize() if fitAddon.fit() silently fails. The renderer + // normally computes desktop dims from the container, but passing them here + // provides a guaranteed fallback to avoid leaving xterm at phone dims. + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) + // Why: mobile clients subscribed to this terminal need to know the desktop + // restored, so they can update their UI (clear fitted state, resubscribe). + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) + + return { + cols: prevCols ?? 0, + rows: prevRows ?? 0, + previousCols: null, + previousRows: null, + mode: 'desktop-fit' + } + } + + getTerminalFitOverride(ptyId: string) { + return this.terminalFitOverrides.get(ptyId) ?? null + } + + getAllTerminalFitOverrides(): Map { + const result = new Map() + for (const [ptyId, override] of this.terminalFitOverrides) { + result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) + } + return result + } + + onClientDisconnected(clientId: string): void { + // Cancel all pending restore timers for this client — the client is gone, + // so the debounce is meaningless and could fire against a stale PTY state. + for (const [ptyId, entry] of this.pendingRestoreTimers) { + if (entry.clientId === clientId) { + clearTimeout(entry.timer) + this.pendingRestoreTimers.delete(ptyId) + } + } + + // Immediately restore PTYs that this client had phone-fitted (no debounce — + // client is gone, no point waiting for a re-subscribe that won't come). + for (const [ptyId, subscriber] of this.mobileSubscribers) { + if (subscriber.clientId !== clientId) { + continue + } + + this.mobileSubscribers.delete(ptyId) + + if (subscriber.wasResizedToPhone) { + const { previousCols, previousRows } = subscriber + if (previousCols != null && previousRows != null) { + this.ptyController?.resize?.(ptyId, previousCols, previousRows) + this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) + } + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged( + ptyId, + 'desktop-fit', + previousCols ?? 0, + previousRows ?? 0 + ) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + } + } + + // Legacy cleanup for any terminalFitOverrides not covered by mobileSubscribers + for (const [ptyId, override] of this.terminalFitOverrides) { + if (override.clientId !== clientId) { + continue + } + try { + this.resizeForClient(ptyId, 'restore', clientId) + } catch { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + } } onPtyExit(ptyId: string, exitCode: number): void { + // Clean up new mobile state for this PTY + this.mobileSubscribers.delete(ptyId) + this.mobileDisplayModes.delete(ptyId) + this.resizeListeners.delete(ptyId) + this.lastRendererSizes.delete(ptyId) + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + if (this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + this.disposeHeadlessTerminal(ptyId) this.agentDetector?.onExit(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.connected = false + } for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { @@ -533,6 +1103,277 @@ export class OrcaRuntimeService { } } + // ─── Server-Authoritative Mobile Display Mode ───────────────────── + + setMobileDisplayMode(ptyId: string, mode: 'auto' | 'phone' | 'desktop'): void { + if (mode === 'auto') { + this.mobileDisplayModes.delete(ptyId) + } else { + this.mobileDisplayModes.set(ptyId, mode) + } + } + + getMobileDisplayMode(ptyId: string): 'auto' | 'phone' | 'desktop' { + return this.mobileDisplayModes.get(ptyId) ?? 'auto' + } + + isMobileSubscriberActive(ptyId: string): boolean { + return this.mobileSubscribers.has(ptyId) + } + + // Why: server-side auto-fit on mobile subscribe. The runtime is the single + // source of truth — the mobile client just passes its viewport and the runtime + // decides whether to resize. This eliminates the measure→RPC→resubscribe + // pipeline that caused race conditions. + handleMobileSubscribe( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } + ): boolean { + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (!viewport) { + return false + } + + // Why: only cancel the restore timer for THIS ptyId (re-subscribe case). + // Other terminals' timers must fire so their banners clear on the desktop. + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore && pendingRestore.clientId === clientId) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + // Why: prefer lastRendererSizes (the actual pane geometry reported by the + // desktop renderer's safeFit via pty:resize IPC) over getTerminalSize (the + // server-side PTY size, which may be stale — e.g. 214 full-width when the + // pane is actually in a split at ~105). Fall back to existing subscriber's + // previousCols (re-subscribe case) then currentSize (first subscribe). + const existing = this.mobileSubscribers.get(ptyId) + const currentSize = this.getTerminalSize(ptyId) + const rendererSize = this.lastRendererSizes.get(ptyId) + const previousCols = existing?.previousCols ?? rendererSize?.cols ?? currentSize?.cols ?? null + const previousRows = existing?.previousRows ?? rendererSize?.rows ?? currentSize?.rows ?? null + + // Why: always register the subscriber so applyMobileDisplayMode can find + // the viewport when the user later toggles from desktop to auto/phone. + // Without this, toggling to auto after subscribing in desktop mode sees + // hasSubscriber=false and can't perform the phone resize. + if (mode === 'desktop') { + // Why: set previousCols/Rows to null so we don't capture a stale PTY + // size that may not match the actual pane geometry (e.g. 214 when the + // pane is in a split at 105). When the user later toggles to auto/phone, + // handleMobileSubscribe will capture currentSize at that point, which + // will be correct because safeFit has had time to adjust the PTY. + this.mobileSubscribers.set(ptyId, { + clientId, + viewport, + wasResizedToPhone: false, + previousCols: null, + previousRows: null + }) + return false + } + + this.mobileSubscribers.set(ptyId, { + clientId, + viewport, + wasResizedToPhone: true, + previousCols, + previousRows + }) + + const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + + // Why: skip the PTY resize if already at the target dims. Re-subscribing + // to a terminal that was left at phone dims (no restore on tab switch) + // should not trigger another SIGWINCH → shell prompt redraw. + const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows + if (!alreadyAtTarget) { + this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) + this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) + } + this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) + + // Update terminalFitOverrides for desktop safeFit compatibility + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + updatedAt: Date.now(), + clientId + }) + + return true + } + + // Why: delayed restore prevents resize thrashing during rapid tab switches. + // The 300ms debounce means only the final tab triggers a PTY restore; + // intermediate terminals keep their current dims harmlessly. + handleMobileUnsubscribe(ptyId: string, clientId: string): void { + const subscriber = this.mobileSubscribers.get(ptyId) + if (!subscriber || subscriber.clientId !== clientId) { + return + } + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + this.mobileSubscribers.delete(ptyId) + + if (mode === 'auto' && subscriber.wasResizedToPhone) { + const existing = this.pendingRestoreTimers.get(ptyId) + if (existing) { + clearTimeout(existing.timer) + } + + const { previousCols, previousRows } = subscriber + const timer = setTimeout(() => { + this.pendingRestoreTimers.delete(ptyId) + if (this.mobileSubscribers.has(ptyId)) { + return + } + if (previousCols != null && previousRows != null) { + this.ptyController?.resize?.(ptyId, previousCols, previousRows) + this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) + } + this.lastRendererSizes.delete(ptyId) + this.suppressResizesForMs(500) + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged( + ptyId, + 'desktop-fit', + previousCols ?? 0, + previousRows ?? 0 + ) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + }, 300) + + this.pendingRestoreTimers.set(ptyId, { timer, clientId }) + } + // 'phone' mode: keep phone dims (no restore needed) + // 'desktop' mode: was never resized, nothing to restore + } + + // Why: called when mode changes via terminal.setDisplayMode. Applies the + // mode change immediately if there's an active subscriber, and emits a + // 'resized' event so the mobile client can reinitialize xterm inline. + applyMobileDisplayMode(ptyId: string): void { + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + const subscriber = this.mobileSubscribers.get(ptyId) + + if (mode === 'desktop') { + if (subscriber?.wasResizedToPhone) { + const { previousCols, previousRows } = subscriber + if (previousCols != null && previousRows != null) { + this.ptyController?.resize?.(ptyId, previousCols, previousRows) + this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) + } + subscriber.wasResizedToPhone = false + // Why: clear stale renderer size so the next mobile subscribe falls + // through to currentSize (which is correct after the server restore). + // Without this, a polluted 214 from a prior collateral safeFit cascade + // persists in lastRendererSizes and gets used as previousCols. + this.lastRendererSizes.delete(ptyId) + // Why: 500ms not 200ms — the desktop renderer's collateral safeFit + // cascade (IPC → React re-render → rAF → DOM measure → IPC back) + // takes ~360ms to propagate to background-tab terminals. + this.suppressResizesForMs(500) + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged( + ptyId, + 'desktop-fit', + previousCols ?? 0, + previousRows ?? 0 + ) + } + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'desktop', + reason: 'mode-change' + }) + } else if (mode === 'phone' || mode === 'auto') { + if (subscriber && !subscriber.wasResizedToPhone) { + const viewport = subscriber.viewport + if (viewport) { + this.handleMobileSubscribe(ptyId, subscriber.clientId, viewport) + } + } + // Why: always emit the mode change even when no resize occurred (e.g. + // subscriber missing, wasResizedToPhone already true, or no viewport). + // Without this the mobile client never learns the mode changed and its + // toggle button gets stuck showing the old state. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: mode, + reason: 'mode-change' + }) + } + } + + // Why: called from the pty:resize IPC handler whenever the desktop renderer + // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode + // restore). Stores the renderer-reported size so handleMobileSubscribe can use + // the actual pane geometry instead of a stale PTY size for previousCols. + onExternalPtyResize(ptyId: string, cols: number, rows: number): void { + this.lastRendererSizes.set(ptyId, { cols, rows }) + + const subscriber = this.mobileSubscribers.get(ptyId) + if (!subscriber) { + return + } + if (!subscriber.wasResizedToPhone) { + subscriber.previousCols = cols + subscriber.previousRows = rows + } + } + + // Why: the pty:resize IPC handler calls this to check if the global + // suppress window is active. During this window, all desktop renderer + // pty:resize events are ignored to prevent collateral safeFit corruption. + isResizeSuppressed(): boolean { + return Date.now() < this.resizeSuppressedUntil + } + + private suppressResizesForMs(ms: number): void { + this.resizeSuppressedUntil = Date.now() + ms + } + + subscribeToTerminalResize( + ptyId: string, + listener: (event: { cols: number; rows: number; displayMode: string; reason: string }) => void + ): () => void { + let listeners = this.resizeListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.resizeListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.resizeListeners.delete(ptyId) + } + } + } + + private notifyTerminalResize( + ptyId: string, + event: { cols: number; rows: number; displayMode: string; reason: string } + ): void { + const listeners = this.resizeListeners.get(ptyId) + if (!listeners) { + return + } + for (const listener of listeners) { + listener(event) + } + } + // Why: Section 7.2 — the runtime detects agent exit directly and updates // dispatch contexts immediately, rather than waiting for the coordinator's // next poll cycle. This catches agent crashes and unexpected exits within @@ -589,13 +1430,44 @@ export class OrcaRuntimeService { const worktreesById = await this.getResolvedWorktreeMap() this.assertStableReadyGraph(graphEpoch) + const resolvedWorktrees = [...worktreesById.values()] + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + + const livePtyWorktreeIds = new Set() + for (const pty of this.ptysById.values()) { + if (pty.connected) { + livePtyWorktreeIds.add(pty.worktreeId) + } + } + const terminals: RuntimeTerminalSummary[] = [] + const ptyIdsFromLeaves = new Set() for (const leaf of this.leaves.values()) { if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { continue } + if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) { + continue + } + if (leaf.ptyId) { + ptyIdsFromLeaves.add(leaf.ptyId) + } terminals.push(this.buildTerminalSummary(leaf, worktreesById)) } + + // Why: worktree.ps can classify active worktrees from PTY records even when + // the renderer graph is missing a leaf. terminal.list needs the same fallback + // so mobile does not show a false "No terminals" create flow. + for (const pty of this.ptysById.values()) { + if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) { + continue + } + if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { + continue + } + terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) + } + return { terminals: terminals.slice(0, limit), totalCount: terminals.length, @@ -642,6 +1514,15 @@ export class OrcaRuntimeService { const graphEpoch = this.captureReadyGraphEpoch() const worktreesById = await this.getResolvedWorktreeMap() this.assertStableReadyGraph(graphEpoch) + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return { + ...this.buildPtyTerminalSummary(pty.pty, worktreesById), + paneRuntimeId: -1, + ptyId: pty.pty.ptyId, + rendererGraphEpoch: this.rendererGraphEpoch + } + } const { leaf } = this.getLiveLeafForHandle(handle) const summary = this.buildTerminalSummary(leaf, worktreesById) return { @@ -653,6 +1534,11 @@ export class OrcaRuntimeService { } async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return this.readPtyTerminal(handle, pty.pty, opts) + } + const { leaf } = this.getLiveLeafForHandle(handle) const allLines = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine) @@ -700,6 +1586,26 @@ export class OrcaRuntimeService { interrupt?: boolean } ): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_not_writable') + } + const payload = buildSendPayload(action) + if (payload === null) { + throw new Error('invalid_terminal_send') + } + const wrote = this.ptyController?.write(pty.pty.ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + return { + handle, + accepted: true, + bytesWritten: Buffer.byteLength(payload, 'utf8') + } + } + const { leaf } = this.getLiveLeafForHandle(handle) if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') @@ -839,36 +1745,71 @@ export class OrcaRuntimeService { throw new Error('invalid_limit') } const resolvedWorktrees = await this.listResolvedWorktrees() + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const summaries = new Map() + // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), + // matching how the renderer's fetchPRForBranch stores entries. We look up cached + // PR info so mobile clients can group worktrees by PR state without making + // expensive `gh` CLI calls. Falls back to meta.linkedPR if no cache entry exists. + const ghCache = this.store?.getGitHubCache?.() for (const worktree of resolvedWorktrees) { const meta = this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id] + const repo = repoById.get(worktree.repoId) + let linkedPR: { number: number; state: string } | null = null + const branch = worktree.branch.replace(/^refs\/heads\//, '') + if (repo?.path && branch && ghCache) { + const prCacheKey = `${repo.path}::${branch}` + const cached = ghCache.pr[prCacheKey] + if (cached?.data) { + linkedPR = { number: cached.data.number, state: cached.data.state } + } + } + if (!linkedPR && meta?.linkedPR != null) { + linkedPR = { number: meta.linkedPR, state: 'unknown' } + } summaries.set(worktree.id, { worktreeId: worktree.id, repoId: worktree.repoId, - repo: repoById.get(worktree.repoId)?.displayName ?? worktree.repoId, + repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, + displayName: worktree.displayName, linkedIssue: worktree.linkedIssue, + linkedPR, + isPinned: meta?.isPinned ?? false, unread: meta?.isUnread ?? false, liveTerminalCount: 0, hasAttachedPty: false, lastOutputAt: null, - preview: '' + preview: '', + status: 'inactive' }) } + const countedPtyIds = new Set() for (const leaf of this.leaves.values()) { - const summary = summaries.get(leaf.worktreeId) + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + resolvedWorktrees, + leaf.worktreeId + ) if (!summary) { continue } + if (leaf.ptyId) { + countedPtyIds.add(leaf.ptyId) + } const previousLastOutputAt = summary.lastOutputAt summary.liveTerminalCount += 1 summary.hasAttachedPty = summary.hasAttachedPty || leaf.connected summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) + summary.status = mergeWorktreeStatus( + summary.status, + getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) + ) if ( leaf.preview && (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -877,6 +1818,53 @@ export class OrcaRuntimeService { } } + for (const pty of this.ptysById.values()) { + if (!pty.connected || countedPtyIds.has(pty.ptyId)) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + resolvedWorktrees, + pty.worktreeId + ) + if (!summary) { + continue + } + const previousLastOutputAt = summary.lastOutputAt + summary.liveTerminalCount += 1 + summary.hasAttachedPty = true + summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) + summary.status = mergeWorktreeStatus(summary.status, 'active') + if ( + pty.preview && + (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) + ) { + summary.preview = pty.preview + } + } + + const session = this.store?.getWorkspaceSession?.() + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + if (tabs.length === 0) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId(summaries, resolvedWorktrees, worktreeId) + if (!summary) { + continue + } + // Why: desktop can show terminal tabs that are not mounted as renderer + // leaves and are not currently visible in the PTY provider list. Mobile + // still needs those worktrees to show as terminal-bearing entries. + summary.liveTerminalCount = Math.max(summary.liveTerminalCount, tabs.length) + summary.hasAttachedPty = summary.hasAttachedPty || tabs.some((tab) => tab.ptyId !== null) + for (const tab of tabs) { + summary.status = mergeWorktreeStatus( + summary.status, + getSavedTabWorktreeStatus(tab.title, tab.ptyId !== null) + ) + } + } + const sorted = [...summaries.values()].sort(compareWorktreePs) return { worktrees: sorted.slice(0, limit), @@ -959,6 +1947,19 @@ export class OrcaRuntimeService { } } + async getRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + const hasFile = hasHooksFile(repo.path) + const hooks = getEffectiveHooks(repo) + const setupRunPolicy = getEffectiveSetupRunPolicy(repo) + return { + hasHooksFile: hasFile, + hooks, + setupRunPolicy, + source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null + } + } + async listManagedWorktrees( repoSelector?: string, limit = DEFAULT_WORKTREE_LIST_LIMIT @@ -980,6 +1981,33 @@ export class OrcaRuntimeService { return await this.resolveWorktreeSelector(worktreeSelector) } + async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + // Why: sleep is renderer-initiated on desktop (it tears down tab state + // before killing PTYs). The notifier tells the renderer to run its own + // sleep flow so all cleanup happens in the correct order. + this.notifier?.sleepWorktree(worktree.id) + return { worktreeId: worktree.id } + } + + async activateManagedWorktree(worktreeSelector: string): Promise<{ + repoId: string + worktreeId: string + activated: boolean + }> { + this.assertGraphReady() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('repo_not_found') + } + + // Why: inactive worktree terminal panes are renderer-owned and may not have + // live PTYs until the desktop activates the worktree and mounts them. + this.notifier?.activateWorktree(repo.id, worktree.id) + return { repoId: repo.id, worktreeId: worktree.id, activated: true } + } + async createManagedWorktree(args: { repoSelector: string name: string @@ -987,6 +2015,8 @@ export class OrcaRuntimeService { linkedIssue?: number | null comment?: string runHooks?: boolean + setupDecision?: 'run' | 'skip' | 'inherit' + startup?: WorktreeStartupLaunch }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -1085,7 +2115,13 @@ export class OrcaRuntimeService { // against. Trust is granted by the direct CLI invocation (`--run-hooks`), // so loading the setup hook from the created worktree is intentional here. const hooks = getEffectiveHooks(repo, worktreePath) - if (hooks?.scripts.setup && args.runHooks === true) { + // Why: setupDecision lets mobile/CLI callers control whether the setup + // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) + // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps + // to 'run' for backwards compatibility with the desktop create flow. + const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) + if (shouldRunSetup && hooks?.scripts.setup) { if (this.authoritativeWindowId !== null) { try { // Why: CLI-created worktrees must use the same runner-script path as the @@ -1117,7 +2153,11 @@ export class OrcaRuntimeService { // renderer-side consequence of activating a worktree. CLI-created // worktrees must trigger that same activation path or they will exist on // disk without becoming the active workspace in the UI. - this.notifier?.activateWorktree(repo.id, worktree.id, setup) + if (args.startup) { + this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup) + } else { + this.notifier?.activateWorktree(repo.id, worktree.id, setup) + } this.invalidateResolvedWorktreeCache() // Why: the filesystem-auth layer maintains a separate cache of registered // worktree roots used by git IPC handlers (branchCompare, diff, status, etc.) @@ -1228,6 +2268,7 @@ export class OrcaRuntimeService { displayName?: string linkedIssue?: number | null comment?: string + isPinned?: boolean } ) { if (!this.store) { @@ -1237,7 +2278,8 @@ export class OrcaRuntimeService { const meta = this.store.setWorktreeMeta(worktree.id, { ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}), ...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}), - ...(updates.comment !== undefined ? { comment: updates.comment } : {}) + ...(updates.comment !== undefined ? { comment: updates.comment } : {}), + ...(updates.isPinned !== undefined ? { isPinned: updates.isPinned } : {}) }) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. @@ -1430,6 +2472,54 @@ export class OrcaRuntimeService { }) } + // Why: mobile clients may subscribe before the PTY spawns (the left pane + // of a new workspace). Instead of bailing with a bare scrollback+end, + // wait for the PTY to appear so the subscribe can proceed with phone-fit. + waitForLeafPtyId(handle: string, timeoutMs = 10_000): Promise { + const leaf = this.resolveLeafForHandle(handle) + if (leaf?.ptyId) { + return Promise.resolve(leaf.ptyId) + } + + // Why: when the ptyId changes from null to a real value, the old handle + // is invalidated (deleted from this.handles). Capture the tabId+leafId + // now so we can look up the leaf directly even after handle invalidation. + const record = this.handles.get(handle) + const savedTabId = record?.tabId ?? null + const savedLeafId = record?.leafId ?? null + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for PTY to spawn')) + }, timeoutMs) + + const check = (): void => { + // Try the handle first (works if handle wasn't invalidated yet) + let ptyId = this.resolveLeafForHandle(handle)?.ptyId + // Why: when ptyId transitions null→real, issueHandle invalidates the + // old handle. Fall back to direct leaf lookup by the saved coordinates. + if (!ptyId && savedTabId && savedLeafId) { + const directLeaf = this.leaves.get(this.getLeafKey(savedTabId, savedLeafId)) + ptyId = directLeaf?.ptyId ?? null + } + if (ptyId) { + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(ptyId) + } + } + this.graphSyncCallbacks.push(check) + check() + }) + } + // Why: a leaf appears in the graph before its PTY spawns. If we issue a // handle while ptyId is null, the next graph sync after PTY spawn will // change ptyId and invalidate the handle. Wait for a connected PTY so @@ -1455,6 +2545,10 @@ export class OrcaRuntimeService { async focusTerminal(handle: string): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return { handle, tabId: pty.record.tabId, worktreeId: pty.pty.worktreeId } + } const { leaf } = this.getLiveLeafForHandle(handle) this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId) return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } @@ -1588,6 +2682,10 @@ export class OrcaRuntimeService { this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() + // Why: handleByPtyId maps ptyId → pre-allocated CLI handle (ORCA_TERMINAL_HANDLE). + // These must survive renderer reloads so CLI agents can keep controlling the + // same terminal across graph rebuilds — adoptPreAllocatedHandle re-links + // them when the new graph arrives. this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() } @@ -1616,6 +2714,8 @@ export class OrcaRuntimeService { this.leaves.clear() this.handles.clear() this.handleByLeafKey.clear() + // Why: same as markRendererReloading — pre-allocated CLI handles must + // survive graph unavailability so they can be re-adopted on reconnect. this.rejectAllWaiters('terminal_handle_stale') } @@ -1756,6 +2856,108 @@ export class OrcaRuntimeService { this.resolvedWorktreeCache = null } + private recordPtyWorktree( + ptyId: string, + worktreeId: string, + state: Partial> = {} + ): RuntimePtyWorktreeRecord { + let pty = this.ptysById.get(ptyId) + if (!pty) { + pty = { + ptyId, + worktreeId, + connected: state.connected ?? true, + lastOutputAt: state.lastOutputAt ?? null, + tailBuffer: [], + tailPartialLine: '', + tailTruncated: false, + tailLinesTotal: 0, + preview: state.preview ?? '' + } + this.ptysById.set(ptyId, pty) + return pty + } + + pty.worktreeId = worktreeId + if (state.connected !== undefined) { + pty.connected = state.connected + } + if (state.lastOutputAt !== undefined) { + pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt) + } + if (state.preview !== undefined && state.preview.length > 0) { + pty.preview = state.preview + } + return pty + } + + private getOrCreatePtyWorktreeRecord(ptyId: string): RuntimePtyWorktreeRecord | null { + const existing = this.ptysById.get(ptyId) + if (existing) { + return existing + } + const inferredWorktreeId = inferWorktreeIdFromPtyId(ptyId) + if (!inferredWorktreeId) { + return null + } + // Why: daemon-backed PTY session IDs are prefixed with the worktree ID so + // mobile summaries survive renderer graph gaps and Electron reloads. + return this.recordPtyWorktree(ptyId, inferredWorktreeId) + } + + private async refreshPtyWorktreeRecordsFromController( + resolvedWorktrees: ResolvedWorktree[] + ): Promise { + if (!this.ptyController?.listProcesses) { + return + } + const sessions = await this.ptyController.listProcesses().catch(() => []) + const livePtyIds = new Set(sessions.map((session) => session.id)) + for (const session of sessions) { + const worktreeId = + inferWorktreeIdFromPtyId(session.id) ?? + findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) + if (worktreeId) { + this.recordPtyWorktree(session.id, worktreeId, { connected: true }) + } + } + for (const pty of this.ptysById.values()) { + if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { + pty.connected = false + } + } + } + + private leafExistsForPty(ptyId: string): boolean { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + return true + } + } + return false + } + + private getSummaryForRuntimeWorktreeId( + summaries: Map, + resolvedWorktrees: ResolvedWorktree[], + runtimeWorktreeId: string + ): RuntimeWorktreePsSummary | null { + const exact = summaries.get(runtimeWorktreeId) + if (exact) { + return exact + } + const parsed = parseRuntimeWorktreeId(runtimeWorktreeId) + if (!parsed) { + return null + } + const resolved = resolvedWorktrees.find( + (worktree) => + worktree.repoId === parsed.repoId && + areWorktreePathsEqual(worktree.path, parsed.worktreePath) + ) + return resolved ? (summaries.get(resolved.id) ?? null) : null + } + private buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map @@ -1915,6 +3117,27 @@ export class OrcaRuntimeService { } } + private buildPtyTerminalSummary( + pty: RuntimePtyWorktreeRecord, + worktreesById: Map + ): RuntimeTerminalSummary { + const worktree = worktreesById.get(pty.worktreeId) + + return { + handle: this.issuePtyHandle(pty), + worktreeId: pty.worktreeId, + worktreePath: worktree?.path ?? '', + branch: worktree?.branch ?? '', + tabId: `pty:${pty.ptyId}`, + leafId: `pty:${pty.ptyId}`, + title: null, + connected: pty.connected, + writable: pty.connected, + lastOutputAt: pty.lastOutputAt, + preview: pty.preview + } + } + private getLiveLeafForHandle(handle: string): { record: TerminalHandleRecord leaf: RuntimeLeafRecord @@ -1935,6 +3158,53 @@ export class OrcaRuntimeService { return { record, leaf } } + private getLivePtyForHandle(handle: string): { + record: TerminalHandleRecord + pty: RuntimePtyWorktreeRecord + } | null { + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) { + return null + } + if (!record.ptyId) { + return null + } + const pty = this.ptysById.get(record.ptyId) + if (!pty || pty.ptyId !== record.ptyId) { + return null + } + return { record, pty } + } + + private readPtyTerminal( + handle: string, + pty: RuntimePtyWorktreeRecord, + opts: { cursor?: number } = {} + ): RuntimeTerminalRead { + const allLines = buildTailLines(pty.tailBuffer, pty.tailPartialLine) + + let tail: string[] + let truncated: boolean + + if (typeof opts.cursor === 'number' && opts.cursor >= 0) { + const bufferStart = pty.tailLinesTotal - pty.tailBuffer.length + const sliceFrom = Math.max(0, opts.cursor - bufferStart) + tail = pty.tailBuffer.slice(sliceFrom) + truncated = opts.cursor < bufferStart + } else { + tail = allLines + truncated = pty.tailTruncated + } + + return { + handle, + status: pty.connected ? 'running' : 'unknown', + tail, + truncated, + nextCursor: String(pty.tailLinesTotal) + } + } + private issueHandle(leaf: RuntimeLeafRecord): string { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existingHandle = this.handleByLeafKey.get(leafKey) @@ -1991,6 +3261,35 @@ export class OrcaRuntimeService { return preAllocated } + private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.handleByPtyId.get(pty.ptyId) + if (existingHandle) { + const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.runtimeId === this.runtimeId && + existingRecord.ptyId === pty.ptyId + ) { + return existingHandle + } + } + + const handle = `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + private refreshWritableFlags(): void { for (const leaf of this.leaves.values()) { leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null @@ -3273,6 +4572,13 @@ const MAX_TAIL_LINES = 120 const MAX_TAIL_CHARS = 4000 const MAX_PREVIEW_LINES = 6 const MAX_PREVIEW_CHARS = 300 +const WORKTREE_STATUS_PRIORITY: Record = { + inactive: 0, + active: 1, + done: 2, + working: 3, + permission: 4 +} const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25 const DEFAULT_TERMINAL_LIST_LIMIT = 200 const DEFAULT_WORKTREE_LIST_LIMIT = 200 @@ -3408,6 +4714,89 @@ function normalizeBranchRef(branch: string): string { return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch } +function inferWorktreeIdFromPtyId(ptyId: string): string | null { + const separatorIndex = ptyId.lastIndexOf('@@') + if (separatorIndex <= 0) { + return null + } + const worktreeId = ptyId.slice(0, separatorIndex) + return parseRuntimeWorktreeId(worktreeId) ? worktreeId : null +} + +function parseRuntimeWorktreeId( + worktreeId: string +): { repoId: string; worktreePath: string } | null { + const separatorIndex = worktreeId.indexOf('::') + if (separatorIndex <= 0) { + return null + } + const worktreePath = worktreeId.slice(separatorIndex + 2) + if (!worktreePath) { + return null + } + return { + repoId: worktreeId.slice(0, separatorIndex), + worktreePath + } +} + +function findResolvedWorktreeIdForPath( + resolvedWorktrees: ResolvedWorktree[], + cwd: string +): string | null { + if (!cwd) { + return null + } + const matches = resolvedWorktrees + .filter( + (worktree) => + areWorktreePathsEqual(worktree.path, cwd) || isPathInsideWorktree(cwd, worktree.path) + ) + .sort((left, right) => right.path.length - left.path.length) + return matches[0]?.id ?? null +} + +function isPathInsideWorktree(candidatePath: string, worktreePath: string): boolean { + if (candidatePath === worktreePath) { + return true + } + const normalizedCandidate = candidatePath.replace(/\\/g, '/').replace(/\/+$/, '') + const normalizedWorktree = worktreePath.replace(/\\/g, '/').replace(/\/+$/, '') + return normalizedCandidate.startsWith(`${normalizedWorktree}/`) +} + +function getLeafWorktreeStatus( + leaf: RuntimeLeafRecord, + tabTitle: string | null +): RuntimeWorktreeStatus { + const detected = leaf.lastAgentStatus ?? detectAgentStatusFromTitle(leaf.title ?? tabTitle ?? '') + if (detected === 'permission') { + return 'permission' + } + if (detected === 'working') { + return 'working' + } + return leaf.ptyId ? 'active' : 'inactive' +} + +function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { + const detected = detectAgentStatusFromTitle(title) + if (detected === 'permission') { + return 'permission' + } + if (detected === 'working') { + return 'working' + } + return hasPty ? 'active' : 'inactive' +} + +function mergeWorktreeStatus( + current: RuntimeWorktreeStatus, + next: RuntimeWorktreeStatus +): RuntimeWorktreeStatus { + return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current +} + function normalizeTerminalChunk(chunk: string): string { return chunk .replace(/\r\n/g, '\n') @@ -3433,6 +4822,13 @@ function compareWorktreePs( left: RuntimeWorktreePsSummary, right: RuntimeWorktreePsSummary ): number { + // Pinned and unread worktrees sort above others so they survive truncation. + if (left.isPinned !== right.isPinned) { + return left.isPinned ? -1 : 1 + } + if (left.unread !== right.unread) { + return left.unread ? -1 : 1 + } const leftLast = left.lastOutputAt ?? -1 const rightLast = right.lastOutputAt ?? -1 if (leftLast !== rightLast) { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index a91745cc87a..9c6eab7212a 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -13,6 +13,7 @@ export type RpcSuccess = { id: string ok: true result: unknown + streaming?: true _meta: RpcEnvelopeMeta } @@ -76,10 +77,53 @@ export function defineMethod( } } -export type RpcRegistry = ReadonlyMap +export type RpcStreamingHandler = ( + params: TParams, + ctx: RpcContext, + emit: (result: unknown) => void +) => Promise -export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry { - const registry = new Map() +// Why: streaming methods emit multiple responses over a long-lived connection. +// The `stream` flag lets the dispatcher distinguish them from one-shot methods +// and route them to the emit-based call path instead of the Promise-based one. +export type RpcStreamingMethod = { + readonly name: string + readonly params: ZodType | null + readonly stream: true + readonly handler: ( + params: unknown, + ctx: RpcContext, + emit: (result: unknown) => void + ) => Promise +} + +type DefineStreamingMethodSpec = { + name: string + params: TSchema + handler: RpcStreamingHandler +} + +export function defineStreamingMethod( + spec: DefineStreamingMethodSpec +): RpcStreamingMethod { + return { + name: spec.name, + params: spec.params, + stream: true, + handler: spec.handler as RpcStreamingMethod['handler'] + } +} + +export type RpcAnyMethod = RpcMethod | RpcStreamingMethod + +export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod { + return 'stream' in method && method.stream === true +} + +export type RpcRegistry = ReadonlyMap + +export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry { + const registry = new Map() for (const method of methods) { if (registry.has(method.name)) { throw new Error(`duplicate_rpc_method:${method.name}`) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 8d0d1dc5cd3..faa8c57e33d 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -6,8 +6,9 @@ import { ZodError, buildRegistry, formatZodError, + isStreamingMethod, + type RpcAnyMethod, type RpcEnvelopeMeta, - type RpcMethod, type RpcRegistry, type RpcRequest, type RpcResponse @@ -18,7 +19,7 @@ import type { OrcaRuntimeService } from '../orca-runtime' export type DispatcherOptions = { runtime: OrcaRuntimeService - methods?: readonly RpcMethod[] + methods?: readonly RpcAnyMethod[] } export class RpcDispatcher { @@ -42,39 +43,110 @@ export class RpcDispatcher { ) } - let parsedParams: unknown - if (method.params === null) { - parsedParams = undefined - } else { - const rawParams = request.params ?? {} - const result = method.params.safeParse(rawParams) - if (!result.success) { - return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error)) - } - parsedParams = result.data + const parsedParams = this.parseParams(request, method, meta) + if (parsedParams.error) { + return parsedParams.error + } + + // Why: streaming methods are not supported over one-shot transports like + // Unix sockets. They require a reply function that can be called multiple + // times, which is only available via dispatchStreaming. + if (isStreamingMethod(method)) { + return errorResponse( + request.id, + meta, + 'method_not_supported', + `Method ${request.method} requires a streaming transport` + ) } try { - const result = await method.handler(parsedParams, { + const result = await method.handler(parsedParams.value, { runtime: this.runtime, signal: options?.signal }) return successResponse(request.id, meta, result) } catch (error) { - // Why: browser methods throw BrowserError with a structured `code`; - // every other runtime error has a plain-message code. Routing by method - // prefix keeps the mapping a single decision rather than a per-method - // flag callers must remember to set. - if (request.method.startsWith('browser.')) { - return mapBrowserError(request.id, meta, error) - } - if (error instanceof ZodError) { - return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error)) - } - return mapRuntimeError(request.id, meta, error) + return this.mapError(request, meta, error) } } + // Why: streaming dispatch sends multiple responses through the reply callback + // instead of returning a single Promise. This enables terminal.subscribe and + // other subscription-style methods that push data over time. + async dispatchStreaming(request: RpcRequest, reply: (response: string) => void): Promise { + const meta = this.meta() + const method = this.registry.get(request.method) + if (!method) { + reply( + JSON.stringify( + errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`) + ) + ) + return + } + + const parsedParams = this.parseParams(request, method, meta) + if (parsedParams.error) { + reply(JSON.stringify(parsedParams.error)) + return + } + + if (!isStreamingMethod(method)) { + try { + const result = await method.handler(parsedParams.value, { runtime: this.runtime }) + reply(JSON.stringify(successResponse(request.id, meta, result))) + } catch (error) { + reply(JSON.stringify(this.mapError(request, meta, error))) + } + return + } + + const emit = (result: unknown): void => { + const response = successResponse(request.id, meta, result) + response.streaming = true + reply(JSON.stringify(response)) + } + + try { + await method.handler(parsedParams.value, { runtime: this.runtime }, emit) + } catch (error) { + reply(JSON.stringify(this.mapError(request, meta, error))) + } + } + + private parseParams( + request: RpcRequest, + method: RpcAnyMethod, + meta: RpcEnvelopeMeta + ): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } { + if (method.params === null) { + return { value: undefined } + } + const rawParams = request.params ?? {} + const result = method.params.safeParse(rawParams) + if (!result.success) { + return { + error: errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error)) + } + } + return { value: result.data } + } + + private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse { + // Why: browser methods throw BrowserError with a structured `code`; + // every other runtime error has a plain-message code. Routing by method + // prefix keeps the mapping a single decision rather than a per-method + // flag callers must remember to set. + if (request.method.startsWith('browser.')) { + return mapBrowserError(request.id, meta, error) + } + if (error instanceof ZodError) { + return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error)) + } + return mapRuntimeError(request.id, meta, error) + } + private meta(): RpcEnvelopeMeta { return { runtimeId: this.runtime.getRuntimeId() } } diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts new file mode 100644 index 00000000000..43920ed4d79 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +function createMockWs() { + const sent: string[] = [] + return { + OPEN: 1 as const, + readyState: 1, + send: vi.fn((data: string) => sent.push(data)), + close: vi.fn(), + sent + } +} + +function setup(overrides?: Partial) { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const ws = createMockWs() + const onReady = vi.fn() + const onError = vi.fn() + + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'valid-token', + onReady, + onError, + ...overrides + }) + + return { channel, ws, serverKeys, clientKeys, onReady, onError } +} + +function doHandshake(ctx: ReturnType) { + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ctx.channel.handleRawMessage(hello) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + return sharedKey +} + +describe('E2EEChannel', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('handshake', () => { + it('completes handshake with valid encrypted auth', () => { + const ctx = setup() + doHandshake(ctx) + + expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel) + expect(ctx.onError).not.toHaveBeenCalled() + expect(ctx.channel.deviceToken).toBe('valid-token') + + const readyMsg = JSON.parse(ctx.ws.sent[0]!) + expect(readyMsg).toEqual({ type: 'e2ee_ready' }) + const authMsg = decrypt( + ctx.ws.sent[1]!, + deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ) + expect(JSON.parse(authMsg!)).toEqual({ type: 'e2ee_authenticated' }) + }) + + it('does not authenticate from plaintext hello alone', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + + expect(ctx.onReady).not.toHaveBeenCalled() + expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' }) + }) + + it('rejects invalid encrypted token', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'bad-token' }), sharedKey) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Unauthorized') + expect(ctx.onReady).not.toHaveBeenCalled() + }) + + it('rejects malformed JSON', () => { + const ctx = setup() + ctx.channel.handleRawMessage('not json') + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid handshake message') + }) + + it('rejects missing fields', () => { + const ctx = setup() + ctx.channel.handleRawMessage(JSON.stringify({ type: 'e2ee_hello' })) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_hello') + }) + + it('rejects invalid public key length', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from('short').toString('base64') + }) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid public key') + }) + + it('times out if no hello received', () => { + const ctx = setup() + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).toHaveBeenCalledWith(4002, 'E2EE handshake timeout') + }) + + it('clears timeout after successful handshake', () => { + const ctx = setup() + doHandshake(ctx) + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('post-handshake messaging', () => { + it('decrypts and forwards messages', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const request = '{"id":"rpc-1","method":"status.get"}' + ctx.channel.handleRawMessage(encrypt(request, sharedKey)) + + expect(received).toEqual([request]) + }) + + it('provides encrypted reply function', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + + ctx.channel.onMessage((_plaintext, encryptedReply) => { + encryptedReply('{"id":"rpc-1","ok":true}') + }) + + ctx.channel.handleRawMessage(encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey)) + + // The reply (ws.sent[2], after ready + authenticated) should be encrypted + const replyEncrypted = ctx.ws.sent[2]! + const replyPlain = decrypt(replyEncrypted, sharedKey) + expect(replyPlain).toBe('{"id":"rpc-1","ok":true}') + }) + + it('silently drops messages with wrong key', () => { + const ctx = setup() + doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const attackerKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + ctx.channel.handleRawMessage(encrypt('attack', attackerKey)) + + expect(received).toEqual([]) + }) + + it('closes after too many consecutive decrypt failures', () => { + const ctx = setup() + doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + expect(ctx.onError).toHaveBeenCalledWith(4003, 'Too many decryption failures') + }) + + it('resets failure count on successful decrypt', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + + // Successful decrypt resets the counter + ctx.channel.handleRawMessage(encrypt('good', sharedKey)) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('cross-compatibility', () => { + it('desktop encrypt is decryptable by desktop decrypt (sanity)', () => { + const a = generateKeyPair() + const b = generateKeyPair() + const sharedA = deriveSharedKey(a.secretKey, b.publicKey) + const sharedB = deriveSharedKey(b.secretKey, a.publicKey) + + const msg = '{"method":"terminal.subscribe","params":{"terminal":"t1"}}' + const enc = encrypt(msg, sharedA) + expect(decrypt(enc, sharedB)).toBe(msg) + }) + }) + + describe('destroy', () => { + it('clears state and stops forwarding', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + ctx.channel.destroy() + ctx.channel.handleRawMessage(encrypt('after destroy', sharedKey)) + + expect(received).toEqual([]) + }) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts new file mode 100644 index 00000000000..25bfd1e72fb --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -0,0 +1,178 @@ +// Why: the E2EE channel sits between the WebSocket transport and the RPC handler. +// It owns the handshake state machine and transparent encrypt/decrypt so the RPC +// handler only sees plaintext JSON, identical to the Unix socket path. +import type { WebSocket } from 'ws' +import { deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' + +const HANDSHAKE_TIMEOUT_MS = 10_000 +const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 + +type E2EEHello = { + type: 'e2ee_hello' + publicKeyB64: string +} + +type E2EEAuth = { + type: 'e2ee_auth' + deviceToken: string +} + +export type E2EEChannelOptions = { + serverSecretKey: Uint8Array + validateToken: (token: string) => boolean + onReady: (channel: E2EEChannel) => void + onError: (code: number, reason: string) => void +} + +export class E2EEChannel { + private state: ChannelState = 'awaiting_hello' + private sharedKey: Uint8Array | null = null + private consecutiveFailures = 0 + private handshakeTimer: ReturnType | null = null + private readonly ws: WebSocket + private readonly serverSecretKey: Uint8Array + private readonly validateToken: (token: string) => boolean + private readonly onReady: (channel: E2EEChannel) => void + private readonly onError: (code: number, reason: string) => void + // Why: the RPC handler is set after the channel is ready, so the channel + // can forward decrypted messages. Kept as a callback rather than constructor + // param because the handler needs the encrypt function for replies. + private messageHandler: + | ((plaintext: string, encryptedReply: (response: string) => void) => void) + | null = null + + deviceToken: string | null = null + + constructor(ws: WebSocket, options: E2EEChannelOptions) { + this.ws = ws + this.serverSecretKey = options.serverSecretKey + this.validateToken = options.validateToken + this.onReady = options.onReady + this.onError = options.onError + + this.handshakeTimer = setTimeout(() => { + this.onError(4002, 'E2EE handshake timeout') + }, HANDSHAKE_TIMEOUT_MS) + } + + onMessage( + handler: (plaintext: string, encryptedReply: (response: string) => void) => void + ): void { + this.messageHandler = handler + } + + handleRawMessage(raw: string): void { + if (this.state === 'awaiting_hello') { + this.handleHello(raw) + return + } + + if (!this.sharedKey) { + return + } + + const plaintext = decrypt(raw, this.sharedKey) + if (plaintext === null) { + this.consecutiveFailures++ + if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + this.onError(4003, 'Too many decryption failures') + } + return + } + + this.consecutiveFailures = 0 + if (this.state === 'awaiting_auth') { + this.handleAuth(plaintext) + return + } + + const encryptedReply = (response: string) => { + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(encrypt(response, this.sharedKey!)) + } + } + this.messageHandler?.(plaintext, encryptedReply) + } + + private handleHello(raw: string): void { + let hello: E2EEHello + try { + hello = JSON.parse(raw) as E2EEHello + } catch { + this.onError(4001, 'Invalid handshake message') + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + this.onError(4001, 'Invalid e2ee_hello') + return + } + + // Why: derive the shared key from our secret + client's public key. + // Both sides compute the same shared secret via ECDH. + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + this.onError(4001, 'Invalid public key') + return + } + + this.sharedKey = deriveSharedKey(this.serverSecretKey, clientPublicKey) + this.state = 'awaiting_auth' + + // Why: send e2ee_ready as plaintext — the client needs it to know the + // key exchange succeeded before it can send encrypted authentication. + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(JSON.stringify({ type: 'e2ee_ready' })) + } + } + + private handleAuth(plaintext: string): void { + let auth: E2EEAuth + try { + auth = JSON.parse(plaintext) as E2EEAuth + } catch { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) + this.onError(4001, 'Invalid e2ee_auth') + return + } + + if (auth.type !== 'e2ee_auth' || !auth.deviceToken) { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) + this.onError(4001, 'Invalid e2ee_auth') + return + } + if (!this.validateToken(auth.deviceToken)) { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'unauthorized' } }) + this.onError(4001, 'Unauthorized') + return + } + + this.deviceToken = auth.deviceToken + this.state = 'ready' + + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + + this.sendEncryptedControl({ type: 'e2ee_authenticated' }) + this.onReady(this) + } + + private sendEncryptedControl(message: unknown): void { + if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { + this.ws.send(encrypt(JSON.stringify(message), this.sharedKey)) + } + } + + destroy(): void { + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + this.sharedKey = null + this.messageHandler = null + } +} diff --git a/src/main/runtime/rpc/e2ee-crypto.test.ts b/src/main/runtime/rpc/e2ee-crypto.test.ts new file mode 100644 index 00000000000..09e546db63c --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +describe('e2ee-crypto', () => { + it('encrypt/decrypt round-trips with shared key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + + const serverShared = deriveSharedKey(server.secretKey, client.publicKey) + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '{"id":"rpc-1","method":"status.get"}' + const encrypted = encrypt(message, clientShared) + const decrypted = decrypt(encrypted, serverShared) + + expect(decrypted).toBe(message) + }) + + it('decrypt returns null with wrong key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const attacker = generateKeyPair() + + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + const attackerShared = deriveSharedKey(attacker.secretKey, server.publicKey) + + const encrypted = encrypt('secret data', clientShared) + expect(decrypt(encrypted, attackerShared)).toBeNull() + }) + + it('each encryption produces unique ciphertext (random nonce)', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = 'same message' + const a = encrypt(message, shared) + const b = encrypt(message, shared) + + expect(a).not.toBe(b) + }) + + it('handles empty string', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('', shared) + expect(decrypt(encrypted, shared)).toBe('') + }) + + it('handles unicode content', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '日本語テスト 🎉 émojis' + const encrypted = encrypt(message, shared) + expect(decrypt(encrypted, shared)).toBe(message) + }) + + it('decrypt returns null for truncated data', () => { + const shared = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + expect(decrypt('dG9vc2hvcnQ=', shared)).toBeNull() + }) + + it('decrypt returns null for tampered data', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('hello', shared) + const bytes = Buffer.from(encrypted, 'base64') + bytes[bytes.length - 1] ^= 0xff + const tampered = bytes.toString('base64') + + expect(decrypt(tampered, shared)).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/e2ee-crypto.ts b/src/main/runtime/rpc/e2ee-crypto.ts new file mode 100644 index 00000000000..39adec27160 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.ts @@ -0,0 +1,41 @@ +// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide +// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext], encoded +// as base64 for transmission over WebSocket text frames. +import nacl from 'tweetnacl' + +export function generateKeyPair(): nacl.BoxKeyPair { + return nacl.box.keyPair() +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const messageBytes = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return Buffer.from(bundle).toString('base64') +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/src/main/runtime/rpc/e2ee-integration.test.ts b/src/main/runtime/rpc/e2ee-integration.test.ts new file mode 100644 index 00000000000..f4029b82473 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-integration.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +// Why: this test simulates the full mobile → desktop E2EE flow without a real +// WebSocket. The "mobile" side generates an ephemeral keypair, sends e2ee_hello, +// the E2EEChannel (desktop) derives the shared key, and then both sides can +// exchange encrypted RPC messages. This validates that the handshake protocol +// and crypto are end-to-end compatible. + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +describe('E2EE integration (simulated mobile ↔ desktop)', () => { + let serverKeys: ReturnType + let mobileEphemeralKeys: ReturnType + let wsSent: string[] + let mockWs: { + OPEN: 1 + readyState: number + send: ReturnType + close: ReturnType + } + let channel: E2EEChannel + let onReady: (channel: E2EEChannel) => void + let onError: (code: number, reason: string) => void + + beforeEach(() => { + vi.useFakeTimers() + + serverKeys = generateKeyPair() + mobileEphemeralKeys = generateKeyPair() + wsSent = [] + mockWs = { + OPEN: 1, + readyState: 1, + send: vi.fn((data: string) => wsSent.push(data)), + close: vi.fn() + } + onReady = vi.fn() as unknown as (channel: E2EEChannel) => void + onError = vi.fn() as unknown as (code: number, reason: string) => void + + channel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + }) + + afterEach(() => { + channel.destroy() + vi.useRealTimers() + }) + + function completeHandshake(targetChannel = channel, keys = mobileEphemeralKeys): Uint8Array { + targetChannel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(keys.secretKey, serverKeys.publicKey) + targetChannel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'device-abc' }), sharedKey) + ) + return sharedKey + } + + it('full handshake → encrypted RPC round-trip', () => { + const mobileShared = completeHandshake() + + // Desktop should respond with plaintext ready, then encrypted auth ack. + expect(onReady).toHaveBeenCalled() + expect(wsSent).toHaveLength(2) + expect(JSON.parse(wsSent[0]!)).toEqual({ type: 'e2ee_ready' }) + expect(JSON.parse(decrypt(wsSent[1]!, mobileShared)!)).toEqual({ + type: 'e2ee_authenticated' + }) + + // Set up the desktop message handler + const desktopReceived: string[] = [] + channel.onMessage((plaintext, encryptedReply) => { + desktopReceived.push(plaintext) + encryptedReply(JSON.stringify({ id: 'rpc-1', ok: true, result: { status: 'ready' } })) + }) + + // Mobile sends encrypted RPC request + const request = JSON.stringify({ id: 'rpc-1', method: 'status.get' }) + channel.handleRawMessage(encrypt(request, mobileShared)) + + // Desktop received the plaintext + expect(desktopReceived).toEqual([request]) + + // Desktop's encrypted reply (wsSent[2]) is decryptable by mobile + expect(wsSent).toHaveLength(3) + const replyPlain = decrypt(wsSent[2]!, mobileShared) + expect(JSON.parse(replyPlain!)).toEqual({ + id: 'rpc-1', + ok: true, + result: { status: 'ready' } + }) + }) + + it('mobile reconnects with fresh ephemeral key', () => { + // First connection + completeHandshake() + expect(onReady).toHaveBeenCalledTimes(1) + + const firstShared = deriveSharedKey(mobileEphemeralKeys.secretKey, serverKeys.publicKey) + + // Simulate disconnect + reconnect with new ephemeral key + channel.destroy() + wsSent.length = 0 + + const newMobileKeys = generateKeyPair() + const newChannel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + + completeHandshake(newChannel, newMobileKeys) + + expect(onReady).toHaveBeenCalledTimes(2) + + const secondShared = deriveSharedKey(newMobileKeys.secretKey, serverKeys.publicKey) + + // Keys from different sessions must not be interchangeable + expect(Buffer.from(firstShared).toString('hex')).not.toBe( + Buffer.from(secondShared).toString('hex') + ) + + // Verify new session works + const received: string[] = [] + newChannel.onMessage((plaintext) => received.push(plaintext)) + newChannel.handleRawMessage(encrypt('{"id":"2","method":"test"}', secondShared)) + expect(received).toEqual(['{"id":"2","method":"test"}']) + + newChannel.destroy() + }) + + it('streaming messages work through E2EE', () => { + const mobileShared = completeHandshake() + const received: string[] = [] + + channel.onMessage((plaintext, encryptedReply) => { + received.push(plaintext) + // Simulate streaming: send multiple encrypted responses + for (let i = 0; i < 3; i++) { + encryptedReply( + JSON.stringify({ + id: 'stream-1', + ok: true, + streaming: true, + result: { type: 'data', chunk: `line ${i}\n` } + }) + ) + } + }) + + channel.handleRawMessage( + encrypt(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' }), mobileShared) + ) + + // 1 plaintext ready + 1 encrypted auth ack + 3 streaming responses + expect(wsSent).toHaveLength(5) + + for (let i = 2; i < 5; i++) { + const plain = decrypt(wsSent[i]!, mobileShared) + const parsed = JSON.parse(plain!) + expect(parsed.streaming).toBe(true) + expect(parsed.result.chunk).toBe(`line ${i - 2}\n`) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 9bdcc514c26..cf3fbf34f86 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,4 +1,4 @@ -import type { RpcMethod } from '../core' +import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' @@ -6,16 +6,20 @@ import { TERMINAL_METHODS } from './terminal' import { BROWSER_CORE_METHODS } from './browser-core' import { BROWSER_EXTRA_METHODS } from './browser-extras' import { ORCHESTRATION_METHODS } from './orchestration' +import { NOTIFICATION_METHODS } from './notifications' +import { STATS_METHODS } from './stats' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. -export const ALL_RPC_METHODS: readonly RpcMethod[] = [ +export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, ...TERMINAL_METHODS, ...BROWSER_CORE_METHODS, ...BROWSER_EXTRA_METHODS, - ...ORCHESTRATION_METHODS + ...ORCHESTRATION_METHODS, + ...NOTIFICATION_METHODS, + ...STATS_METHODS ] diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts new file mode 100644 index 00000000000..607474bb941 --- /dev/null +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' + +const NotificationUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: notifications.subscribe streams desktop notification events to mobile +// clients over WebSocket. The mobile client shows a local push notification +// for each event. This avoids requiring Firebase/APNs — the existing +// persistent WebSocket connection doubles as the push channel. +export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'notifications.subscribe', + params: null, + handler: async (_params, { runtime }, emit) => { + await new Promise((resolve) => { + const unsubscribe = runtime.onNotificationDispatched((event) => { + emit({ type: 'notification', ...event }) + }) + + const subscriptionId = `notifications-${Date.now()}` + runtime.registerSubscriptionCleanup(subscriptionId, () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }) + + emit({ type: 'ready', subscriptionId }) + }) + } + }), + defineMethod({ + name: 'notifications.unsubscribe', + params: NotificationUnsubscribeParams, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index b3a851dac0d..3af1133f836 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -52,5 +52,10 @@ export const REPO_METHODS: RpcMethod[] = [ params: RepoSearchRefs, handler: async (params, { runtime }) => runtime.searchRepoRefs(params.repo, params.query, params.limit) + }), + defineMethod({ + name: 'repo.hooks', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) }) ] diff --git a/src/main/runtime/rpc/methods/stats.ts b/src/main/runtime/rpc/methods/stats.ts new file mode 100644 index 00000000000..59f71701c3a --- /dev/null +++ b/src/main/runtime/rpc/methods/stats.ts @@ -0,0 +1,11 @@ +import { defineMethod, type RpcMethod } from '../core' + +export const STATS_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'stats.summary', + params: null, + handler: async (_params, { runtime }) => { + return runtime.getStatsSummary() ?? {} + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index cc3db888eb9..f69a3fc9c84 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,5 +1,6 @@ +/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' const TerminalHandle = z.object({ @@ -77,7 +78,45 @@ const TerminalStop = z.object({ worktree: requiredString('Missing worktree selector') }) -export const TERMINAL_METHODS: RpcMethod[] = [ +const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }) + .optional() +}) + +const TerminalSetDisplayMode = TerminalHandle.extend({ + mode: z.enum(['auto', 'phone', 'desktop']) +}) + +const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID') +}) + +export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.list', params: TerminalListParams, @@ -157,6 +196,29 @@ export const TERMINAL_METHODS: RpcMethod[] = [ params: TerminalStop, handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) }), + defineMethod({ + name: 'terminal.resizeForClient', + params: TerminalResizeForClient, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const result = runtime.resizeForClient( + leaf.ptyId, + params.mode, + params.clientId, + params.mode === 'mobile-fit' ? params.cols : undefined, + params.mode === 'mobile-fit' ? params.rows : undefined + ) + return { + terminal: { + handle: params.terminal, + ...result + } + } + } + }), defineMethod({ name: 'terminal.focus', params: TerminalHandle, @@ -170,5 +232,141 @@ export const TERMINAL_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => ({ close: await runtime.closeTerminal(params.terminal) }) + }), + defineMethod({ + name: 'terminal.setDisplayMode', + params: TerminalSetDisplayMode, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + runtime.setMobileDisplayMode(leaf.ptyId, params.mode) + runtime.applyMobileDisplayMode(leaf.ptyId) + return { mode: params.mode } + } + }), + defineMethod({ + name: 'terminal.getDisplayMode', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' + const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false + return { mode, isPhoneFitted } + } + }), + // Why: terminal.subscribe streams live terminal output over WebSocket. + // It sends initial scrollback, then live data chunks as they arrive. + // Mobile clients pass client+viewport params for server-side auto-fit. + defineStreamingMethod({ + name: 'terminal.subscribe', + params: TerminalSubscribe, + handler: async (params, { runtime }, emit) => { + let leaf = runtime.resolveLeafForHandle(params.terminal) + const isMobile = params.client?.type === 'mobile' + + // Why: the left pane's PTY spawns asynchronously after the tab is created. + // Mobile clients that subscribe before the PTY is ready would get a bare + // scrollback+end with no live stream or phone-fit. Wait for the PTY so + // the subscribe can proceed normally. + if (!leaf?.ptyId && isMobile) { + try { + const ptyId = await runtime.waitForLeafPtyId(params.terminal) + leaf = { ptyId } + } catch { + // PTY wait timed out — fall through to scrollback-only path below + } + } + + if (!leaf?.ptyId) { + const read = await runtime.readTerminal(params.terminal) + emit({ + type: 'scrollback', + lines: read.tail, + truncated: read.truncated, + serialized: undefined, + cols: undefined, + rows: undefined + }) + emit({ type: 'end' }) + return + } + + const ptyId = leaf.ptyId + const clientId = params.client?.id + + // Server-side auto-fit: resize PTY to phone dims before serializing scrollback + if (isMobile && clientId) { + runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } + + const read = await runtime.readTerminal(params.terminal) + const serialized = await runtime.serializeTerminalBuffer(ptyId) + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + emit({ + type: 'scrollback', + lines: read.tail, + truncated: read.truncated, + serialized: serialized?.data, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode + }) + + await new Promise((resolve) => { + const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + emit({ type: 'data', chunk: data }) + }) + + // Inline resize events replace the old fit-override-changed event for + // mobile clients. They include fresh serialized scrollback so the client + // can reinitialize xterm without resubscribing. + const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, async (event) => { + const fresh = await runtime.serializeTerminalBuffer(ptyId) + emit({ + type: 'resized', + cols: event.cols, + rows: event.rows, + serialized: fresh?.data, + displayMode: event.displayMode, + reason: event.reason + }) + }) + + // Legacy fit-override-changed for non-mobile (desktop) subscribers + const unsubscribeFit = !isMobile + ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + emit({ + type: 'fit-override-changed', + mode: event.mode, + cols: event.cols, + rows: event.rows + }) + }) + : () => {} + + const subscriptionId = params.terminal + runtime.registerSubscriptionCleanup(subscriptionId, () => { + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + if (isMobile && clientId) { + runtime.handleMobileUnsubscribe(ptyId, clientId) + } + emit({ type: 'end' }) + resolve() + }) + }) + } + }), + defineMethod({ + name: 'terminal.unsubscribe', + params: TerminalUnsubscribe, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } }) ] diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 8f5bd832a5e..248d81f9c2b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -28,20 +28,27 @@ const WorktreeCreate = z.object({ .unknown() .transform((v) => (typeof v === 'string' ? v : '')) .pipe(z.string().min(1, 'Missing repo selector')), - name: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree name')), + name: OptionalString, baseBranch: OptionalString, linkedIssue: TriStateLinkedIssue, comment: OptionalString, - runHooks: OptionalBoolean + runHooks: OptionalBoolean, + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.enum(['run', 'skip', 'inherit']).optional()), + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. + startupCommand: OptionalString }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, linkedIssue: TriStateLinkedIssue, - comment: OptionalString + comment: OptionalString, + isPinned: OptionalBoolean }) const WorktreeRemove = WorktreeSelector.extend({ @@ -67,17 +74,29 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.showManagedWorktree(params.worktree) }) }), + defineMethod({ + name: 'worktree.sleep', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree) + }), + defineMethod({ + name: 'worktree.activate', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.activateManagedWorktree(params.worktree) + }), defineMethod({ name: 'worktree.create', params: WorktreeCreate, handler: async (params, { runtime }) => runtime.createManagedWorktree({ repoSelector: params.repo, - name: params.name, + name: params.name ?? '', baseBranch: params.baseBranch, linkedIssue: params.linkedIssue, comment: params.comment, - runHooks: params.runHooks === true + runHooks: params.runHooks === true, + setupDecision: params.setupDecision, + startup: params.startupCommand ? { command: params.startupCommand } : undefined }) }), defineMethod({ @@ -87,7 +106,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { displayName: params.displayName, linkedIssue: params.linkedIssue, - comment: params.comment + comment: params.comment, + isPinned: params.isPinned }) }) }), diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts new file mode 100644 index 00000000000..7afa5ae7553 --- /dev/null +++ b/src/main/runtime/rpc/streaming.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { RpcDispatcher } from './dispatcher' +import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('RpcDispatcher streaming', () => { + it('sends initial scrollback via emit', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime({ + readTerminal: vi.fn().mockResolvedValue({ tail: 'hello\nworld\n', truncated: false }), + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }) + }), + methods: [ + defineStreamingMethod({ + name: 'terminal.subscribe', + params: z.object({ terminal: z.string() }), + handler: async (params, { runtime }, emit) => { + const read = await (runtime as OrcaRuntimeService).readTerminal(params.terminal) + emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated }) + + const leaf = (runtime as OrcaRuntimeService).resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + emit({ type: 'end' }) + } + } + }) + ] + }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { terminal: 'h-1' }), + (msg) => messages.push(msg) + ) + + expect(messages).toHaveLength(2) + const scrollback = JSON.parse(messages[0]!) + expect(scrollback).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'scrollback', lines: 'hello\nworld\n', truncated: false } + }) + const end = JSON.parse(messages[1]!) + expect(end).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'end' } + }) + }) + + it('streams live data chunks via emit', async () => { + const messages: string[] = [] + let emitFn: ((result: unknown) => void) | null = null + let resolveHandler: (() => void) | null = null + + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.stream', + params: null, + handler: async (_params, _ctx, emit) => { + emitFn = emit + await new Promise((resolve) => { + resolveHandler = resolve + }) + } + }) + ] + }) + + const dispatchPromise = dispatcher.dispatchStreaming(makeRequest('test.stream'), (msg) => + messages.push(msg) + ) + + // Wait for handler to capture emit + await vi.waitFor(() => expect(emitFn).not.toBeNull()) + + emitFn!({ type: 'data', chunk: 'line 1\n' }) + emitFn!({ type: 'data', chunk: 'line 2\n' }) + emitFn!({ type: 'end' }) + resolveHandler!() + + await dispatchPromise + + expect(messages).toHaveLength(3) + expect(JSON.parse(messages[0]!)).toMatchObject({ + streaming: true, + result: { type: 'data', chunk: 'line 1\n' } + }) + expect(JSON.parse(messages[1]!)).toMatchObject({ + streaming: true, + result: { type: 'data', chunk: 'line 2\n' } + }) + expect(JSON.parse(messages[2]!)).toMatchObject({ + streaming: true, + result: { type: 'end' } + }) + }) + + it('unsubscribe stops further streaming', async () => { + const messages: string[] = [] + let cleanup: (() => void) | null = null + + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime({ + registerSubscriptionCleanup: vi.fn().mockImplementation((_id: string, fn: () => void) => { + cleanup = fn + }), + cleanupSubscription: vi.fn().mockImplementation(() => { + cleanup?.() + }) + }), + methods: [ + defineStreamingMethod({ + name: 'test.subscribe', + params: null, + handler: async (_params, { runtime }, emit) => { + emit({ type: 'scrollback', lines: '' }) + + await new Promise((resolve) => { + ;(runtime as OrcaRuntimeService).registerSubscriptionCleanup('sub-1', () => { + emit({ type: 'end' }) + resolve() + }) + }) + } + }), + defineMethod({ + name: 'test.unsubscribe', + params: z.object({ subscriptionId: z.string() }), + handler: async (params, { runtime }) => { + ;(runtime as OrcaRuntimeService).cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) + ] + }) + + const subPromise = dispatcher.dispatchStreaming(makeRequest('test.subscribe'), (msg) => + messages.push(msg) + ) + + await vi.waitFor(() => expect(cleanup).not.toBeNull()) + + const unsubMessages: string[] = [] + await dispatcher.dispatchStreaming( + { + id: 'req-unsub', + authToken: 'tok', + method: 'test.unsubscribe', + params: { subscriptionId: 'sub-1' } + }, + (msg) => unsubMessages.push(msg) + ) + + await subPromise + + expect(unsubMessages).toHaveLength(1) + expect(JSON.parse(unsubMessages[0]!)).toMatchObject({ + ok: true, + result: { unsubscribed: true } + }) + + const streamMessages = messages.map((m) => JSON.parse(m)) + expect(streamMessages).toContainEqual( + expect.objectContaining({ result: { type: 'scrollback', lines: '' } }) + ) + expect(streamMessages).toContainEqual(expect.objectContaining({ result: { type: 'end' } })) + }) + + it('falls back to one-shot dispatch for non-streaming methods via dispatchStreaming', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineMethod({ + name: 'status.get', + params: null, + handler: async () => ({ status: 'ok' }) + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('status.get'), (msg) => messages.push(msg)) + + expect(messages).toHaveLength(1) + const response = JSON.parse(messages[0]!) + expect(response).toMatchObject({ ok: true, result: { status: 'ok' } }) + expect(response.streaming).toBeUndefined() + }) + + it('returns error for unknown method via dispatchStreaming', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [] + }) + + await dispatcher.dispatchStreaming(makeRequest('nonexistent.method'), (msg) => + messages.push(msg) + ) + + expect(messages).toHaveLength(1) + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + }) + + it('returns error when streaming method is called via one-shot dispatch', async () => { + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.stream', + params: null, + handler: async () => {} + }) + ] + }) + + const response = await dispatcher.dispatch(makeRequest('test.stream')) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'method_not_supported' } + }) + }) + + it('captures handler errors in streaming dispatch', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.explode', + params: null, + handler: async () => { + throw new Error('boom') + } + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('test.explode'), (msg) => messages.push(msg)) + + expect(messages).toHaveLength(1) + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'runtime_error' } + }) + }) +}) diff --git a/src/main/runtime/rpc/transport.ts b/src/main/runtime/rpc/transport.ts new file mode 100644 index 00000000000..8eedd20c0fb --- /dev/null +++ b/src/main/runtime/rpc/transport.ts @@ -0,0 +1,22 @@ +// Why: the transport interface decouples the RPC server from a specific +// transport mechanism (Unix socket, WebSocket, named pipe). Each transport +// owns its own connection lifecycle — the RPC server just binds message +// handling to whatever transports are registered. Individual transports +// override `onMessage` with their own richer signatures (e.g. Unix adds a +// `RpcMessageContext` with an abort signal; WebSocket adds the `ws` handle +// for auth association). Consumers hold a concrete transport type, not +// `RpcTransport`, when they need those extensions. + +// Why: per-message hook bag owned by the Unix transport. `signal` aborts +// when the underlying connection terminates so long-poll handlers can bail +// out. `startKeepalive` is opt-in per request — only long-poll dispatches +// call it, so short RPCs pay no timer overhead. See design doc §3.1. +export type RpcMessageContext = { + signal: AbortSignal + startKeepalive: () => void +} + +export type RpcTransport = { + start(): Promise + stop(): Promise +} diff --git a/src/main/runtime/rpc/unix-socket-transport.ts b/src/main/runtime/rpc/unix-socket-transport.ts new file mode 100644 index 00000000000..50c7d9822ca --- /dev/null +++ b/src/main/runtime/rpc/unix-socket-transport.ts @@ -0,0 +1,203 @@ +// Why: this is the original Unix socket / named pipe transport extracted from +// runtime-rpc.ts. It preserves the exact same behavior: newline-delimited JSON, +// 30s idle timeout, 1MB max message, 32 max connections, chmod 0o600 on Unix. +// It also owns the keepalive timer and per-connection abort signal so the +// server-side handler can cancel long-poll dispatches when the client goes +// away. See design doc §3.1. +import { createServer, type Server, type Socket } from 'net' +import { chmodSync, existsSync, rmSync } from 'fs' +import type { RpcMessageContext, RpcTransport } from './transport' + +const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 +const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000 +const MAX_RUNTIME_RPC_CONNECTIONS = 32 +const DEFAULT_KEEPALIVE_INTERVAL_MS = 10_000 + +export type UnixSocketTransportOptions = { + endpoint: string + kind: 'unix' | 'named-pipe' + // Why: how often to write `{"_keepalive":true}\n` frames while a dispatch + // is pending. Each write resets both the server-side idle timer and, once + // the client honours them, the client-side idle timer. Tests override this + // to avoid waiting 10 s for a frame. + keepaliveIntervalMs?: number +} + +type MessageHandler = ( + msg: string, + reply: (response: string) => void, + context?: RpcMessageContext +) => void + +export class UnixSocketTransport implements RpcTransport { + private readonly endpoint: string + private readonly kind: 'unix' | 'named-pipe' + private readonly keepaliveIntervalMs: number + private server: Server | null = null + private messageHandler: MessageHandler | null = null + + constructor({ endpoint, kind, keepaliveIntervalMs }: UnixSocketTransportOptions) { + this.endpoint = endpoint + this.kind = kind + this.keepaliveIntervalMs = keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS + } + + onMessage(handler: MessageHandler): void { + this.messageHandler = handler + } + + async start(): Promise { + if (this.server) { + return + } + + if (this.kind === 'unix' && existsSync(this.endpoint)) { + rmSync(this.endpoint, { force: true }) + } + + const server = createServer((socket) => { + this.handleConnection(socket) + }) + server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(this.endpoint, () => { + server.off('error', reject) + resolve() + }) + }) + + if (this.kind === 'unix') { + chmodSync(this.endpoint, 0o600) + } + + this.server = server + } + + async stop(): Promise { + const server = this.server + this.server = null + if (!server) { + return + } + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + if (this.kind === 'unix' && existsSync(this.endpoint)) { + rmSync(this.endpoint, { force: true }) + } + } + + private handleConnection(socket: Socket): void { + let buffer = '' + let oversized = false + // Why: each in-flight dispatch registers its own AbortController here so + // `socket.on('close')` can abort them all at once. Keeping the set scoped + // to the connection (rather than a single shared controller) means + // completing one dispatch does not abort any other dispatch still running + // on the same socket — future-proofing for a persistent CLI socket that + // multiplexes sequential requests. + const inflight = new Set() + + socket.setEncoding('utf8') + socket.setNoDelay(true) + socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { + socket.destroy() + }) + socket.on('error', () => { + socket.destroy() + }) + socket.on('close', () => { + for (const ctrl of inflight) { + ctrl.abort() + } + inflight.clear() + }) + socket.on('data', (chunk: string) => { + if (oversized) { + return + } + buffer += chunk + // Why: the Orca runtime lives in Electron main, so it must reject + // oversized local RPC frames instead of letting a local client grow an + // unbounded buffer and stall the app. + if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { + oversized = true + this.messageHandler?.('', (response) => { + socket.write(`${response}\n`) + socket.end() + }) + return + } + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + const rawMessage = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) + if (rawMessage) { + this.dispatchMessage(socket, rawMessage, inflight) + } + newlineIndex = buffer.indexOf('\n') + } + }) + } + + // Why: the keepalive timer is opt-in per request via `startKeepalive()`. + // Short RPCs never call it and pay no timer overhead; only long-poll + // handlers (e.g. orchestration.check --wait) arm it. See §3.1. + private dispatchMessage( + socket: Socket, + rawMessage: string, + inflight: Set + ): void { + let replied = false + let keepaliveTimer: NodeJS.Timeout | null = null + // Why: per-dispatch AbortController so completing one request does not + // abort any sibling request running on the same connection. The + // connection-level `socket.on('close')` iterates `inflight` to cancel all + // outstanding dispatches at once. + const abortController = new AbortController() + inflight.add(abortController) + + const reply = (response: string): void => { + if (replied) { + return + } + replied = true + inflight.delete(abortController) + if (keepaliveTimer) { + clearInterval(keepaliveTimer) + } + if (!socket.destroyed && socket.writable) { + socket.write(`${response}\n`) + } + } + + const startKeepalive = (): void => { + if (keepaliveTimer || replied) { + return + } + keepaliveTimer = setInterval(() => { + if (replied || socket.destroyed || !socket.writable) { + return + } + socket.write('{"_keepalive":true}\n') + }, this.keepaliveIntervalMs) + // Why: don't hold the process open solely on the keepalive interval. + if (typeof keepaliveTimer.unref === 'function') { + keepaliveTimer.unref() + } + } + + this.messageHandler?.(rawMessage, reply, { + signal: abortController.signal, + startKeepalive + }) + } +} diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts new file mode 100644 index 00000000000..ea3acd486fb --- /dev/null +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -0,0 +1,256 @@ +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, afterEach } from 'vitest' +import WebSocket from 'ws' +import { WebSocketTransport } from './ws-transport' +import { loadOrCreateTlsCertificate } from '../tls-certificate' + +// Why: disable TLS verification for self-signed certs in tests. +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + +function makeTls() { + const userDataPath = mkdtempSync(join(tmpdir(), 'ws-transport-test-')) + return loadOrCreateTlsCertificate(userDataPath) +} + +function findFreePort(): number { + // Why: use port 0 to let the OS assign an available port, but the transport + // needs an explicit port. Use a high random port to minimize collisions. + return 30000 + Math.floor(Math.random() * 20000) +} + +describe('WebSocketTransport', () => { + const transports: WebSocketTransport[] = [] + + afterEach(async () => { + await Promise.all(transports.map((t) => t.stop().catch(() => {}))) + transports.length = 0 + }) + + async function createTransport( + handler?: (msg: string, reply: (response: string) => void) => void + ) { + const tls = makeTls() + const port = findFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key + }) + if (handler) { + transport.onMessage(handler) + } + transports.push(transport) + return { transport, port, tls } + } + + function connectWs(port: number): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`wss://127.0.0.1:${port}`, { + rejectUnauthorized: false + }) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) + } + + function sendAndReceive(ws: WebSocket, message: string): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => { + resolve(typeof data === 'string' ? data : data.toString('utf-8')) + }) + ws.send(message) + }) + } + + it('starts and stops cleanly', async () => { + const { transport } = await createTransport() + + await transport.start() + await transport.stop() + }) + + it('handles request/response round-trip', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, result: { echo: true } })) + }) + + await transport.start() + + const ws = await connectWs(port) + const response = await sendAndReceive( + ws, + JSON.stringify({ id: 'req-1', method: 'test', deviceToken: 'tok' }) + ) + + expect(JSON.parse(response)).toMatchObject({ + id: 'req-1', + ok: true, + result: { echo: true } + }) + + ws.close() + }) + + it('supports multiple concurrent connections', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true })) + }) + + await transport.start() + + const clients = await Promise.all([connectWs(port), connectWs(port), connectWs(port)]) + + const responses = await Promise.all( + clients.map((ws, i) => sendAndReceive(ws, JSON.stringify({ id: `req-${i}`, method: 'test' }))) + ) + + for (let i = 0; i < 3; i++) { + expect(JSON.parse(responses[i]!)).toMatchObject({ id: `req-${i}`, ok: true }) + } + + for (const ws of clients) { + ws.close() + } + }) + + it('multiplexes multiple requests on a single connection', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, result: { method: request.method } })) + }) + + await transport.start() + + const ws = await connectWs(port) + + const r1 = sendAndReceive(ws, JSON.stringify({ id: 'a', method: 'first' })) + const resp1 = JSON.parse(await r1) + expect(resp1).toMatchObject({ id: 'a', result: { method: 'first' } }) + + const r2 = sendAndReceive(ws, JSON.stringify({ id: 'b', method: 'second' })) + const resp2 = JSON.parse(await r2) + expect(resp2).toMatchObject({ id: 'b', result: { method: 'second' } }) + + ws.close() + }) + + it('sends multiple streaming responses via reply callback', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 1 } })) + reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 2 } })) + reply(JSON.stringify({ id: request.id, ok: true, result: { type: 'end' } })) + }) + + await transport.start() + + const ws = await connectWs(port) + const messages: string[] = [] + + await new Promise((resolve) => { + ws.on('message', (data) => { + messages.push(typeof data === 'string' ? data : data.toString('utf-8')) + if (messages.length === 3) { + resolve() + } + }) + ws.send(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' })) + }) + + expect(JSON.parse(messages[0]!)).toMatchObject({ streaming: true, result: { chunk: 1 } }) + expect(JSON.parse(messages[1]!)).toMatchObject({ streaming: true, result: { chunk: 2 } }) + expect(JSON.parse(messages[2]!)).toMatchObject({ result: { type: 'end' } }) + + ws.close() + }) + + it('rejects oversized messages by closing the connection', async () => { + const { transport, port } = await createTransport() + + await transport.start() + + const ws = await connectWs(port) + + // Why: ws maxPayload is 1MB — sending >1MB should trigger close. + const oversized = 'x'.repeat(1024 * 1024 + 100) + + await new Promise((resolve) => { + ws.once('close', () => resolve()) + ws.send(oversized) + }) + }) + + it('does not crash when replying to a closed connection', async () => { + let capturedReply: ((response: string) => void) | null = null + + const { transport, port } = await createTransport((_msg, reply) => { + capturedReply = reply + }) + + await transport.start() + + const ws = await connectWs(port) + ws.send(JSON.stringify({ id: 'req-1', method: 'test' })) + + // Why: wait for the handler to capture the reply function. + await new Promise((resolve) => { + const interval = setInterval(() => { + if (capturedReply) { + clearInterval(interval) + resolve() + } + }, 10) + }) + + ws.close() + + // Why: wait for the WebSocket to fully close before trying to reply. + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Should not throw — guards with readyState check. + expect(() => capturedReply!(JSON.stringify({ id: 'req-1', ok: true }))).not.toThrow() + }) + + it('is idempotent on double start', async () => { + const { transport } = await createTransport() + + await transport.start() + await transport.start() + + await transport.stop() + }) + + it('is safe to stop without starting', async () => { + const { transport } = await createTransport() + await transport.stop() + }) + + it('falls back to OS-assigned port when preferred port is in use', async () => { + const { transport: first, port } = await createTransport() + await first.start() + + // Why: second transport requests the same port, which is now occupied. + // It should silently fall back to an OS-assigned port instead of throwing. + const tls = makeTls() + const second = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key + }) + transports.push(second) + + await second.start() + + expect(second.resolvedPort).not.toBe(port) + expect(second.resolvedPort).toBeGreaterThan(0) + + const ws = await connectWs(second.resolvedPort) + ws.close() + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts new file mode 100644 index 00000000000..218bdfe2851 --- /dev/null +++ b/src/main/runtime/rpc/ws-transport.ts @@ -0,0 +1,193 @@ +// Why: the WebSocket transport enables mobile clients to connect to the Orca +// runtime over the local network. When TLS cert/key are provided it uses wss:// +// to prevent passive sniffing; otherwise it falls back to plain ws://. Per-device +// tokens (validated by the message handler in OrcaRuntimeRpcServer) provide auth +// regardless of transport encryption. +import { createServer as createHttpsServer, type Server as HttpsServer } from 'https' +import { createServer as createHttpServer, type Server as HttpServer } from 'http' +import { WebSocketServer, type WebSocket } from 'ws' +import type { RpcTransport } from './transport' + +const MAX_WS_MESSAGE_BYTES = 1024 * 1024 +const MAX_WS_CONNECTIONS = 32 + +export type WebSocketTransportOptions = { + host: string + port: number + tlsCert?: string + tlsKey?: string +} + +export class WebSocketTransport implements RpcTransport { + private readonly host: string + private readonly port: number + private readonly tlsCert: string | undefined + private readonly tlsKey: string | undefined + private httpServer: HttpsServer | HttpServer | null = null + private wss: WebSocketServer | null = null + private messageHandler: + | ((msg: string, reply: (response: string) => void, ws: WebSocket) => void) + | null = null + private connectionCloseHandler: ((clientId: string) => void) | null = null + // Why: maps each WebSocket to the clientId (deviceToken) that authenticated it, + // so ws.on('close') can notify the runtime which mobile client disconnected. + private wsClientIds = new Map() + + constructor({ host, port, tlsCert, tlsKey }: WebSocketTransportOptions) { + this.host = host + this.port = port + this.tlsCert = tlsCert + this.tlsKey = tlsKey + } + + onMessage( + handler: (msg: string, reply: (response: string) => void, ws: WebSocket) => void + ): void { + this.messageHandler = handler + } + + onConnectionClose(handler: (clientId: string) => void): void { + this.connectionCloseHandler = handler + } + + setClientId(ws: WebSocket, clientId: string): void { + this.wsClientIds.set(ws, clientId) + } + + // Why: when port 0 is passed the OS assigns a random available port. The + // runtime metadata and mobile QR code need the real port, so callers read + // it here after start() resolves. + get resolvedPort(): number { + const addr = this.httpServer?.address() + if (addr && typeof addr === 'object') { + return addr.port + } + return this.port + } + + async start(): Promise { + if (this.wss) { + return + } + + // Why: when the preferred port is occupied (e.g. another Orca instance is + // already running), fall back to an OS-assigned port so mobile pairing + // still works. The QR code reads resolvedPort after start, so it will + // advertise the correct port regardless. + let port = this.port + try { + await this.tryListen(port) + } catch (error: unknown) { + if (isEAddressInUse(error) && port !== 0) { + console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) + port = 0 + await this.tryListen(port) + } else { + throw error + } + } + } + + private createHttpServer(): HttpServer | HttpsServer { + return this.tlsCert && this.tlsKey + ? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey }) + : createHttpServer() + } + + // Why: the WebSocketServer is attached only after listen succeeds. If we + // attached it before, the WSS would re-emit the EADDRINUSE error from the + // httpServer as an uncatchable exception, preventing the fallback from working. + private async tryListen(port: number): Promise { + const httpServer = this.createHttpServer() + + await new Promise((resolve, reject) => { + httpServer.once('error', reject) + httpServer.listen(port, this.host, () => { + httpServer.off('error', reject) + resolve() + }) + }) + + const wss = new WebSocketServer({ + server: httpServer, + maxPayload: MAX_WS_MESSAGE_BYTES + }) + + wss.on('connection', (ws) => { + if (wss.clients.size > MAX_WS_CONNECTIONS) { + ws.close(1013, 'Maximum connections reached') + return + } + this.handleConnection(ws) + }) + + this.httpServer = httpServer + this.wss = wss + } + + async stop(): Promise { + const wss = this.wss + const httpServer = this.httpServer + this.wss = null + this.httpServer = null + + if (wss) { + for (const client of wss.clients) { + client.close(1001, 'Server shutting down') + } + wss.close() + } + + if (httpServer) { + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + } + + // Why: WebSocket connections are long-lived (unlike Unix socket which is + // one-per-request). Multiple requests can be multiplexed on the same + // connection via the RPC `id` field. The transport delegates all auth + // and dispatch logic to the message handler set by OrcaRuntimeRpcServer. + private handleConnection(ws: WebSocket): void { + ws.on('message', (data) => { + const msg = typeof data === 'string' ? data : data.toString('utf-8') + this.messageHandler?.( + msg, + (response) => { + // Why: mobile clients disconnect frequently (backgrounding, network + // switch, phone locked). Guard writes to avoid errors on dead sockets. + if (ws.readyState === ws.OPEN) { + ws.send(response) + } + }, + ws + ) + }) + + // Why: mobile clients disconnect when the phone locks, loses wifi, or + // backgrounds the app. The runtime must clean up connection-scoped state + // (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop. + ws.on('close', () => { + const clientId = this.wsClientIds.get(ws) + this.wsClientIds.delete(ws) + if (clientId) { + this.connectionCloseHandler?.(clientId) + } + }) + + ws.on('error', () => { + ws.close() + }) + } +} + +function isEAddressInUse(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE' +} diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index 01774eca88f..4e93e909dd6 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -26,10 +26,12 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) @@ -37,10 +39,12 @@ describe('runtime metadata', () => { expect(readRuntimeMetadata(userDataPath)).toEqual({ runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) @@ -53,7 +57,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: null, + transports: [], authToken: null, startedAt: 100 }) @@ -71,7 +75,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_owner', pid: 42, - transport: null, + transports: [], authToken: null, startedAt: 100 }) @@ -87,7 +91,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_replacement', pid: 999, - transport: null, + transports: [], authToken: null, startedAt: 200 }) @@ -109,7 +113,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_replacement', pid: 42, - transport: null, + transports: [], authToken: null, startedAt: 200 }) @@ -140,10 +144,12 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 77857c19abd..d07fc93b01b 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -167,8 +167,8 @@ describe('OrcaRuntimeRpcServer', () => { const metadata = readRuntimeMetadata(userDataPath) expect(metadata?.runtimeId).toBe(runtime.getRuntimeId()) expect(metadata?.authToken).toBeTruthy() - expect(metadata?.transport?.endpoint).toBeTruthy() - expect(metadata?.transport).toEqual(server['transport']) + expect(metadata?.transports?.[0]?.endpoint).toBeTruthy() + expect(metadata?.transports).toEqual(server['transports']) await server.stop() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ @@ -215,8 +215,8 @@ describe('OrcaRuntimeRpcServer', () => { await expect(server.start()).rejects.toThrow('write failed') expect(readRuntimeMetadata(userDataPath)).toBeNull() expect(existsSync(endpoint)).toBe(false) - expect(server['transport']).toBeNull() - expect(server['server']).toBeNull() + expect(server['transports']).toEqual([]) + expect(server['activeTransports']).toEqual([]) writeMetadataSpy.mockRestore() }) @@ -229,7 +229,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_1', authToken: metadata!.authToken, method: 'status.get' @@ -255,7 +255,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_1', authToken: 'wrong', method: 'status.get' @@ -280,7 +280,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { authToken: metadata!.authToken, method: 'status.get' }) @@ -336,7 +336,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const listResponse = await sendRequest(metadata!.transport!.endpoint, { + const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_list', authToken: metadata!.authToken, method: 'terminal.list', @@ -360,7 +360,7 @@ describe('OrcaRuntimeRpcServer', () => { ).handle expect(handle).toBeTruthy() - const showResponse = await sendRequest(metadata!.transport!.endpoint, { + const showResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_show', authToken: metadata!.authToken, method: 'terminal.show', @@ -373,7 +373,7 @@ describe('OrcaRuntimeRpcServer', () => { ok: true }) - const readResponse = await sendRequest(metadata!.transport!.endpoint, { + const readResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_read', authToken: metadata!.authToken, method: 'terminal.read', @@ -386,7 +386,7 @@ describe('OrcaRuntimeRpcServer', () => { ok: true }) - const sendResponse = await sendRequest(metadata!.transport!.endpoint, { + const sendResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_send', authToken: metadata!.authToken, method: 'terminal.send', @@ -402,7 +402,7 @@ describe('OrcaRuntimeRpcServer', () => { }) expect(writes).toEqual(['continue', '\r']) - const waitPromise = sendRequest(metadata!.transport!.endpoint, { + const waitPromise = sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_wait', authToken: metadata!.authToken, method: 'terminal.wait', @@ -462,7 +462,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_ps', authToken: metadata!.authToken, method: 'worktree.ps' @@ -503,7 +503,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_worktrees', authToken: metadata!.authToken, method: 'worktree.list', @@ -533,7 +533,7 @@ describe('OrcaRuntimeRpcServer', () => { const metadata = readRuntimeMetadata(userDataPath) const response = await new Promise>((resolve, reject) => { - const socket = createConnection(metadata!.transport!.endpoint) + const socket = createConnection(metadata!.transports[0]!.endpoint) let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) @@ -581,7 +581,7 @@ describe('OrcaRuntimeRpcServer', () => { try { const metadata = readRuntimeMetadata(userDataPath) - const session = openFramedSession(metadata!.transport!.endpoint, { + const session = openFramedSession(metadata!.transports[0]!.endpoint, { id: 'req_wait', authToken: metadata!.authToken, method: 'orchestration.check', @@ -621,7 +621,7 @@ describe('OrcaRuntimeRpcServer', () => { try { const metadata = readRuntimeMetadata(userDataPath) - const endpoint = metadata!.transport!.endpoint + const endpoint = metadata!.transports[0]!.endpoint // Fill the cap with two long waits (10s each — we'll kill them). const a = openFramedSession(endpoint, { @@ -681,7 +681,7 @@ describe('OrcaRuntimeRpcServer', () => { try { const metadata = readRuntimeMetadata(userDataPath) - const endpoint = metadata!.transport!.endpoint + const endpoint = metadata!.transports[0]!.endpoint const a = openFramedSession(endpoint, { id: 'req_a', @@ -722,6 +722,73 @@ describe('OrcaRuntimeRpcServer', () => { await server.stop() } }) + + it('does not emit keepalive frames for short RPCs', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + // Why: a 10ms interval means any frame in the first ~100ms of a short + // RPC would show up; `status.get` returns in <10ms so no keepalive + // should ever fire. Locks in the "keepalive is long-poll-only" invariant + // so a future refactor can't silently re-broaden the timer. + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 10 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_short', + authToken: metadata!.authToken, + method: 'status.get' + }) + await session.done + + const keepalives = session.frames.filter((f) => f._keepalive === true) + const terminals = session.frames.filter((f) => f.ok !== undefined) + expect(terminals).toHaveLength(1) + expect(terminals[0]).toMatchObject({ id: 'req_short', ok: true }) + expect(keepalives).toHaveLength(0) + } finally { + await server.stop() + } + }) + + it('returns an internal_error envelope when the dispatcher throws', async () => { + // Why: handlers are designed to return error envelopes, never to throw, + // but a bug somewhere in the RPC stack (e.g. JSON.stringify choking on + // a response with circular refs) must still produce a terminal frame. + // Without the `.catch` on handleMessage's promise, a throw would leave + // the client hanging until the 30s idle timer and leak the dispatch's + // AbortController in the transport's in-flight set. + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + + // Force the dispatcher to throw a non-envelope error. + const originalDispatch = server['dispatcher'].dispatch.bind(server['dispatcher']) + server['dispatcher'].dispatch = vi.fn().mockRejectedValue(new Error('boom')) + + try { + const metadata = readRuntimeMetadata(userDataPath) + const response = await sendRequest(metadata!.transports[0]!.endpoint, { + id: 'req_throw', + authToken: metadata!.authToken, + method: 'status.get' + }) + expect(response).toMatchObject({ + id: 'req_throw', + ok: false, + error: { code: 'internal_error', message: 'boom' } + }) + } finally { + server['dispatcher'].dispatch = originalDispatch + await server.stop() + } + }) }) // Why: §6 test for the idempotent + hard-fail schema migration. A broken diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 70dbd4af1f3..b2e4cf27660 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1,12 +1,11 @@ /* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */ // Why: this is the single security boundary for the bundled CLI. It owns -// transport setup (unix socket / named pipe), auth-token enforcement, and -// bootstrap-metadata publication so a running runtime is always discoverable -// via exactly one on-disk file. Method handling lives in `rpc/` so this file -// stays easy to audit in one sitting. +// auth-token enforcement, bootstrap-metadata publication, and transport +// orchestration so a running runtime is always discoverable via exactly +// one on-disk file. Method handling lives in `rpc/` and transport specifics +// live in `rpc/unix-socket-transport.ts` and `rpc/ws-transport.ts`. import { randomBytes } from 'crypto' -import { createServer, type Server, type Socket } from 'net' -import { chmodSync, existsSync, readdirSync, rmSync } from 'fs' +import { readdirSync, rmSync } from 'fs' import { join } from 'path' import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' import type { OrcaRuntimeService } from './orca-runtime' @@ -14,12 +13,23 @@ import { writeRuntimeMetadata } from './runtime-metadata' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest, RpcResponse } from './rpc/core' import { errorResponse } from './rpc/errors' +import type { RpcMessageContext, RpcTransport } from './rpc/transport' +import { UnixSocketTransport } from './rpc/unix-socket-transport' +import { WebSocketTransport } from './rpc/ws-transport' +import type { WebSocket } from 'ws' +import { DeviceRegistry } from './device-registry' +import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { E2EEChannel } from './rpc/e2ee-channel' + +const DEFAULT_WS_PORT = 6768 type OrcaRuntimeRpcServerOptions = { runtime: OrcaRuntimeService userDataPath: string pid?: number platform?: NodeJS.Platform + enableWebSocket?: boolean + wsPort?: number // Why: test-only overrides for the two time-bound constants below. // Production callers must not pass these — defaults are set by the design // doc (§3.1) and changing them in production would weaken the admission @@ -28,10 +38,6 @@ type OrcaRuntimeRpcServerOptions = { longPollCap?: number } -const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 -const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000 -const MAX_RUNTIME_RPC_CONNECTIONS = 32 - // Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}` // frame every 10 s until the handler resolves. Each write resets both the // server's own socket idle timer (30 s) and — once §3.1 ships on the client — @@ -69,11 +75,19 @@ export class OrcaRuntimeRpcServer { private readonly userDataPath: string private readonly pid: number private readonly platform: NodeJS.Platform + private readonly enableWebSocket: boolean + private readonly wsPort: number private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number private readonly longPollCap: number - private server: Server | null = null - private transport: RuntimeTransportMetadata | null = null + private deviceRegistry: DeviceRegistry | null = null + private e2eeKeypair: E2EEKeypair | null = null + private tlsFingerprint: string | null = null + private activeTransports: RpcTransport[] = [] + private transports: RuntimeTransportMetadata[] = [] + // Why: each WebSocket connection has its own E2EE channel that manages the + // handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance. + private e2eeChannels = new Map() // Why: separate from Node's server.maxConnections because we need to count // only long-running dispatches, not every in-flight short RPC. See §3.1 + // §7 risk #2. @@ -84,6 +98,8 @@ export class OrcaRuntimeRpcServer { userDataPath, pid = process.pid, platform = process.platform, + enableWebSocket = false, + wsPort = DEFAULT_WS_PORT, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP }: OrcaRuntimeRpcServerOptions) { @@ -92,12 +108,35 @@ export class OrcaRuntimeRpcServer { this.userDataPath = userDataPath this.pid = pid this.platform = platform + this.enableWebSocket = enableWebSocket + this.wsPort = wsPort this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap } + getDeviceRegistry(): DeviceRegistry | null { + return this.deviceRegistry + } + + getTlsFingerprint(): string | null { + return this.tlsFingerprint + } + + getE2EEPublicKey(): string | null { + return this.e2eeKeypair?.publicKeyB64 ?? null + } + + getE2EEKeypair(): E2EEKeypair | null { + return this.e2eeKeypair + } + + getWebSocketEndpoint(): string | null { + const ws = this.transports.find((t) => t.kind === 'websocket') + return ws?.endpoint ?? null + } + async start(): Promise { - if (this.server) { + if (this.activeTransports.length > 0) { return } @@ -111,82 +150,160 @@ export class OrcaRuntimeRpcServer { sweepOrphanedRuntimeSockets(this.userDataPath, this.pid) } - const transport = createRuntimeTransportMetadata( + const transportMeta = createRuntimeTransportMetadata( this.userDataPath, this.pid, this.platform, this.runtime.getRuntimeId() ) - if (transport.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } - const server = createServer((socket) => { - this.handleConnection(socket) + const socketTransport = new UnixSocketTransport({ + endpoint: transportMeta.endpoint, + kind: transportMeta.kind as 'unix' | 'named-pipe', + keepaliveIntervalMs: this.keepaliveIntervalMs }) - server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(transport.endpoint, () => { - server.off('error', reject) - resolve() - }) + // Why: Unix socket transport uses the shared runtime auth token. This is + // the existing security model for CLI connections — the token lives in a + // 0o600-permissioned file on disk. + // Why: the `.catch` guarantees `reply()` always fires even if + // `handleMessage` (or `JSON.stringify` on a pathological response) throws. + // Without it, a throw would leave the client waiting for a terminal frame + // that never arrives AND leak the dispatch's AbortController in the + // transport's in-flight set until the 30 s socket idle timer closes the + // connection. + socketTransport.onMessage((msg, reply, context) => { + void this.handleMessage(msg, context) + .then((response) => { + reply(JSON.stringify(response)) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + // Why: best-effort id recovery so the client can correlate the + // error frame to its pending request. A malformed message would + // have been caught by handleMessage and returned an envelope + // instead of throwing, so in practice the id is always present. + let id = 'unknown' + try { + const parsed = JSON.parse(msg) as { id?: unknown } + if (typeof parsed.id === 'string' && parsed.id.length > 0) { + id = parsed.id + } + } catch { + // ignore — fall through with id='unknown' + } + reply(JSON.stringify(this.buildError(id, 'internal_error', message))) + }) }) - if (transport.kind === 'unix') { - chmodSync(transport.endpoint, 0o600) + + await socketTransport.start() + + const activeTransports: RpcTransport[] = [socketTransport] + const transportsMeta: RuntimeTransportMetadata[] = [transportMeta] + + // Why: WebSocket transport is opt-in and starts alongside the Unix socket. + // It uses per-device tokens and E2EE (application-layer encryption via + // tweetnacl) rather than TLS, since React Native can't pin self-signed certs. + if (this.enableWebSocket) { + try { + this.deviceRegistry = new DeviceRegistry(this.userDataPath) + this.e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) + + const wsTransport = new WebSocketTransport({ + host: '0.0.0.0', + port: this.wsPort + }) + + // Why: each WebSocket connection gets an E2EE channel that handles the + // handshake before any RPC messages are processed. The channel decrypts + // inbound messages and encrypts outbound replies transparently. + wsTransport.onMessage((msg, _reply, ws) => { + let channel = this.e2eeChannels.get(ws) + if (!channel) { + channel = new E2EEChannel(ws, { + serverSecretKey: this.e2eeKeypair!.secretKey, + validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, + onReady: (ch) => { + if (ch.deviceToken) { + wsTransport.setClientId(ws, ch.deviceToken) + // Why: mark the device as actually connected so it appears + // in the "Paired Devices" list. Devices that were only + // generated as QR codes but never scanned stay hidden. + const device = this.deviceRegistry?.validateToken(ch.deviceToken) + if (device) { + this.deviceRegistry?.updateLastSeen(device.deviceId) + } + } + }, + onError: (code, reason) => { + this.e2eeChannels.get(ws)?.destroy() + this.e2eeChannels.delete(ws) + ws.close(code, reason) + } + }) + channel.onMessage((plaintext, encryptedReply) => { + void this.handleWebSocketMessage(plaintext, encryptedReply, wsTransport, ws) + }) + this.e2eeChannels.set(ws, channel) + } + channel.handleRawMessage(msg) + }) + + // Why: when a mobile client disconnects, the runtime must clean up + // connection-scoped state like mobile-fit overrides and the E2EE + // channel to prevent orphaned state. + wsTransport.onConnectionClose((clientId) => { + for (const [ws, channel] of this.e2eeChannels) { + if (channel.deviceToken === clientId) { + channel.destroy() + this.e2eeChannels.delete(ws) + break + } + } + this.runtime.onClientDisconnected(clientId) + }) + + await wsTransport.start() + activeTransports.push(wsTransport) + transportsMeta.push({ + kind: 'websocket', + endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}` + }) + } catch (error) { + // Why: WebSocket transport is supplementary — the runtime must still + // function if it fails to start (e.g., port in use). Log and continue + // with Unix socket only. + console.error('[runtime] Failed to start WebSocket transport:', error) + } } // Why: publish the transport into in-memory state before writing metadata // so the bootstrap file always contains the real endpoint/token pair. The // CLI only discovers the runtime through that file. - this.server = server - this.transport = transport + this.activeTransports = activeTransports + this.transports = transportsMeta try { this.writeMetadata() } catch (error) { // Why: a runtime that cannot publish bootstrap metadata is invisible to - // the `orca` CLI. Close the socket immediately instead of leaving behind - // a live but undiscoverable control plane. - this.server = null - this.transport = null - await new Promise((resolve, reject) => { - server.close((closeError) => { - if (closeError) { - reject(closeError) - return - } - resolve() - }) - }).catch(() => {}) - if (transport.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } + // the `orca` CLI. Close all transports immediately instead of leaving + // behind a live but undiscoverable control plane. + this.activeTransports = [] + this.transports = [] + await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) throw error } } async stop(): Promise { - const server = this.server - const transport = this.transport - this.server = null - this.transport = null - if (!server) { + const transports = this.activeTransports + this.activeTransports = [] + this.transports = [] + if (transports.length === 0) { return } - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) - if (transport?.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } + await Promise.all(transports.map((t) => t.stop())) // Why: we intentionally leave the last metadata file behind instead of // deleting it on shutdown. Shared userData paths can briefly host multiple // Orca processes during restarts, updates, or development, and stale @@ -194,120 +311,53 @@ export class OrcaRuntimeRpcServer { // bootstrap file. } - private handleConnection(socket: Socket): void { - let buffer = '' + // Why: Unix socket messages use one-shot dispatch (single response per + // request) and the shared runtime auth token from the 0o600 metadata file. + // The transport layer owns socket lifecycle, keepalive writes, and the + // per-connection abort signal — this method just parses, auths, and + // dispatches. See design doc §3.1. + private async handleMessage( + rawMessage: string, + context?: RpcMessageContext + ): Promise { + // Why: empty messages are sent by the Unix socket transport layer when a + // client exceeds the max message size. The transport closes the connection + // after this response. + if (!rawMessage) { + return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size') + } - socket.setEncoding('utf8') - socket.setNoDelay(true) - socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { - socket.destroy() - }) - socket.on('error', () => { - socket.destroy() - }) - socket.on('data', (chunk: string) => { - buffer += chunk - // Why: the Orca runtime lives in Electron main, so it must reject - // oversized local RPC frames instead of letting a local client grow an - // unbounded buffer and stall the app. - if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { - socket.write( - `${JSON.stringify(this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n` - ) - socket.end() - return - } - let newlineIndex = buffer.indexOf('\n') - while (newlineIndex !== -1) { - const rawMessage = buffer.slice(0, newlineIndex).trim() - buffer = buffer.slice(newlineIndex + 1) - if (rawMessage) { - void this.handleRequest(socket, rawMessage) - } - newlineIndex = buffer.indexOf('\n') - } - }) - } - - // Why: a single entry point per inbound request so keepalive + long-poll - // admission + AbortController wiring + response write all live in one - // place. See design doc §3.1. - private async handleRequest(socket: Socket, rawMessage: string): Promise { const parsed = this.parseAndAuth(rawMessage) if ('error' in parsed) { - this.safeWrite(socket, `${JSON.stringify(parsed.error)}\n`) - return + return parsed.error } const request = parsed.request // Why: long-poll admission fence. Short RPCs bypass the counter entirely // — it only guards handlers that can block for minutes. See §7 risk #2. const longPoll = isLongPollRequest(request) + if (longPoll && this.activeLongPolls >= this.longPollCap) { + return this.buildError( + request.id, + 'runtime_busy', + 'long-poll capacity reached; retry with backoff' + ) + } if (longPoll) { - if (this.activeLongPolls >= this.longPollCap) { - const busy = this.buildError( - request.id, - 'runtime_busy', - 'long-poll capacity reached; retry with backoff' - ) - this.safeWrite(socket, `${JSON.stringify(busy)}\n`) - socket.end() - return - } this.activeLongPolls += 1 - } - - // Why: `decremented` must guard against double-decrement when both - // `close` and a post-resolve cleanup path fire. `socket.on('close')` is - // the only path that fires for every termination (normal end, destroy, - // idle timer, client kill -9, OS reset), so it carries the decrement. - // Tying it to `.finally` alone would leak a slot any time a client dies - // mid-wait because the inner waitForMessage can keep counting down for - // minutes after the socket is gone. See §3.1 counter-lifecycle. - let decremented = !longPoll - const abortController = new AbortController() - const onClose = (): void => { - if (!decremented) { - decremented = true - this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) - } - abortController.abort() - } - socket.on('close', onClose) - - // Why: for long-poll requests we start a keepalive ticker after 10 s. The - // first frame at 10 s resets both the server's 30 s idle timer and the - // client's configured timeout. Short RPCs never see a keepalive — the - // ticker never fires because the handler resolves first. - let keepaliveTimer: NodeJS.Timeout | null = null - if (longPoll) { - keepaliveTimer = setInterval(() => { - if (socket.writable && !socket.destroyed) { - socket.write('{"_keepalive":true}\n') - } - }, this.keepaliveIntervalMs) - // Why: don't hold the process open solely on the keepalive interval — - // .unref() lets the event loop exit when nothing else is pending. - if (typeof keepaliveTimer.unref === 'function') { - keepaliveTimer.unref() - } + // Why: arm the keepalive timer only for long-polls. Short RPCs never + // touch it so the `setInterval` is never created. See §3.1. + context?.startKeepalive() } try { - const response = await this.dispatcher.dispatch(request, { - signal: longPoll ? abortController.signal : undefined + return await this.dispatcher.dispatch(request, { + signal: longPoll ? context?.signal : undefined }) - if (!socket.destroyed) { - this.safeWrite(socket, `${JSON.stringify(response)}\n`) - } } finally { - if (keepaliveTimer) { - clearInterval(keepaliveTimer) + if (longPoll) { + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) } - // Why: the close-handler path is still what decrements the counter (the - // socket may be closed by the client before the response write flushes). - // We don't remove the listener here — `once` semantics are handled by - // the boolean guard. } } @@ -335,16 +385,51 @@ export class OrcaRuntimeRpcServer { return { request } } - private safeWrite(socket: Socket, payload: string): void { - if (socket.destroyed || !socket.writable) { + // Why: WebSocket messages go through streaming dispatch which can emit + // multiple responses. Auth uses per-device tokens from the device registry. + private async handleWebSocketMessage( + rawMessage: string, + reply: (response: string) => void, + wsTransport?: WebSocketTransport, + ws?: WebSocket + ): Promise { + let request: RpcRequest + try { + request = JSON.parse(rawMessage) as RpcRequest + } catch { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) return } - try { - socket.write(payload) - } catch { - // Socket was closed in between the writable check and the write — - // nothing we can do; the client already disconnected. + + if (typeof request.id !== 'string' || request.id.length === 0) { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id'))) + return } + if (typeof request.method !== 'string' || request.method.length === 0) { + reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method'))) + return + } + + const token = + typeof (request as Record).deviceToken === 'string' + ? ((request as Record).deviceToken as string) + : null + if (!token) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) + return + } + if (!this.deviceRegistry?.validateToken(token)) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) + return + } + + // Why: associate the deviceToken with this WebSocket so ws.on('close') + // can notify the runtime which mobile client disconnected. + if (wsTransport && ws) { + wsTransport.setClientId(ws, token) + } + + await this.dispatcher.dispatchStreaming(request, reply) } private buildError(id: string, code: string, message: string): RpcResponse { @@ -355,7 +440,7 @@ export class OrcaRuntimeRpcServer { const metadata: RuntimeMetadata = { runtimeId: this.runtime.getRuntimeId(), pid: this.pid, - transport: this.transport, + transports: this.transports, authToken: this.authToken, startedAt: this.runtime.getStartedAt() } diff --git a/src/main/runtime/tls-certificate.ts b/src/main/runtime/tls-certificate.ts new file mode 100644 index 00000000000..1972d13d16e --- /dev/null +++ b/src/main/runtime/tls-certificate.ts @@ -0,0 +1,63 @@ +// Why: the WebSocket transport uses wss:// with a self-signed TLS certificate +// to prevent passive sniffing of device tokens on shared WiFi networks. The +// cert is generated once on first run and reused across restarts. The mobile +// app pins the certificate fingerprint received during QR pairing. +import { createHash } from 'crypto' +import { execSync } from 'child_process' +import { existsSync, readFileSync, chmodSync } from 'fs' +import { join } from 'path' + +const TLS_CERT_FILENAME = 'orca-tls-cert.pem' +const TLS_KEY_FILENAME = 'orca-tls-key.pem' + +export type TlsCertificate = { + cert: string + key: string + fingerprint: string +} + +export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate { + const certPath = join(userDataPath, TLS_CERT_FILENAME) + const keyPath = join(userDataPath, TLS_KEY_FILENAME) + + if (existsSync(certPath) && existsSync(keyPath)) { + const cert = readFileSync(certPath, 'utf-8') + const key = readFileSync(keyPath, 'utf-8') + const fingerprint = computeFingerprint(cert) + if (fingerprint) { + return { cert, key, fingerprint } + } + // Why: if the existing cert is malformed (e.g., from a buggy earlier + // generation), regenerate rather than failing the WebSocket transport. + } + + const keyPath_ = join(userDataPath, TLS_KEY_FILENAME) + const certPath_ = join(userDataPath, TLS_CERT_FILENAME) + + // Why: openssl is available on macOS, Linux, and Windows (via Git Bash). + // Using it avoids hand-rolling ASN.1 DER encoding which is error-prone. + execSync( + `openssl req -new -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` + + `-nodes -days 3650 -subj "/CN=Orca Runtime" ` + + `-keyout "${keyPath_}" -out "${certPath_}" 2>/dev/null` + ) + + chmodSync(keyPath_, 0o600) + chmodSync(certPath_, 0o600) + + const cert = readFileSync(certPath_, 'utf-8') + const key = readFileSync(keyPath_, 'utf-8') + return { cert, key, fingerprint: computeFingerprint(cert)! } +} + +function computeFingerprint(certPem: string): string | null { + const derMatch = certPem.match( + /-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/ + ) + if (!derMatch?.[1]) { + return null + } + const der = Buffer.from(derMatch[1].replace(/\n/g, ''), 'base64') + const hash = createHash('sha256').update(der).digest('hex') + return `sha256:${hash}` +} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index c5ba117331d..58099648cf4 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { app, clipboard, ipcMain, nativeImage, session } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import type { CreateWorktreeResult } from '../../shared/types' +import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' @@ -199,56 +199,43 @@ function registerRuntimeWindowLifecycle( runtime: OrcaRuntimeService ): void { runtime.attachWindow(mainWindow.id) - runtime.setNotifier({ - worktreesChanged: (repoId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('worktrees:changed', { repoId }) - } - }, - reposChanged: () => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('repos:changed') - } - }, - activateWorktree: (repoId, worktreeId, setup?: CreateWorktreeResult['setup']) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:activateWorktree', { repoId, worktreeId, setup }) - } - }, - createTerminal: (worktreeId, opts) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:createTerminal', { - worktreeId, - command: opts.command, - title: opts.title - }) - } - }, - splitTerminal: (tabId, paneRuntimeId, opts) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:splitTerminal', { - tabId, - paneRuntimeId, - direction: opts.direction, - command: opts.command - }) - } - }, - renameTerminal: (tabId, title) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:renameTerminal', { tabId, title }) - } - }, - focusTerminal: (tabId, worktreeId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:focusTerminal', { tabId, worktreeId }) - } - }, - closeTerminal: (tabId, paneRuntimeId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:closeTerminal', { tabId, paneRuntimeId }) - } + const send = (channel: string, ...args: unknown[]): void => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, ...args) } + } + runtime.setNotifier({ + worktreesChanged: (repoId) => send('worktrees:changed', { repoId }), + reposChanged: () => send('repos:changed'), + activateWorktree: ( + repoId, + worktreeId, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch + ) => { + send('ui:activateWorktree', { + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}) + }) + }, + createTerminal: (worktreeId, opts) => + send('ui:createTerminal', { worktreeId, command: opts.command, title: opts.title }), + splitTerminal: (tabId, paneRuntimeId, opts) => { + send('ui:splitTerminal', { + tabId, + paneRuntimeId, + direction: opts.direction, + command: opts.command + }) + }, + renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), + focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }), + closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), + sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => + send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }) }) // Why: the runtime must fail closed while the renderer graph is being torn // down or rebuilt, otherwise future CLI calls could act on stale terminal diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3c5551eb3b8..f687cb8d36f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -58,6 +58,7 @@ import type { Worktree, WorktreeMeta, WorktreeSetupLaunch, + WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' import type { @@ -440,6 +441,13 @@ export type PreloadApi = { onData: (callback: (data: { id: string; data: string }) => void) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void + onSerializeBufferRequest: ( + callback: (data: { requestId: string; ptyId: string }) => void + ) => () => void + sendSerializedBuffer: ( + requestId: string, + snapshot: { data: string; cols: number; rows: number } | null + ) => void management: PtyManagementApi } feedback: { @@ -863,7 +871,12 @@ export type PreloadApi = { onToggleStatusBar: (callback: () => void) => () => void onExportPdfRequested: (callback: () => void) => () => void onActivateWorktree: ( - callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void + callback: (data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: WorktreeStartupLaunch + }) => void ) => () => void onCreateTerminal: ( callback: (data: { worktreeId: string; command?: string; title?: string }) => void @@ -897,6 +910,7 @@ export type PreloadApi = { onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void + onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise saveClipboardImageAsTempFile: () => Promise @@ -922,6 +936,18 @@ export type PreloadApi = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise getStatus: () => Promise + getTerminalFitOverrides: () => Promise< + { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + > + restoreTerminalFit: (ptyId: string) => Promise<{ restored: boolean }> + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ) => () => void } rateLimits: { get: () => Promise @@ -1011,6 +1037,22 @@ export type PreloadApi = { }) => void ) => () => void } + mobile: { + listNetworkInterfaces: () => Promise<{ + interfaces: { name: string; address: string }[] + }> + getPairingQR: (args?: { + address?: string + }) => Promise< + | { available: false } + | { available: true; qrDataUrl: string; endpoint: string; deviceId: string } + > + listDevices: () => Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> + revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }> + isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> + } } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index c568c75a24d..0b4cac85b22 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -395,6 +395,24 @@ const api = { return () => ipcRenderer.removeListener('pty:exit', listener) }, + onSerializeBufferRequest: ( + callback: (data: { requestId: string; ptyId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; ptyId: string } + ) => callback(data) + ipcRenderer.on('pty:serializeBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) + }, + + sendSerializedBuffer: ( + requestId: string, + snapshot: { data: string; cols: number; rows: number } | null + ): void => { + ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) + }, + management: { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), @@ -1462,6 +1480,7 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } }) => void ): (() => void) => { const listener = ( @@ -1470,6 +1489,7 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) @@ -1558,6 +1578,12 @@ const api = { ipcRenderer.on('ui:closeTerminal', listener) return () => ipcRenderer.removeListener('ui:closeTerminal', listener) }, + onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:sleepWorktree', listener) + return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) @@ -1673,7 +1699,27 @@ const api = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), - getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus') + getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + getTerminalFitOverrides: (): Promise< + { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), + restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => + ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { ptyId: string; mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number } + ) => callback(data) + ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) + } }, rateLimits: { @@ -1824,6 +1870,29 @@ const api = { getConfig: () => preloadE2EConfig }, + mobile: { + listNetworkInterfaces: (): Promise<{ + interfaces: { name: string; address: string }[] + }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + + getPairingQR: (args?: { + address?: string + }): Promise< + | { available: false } + | { available: true; qrDataUrl: string; endpoint: string; deviceId: string } + > => ipcRenderer.invoke('mobile:getPairingQR', args), + + listDevices: (): Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> => ipcRenderer.invoke('mobile:listDevices'), + + revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeDevice', args), + + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => + ipcRenderer.invoke('mobile:isWebSocketReady') + }, + agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: ( diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 57c12765ed5..8046ab172db 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { Copy, RotateCw } from 'lucide-react' import { toast } from 'sonner' -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' @@ -9,32 +9,9 @@ import { useAppStore } from '../../store' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './experimental-search' -import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' - -// Why: agents with a per-agent hook-service module under src/main that posts -// status to the shared agent-hooks server. Keep this list in sync with the -// hook-service.ts files — any agent without one will not appear in the inline -// per-workspace-card agent activity list even when the experimental setting -// is on. -const AGENT_DASHBOARD_SUPPORTED_AGENTS: readonly TuiAgent[] = [ - 'claude', - 'codex', - 'gemini', - 'cursor', - 'opencode' -] as const - -// Why: both AGENT_DASHBOARD_SUPPORTED_AGENTS and AGENT_CATALOG are static -// module-level constants, so the resolved {id, label} pairs never change at -// runtime. Computing this inside SupportedAgentsDisclaimer was O(N×M) work on -// every parent re-render — notably on every keystroke in the settings search — -// for a list that can only change at build time. Hoisting it makes the cost -// a one-time module-load expense. -const SUPPORTED_AGENT_ENTRIES: readonly { id: TuiAgent; label: string }[] = - AGENT_DASHBOARD_SUPPORTED_AGENTS.map((id) => { - const entry = AGENT_CATALOG.find((a) => a.id === id) - return { id, label: entry?.label ?? id } - }) +import { MobilePane } from './MobilePane' +import { SupportedAgentsDisclaimer } from './SupportedAgentsDisclaimer' +import { HiddenExperimentalGroup } from './HiddenExperimentalGroup' export { EXPERIMENTAL_PANE_SEARCH_ENTRIES } @@ -91,9 +68,10 @@ export function ExperimentalPane({ const showAgentDashboard = matchesSettingsSearch(searchQuery, [ EXPERIMENTAL_PANE_SEARCH_ENTRIES[0] ]) - const showSidekick = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[1]]) + const showMobile = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[1]]) + const showSidekick = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[2]]) const showOrchestration = matchesSettingsSearch(searchQuery, [ - EXPERIMENTAL_PANE_SEARCH_ENTRIES[2] + EXPERIMENTAL_PANE_SEARCH_ENTRIES[3] ]) const [orchestrationEnabled, setOrchestrationEnabled] = useState(() => { @@ -229,11 +207,71 @@ export function ExperimentalPane({ ) : null} + {showMobile ? ( + +
+
+ +

+ Control Orca from your phone by scanning a QR code. Beta / early preview — + expect bugs and breaking changes. Get started from the{' '} + + . +

+
+ +
+ + {settings.experimentalMobile ? ( +
+ +
+ ) : null} +
+ ) : null} + {showSidekick ? ( @@ -272,7 +310,7 @@ export function ExperimentalPane({
@@ -353,62 +391,3 @@ export function ExperimentalPane({
) } - -function SupportedAgentsDisclaimer(): React.JSX.Element { - return ( -
-
- Supported agents: - {SUPPORTED_AGENT_ENTRIES.map(({ id, label }) => ( - - - {label} - - ))} -
-

- We're currently working on support for more agent CLIs. -

-
- ) -} - -// Why: anything in this group is deliberately unfinished or staff-only. The -// orange treatment (header tint, label colors) is the shared visual signal -// for hidden-experimental items so future entries inherit the same -// affordance without another round of styling decisions. -function HiddenExperimentalGroup(): React.JSX.Element { - return ( -
-
-

- Hidden experimental -

-

- Unlisted toggles for internal testing. Nothing here is supported. -

-
- -
-
- -

- Does nothing today. Reserved as the first slot for hidden experimental options. -

-
- -
-
- ) -} diff --git a/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx b/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx new file mode 100644 index 00000000000..74ab617c988 --- /dev/null +++ b/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx @@ -0,0 +1,37 @@ +import { Label } from '../ui/label' + +// Why: anything in this group is deliberately unfinished or staff-only. The +// orange treatment (header tint, label colors) is the shared visual signal +// for hidden-experimental items so future entries inherit the same +// affordance without another round of styling decisions. +export function HiddenExperimentalGroup(): React.JSX.Element { + return ( +
+
+

+ Hidden experimental +

+

+ Unlisted toggles for internal testing. Nothing here is supported. +

+
+ +
+
+ +

+ Does nothing today. Reserved as the first slot for hidden experimental options. +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx new file mode 100644 index 00000000000..2dcc31ee03e --- /dev/null +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -0,0 +1,244 @@ +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react' +import { Button } from '../ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import type { SettingsSearchEntry } from './settings-search' + +export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Mobile Pairing', + description: 'Pair a mobile device by scanning a QR code.', + keywords: ['mobile', 'qr', 'code', 'pair', 'phone', 'scan'] + }, + { + title: 'Connected Devices', + description: 'Manage paired mobile devices.', + keywords: ['mobile', 'devices', 'revoke', 'paired', 'connected'] + }, + { + title: 'Network Interface', + description: 'Choose which network address to use for mobile pairing.', + keywords: ['network', 'interface', 'tailscale', 'vpn', 'overlay', 'ip', 'address', 'wifi'] + } +] + +type PairedDevice = { + deviceId: string + name: string + pairedAt: number + lastSeenAt: number +} + +type NetworkInterface = { + name: string + address: string +} + +export function MobilePane(): React.JSX.Element { + const [qrDataUrl, setQrDataUrl] = useState(null) + const [endpoint, setEndpoint] = useState(null) + const [loading, setLoading] = useState(false) + const [devices, setDevices] = useState([]) + const [qrEnlarged, setQrEnlarged] = useState(false) + const [networkInterfaces, setNetworkInterfaces] = useState([]) + const [selectedAddress, setSelectedAddress] = useState(undefined) + + const loadDevices = useCallback(async () => { + try { + const result = await window.api.mobile.listDevices() + setDevices(result.devices) + } catch { + // Silently fail — device list is non-critical + } + }, []) + + const loadNetworkInterfaces = useCallback(async () => { + try { + const result = await window.api.mobile.listNetworkInterfaces() + setNetworkInterfaces(result.interfaces) + if (result.interfaces.length > 0 && !selectedAddress) { + setSelectedAddress(result.interfaces[0]!.address) + } + } catch { + // Silently fail + } + }, [selectedAddress]) + + const generateQR = useCallback(async () => { + setLoading(true) + try { + const result = await window.api.mobile.getPairingQR( + selectedAddress ? { address: selectedAddress } : undefined + ) + if (result.available) { + setQrDataUrl(result.qrDataUrl) + setEndpoint(result.endpoint) + void loadDevices() + } else { + toast.error('WebSocket transport is not running') + } + } catch { + toast.error('Failed to generate QR code') + } finally { + setLoading(false) + } + }, [loadDevices, selectedAddress]) + + useEffect(() => { + void loadDevices() + void loadNetworkInterfaces() + }, [loadDevices, loadNetworkInterfaces]) + + // Why: after generating a QR code the device only appears once the phone + // actually connects (lastSeenAt > 0). Poll until a new device shows up. + const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) + useEffect(() => { + if (!qrDataUrl) { + setDeviceCountAtQr(null) + return + } + setDeviceCountAtQr(devices.length) + }, [qrDataUrl]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (deviceCountAtQr === null || devices.length > deviceCountAtQr) { + return + } + const interval = setInterval(() => void loadDevices(), 3000) + return () => clearInterval(interval) + }, [deviceCountAtQr, devices.length, loadDevices]) + + async function revokeDevice(deviceId: string) { + try { + await window.api.mobile.revokeDevice({ deviceId }) + setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId)) + toast.success('Device revoked') + } catch { + toast.error('Failed to revoke device') + } + } + + function formatInterfaceLabel(iface: NetworkInterface): string { + return `${iface.address} (${iface.name})` + } + + return ( +
+ {/* Network interface selector + generate */} +
+
+ + Network Interface +
+

+ Choose which network address to advertise in the QR code. Use your LAN address for + same-network pairing, or an overlay network address (Tailscale, ZeroTier) for + cross-network access. +

+
+ + +
+
+ + {/* QR code display */} + {qrDataUrl && ( +
+ + {endpoint && {endpoint}} +

+ Scan this code with the Orca mobile app. Each code creates a unique device token. +

+
+ )} + + {/* Paired devices */} +
+

Paired Devices

+ {devices.length === 0 ? ( +

+ {qrDataUrl + ? 'No devices paired yet. Scan the QR code with the Orca mobile app.' + : 'No devices paired yet.'} +

+ ) : ( +
+ {devices.map((device) => ( +
+
+
{device.name}
+
+ Paired {new Date(device.pairedAt).toLocaleDateString()} +
+
+ +
+ ))} +
+ )} + {devices.length > 0 && ( +

+ Revoking a device disconnects it immediately. +

+ )} +
+ + {/* Enlarged QR dialog */} + + + + Scan with Orca Mobile + + {qrDataUrl && ( +
+
+ QR Code for mobile pairing +
+ {endpoint && ( + {endpoint} + )} +
+ )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 34247e4bbd8..9e1ad0209e0 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -63,6 +63,7 @@ type SettingsNavTarget = | 'ssh' | 'experimental' | 'agents' + | 'mobile' | 'repo' type SettingsNavSection = { diff --git a/src/renderer/src/components/settings/SupportedAgentsDisclaimer.tsx b/src/renderer/src/components/settings/SupportedAgentsDisclaimer.tsx new file mode 100644 index 00000000000..66a609fd0eb --- /dev/null +++ b/src/renderer/src/components/settings/SupportedAgentsDisclaimer.tsx @@ -0,0 +1,50 @@ +import type { TuiAgent } from '../../../../shared/types' +import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' + +// Why: agents with a per-agent hook-service module under src/main that posts +// status to the shared agent-hooks server. Keep this list in sync with the +// hook-service.ts files — any agent without one will not appear in the inline +// per-workspace-card agent activity list even when the experimental setting +// is on. +const AGENT_DASHBOARD_SUPPORTED_AGENTS: readonly TuiAgent[] = [ + 'claude', + 'codex', + 'gemini', + 'cursor', + 'opencode' +] as const + +// Why: both AGENT_DASHBOARD_SUPPORTED_AGENTS and AGENT_CATALOG are static +// module-level constants, so the resolved {id, label} pairs never change at +// runtime. Computing this inside SupportedAgentsDisclaimer was O(N×M) work on +// every parent re-render — notably on every keystroke in the settings search — +// for a list that can only change at build time. Hoisting it makes the cost +// a one-time module-load expense. +const SUPPORTED_AGENT_ENTRIES: readonly { id: TuiAgent; label: string }[] = + AGENT_DASHBOARD_SUPPORTED_AGENTS.map((id) => { + const entry = AGENT_CATALOG.find((a) => a.id === id) + return { id, label: entry?.label ?? id } + }) + +export function SupportedAgentsDisclaimer(): React.JSX.Element { + return ( +
+
+ Supported agents: + {SUPPORTED_AGENT_ENTRIES.map(({ id, label }) => ( + + + {label} + + ))} +
+

+ We're currently working on support for more agent CLIs. +

+
+ ) +} diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 8f0b79c3b60..9066f38794d 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -21,6 +21,23 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'sidebar' ] }, + { + title: 'Mobile Pairing', + description: + 'Pair a mobile device to control Orca remotely. Experimental — requires the Orca mobile APK from GitHub Releases.', + keywords: [ + 'experimental', + 'mobile', + 'phone', + 'pair', + 'qr', + 'code', + 'scan', + 'remote', + 'android', + 'apk' + ] + }, { title: 'Sidekick', description: 'Floating animated sidekick in the bottom-right corner.', diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 9d938fc5bb5..06e852651b7 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -29,6 +29,12 @@ import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' +import { + getFitOverrideForPty, + getPaneIdsForPty, + onOverrideChange +} from '@/lib/pane-manager/mobile-fit-overrides' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' /** Global set of buffer-capture callbacks, one per mounted TerminalPane. * The beforeunload handler in App.tsx invokes every callback to populate @@ -100,6 +106,66 @@ export default function TerminalPane({ const searchStateRef = useRef({ query: '', caseSensitive: false, regex: false }) const [closeConfirmPaneId, setCloseConfirmPaneId] = useState(null) const [terminalError, setTerminalError] = useState(null) + // Why: override state lives in a plain Map for perf (safeFit reads it on + // every resize). This counter forces a re-render when overrides change so + // the mobile-fit banner appears/disappears. When an override is cleared + // (desktop-fit), we also trigger safeFit on affected panes so the terminal + // resizes back to desktop dimensions. + const [, setOverrideTick] = useState(0) + useEffect( + () => + onOverrideChange((event) => { + setOverrideTick((n) => n + 1) + if (event.mode === 'desktop-fit') { + const paneIds = getPaneIdsForPty(event.ptyId) + const manager = managerRef.current + if (!manager) { + return + } + // Why: fitAddon.fit() measures DOM dimensions, so it must run after + // the browser has settled layout. Running synchronously inside the + // IPC callback can produce stale measurements. rAF ensures the DOM + // is ready. The follow-up timeout acts as a safety net: if + // fitAddon.fit() silently threw (its errors are caught), the timeout + // falls back to a direct terminal.resize() using the restored + // dimensions from the runtime. This guarantees xterm exits mobile + // dims even when the DOM-based fit path fails. + const fitAffectedPanes = (): void => { + for (const paneId of paneIds) { + const pane = manager.getPanes().find((p) => p.id === paneId) + if (pane) { + safeFit(pane) + } + } + } + requestAnimationFrame(fitAffectedPanes) + // Why: belt-and-suspenders — if safeFit's fitAddon.fit() threw or + // was a no-op due to stale dimensions, this fallback uses the + // restored cols/rows from the runtime to force the resize. If + // safeFit already succeeded, the terminal is already at the right + // dims and this is a harmless no-op. + setTimeout(() => { + for (const paneId of paneIds) { + const pane = manager.getPanes().find((p) => p.id === paneId) + if (!pane) { + continue + } + safeFit(pane) + // Fallback: if terminal is still at mobile dims, force resize + // using the restored dimensions from the runtime notification. + if ( + event.cols > 0 && + event.rows > 0 && + (pane.terminal.cols !== event.cols || pane.terminal.rows !== event.rows) + ) { + pane.terminal.resize(event.cols, event.rows) + } + } + }, 100) + } + }), + [] + ) // Pane title state — keyed by ephemeral paneId, persisted via titlesByLeafId // in the layout snapshot. Ref keeps persistLayoutSnapshot closures fresh. @@ -1052,6 +1118,61 @@ export default function TerminalPane({ `pane-title-${pane.id}` ) })} + {(managerRef.current?.getPanes() ?? []).map((pane) => { + // Why: pane IDs can collide across tabs (e.g. tab 0 pane 1 and tab 1 + // pane 1). Using the transport's actual ptyId avoids showing banners + // on the wrong pane when IDs overlap. + const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() + const override = ptyId ? getFitOverrideForPty(ptyId) : null + if (!override) { + return null + } + return createPortal( +
+ + Terminal resized for phone ({override.cols}×{override.rows}) + + +
, + pane.container, + `mobile-fit-banner-${pane.id}` + ) + })} setCloseConfirmPaneId(null)} diff --git a/src/renderer/src/components/terminal-pane/expand-collapse.ts b/src/renderer/src/components/terminal-pane/expand-collapse.ts index 655841f7402..83ad95e9ce4 100644 --- a/src/renderer/src/components/terminal-pane/expand-collapse.ts +++ b/src/renderer/src/components/terminal-pane/expand-collapse.ts @@ -1,4 +1,5 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' type ExpandCollapseState = { expandedPaneIdRef: React.MutableRefObject @@ -107,11 +108,7 @@ export function createExpandCollapseActions(state: ExpandCollapseState) { } const panes = manager.getPanes() for (const p of panes) { - try { - p.fitAddon.fit() - } catch { - /* container may not have dimensions */ - } + safeFit(p) } if (focusActive) { const active = manager.getActivePane() ?? panes[0] diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts new file mode 100644 index 00000000000..b5106e7349a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -0,0 +1,36 @@ +// Why: mobile terminal streaming needs the exact screen state from the +// desktop's xterm.js instance. This module maintains a global registry of +// serialize functions keyed by ptyId, and handles IPC requests from the +// main process to serialize a specific terminal's buffer. + +type SerializedBuffer = { data: string; cols: number; rows: number } +type SerializeFn = () => SerializedBuffer | null | Promise + +const serializersByPtyId = new Map() +let listenerAttached = false + +export function registerPtySerializer(ptyId: string, serialize: SerializeFn): () => void { + serializersByPtyId.set(ptyId, serialize) + ensureSerializerListener() + return () => { + serializersByPtyId.delete(ptyId) + } +} + +function ensureSerializerListener(): void { + if (listenerAttached) { + return + } + listenerAttached = true + + window.api.pty.onSerializeBufferRequest((request) => { + const serializer = serializersByPtyId.get(request.ptyId) + void Promise.resolve(serializer?.() ?? null) + .then((result) => { + window.api.pty.sendSerializedBuffer(request.requestId, result ?? null) + }) + .catch(() => { + window.api.pty.sendSerializedBuffer(request.requestId, null) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1eacee1a982..c51539cd048 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -141,6 +141,7 @@ function createPane(paneId: number) { onData: vi.fn(() => ({ dispose: vi.fn() })), onResize: vi.fn(() => ({ dispose: vi.fn() })) }, + container: { dataset: {} }, fitAddon: { fit: vi.fn() } @@ -236,7 +237,8 @@ describe('connectPanePty', () => { }, pty: { signal: vi.fn(), - ackColdRestore: vi.fn() + ackColdRestore: vi.fn(), + onSerializeBufferRequest: vi.fn(() => vi.fn()) }, notifications: { dispatch: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 71fdaadc712..ce27e9a7b00 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -9,7 +9,9 @@ import type { PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' -import { isPaneReplaying, replayIntoTerminal } from './replay-guard' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' +import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' import { paneLeafId, POST_REPLAY_MODE_RESET, @@ -17,6 +19,7 @@ import { } from './layout-serialization' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { detectDeveloperPermissionHint } from './developer-permission-hints' +import { registerPtySerializer } from './pty-buffer-serializer' const pendingSpawnByPaneKey = new Map>() const developerPermissionHintKeys = new Set() @@ -209,6 +212,8 @@ export function connectPanePty( } const onPtySpawn = (ptyId: string): void => { + bindPanePtyId(pane.id, ptyId, deps.tabId) + pane.container.dataset.ptyId = ptyId deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) // Spawn completion is when a pane gains a concrete PTY ID. The initial @@ -389,6 +394,14 @@ export function connectPanePty( }) const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => { + // Why: when a mobile-fit override is active, the PTY is already at the + // correct phone dimensions. Suppress resize forwarding to avoid spurious + // SIGWINCH signals that could cause TUI flicker. Uses the transport's + // ptyId directly to avoid pane ID collisions across tabs. + const currentPtyId = transport.getPtyId() + if (currentPtyId && getFitOverrideForPty(currentPtyId)) { + return + } transport.resize(cols, rows) }) @@ -400,11 +413,7 @@ export function connectPanePty( if (disposed) { return } - try { - pane.fitAddon.fit() - } catch { - /* ignore */ - } + safeFit(pane) const cols = pane.terminal.cols const rows = pane.terminal.rows @@ -551,9 +560,39 @@ export function connectPanePty( startFreshSpawn() return } + bindPanePtyId(pane.id, ptyId, deps.tabId) + pane.container.dataset.ptyId = ptyId deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) + // Why: mobile terminal streaming needs the exact screen state from + // xterm.js. Register a serializer for this ptyId so the main process + // can request a buffer snapshot when a mobile client subscribes. + const unregisterSerializer = registerPtySerializer(ptyId, async () => { + try { + const pending = deps.pendingWritesRef.current.get(pane.id) + if (pending) { + deps.pendingWritesRef.current.set(pane.id, '') + // Why: hidden/background panes buffer PTY output instead of writing + // to xterm. Mobile snapshots must include that pending output, and + // replay guard prevents xterm query auto-replies from hitting stdin. + await replayIntoTerminalAsync(pane, deps.replayingPanesRef, pending) + } + return { + data: pane.serializeAddon.serialize(), + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + } catch { + return null + } + }) + const origOnDataDisposableDispose = onDataDisposable.dispose.bind(onDataDisposable) + onDataDisposable.dispose = () => { + unregisterSerializer() + origOnDataDisposableDispose() + } + if (connectResult?.coldRestore) { // Why: restoreScrollbackBuffers() already wrote the saved xterm // buffer before this rAF ran. The cold-restore scrollback from @@ -611,7 +650,12 @@ export function connectPanePty( }) } - transport.resize(cols, rows) + // Why: when a mobile-fit override is active, skip sending desktop dims + // to the PTY — the PTY is already at phone dimensions and must stay there. + const reattachPtyId = transport.getPtyId() + if (!reattachPtyId || !getFitOverrideForPty(reattachPtyId)) { + transport.resize(cols, rows) + } // Why: POSIX only delivers SIGWINCH when terminal dimensions actually // change. Sending it explicitly guarantees restored TUIs repaint at // the correct cursor position after snapshot replay. @@ -736,8 +780,17 @@ export function connectPanePty( isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId) ? candidateReattachSessionId : null + const _diagMsg = `pane=${pane.id} tab=${deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${hasExistingPaneTransport} pendingKey=${pendingSpawnKey}` + console.log(`[pty-connect] ${_diagMsg}`) + ;((globalThis as Record).__ptyConnectDiag ??= [] as string[]) as string[] + ;((globalThis as Record).__ptyConnectDiag as string[]).push(_diagMsg) + if (deferredReattachSessionId) { allowInitialIdleCacheSeed = true + console.log(`[pty-connect] pane=${pane.id} → REATTACH ${deferredReattachSessionId}`) + ;((globalThis as Record).__ptyConnectDiag as string[])?.push( + `pane=${pane.id} → REATTACH` + ) const reattachPromise = transport.connect({ url: '', @@ -771,6 +824,10 @@ export function connectPanePty( startFreshSpawn() }) } else if (detachedLivePtyId) { + console.log(`[pty-connect] pane=${pane.id} → ATTACH detached=${detachedLivePtyId}`) + ;((globalThis as Record).__ptyConnectDiag as string[])?.push( + `pane=${pane.id} → ATTACH ${detachedLivePtyId}` + ) allowInitialIdleCacheSeed = false // Why: surface synchronous attach failures (e.g., the PTY died between // mount and remount, so window.api.pty.resize rejects) through @@ -805,6 +862,10 @@ export function connectPanePty( ? undefined : pendingSpawnByPaneKey.get(pendingSpawnKey) if (pendingSpawn) { + console.log(`[pty-connect] pane=${pane.id} → PENDING SPAWN (waiting on sibling)`) + ;((globalThis as Record).__ptyConnectDiag as string[])?.push( + `pane=${pane.id} → PENDING SPAWN` + ) void pendingSpawn .then((spawnedPtyId) => { if (disposed) { @@ -845,6 +906,10 @@ export function connectPanePty( reportError(err instanceof Error ? err.message : String(err)) }) } else { + console.log(`[pty-connect] pane=${pane.id} → FRESH SPAWN`) + ;((globalThis as Record).__ptyConnectDiag as string[])?.push( + `pane=${pane.id} → FRESH SPAWN` + ) startFreshSpawn() } } diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index e949fae3efd..904f2e532e3 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -53,3 +53,26 @@ export function replayIntoTerminal( } }) } + +export function replayIntoTerminalAsync( + pane: ManagedPane, + replayingPanesRef: ReplayingPanesRef, + data: string +): Promise { + if (!data) { + return Promise.resolve() + } + const map = replayingPanesRef.current + map.set(pane.id, (map.get(pane.id) ?? 0) + 1) + return new Promise((resolve) => { + pane.terminal.write(data, () => { + const remaining = (map.get(pane.id) ?? 1) - 1 + if (remaining <= 0) { + map.delete(pane.id) + } else { + map.set(pane.id, remaining) + } + resolve() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index ddb912d9ba7..b9b87976070 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -9,7 +9,8 @@ import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' -import { captureScrollState, restoreScrollState } from '@/lib/pane-manager/pane-tree-ops' +import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' @@ -221,13 +222,17 @@ export function applyTerminalAppearance( manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled) try { const state = captureScrollState(pane.terminal) - pane.fitAddon.fit() + safeFit(pane) restoreScrollState(pane.terminal, state) } catch { /* ignore */ } const transport = paneTransports.get(pane.id) - if (transport?.isConnected()) { + // Why: skip PTY resize when a mobile-fit override is active — the PTY + // is already at the correct phone dimensions and must not be resized + // back to desktop dimensions by an appearance change. + const appearancePtyId = transport?.getPtyId() + if (transport?.isConnected() && (!appearancePtyId || !getFitOverrideForPty(appearancePtyId))) { transport.resize(pane.terminal.cols, pane.terminal.rows) maybePushMode2031Flip(pane.id, appearance.mode, transport, paneMode2031, paneLastThemeMode) } diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index e1c353ab216..902b6561c6a 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { captureScrollState, restoreScrollState } from '@/lib/pane-manager/pane-tree-ops' +import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' type FontZoomDeps = { isActive: boolean @@ -52,7 +52,7 @@ export function useTerminalFontZoom({ pane.terminal.options.fontSize = nextSize try { const state = captureScrollState(pane.terminal) - pane.fitAddon.fit() + safeFit(pane) restoreScrollState(pane.terminal, state) } catch { /* ignore */ diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index a66728403dc..ae6bf25071f 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -163,6 +163,7 @@ describe('useIpcEvents updater integration', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -200,6 +201,10 @@ describe('useIpcEvents updater integration', () => { get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), onUpdate: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, ssh: { listTargets: () => Promise.resolve([]), listPortForwards: () => Promise.resolve([]), @@ -354,6 +359,7 @@ describe('useIpcEvents updater integration', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -388,6 +394,10 @@ describe('useIpcEvents updater integration', () => { get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), onUpdate: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, ssh: { listTargets: () => Promise.resolve([]), listPortForwards: () => Promise.resolve([]), @@ -544,6 +554,7 @@ describe('useIpcEvents updater integration', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -578,6 +589,10 @@ describe('useIpcEvents updater integration', () => { get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), onUpdate: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, ssh: { listTargets: () => Promise.resolve([]), listPortForwards: () => Promise.resolve([]), @@ -736,6 +751,7 @@ describe('useIpcEvents browser tab close routing', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -790,6 +806,10 @@ describe('useIpcEvents browser tab close routing', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) @@ -922,6 +942,7 @@ describe('useIpcEvents browser tab close routing', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -976,6 +997,10 @@ describe('useIpcEvents browser tab close routing', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) @@ -1103,6 +1128,7 @@ describe('useIpcEvents browser tab close routing', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -1157,6 +1183,10 @@ describe('useIpcEvents browser tab close routing', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) @@ -1302,6 +1332,7 @@ describe('useIpcEvents shortcut hint clearing', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -1336,6 +1367,10 @@ describe('useIpcEvents shortcut hint clearing', () => { get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), onUpdate: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, ssh: { listTargets: () => Promise.resolve([]), listPortForwards: () => Promise.resolve([]), @@ -1499,6 +1534,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, onRequestTabCreate: () => () => {}, replyTabCreate: () => {}, @@ -1544,6 +1580,10 @@ describe('useIpcEvents CLI-created worktree activation', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 6bae8766f71..2a306409743 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { useAppStore } from '../store' import { applyUIZoom } from '@/lib/ui-zoom' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' @@ -22,6 +23,7 @@ import { dispatchClearModifierHints } from './useModifierHint' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' import { isGitRepoKind } from '../../../shared/repo-kind' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' export { resolveZoomTarget } from './resolve-zoom-target' @@ -195,7 +197,7 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => { + window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => { void (async () => { // Why: fetch worktrees first so the activation helper can resolve // the CLI-created worktree via findWorktreeById — it arrived from @@ -206,7 +208,7 @@ export function useIpcEvents(): void { // ones. This records the visit in the back/forward history stack // (recordWorktreeVisit), without which the nav buttons would // ignore the CLI-driven workspace switch. - activateAndRevealWorktree(worktreeId, { setup }) + activateAndRevealWorktree(worktreeId, { setup, startup }) })().catch((error) => { console.error('Failed to activate CLI-created worktree:', error) }) @@ -320,6 +322,12 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onSleepWorktree(({ worktreeId }) => { + void runSleepWorktree(worktreeId) + }) + ) + // Hydrate initial update status then subscribe to changes window.api.updater.getStatus().then((status) => { useAppStore.getState().setUpdateStatus(status as UpdateStatus) @@ -812,6 +820,18 @@ export function useIpcEvents(): void { }) ) + // Why: hydrate mobile-fit overrides before terminal panes run their first + // attach/fit logic, so a renderer reload doesn't undo active mobile fits. + void window.api.runtime.getTerminalFitOverrides().then((overrides) => { + hydrateOverrides(overrides) + }) + + unsubs.push( + window.api.runtime.onTerminalFitOverrideChanged((event) => { + setFitOverride(event.ptyId, event.mode, event.cols, event.rows) + }) + ) + return () => unsubs.forEach((fn) => fn()) }, []) } diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts new file mode 100644 index 00000000000..66a4cf4cf28 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts @@ -0,0 +1,409 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + setFitOverride, + getFitOverrideForPty, + getFitOverrideForPane, + bindPanePtyId, + unbindPane, + getPaneIdsForPty, + onOverrideChange, + hydrateOverrides, + getAllOverrides +} from './mobile-fit-overrides' + +afterEach(() => { + // Reset module-level maps between tests by clearing all overrides + // and unbinding all known panes. + hydrateOverrides([]) + // Unbind any panes bound during tests. We don't have direct access + // to the internal map, but we can unbind known test keys. + for (const tabId of ['tab-0', 'tab-1', 'tab-2']) { + for (let paneId = 0; paneId < 5; paneId++) { + unbindPane(paneId, tabId) + } + } +}) + +// --------------------------------------------------------------------------- +// setFitOverride + getFitOverrideForPty +// --------------------------------------------------------------------------- + +describe('setFitOverride / getFitOverrideForPty', () => { + it('stores a mobile-fit override keyed by ptyId', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + + const override = getFitOverrideForPty('pty-1') + expect(override).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) + }) + + it('removes the override when mode is desktop-fit', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + setFitOverride('pty-1', 'desktop-fit', 120, 40) + + expect(getFitOverrideForPty('pty-1')).toBeNull() + }) + + it('returns null for unknown ptyId', () => { + expect(getFitOverrideForPty('nonexistent')).toBeNull() + }) + + it('overwrites previous override dimensions', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + setFitOverride('pty-1', 'mobile-fit', 60, 25) + + expect(getFitOverrideForPty('pty-1')).toEqual({ mode: 'mobile-fit', cols: 60, rows: 25 }) + }) + + it('tracks multiple ptyIds independently', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + setFitOverride('pty-2', 'mobile-fit', 80, 30) + + expect(getFitOverrideForPty('pty-1')?.cols).toBe(49) + expect(getFitOverrideForPty('pty-2')?.cols).toBe(80) + }) +}) + +// --------------------------------------------------------------------------- +// bindPanePtyId + getFitOverrideForPane (tab-scoped composite key) +// --------------------------------------------------------------------------- + +describe('bindPanePtyId / getFitOverrideForPane', () => { + it('resolves override through tab:pane → ptyId → override chain', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-1', 'tab-0') + + expect(getFitOverrideForPane(1, 'tab-0')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) + }) + + it('returns null when tabId is not provided', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-1', 'tab-0') + + expect(getFitOverrideForPane(1)).toBeNull() + }) + + it('returns null for unbound pane', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + }) + + it('returns null when ptyId has no override', () => { + bindPanePtyId(1, 'pty-1', 'tab-0') + + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + }) + + it('does not collide when different tabs have the same pane ID', () => { + setFitOverride('pty-A', 'mobile-fit', 49, 20) + setFitOverride('pty-B', 'mobile-fit', 80, 30) + bindPanePtyId(1, 'pty-A', 'tab-0') + bindPanePtyId(1, 'pty-B', 'tab-1') + + expect(getFitOverrideForPane(1, 'tab-0')?.cols).toBe(49) + expect(getFitOverrideForPane(1, 'tab-1')?.cols).toBe(80) + }) + + it('clears binding when ptyId is null', () => { + bindPanePtyId(1, 'pty-1', 'tab-0') + bindPanePtyId(1, null, 'tab-0') + + setFitOverride('pty-1', 'mobile-fit', 49, 20) + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + }) + + it('is a no-op when tabId is not provided', () => { + bindPanePtyId(1, 'pty-1') + setFitOverride('pty-1', 'mobile-fit', 49, 20) + + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// unbindPane +// --------------------------------------------------------------------------- + +describe('unbindPane', () => { + it('removes the tab:pane binding', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-1', 'tab-0') + unbindPane(1, 'tab-0') + + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + }) + + it('does not affect other tabs with the same pane ID', () => { + setFitOverride('pty-A', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-A', 'tab-0') + bindPanePtyId(1, 'pty-A', 'tab-1') + + unbindPane(1, 'tab-0') + + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + expect(getFitOverrideForPane(1, 'tab-1')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) + }) + + it('is a no-op when tabId is not provided', () => { + bindPanePtyId(1, 'pty-1', 'tab-0') + unbindPane(1) + + setFitOverride('pty-1', 'mobile-fit', 49, 20) + expect(getFitOverrideForPane(1, 'tab-0')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) + }) +}) + +// --------------------------------------------------------------------------- +// getPaneIdsForPty +// --------------------------------------------------------------------------- + +describe('getPaneIdsForPty', () => { + it('returns pane IDs bound to a ptyId', () => { + bindPanePtyId(1, 'pty-1', 'tab-0') + bindPanePtyId(2, 'pty-1', 'tab-0') + + const ids = getPaneIdsForPty('pty-1') + expect(ids).toEqual(expect.arrayContaining([1, 2])) + expect(ids).toHaveLength(2) + }) + + it('returns pane IDs across different tabs', () => { + bindPanePtyId(1, 'pty-1', 'tab-0') + bindPanePtyId(1, 'pty-1', 'tab-1') + + const ids = getPaneIdsForPty('pty-1') + expect(ids).toEqual([1, 1]) + }) + + it('returns empty array for unknown ptyId', () => { + expect(getPaneIdsForPty('nonexistent')).toEqual([]) + }) + + it('does not include panes bound to a different ptyId', () => { + bindPanePtyId(1, 'pty-1', 'tab-0') + bindPanePtyId(2, 'pty-2', 'tab-0') + + expect(getPaneIdsForPty('pty-1')).toEqual([1]) + }) +}) + +// --------------------------------------------------------------------------- +// onOverrideChange +// --------------------------------------------------------------------------- + +describe('onOverrideChange', () => { + it('fires listener on mobile-fit override', () => { + const listener = vi.fn() + const unsub = onOverrideChange(listener) + + setFitOverride('pty-1', 'mobile-fit', 49, 20) + + expect(listener).toHaveBeenCalledWith({ + ptyId: 'pty-1', + mode: 'mobile-fit', + cols: 49, + rows: 20 + }) + + unsub() + }) + + it('fires listener on desktop-fit restore', () => { + const listener = vi.fn() + const unsub = onOverrideChange(listener) + + setFitOverride('pty-1', 'desktop-fit', 120, 40) + + expect(listener).toHaveBeenCalledWith({ + ptyId: 'pty-1', + mode: 'desktop-fit', + cols: 120, + rows: 40 + }) + + unsub() + }) + + it('unsubscribes cleanly', () => { + const listener = vi.fn() + const unsub = onOverrideChange(listener) + unsub() + + setFitOverride('pty-1', 'mobile-fit', 49, 20) + + expect(listener).not.toHaveBeenCalled() + }) + + it('supports multiple listeners', () => { + const a = vi.fn() + const b = vi.fn() + const unsubA = onOverrideChange(a) + const unsubB = onOverrideChange(b) + + setFitOverride('pty-1', 'mobile-fit', 49, 20) + + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledTimes(1) + + unsubA() + unsubB() + }) +}) + +// --------------------------------------------------------------------------- +// hydrateOverrides +// --------------------------------------------------------------------------- + +describe('hydrateOverrides', () => { + it('replaces all overrides with the given list', () => { + setFitOverride('pty-old', 'mobile-fit', 49, 20) + + hydrateOverrides([{ ptyId: 'pty-new', mode: 'mobile-fit', cols: 60, rows: 25 }]) + + expect(getFitOverrideForPty('pty-old')).toBeNull() + expect(getFitOverrideForPty('pty-new')).toEqual({ mode: 'mobile-fit', cols: 60, rows: 25 }) + }) + + it('clears all overrides when given an empty list', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + + hydrateOverrides([]) + + expect(getFitOverrideForPty('pty-1')).toBeNull() + }) + + it('hydrates multiple overrides', () => { + hydrateOverrides([ + { ptyId: 'pty-1', mode: 'mobile-fit', cols: 49, rows: 20 }, + { ptyId: 'pty-2', mode: 'mobile-fit', cols: 80, rows: 30 } + ]) + + expect(getAllOverrides().size).toBe(2) + expect(getFitOverrideForPty('pty-1')?.cols).toBe(49) + expect(getFitOverrideForPty('pty-2')?.cols).toBe(80) + }) +}) + +// --------------------------------------------------------------------------- +// getAllOverrides +// --------------------------------------------------------------------------- + +describe('getAllOverrides', () => { + it('returns a copy of all current overrides', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + setFitOverride('pty-2', 'mobile-fit', 80, 30) + + const all = getAllOverrides() + expect(all.size).toBe(2) + + // Verify it's a copy, not the internal map + all.delete('pty-1') + expect(getFitOverrideForPty('pty-1')).not.toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Scenario tests (from design doc verification matrix) +// --------------------------------------------------------------------------- + +describe('scenario: desktop window resize while mobile is viewing', () => { + it('override persists across setFitOverride calls — desktop safeFit will see it', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-1', 'tab-0') + + // Simulate desktop resize triggering a re-check — override should still be there + expect(getFitOverrideForPty('pty-1')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) + expect(getFitOverrideForPane(1, 'tab-0')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) + }) +}) + +describe('scenario: mobile disconnect restores all terminals', () => { + it('clearing all overrides for a disconnected client removes all traces', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + setFitOverride('pty-2', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-1', 'tab-0') + bindPanePtyId(2, 'pty-2', 'tab-0') + + // Simulate runtime clearing overrides on disconnect + setFitOverride('pty-1', 'desktop-fit', 120, 40) + setFitOverride('pty-2', 'desktop-fit', 120, 40) + + expect(getFitOverrideForPty('pty-1')).toBeNull() + expect(getFitOverrideForPty('pty-2')).toBeNull() + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + expect(getFitOverrideForPane(2, 'tab-0')).toBeNull() + }) +}) + +describe('scenario: PTY exits while phone-fitted', () => { + it('clearing override for exited PTY leaves no stale state', () => { + setFitOverride('pty-1', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-1', 'tab-0') + + // Runtime clears override on PTY exit + setFitOverride('pty-1', 'desktop-fit', 0, 0) + unbindPane(1, 'tab-0') + + expect(getFitOverrideForPty('pty-1')).toBeNull() + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + expect(getPaneIdsForPty('pty-1')).toEqual([]) + expect(getAllOverrides().size).toBe(0) + }) +}) + +describe('scenario: mobile reconnects after disconnect', () => { + it('new override after clear works correctly', () => { + // First session + setFitOverride('pty-1', 'mobile-fit', 49, 20) + bindPanePtyId(1, 'pty-1', 'tab-0') + + // Disconnect + setFitOverride('pty-1', 'desktop-fit', 120, 40) + + // Reconnect — new session sets override again + setFitOverride('pty-1', 'mobile-fit', 55, 22) + + expect(getFitOverrideForPty('pty-1')).toEqual({ mode: 'mobile-fit', cols: 55, rows: 22 }) + expect(getFitOverrideForPane(1, 'tab-0')).toEqual({ mode: 'mobile-fit', cols: 55, rows: 22 }) + }) +}) + +describe('scenario: multiple tabs with same pane IDs', () => { + it('override on one tab does not affect the other', () => { + bindPanePtyId(1, 'pty-A', 'tab-0') + bindPanePtyId(1, 'pty-B', 'tab-1') + + setFitOverride('pty-A', 'mobile-fit', 49, 20) + + expect(getFitOverrideForPane(1, 'tab-0')?.cols).toBe(49) + expect(getFitOverrideForPane(1, 'tab-1')).toBeNull() + }) + + it('clearing override for one tab does not affect the other', () => { + bindPanePtyId(1, 'pty-A', 'tab-0') + bindPanePtyId(1, 'pty-B', 'tab-1') + + setFitOverride('pty-A', 'mobile-fit', 49, 20) + setFitOverride('pty-B', 'mobile-fit', 60, 25) + + // Clear only tab-0's PTY + setFitOverride('pty-A', 'desktop-fit', 120, 40) + + expect(getFitOverrideForPane(1, 'tab-0')).toBeNull() + expect(getFitOverrideForPane(1, 'tab-1')?.cols).toBe(60) + }) +}) + +describe('scenario: change listener fires for both override and restore', () => { + it('tracks full lifecycle: mobile-fit → desktop-fit', () => { + const events: { mode: string }[] = [] + const unsub = onOverrideChange((e) => events.push({ mode: e.mode })) + + setFitOverride('pty-1', 'mobile-fit', 49, 20) + setFitOverride('pty-1', 'desktop-fit', 120, 40) + + expect(events).toEqual([{ mode: 'mobile-fit' }, { mode: 'desktop-fit' }]) + + unsub() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts new file mode 100644 index 00000000000..7e5a781a081 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts @@ -0,0 +1,111 @@ +// Why: mobile-fit overrides are runtime-owned state that the renderer must +// respect. When a mobile client resizes a PTY to phone dimensions, the desktop +// renderer must not auto-fit that PTY back to desktop size. This module stores +// the override state and provides lookup for safeFit() and transport.resize(). + +type FitOverride = { + mode: 'mobile-fit' + cols: number + rows: number +} + +const overridesByPtyId = new Map() +// Why: keyed by 'tabId:paneId' composite to avoid collisions when different +// tabs have panes with the same numeric ID (pane IDs are per-tab, not global). +const ptyIdByPaneKey = new Map() + +// Why: the override maps are plain JS — React components that read them +// (e.g. the desktop mobile-fit banner) have no way to know when entries +// change. This listener set lets TerminalPane subscribe for re-renders +// and trigger safeFit on affected panes. +type OverrideChangeEvent = { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number +} +type OverrideChangeListener = (event: OverrideChangeEvent) => void +const changeListeners = new Set() + +export function onOverrideChange(listener: OverrideChangeListener): () => void { + changeListeners.add(listener) + return () => changeListeners.delete(listener) +} + +function notifyChange(event: OverrideChangeEvent): void { + for (const listener of changeListeners) { + listener(event) + } +} + +export function setFitOverride( + ptyId: string, + mode: 'mobile-fit' | 'desktop-fit', + cols: number, + rows: number +): void { + if (mode === 'mobile-fit') { + overridesByPtyId.set(ptyId, { mode, cols, rows }) + } else { + overridesByPtyId.delete(ptyId) + } + notifyChange({ ptyId, mode, cols, rows }) +} + +export function getPaneIdsForPty(ptyId: string): number[] { + const result: number[] = [] + for (const [key, boundPtyId] of ptyIdByPaneKey) { + if (boundPtyId === ptyId) { + const paneId = Number(key.split(':').pop()) + if (!Number.isNaN(paneId)) { + result.push(paneId) + } + } + } + return result +} + +export function getFitOverrideForPty(ptyId: string): FitOverride | null { + return overridesByPtyId.get(ptyId) ?? null +} + +export function getFitOverrideForPane(paneId: number, tabId?: string): FitOverride | null { + if (tabId) { + const ptyId = ptyIdByPaneKey.get(`${tabId}:${paneId}`) + if (!ptyId) { + return null + } + return overridesByPtyId.get(ptyId) ?? null + } + return null +} + +export function bindPanePtyId(paneId: number, ptyId: string | null, tabId?: string): void { + if (tabId) { + const key = `${tabId}:${paneId}` + if (ptyId) { + ptyIdByPaneKey.set(key, ptyId) + } else { + ptyIdByPaneKey.delete(key) + } + } +} + +export function unbindPane(paneId: number, tabId?: string): void { + if (tabId) { + ptyIdByPaneKey.delete(`${tabId}:${paneId}`) + } +} + +export function hydrateOverrides( + overrides: { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] +): void { + overridesByPtyId.clear() + for (const o of overrides) { + overridesByPtyId.set(o.ptyId, { mode: o.mode, cols: o.cols, rows: o.rows }) + } +} + +export function getAllOverrides(): Map { + return new Map(overridesByPtyId) +} diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index dc6103eb717..295da95e9b2 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -1,4 +1,4 @@ -import type { DropZone, ManagedPaneInternal } from './pane-manager-types' +import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types' import type { PaneStyleOptions } from './pane-manager-types' import { detachPaneFromTree, insertPaneNextTo } from './pane-tree-ops' @@ -17,7 +17,7 @@ export type DragReorderCallbacks = { getRoot: () => HTMLElement getStyleOptions: () => PaneStyleOptions isDestroyed: () => boolean - safeFit: (pane: ManagedPaneInternal) => void + safeFit: (pane: ManagedPane) => void applyPaneOpacity: () => void applyDividerStyles: () => void refitPanesUnder: (el: HTMLElement) => void diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index 44ba61d5f19..802ffdb4237 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -39,7 +39,7 @@ function createPane(): ManagedPaneInternal { cols: 79, rows: 24 } as never, - container: {} as never, + container: { dataset: {} } as never, xtermContainer: {} as never, linkTooltip: {} as never, terminalGpuAcceleration: 'auto', diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index d6b9fe91256..9fe3c0fb7b5 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -335,7 +335,7 @@ export class PaneManager { getRoot: () => this.root, getStyleOptions: () => this.styleOptions, isDestroyed: () => this.destroyed, - safeFit: (pane: ManagedPaneInternal) => safeFit(pane), + safeFit, applyPaneOpacity: () => applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions), applyDividerStyles: () => applyDividerStyles(this.root, this.styleOptions), diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 1ebc2c2bac3..57fe2cdd176 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -1,23 +1,35 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { safeFit } from './pane-tree-ops' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides' + +afterEach(() => { + hydrateOverrides([]) +}) function createPane({ proposedCols, proposedRows, terminalCols, - terminalRows + terminalRows, + paneId = 1 }: { proposedCols: number proposedRows: number terminalCols: number terminalRows: number + paneId?: number }): ManagedPaneInternal { const fit = vi.fn() const proposeDimensions = vi.fn(() => ({ cols: proposedCols, rows: proposedRows })) const terminal = { cols: terminalCols, rows: terminalRows, + resize: vi.fn((cols: number, rows: number) => { + terminal.cols = cols + terminal.rows = rows + }), + refresh: vi.fn(), buffer: { active: { viewportY: 0, @@ -31,9 +43,9 @@ function createPane({ } return { - id: 1, + id: paneId, terminal: terminal as never, - container: {} as never, + container: { dataset: {} } as never, xtermContainer: {} as never, linkTooltip: {} as never, terminalGpuAcceleration: 'auto', @@ -103,4 +115,97 @@ describe('safeFit', () => { expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) }) + + it('resizes terminal to override dimensions when mobile-fit override is active', () => { + const pane = createPane({ + proposedCols: 120, + proposedRows: 40, + terminalCols: 120, + terminalRows: 40 + }) + pane.container.dataset.ptyId = 'pty-phone' + setFitOverride('pty-phone', 'mobile-fit', 49, 20) + + safeFit(pane) + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20) + }) + + it('skips resize when terminal already matches override dimensions', () => { + const pane = createPane({ + proposedCols: 120, + proposedRows: 40, + terminalCols: 49, + terminalRows: 20 + }) + pane.container.dataset.ptyId = 'pty-phone' + setFitOverride('pty-phone', 'mobile-fit', 49, 20) + + safeFit(pane) + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.terminal.resize).not.toHaveBeenCalled() + }) + + it('does not apply override when pane has no data-pty-id', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + setFitOverride('pty-phone', 'mobile-fit', 49, 20) + + safeFit(pane) + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(pane.terminal.resize).not.toHaveBeenCalled() + }) + + it('falls through to normal fit when override is cleared', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 49, + terminalRows: 20 + }) + pane.container.dataset.ptyId = 'pty-phone' + setFitOverride('pty-phone', 'mobile-fit', 49, 20) + setFitOverride('pty-phone', 'desktop-fit', 120, 40) + + safeFit(pane) + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('does not cross-contaminate overrides between different ptyIds', () => { + const paneA = createPane({ + proposedCols: 120, + proposedRows: 40, + terminalCols: 120, + terminalRows: 40, + paneId: 1 + }) + paneA.container.dataset.ptyId = 'pty-A' + + const paneB = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 40, + paneId: 2 + }) + paneB.container.dataset.ptyId = 'pty-B' + + setFitOverride('pty-A', 'mobile-fit', 49, 20) + + safeFit(paneA) + safeFit(paneB) + + expect(paneA.terminal.resize).toHaveBeenCalledWith(49, 20) + expect(paneA.fitAddon.fit).not.toHaveBeenCalled() + expect(paneB.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(paneB.terminal.resize).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 71f1528545f..2667c2b8f9f 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -1,5 +1,11 @@ -import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types' +import type { + DropZone, + ManagedPane, + ManagedPaneInternal, + PaneStyleOptions +} from './pane-manager-types' import { createDivider } from './pane-divider' +import { getFitOverrideForPty } from './mobile-fit-overrides' import { disposeWebgl, attachWebgl } from './pane-lifecycle' export { findLineByContent, captureScrollState, restoreScrollState } from './pane-scroll' @@ -11,12 +17,12 @@ export { findLineByContent, captureScrollState, restoreScrollState } from './pan type TreeOpsCallbacks = { getRoot: () => HTMLElement getStyleOptions: () => PaneStyleOptions - safeFit: (pane: ManagedPaneInternal) => void + safeFit: (pane: ManagedPane) => void refitPanesUnder: (el: HTMLElement) => void onLayoutChanged?: () => void } -function getProposedDimensions(pane: ManagedPaneInternal): { cols: number; rows: number } | null { +function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { try { return pane.fitAddon.proposeDimensions() ?? null } catch { @@ -34,8 +40,22 @@ function getProposedDimensions(pane: ManagedPaneInternal): { cols: number; rows: // scrollTop to 0 asynchronously. splitPane captures the pre-split state and // scheduleSplitScrollRestore owns the authoritative restore on a timer, so // safeFit here just fits and lets the scheduled restore do its job. -export function safeFit(pane: ManagedPaneInternal): void { +export function safeFit(pane: ManagedPane): void { try { + // Why: when a mobile client has resized this PTY to phone dimensions, + // the desktop must keep xterm at those dimensions instead of fitting to + // the desktop pane geometry. This prevents desktop auto-fit from undoing + // the mobile resize. Uses data-pty-id (set by bindPanePtyId) to look up + // the override by ptyId directly, avoiding pane ID collisions across tabs. + const ptyId = pane.container.dataset.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + if (override) { + if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { + pane.terminal.resize(override.cols, override.rows) + } + return + } + const dims = getProposedDimensions(pane) if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { // Why: divider drags fire refits every frame, but most frames do not diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 53dee0b6f6b..7bc11a1480c 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -115,7 +115,8 @@ async function syncRuntimeGraph(): Promise { leafId, paneRuntimeId: pane.id, ptyId, - paneTitle: paneTitles[pane.id] ?? null + paneTitle: paneTitles[pane.id] ?? null, + title: state.runtimePaneTitlesByTabId[tabId]?.[pane.id] ?? tab.customTitle ?? tab.title }) } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 2f3d748e7c6..81b2b4d473f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -199,6 +199,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // (Claude/Codex/Gemini) stays dormant for existing users and upgraders // (persistence.ts merges defaults first, so upgraders inherit this). experimentalAgentDashboard: false, + experimentalMobile: false, // Why: off by default — opt-in cosmetic joke feature. Leaving the default // false keeps the overlay unmounted for users who never enable it. experimentalSidekick: false diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts new file mode 100644 index 00000000000..de0738b5907 --- /dev/null +++ b/src/shared/pairing.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { encodePairingOffer, decodePairingOffer, type PairingOffer } from './pairing' + +describe('pairing offer', () => { + const offer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'abcdef1234567890abcdef1234567890abcdef1234567890', + publicKeyB64: 'dGVzdC1wdWJsaWMta2V5LWJhc2U2NC1lbmNvZGVk' + } + + it('encode then decode round-trips correctly', () => { + const url = encodePairingOffer(offer) + expect(url).toMatch(/^orca:\/\/pair#/) + + const decoded = decodePairingOffer(url) + expect(decoded).toEqual(offer) + }) + + it('encoded URL uses base64url (no +, /, or = characters)', () => { + const url = encodePairingOffer(offer) + const fragment = url.split('#')[1]! + expect(fragment).not.toMatch(/[+/=]/) + }) + + it('rejects URLs with wrong scheme', () => { + expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL') + }) + + it('rejects URLs without fragment', () => { + expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL') + }) + + it('rejects payloads with missing fields', () => { + const partial = { v: 2, endpoint: 'ws://host:1234' } + const base64 = Buffer.from(JSON.stringify(partial)).toString('base64') + expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow() + }) + + it('rejects payloads with wrong version', () => { + const wrong = { ...offer, v: 1 } + const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64') + expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow() + }) + + it('rejects payloads with missing publicKeyB64', () => { + const wrong = { v: 2, endpoint: 'ws://host:1234', deviceToken: 'tok' } + const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64') + expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow() + }) +}) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts new file mode 100644 index 00000000000..5f8d4afc3b4 --- /dev/null +++ b/src/shared/pairing.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +export const PAIRING_OFFER_VERSION = 2 + +export const PairingOfferSchema = z.object({ + v: z.literal(PAIRING_OFFER_VERSION), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + // Why: the desktop's Curve25519 public key, base64-encoded. The mobile client + // uses this to derive a shared secret via ECDH for end-to-end encryption. + publicKeyB64: z.string().min(1) +}) + +export type PairingOffer = z.infer + +export function encodePairingOffer(offer: PairingOffer): string { + const json = JSON.stringify(offer) + const base64url = Buffer.from(json, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + return `orca://pair#${base64url}` +} + +export function decodePairingOffer(url: string): PairingOffer { + const hashIndex = url.indexOf('#') + if (!url.startsWith('orca://pair') || hashIndex === -1) { + throw new Error('Invalid pairing URL: must start with orca://pair#') + } + const base64url = url.slice(hashIndex + 1) + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const json = Buffer.from(base64, 'base64').toString('utf-8') + return PairingOfferSchema.parse(JSON.parse(json)) +} diff --git a/src/shared/runtime-bootstrap.ts b/src/shared/runtime-bootstrap.ts index 00ac192e522..72ad330d6aa 100644 --- a/src/shared/runtime-bootstrap.ts +++ b/src/shared/runtime-bootstrap.ts @@ -9,15 +9,39 @@ export type RuntimeTransportMetadata = kind: 'named-pipe' endpoint: string } + | { + kind: 'websocket' + endpoint: string + } export type RuntimeMetadata = { runtimeId: string pid: number - transport: RuntimeTransportMetadata | null + transports: RuntimeTransportMetadata[] authToken: string | null startedAt: number } +// Why: the CLI must handle metadata files written by older Orca versions that +// used a singular `transport` field. This helper extracts the first transport +// matching the given kinds from either the new `transports` array or the +// legacy `transport` field. +export function findTransport( + metadata: RuntimeMetadata, + ...kinds: RuntimeTransportMetadata['kind'][] +): RuntimeTransportMetadata | null { + const transports = metadata.transports + if (transports && Array.isArray(transports)) { + return transports.find((t) => kinds.includes(t.kind)) ?? null + } + // Why: backward compatibility with pre-transports-array metadata files. + const legacy = (metadata as Record).transport as RuntimeTransportMetadata | null + if (legacy && kinds.includes(legacy.kind)) { + return legacy + } + return null +} + const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json' export function getRuntimeMetadataPath(userDataPath: string): string { diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index adba1447e6e..95c6cf54f03 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -50,6 +50,7 @@ export type RuntimeSyncedLeaf = { paneRuntimeId: number ptyId: string | null paneTitle?: string | null + title?: string | null } export type RuntimeSyncWindowGraph = { @@ -145,14 +146,20 @@ export type RuntimeWorktreePsSummary = { repo: string path: string branch: string + displayName: string linkedIssue: number | null + linkedPR: { number: number; state: string } | null + isPinned: boolean unread: boolean liveTerminalCount: number hasAttachedPty: boolean lastOutputAt: number | null preview: string + status: RuntimeWorktreeStatus } +export type RuntimeWorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' + export type RuntimeWorktreeRecord = { id: string repoId: string diff --git a/src/shared/types.ts b/src/shared/types.ts index b78869b81f7..a10f9a333d6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -762,6 +762,11 @@ export type WorktreeSetupLaunch = { envVars: Record } +export type WorktreeStartupLaunch = { + command: string + env?: Record +} + export type CreateSparseCheckoutRequest = { directories: string[] /** Set when the directories came from a saved preset and the user did not @@ -1147,6 +1152,7 @@ export type GlobalSettings = { * takes effect on the next app launch. The in-pane status indicators and * the cursor-agent hook path are unaffected by this toggle. */ experimentalAgentDashboard: boolean + experimentalMobile: boolean /** Experimental: floating animated sidekick (claude.webp) in the bottom-right * corner. Opt-in because it's a cosmetic joke feature; users who leave it * off never mount the overlay. Toggling takes effect immediately in the