diff --git a/windows/.editorconfig b/windows/.editorconfig new file mode 100644 index 0000000000..3dce4145ff --- /dev/null +++ b/windows/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/windows/.env.example b/windows/.env.example new file mode 100644 index 0000000000..5c963bd487 --- /dev/null +++ b/windows/.env.example @@ -0,0 +1,38 @@ +# Copy to .env and fill in. .env is gitignored. + +# Firebase — Omi's public web config (project: based-hardware). +# These values let the app authenticate against the same Firebase project that +# the Omi macOS/web apps use, so any Omi account works for sign-in. +# Discovered via: GET https://www.googleapis.com/identitytoolkit/v3/relyingparty/getProjectConfig?key= +VITE_FIREBASE_API_KEY=AIzaSyD9dzBdglc7IO9pPDIOvqnCoTis_xKkkC8 +VITE_FIREBASE_AUTH_DOMAIN=based-hardware.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=based-hardware + +# Omi backend base URLs +VITE_OMI_API_BASE=https://api.omi.me +VITE_OMI_DESKTOP_API_BASE=https://desktop-backend-hhibjajaja-uc.a.run.app + +# PostHog analytics — same project the macOS desktop app reports to. Both have +# sensible defaults baked in (the key is a publishable client key), so these only +# need setting to point at a different PostHog project. +VITE_POSTHOG_HOST=https://us.i.posthog.com +VITE_POSTHOG_KEY=phc_z3qUFhGUgYIOMYnfxVSrLmYISQvbgph8iREQv3sez3Y + +# Omi developer API key — required for POST /v1/dev/user/conversations/from-segments +# (cloud sync of recorded conversations). Generate one in your Omi app under +# Settings → Developer → Create API key. If unset, recordings save locally only. +VITE_OMI_API_KEY= + +# Google integration (parity 3d). Copy this file to `.env` and fill in real values. +# Desktop OAuth client id from Google Cloud Console (an OAuth 2.0 "Desktop app" client). +# Read by the MAIN process (electron-vite MAIN_VITE_ prefix). +MAIN_VITE_GOOGLE_CLIENT_ID= + +# Client secret for the same Desktop client. Google's Desktop clients require it +# at the token endpoint even with PKCE. Main-process only; never exposed to the +# renderer. Leave blank only if your client type genuinely needs no secret. +MAIN_VITE_GOOGLE_CLIENT_SECRET= + +# Show the Google integration in Settings ('1' = on). Read by the RENDERER. +# Leave unset/'0' for non-test builds until Google verification is granted. +VITE_ENABLE_GOOGLE_INTEGRATION=0 diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000000..926d0b802c --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,52 @@ +node_modules +dist +out +.DS_Store +.eslintcache +*.log* + +# Secrets — never commit +.env +.env.local +.env.*.local + +# Build / installer artifacts +build/ +release/ +dist/ +*.exe +*.msi +*.dmg +*.AppImage + +# Perf benchmark artifacts + tooling (dev-only; keep out of the public repo) +.bench/ +scripts/bench/ +src/main/bench/ + +# HuggingFace / model cache +.cache/ +models/ + +# OS +Thumbs.db +desktop.ini + +#Personal files +.agents/ +.vscode/ +docs/ +skills-lock.json + +# Visual brainstorming companion +.superpowers/ + +# .NET build artifacts (win-ocr-helper) +src/main/ocr/win-ocr-helper/bin/ +src/main/ocr/win-ocr-helper/obj/ +resources/win-ocr-helper/*.pdb + +# .NET build artifacts (win-automation-helper) +src/main/automation/helper/bin/ +src/main/automation/helper/obj/ +resources/win-automation-helper/*.pdb diff --git a/windows/.npmrc b/windows/.npmrc new file mode 100644 index 0000000000..cc8df9de0a --- /dev/null +++ b/windows/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted \ No newline at end of file diff --git a/windows/.prettierignore b/windows/.prettierignore new file mode 100644 index 0000000000..9c6b791d53 --- /dev/null +++ b/windows/.prettierignore @@ -0,0 +1,6 @@ +out +dist +pnpm-lock.yaml +LICENSE.md +tsconfig.json +tsconfig.*.json diff --git a/windows/.prettierrc.yaml b/windows/.prettierrc.yaml new file mode 100644 index 0000000000..35893b3be3 --- /dev/null +++ b/windows/.prettierrc.yaml @@ -0,0 +1,4 @@ +singleQuote: true +semi: false +printWidth: 100 +trailingComma: none diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000000..d935aa8424 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,60 @@ +# omi-windows + +Omi for Windows — an Electron + React + TypeScript port of the Omi desktop app. + +## Recommended IDE Setup + +- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + +## Run from source + +```bash +# 1. Install dependencies +npm install + +# 2. Create your local env file (required — the app won't start without it) +cp .env.example .env + +# 3. Start the app +npm run dev +``` + +`.env` is gitignored. `.env.example` ships with Omi's **public** Firebase + PostHog +config, so after `cp .env.example .env` the app runs and sign-in works with no extra +keys to obtain. + +## Authentication + +- **App sign-in:** each user signs in with **their own** Google/Omi account through + the built-in popup. The Firebase project is shared (Omi's `based-hardware`); accounts + are individual. Nothing to configure — it works out of the box from `.env.example`. +- **Google integration** (optional Gmail/Google connect — separate from sign-in): bring + your own credentials. Create an OAuth **Desktop app** client in the + [Google Cloud Console](https://console.cloud.google.com/apis/credentials), then in your + local `.env` set `MAIN_VITE_GOOGLE_CLIENT_ID`, `MAIN_VITE_GOOGLE_CLIENT_SECRET`, and + `VITE_ENABLE_GOOGLE_INTEGRATION=1`. Keep these in your local `.env` only — never commit them. + +## Optional keys + +Everything below is blank in `.env.example` and safe to leave unset: + +- `VITE_OMI_API_KEY` — cloud-sync recorded conversations (generate in Omi → Settings → + Developer). Blank = recordings save locally only. +- `MAIN_VITE_GOOGLE_CLIENT_ID` / `MAIN_VITE_GOOGLE_CLIENT_SECRET` / + `VITE_ENABLE_GOOGLE_INTEGRATION` — the Google integration above. + +## Build + +```bash +# Windows +npm run build:win + +# macOS +npm run build:mac + +# Linux +npm run build:linux +``` + +Vite inlines the `.env` values at build time, so a packaged installer needs no `.env` — +the config is compiled into the binary. diff --git a/windows/electron-builder.yml b/windows/electron-builder.yml new file mode 100644 index 0000000000..1594c459b8 --- /dev/null +++ b/windows/electron-builder.yml @@ -0,0 +1,54 @@ +appId: com.omiwindows.app +productName: Omi for Windows +directories: + buildResources: build +files: + # Explicit default; positive patterns below would otherwise replace it. + - '**/*' + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.{js,ts,mjs,cjs}' + - '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' + - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' + - '!{tailwind.config.ts,postcss.config.js}' + - '!resources/win-ocr-helper/*.pdb' + - '!resources/win-automation-helper/*.pdb' +asarUnpack: + - resources/** + # koffi loads its native .node at runtime, resolved relative to its own package + # dir — it must live outside the asar archive or the foreground monitor fails. + - node_modules/koffi/** +win: + executableName: omi-windows + target: + - target: nsis + arch: [x64] +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + artifactName: ${productName}-Setup-${version}.${ext} + shortcutName: ${productName} + uninstallDisplayName: ${productName} + createDesktopShortcut: always +mac: + entitlementsInherit: build/entitlements.mac.plist + extendInfo: + - NSCameraUsageDescription: Application requests access to the device's camera. + - NSMicrophoneUsageDescription: Application requests access to the device's microphone. + - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. + - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. + notarize: false +dmg: + artifactName: ${name}-${version}.${ext} +linux: + target: + - AppImage + - snap + - deb + maintainer: electronjs.org + category: Utility +appImage: + artifactName: ${name}-${version}.${ext} +npmRebuild: false diff --git a/windows/electron.vite.config.ts b/windows/electron.vite.config.ts new file mode 100644 index 0000000000..93b74cf74d --- /dev/null +++ b/windows/electron.vite.config.ts @@ -0,0 +1,26 @@ +import { resolve } from 'path' +import { defineConfig } from 'electron-vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + main: {}, + preload: {}, + renderer: { + // Pin the dev server to a fixed port so the renderer's origin + // (http://localhost:5179) — and therefore its localStorage (onboarding flag, + // preferences, Firebase session) — stays stable across launches. Without a + // pinned port, a second worktree holding 5173 pushes this one to 5174, which + // is a different origin and silently drops all saved state (re-onboarding). + // strictPort fails fast instead of drifting to a new origin. + server: { + port: 5179, + strictPort: true + }, + resolve: { + alias: { + '@renderer': resolve('src/renderer/src') + } + }, + plugins: [react()] + } +}) diff --git a/windows/eslint.config.mjs b/windows/eslint.config.mjs new file mode 100644 index 0000000000..aff5d3f9f6 --- /dev/null +++ b/windows/eslint.config.mjs @@ -0,0 +1,32 @@ +import { defineConfig } from 'eslint/config' +import tseslint from '@electron-toolkit/eslint-config-ts' +import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier' +import eslintPluginReact from 'eslint-plugin-react' +import eslintPluginReactHooks from 'eslint-plugin-react-hooks' +import eslintPluginReactRefresh from 'eslint-plugin-react-refresh' + +export default defineConfig( + { ignores: ['**/node_modules', '**/dist', '**/out'] }, + tseslint.configs.recommended, + eslintPluginReact.configs.flat.recommended, + eslintPluginReact.configs.flat['jsx-runtime'], + { + settings: { + react: { + version: 'detect' + } + } + }, + { + files: ['**/*.{ts,tsx}'], + plugins: { + 'react-hooks': eslintPluginReactHooks, + 'react-refresh': eslintPluginReactRefresh + }, + rules: { + ...eslintPluginReactHooks.configs.recommended.rules, + ...eslintPluginReactRefresh.configs.vite.rules + } + }, + eslintConfigPrettier +) diff --git a/windows/package.json b/windows/package.json new file mode 100644 index 0000000000..77967d2062 --- /dev/null +++ b/windows/package.json @@ -0,0 +1,83 @@ +{ + "name": "omi-windows", + "version": "1.0.0", + "description": "An Electron application with React and TypeScript", + "main": "./out/main/index.js", + "author": "example.com", + "homepage": "https://electron-vite.org", + "scripts": { + "format": "prettier --write .", + "lint": "eslint --cache .", + "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", + "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", + "typecheck": "npm run typecheck:node && npm run typecheck:web", + "start": "electron-vite preview", + "dev": "electron-vite dev", + "build": "npm run typecheck && electron-vite build", + "rebuild:sqlite": "electron-rebuild -f -w better-sqlite3", + "build:ocr-helper": "powershell -ExecutionPolicy Bypass -File scripts/build-ocr-helper.ps1", + "postinstall": "electron-builder install-app-deps && electron-rebuild -f -w better-sqlite3 && node scripts/ensure-ocr-helper.mjs", + "build:unpack": "npm run build && electron-builder --dir", + "build:win": "npm run build && electron-builder --win --x64", + "build:mac": "electron-vite build && electron-builder --mac", + "build:linux": "electron-vite build && electron-builder --linux", + "test": "vitest run", + "test:watch": "vitest", + "bench": "node scripts/bench/run.mjs", + "bench:anim": "node scripts/bench/anim.mjs" + }, + "dependencies": { + "@electron-toolkit/preload": "^3.0.2", + "@electron-toolkit/utils": "^4.0.0", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "axios": "^1.16.1", + "better-sqlite3": "^12.10.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "d3-force": "^3.0.0", + "d3-force-3d": "^3.0.6", + "firebase": "^12.13.0", + "koffi": "^3.0.2", + "lucide-react": "^1.16.0", + "react-router-dom": "^7.15.1", + "tailwind-merge": "^3.6.0", + "three": "^0.184.0", + "ws": "^8.21.0" + }, + "devDependencies": { + "@electron-toolkit/eslint-config-prettier": "^3.0.0", + "@electron-toolkit/eslint-config-ts": "^3.1.0", + "@electron-toolkit/tsconfig": "^2.0.0", + "@electron/rebuild": "^4.0.4", + "@types/better-sqlite3": "^7.6.13", + "@types/d3-force": "^3.0.10", + "@types/node": "^22.19.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.1", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.5.0", + "electron": "^39.2.6", + "electron-builder": "^26.0.12", + "electron-vite": "^5.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "postcss": "^8.5.15", + "prettier": "^3.7.4", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vite": "^7.2.6", + "vitest": "^3.2.4" + } +} diff --git a/windows/pnpm-lock.yaml b/windows/pnpm-lock.yaml new file mode 100644 index 0000000000..59439f9b4d --- /dev/null +++ b/windows/pnpm-lock.yaml @@ -0,0 +1,9210 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@electron-toolkit/preload': + specifier: ^3.0.2 + version: 3.0.2(electron@39.8.10) + '@electron-toolkit/utils': + specifier: ^4.0.0 + version: 4.0.0(electron@39.8.10) + '@huggingface/transformers': + specifier: ^4.2.0 + version: 4.2.0 + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-three/drei': + specifier: ^10.7.7 + version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0))(@types/react@19.2.16)(@types/three@0.184.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) + '@react-three/fiber': + specifier: ^9.6.1 + version: 9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) + axios: + specifier: ^1.16.1 + version: 1.17.0 + better-sqlite3: + specifier: ^12.10.0 + version: 12.10.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 + d3-force-3d: + specifier: ^3.0.6 + version: 3.0.6 + firebase: + specifier: ^12.13.0 + version: 12.14.0 + koffi: + specifier: ^3.0.2 + version: 3.0.2 + lucide-react: + specifier: ^1.16.0 + version: 1.17.0(react@19.2.7) + react-router-dom: + specifier: ^7.15.1 + version: 7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + three: + specifier: ^0.184.0 + version: 0.184.0 + ws: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@electron-toolkit/eslint-config-prettier': + specifier: ^3.0.0 + version: 3.0.0(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.3) + '@electron-toolkit/eslint-config-ts': + specifier: ^3.1.0 + version: 3.1.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@electron-toolkit/tsconfig': + specifier: ^2.0.0 + version: 2.0.0(@types/node@22.19.19) + '@electron/rebuild': + specifier: ^4.0.4 + version: 4.0.4 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 + '@types/node': + specifier: ^22.19.1 + version: 22.19.19 + '@types/react': + specifier: ^19.2.7 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + '@types/three': + specifier: ^0.184.1 + version: 0.184.1 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.2.0(vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7)) + autoprefixer: + specifier: ^10.5.0 + version: 10.5.0(postcss@8.5.15) + electron: + specifier: ^39.2.6 + version: 39.8.10 + electron-builder: + specifier: ^26.0.12 + version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + electron-vite: + specifier: ^5.0.0 + version: 5.0.0(vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7)) + eslint: + specifier: ^9.39.1 + version: 9.39.4(jiti@1.21.7) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.1.1(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.26(eslint@9.39.4(jiti@1.21.7)) + postcss: + specifier: ^8.5.15 + version: 8.5.15 + prettier: + specifier: ^3.7.4 + version: 3.8.3 + react: + specifier: ^19.2.1 + version: 19.2.7 + react-dom: + specifier: ^19.2.1 + version: 19.2.7(react@19.2.7) + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.2.6 + version: 7.3.5(@types/node@22.19.19)(jiti@1.21.7) + vitest: + specifier: ^3.2.4 + version: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7) + +packages: + + 7zip-bin@5.2.0: + resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@develar/schema-utils@2.6.5': + resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} + engines: {node: '>= 8.9.0'} + + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + + '@electron-toolkit/eslint-config-prettier@3.0.0': + resolution: {integrity: sha512-YapmIOVkbYdHLuTa+ad1SAVtcqYL9A/SJsc7cxQokmhcwAwonGevNom37jBf9slXegcZ/Slh01I/JARG1yhNFw==} + peerDependencies: + eslint: '>= 9.0.0' + prettier: '>= 3.0.0' + + '@electron-toolkit/eslint-config-ts@3.1.0': + resolution: {integrity: sha512-MowZQKd3yxXSDLack5QvjQwYHhpOJFoWBGBwJ/k+DCd7NUSendplECbQGFp86tPQYPUrPBPceR/hdsSAnaY5ZQ==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@electron-toolkit/preload@3.0.2': + resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==} + peerDependencies: + electron: '>=13.0.0' + + '@electron-toolkit/tsconfig@2.0.0': + resolution: {integrity: sha512-AdPsP770WhW7b260h13SHMdmjEEHJL6xFtgi3jwgdsSQbJOkJLeNnnpZW9qxTPCvmRI6vmdzWz5K3gibFS6SNg==} + peerDependencies: + '@types/node': '*' + + '@electron-toolkit/utils@4.0.0': + resolution: {integrity: sha512-qXSntwEzluSzKl4z5yFNBknmPGjPa3zFhE4mp9+h0cgokY5ornAeP+CJQDBhKsL1S58aOQfcwkD3NwLZCl+64g==} + peerDependencies: + electron: '>=13.0.0' + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + 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.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + 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.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + 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.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + 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.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + 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.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@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.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + 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.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + 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} + + '@firebase/ai@2.13.0': + resolution: {integrity: sha512-nJJDQKqjAcbkZdZGT/5WTVLrGZ+pYhWbwKC90nNzmvtoRTtnOJaNS34fhKSHQeB9SALgD2kxuWT5I4AkytdZ/Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@firebase/app-types': 0.x + + '@firebase/analytics-compat@0.2.28': + resolution: {integrity: sha512-lIAlqUUbBu93FJMlQfslryQtBwwzdzvp23ePC6FNgymXk6Ook5v4Uvc0vdutvoIeqmyA3LfP0ZeRFK8+11kOOQ==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/analytics-types@0.8.4': + resolution: {integrity: sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ==} + + '@firebase/analytics@0.10.22': + resolution: {integrity: sha512-8BSaq/QRGU1+xyi8L2PTLTJU7MH9aMA72RQdIxrbhWFauOZY9OXo8f2YDN/972xA8d588tlnNVEQ2Mo69pT9Ow==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-check-compat@0.4.4': + resolution: {integrity: sha512-9iP0MvmaVagulNXmrca96U3tqNAI3j98wsC1z7rj62nnOTajlrHM//jjB9VoHqRw6/islMskp6RsKnM7vhLDqA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/app-check-interop-types@0.3.4': + resolution: {integrity: sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==} + + '@firebase/app-check-types@0.5.4': + resolution: {integrity: sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==} + + '@firebase/app-check@0.11.4': + resolution: {integrity: sha512-G8EsbVJV9gSfoibx0dNoNOUrvr+PkL7J//+W/BST/oUassimkZeq9bjj3bKkB0pn4og5GMQ9qs7FefwP00kkgg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-compat@0.5.13': + resolution: {integrity: sha512-pn3FvXwUR34kWPccDQfCKsNZcM2wD1OS+J1jeEgzM1ZNXoxR2NaF6e5DjDuRrnTwR6LN2XQQt0IqE6yKmgpCQg==} + engines: {node: '>=20.0.0'} + + '@firebase/app-types@0.9.5': + resolution: {integrity: sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==} + + '@firebase/app@0.14.13': + resolution: {integrity: sha512-H89Jeyp31+EZk9GPu6vaeL9mEmoXgM3nASB7UPBYYS/lqAks21mO1BU1dF8NbsVTL6tgGZkGUtiGJgxtDiwHkw==} + engines: {node: '>=20.0.0'} + + '@firebase/auth-compat@0.6.7': + resolution: {integrity: sha512-XgKnOgY1Siq7gylAmLkYtHAlRxNeWEAspH+nO3gJZJnfHqoTHbr9UjJ3nHNFALYXV5CfpQlyPROyB2ztySBHBQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/auth-interop-types@0.2.5': + resolution: {integrity: sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==} + + '@firebase/auth-types@0.13.1': + resolution: {integrity: sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/auth@1.13.2': + resolution: {integrity: sha512-B4w0iS7MxRg28oIh2fJFTE6cM0lYdBrW19eHpc42jqEcloUjlYyVrpPqZvqA4+v9KFEVSKEs2SfWyta7hbzkJQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@react-native-async-storage/async-storage': ^2.2.0 || ^3.0.0 + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + + '@firebase/component@0.7.3': + resolution: {integrity: sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==} + engines: {node: '>=20.0.0'} + + '@firebase/data-connect@0.7.1': + resolution: {integrity: sha512-2LbUU8mmSA63HknxQMmWHjpzuNLBKflvVwQc2tpoVKg0biWleNEJX031ELks0vzFs+dDjOUkCJR72RP6mQHFOg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/database-compat@2.1.4': + resolution: {integrity: sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==} + engines: {node: '>=20.0.0'} + + '@firebase/database-types@1.0.20': + resolution: {integrity: sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==} + + '@firebase/database@1.1.3': + resolution: {integrity: sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==} + engines: {node: '>=20.0.0'} + + '@firebase/firestore-compat@0.4.10': + resolution: {integrity: sha512-yMP3FADDjikdrQv4YmvL4EkIny6Hw+N+a2O5T40rlHiniyMpRPxgYkKiFOvMZnsqKLqBVnKqCAElC0pa/IZtdw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/firestore-types@3.0.4': + resolution: {integrity: sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/firestore@4.15.0': + resolution: {integrity: sha512-Fj9osqYkz2Rqr7kW3/A8BRd8CyJ7yA5K8YjhihRdyJWbL+FsELVcR6DpoCplrp1IyU+xeGgTubo1UOySXpY+EA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/functions-compat@0.4.5': + resolution: {integrity: sha512-10qlUXGY25G5/1g9UihqksPp2po+ZqSE7LEizsrdUP7vrTmkysXxGSZCDyojSEp6mQe/ecRDdDDI+z4XRdb4wQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/functions-types@0.6.4': + resolution: {integrity: sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ==} + + '@firebase/functions@0.13.5': + resolution: {integrity: sha512-bWCx713f4kE/uFV7gdFOLBS7lDoiZj48MRkbAqe35gkXcCeWF4QjRNO07Jhmve7EJIoQOBczL29y2r8VRuN1kw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/installations-compat@0.2.22': + resolution: {integrity: sha512-C/zpAuTP5S9OgKSPvXRupw3hoY/JZSlA1wFjD/Sb7LIQE0FNbcMdO8Y4KXVEkjVzma/DDDDIAzxEXqKMAzc88w==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/installations-types@0.5.4': + resolution: {integrity: sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg==} + peerDependencies: + '@firebase/app-types': 0.x + + '@firebase/installations@0.6.22': + resolution: {integrity: sha512-ef6nn3GGQTdReCfotRMG77PJZu8CqEbiK5pEoBnM0gTu/Z9v0i/az2p3HABsa/1beQmmyh1OsOjf7P5+pgwdZw==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/logger@0.5.1': + resolution: {integrity: sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==} + engines: {node: '>=20.0.0'} + + '@firebase/messaging-compat@0.2.27': + resolution: {integrity: sha512-JNOiu1PPgdHzEPEtoFiNxQuu0x9bm4bfETSQCpGfcTlgWkhlSK7uh7nlsjC10TQLUNgYetLmuutaYTh8aeYLVA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/messaging-interop-types@0.2.5': + resolution: {integrity: sha512-tUEKnaAP2Y/MNIqgnriPpV6e5l13Vs/+p2yrd6NGlncPJT9O3a8muYZtdnWe+IJ4fgKLHJVC79n/asxk/N5Msw==} + + '@firebase/messaging@0.13.0': + resolution: {integrity: sha512-GZoo0uGRvEbszo83xcgbjJp4FpkmBEr4l8Z4hi8gl+P1Spn/MTK3HapanMzSX4yUHuTEiF5hasWRxOaz+o5sxQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/performance-compat@0.2.25': + resolution: {integrity: sha512-q6NjTXpIPoFuUmCmMN/maCdTgzT6aExs9xZo+PxfVLj6uLVGvpyAD6XWjmcrb7jChsFBYbq7E5dyNDF7Zhy9kA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/performance-types@0.2.4': + resolution: {integrity: sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg==} + + '@firebase/performance@0.7.12': + resolution: {integrity: sha512-fe7nV8teUU3OBHlMUZ9Lw4gLhCW2k4m5Uc3pfWGV+fl8uwJQBGp9Q3lqsJ+HSrFu3Q2pJyLAgrClPGSKyDeYgQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/remote-config-compat@0.2.25': + resolution: {integrity: sha512-FnA5S4IxFJAAFrCnYzWlO0FCaizlYdqhe42ygFMA+wE/mUP+w36iXzHyKj1OO1A+2gyMFjeRHyg8HhkJ6c5vRA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/remote-config-types@0.5.1': + resolution: {integrity: sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==} + + '@firebase/remote-config@0.8.4': + resolution: {integrity: sha512-lslywR5lGvHWTu4z/MPoYs3UwS3CKdeY+ELXY87087VsOpBpkD+9Orra23tA9GW683arPTDOM3CM6eKmtiOO3g==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/storage-compat@0.4.3': + resolution: {integrity: sha512-gruVqjtUGX8tEoeNbaWXZm0Zfcfcb7fvmDmBxV8yPAbWvExRnZYLO2+qw9idxNE7BvPXt5csyjSYHy//dAizxw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/storage-types@0.8.4': + resolution: {integrity: sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/storage@0.14.3': + resolution: {integrity: sha512-YX4/YL6P6/fufSSeGnVhjWddcIXbFq2cWIhMKFTZo1E/Rtcl2mJj/BYUQTwJfcE1Tl8un1FOya4L05jcSLN/Eg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/util@1.15.1': + resolution: {integrity: sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==} + engines: {node: '>=20.0.0'} + + '@firebase/webchannel-wrapper@1.0.6': + resolution: {integrity: sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@grpc/grpc-js@1.9.16': + resolution: {integrity: sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==} + engines: {node: ^8.13.0 || >=10.10.0} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@huggingface/jinja@0.5.9': + resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} + engines: {node: '>=18'} + + '@huggingface/tokenizers@0.1.3': + resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} + + '@huggingface/transformers@4.2.0': + resolution: {integrity: sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==} + + '@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'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=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/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@koromix/koffi-darwin-arm64@3.0.2': + resolution: {integrity: sha512-OaL5EViEvsdbc8Ut90zY44AKF3mq6JBqkK/xLkGaSXcLsOJUsD+XaPAZb/WOWcKVg7On31wP+4hB7MwHQPdiUg==} + cpu: [arm64] + os: [darwin] + + '@koromix/koffi-darwin-x64@3.0.2': + resolution: {integrity: sha512-ZkBKudZkIOy5yVcp8cUTFCrE5DSgxkcH26mUxV6m8JgUtvtps4iMIGAAZLSq5aouvQQKhbKpXtZ3YdzpcytM+g==} + cpu: [x64] + os: [darwin] + + '@koromix/koffi-freebsd-arm64@3.0.2': + resolution: {integrity: sha512-eVHMjYL6yMc7GOdH0juDVEYFU4D9Az6cqyI7+ytWwovwAjHNtf96S17Ag/aI2qs7WVCoHceKDdMUATzs5grvMw==} + cpu: [arm64] + os: [freebsd] + + '@koromix/koffi-freebsd-ia32@3.0.2': + resolution: {integrity: sha512-qiSkR5fF/oa8MIukjsXMmFPC/FWGfUtw3ee+jgURg0R+Tuhlvqlfh50W4rQ0Y0faITJcF+3wmCI6WSt7p4iCkA==} + cpu: [ia32] + os: [freebsd] + + '@koromix/koffi-freebsd-x64@3.0.2': + resolution: {integrity: sha512-RDqoD8tBiRoe1C/lZEdMWETKk2dTR298XgJ9PBXbw2ETMVpIX+ci++X+j+4e7J3kVldxHLdFCykNei6d1wmhTA==} + cpu: [x64] + os: [freebsd] + + '@koromix/koffi-linux-arm64@3.0.2': + resolution: {integrity: sha512-zIm5NlzFuxt4f6mDjwTDgfzu8oZngcALTmOkWbp7HSeWdjQeiy8JRaSOEpfzlIlqWoPxhRUX5Yniqkt8564VnA==} + cpu: [arm64] + os: [linux] + + '@koromix/koffi-linux-ia32@3.0.2': + resolution: {integrity: sha512-Le3JxJswxBhO3OeeSWA400/v2sTPwTWz/IIc6ARc4SbTGUcz2jm6jXXQviW01r5OvCQ+w2Q2T4PxP+eaRa2+fQ==} + cpu: [ia32] + os: [linux] + + '@koromix/koffi-linux-loong64@3.0.2': + resolution: {integrity: sha512-TiPOFy4OX0tLhKwL6oSOhDIhwd5b48x7nxCBT8u6IW+nLXMIo2j+jla1h9niQ8HVGknGOzu48k55KVl0YMsaew==} + cpu: [loong64] + os: [linux] + + '@koromix/koffi-linux-riscv64@3.0.2': + resolution: {integrity: sha512-U44szVAHW4WKTV0s6K6rjeZEURJ0CFHbZC0Ik3lpzvEidbOu8dTft7OGNr4hhBvx/XaeLMCCebPLBoYZZNW2Dg==} + cpu: [riscv64] + os: [linux] + + '@koromix/koffi-linux-x64@3.0.2': + resolution: {integrity: sha512-bm4qT+e59KWn4V/jK8uF1RV2k0/JJvmGPUj4qCJ3E9663H+6ZLqz1rpCdgXSEA95f5CNR3RWcgjQwxgKO9xFtQ==} + cpu: [x64] + os: [linux] + + '@koromix/koffi-openbsd-ia32@3.0.2': + resolution: {integrity: sha512-tUu2LMSyeCSe1DbJKZrjGOKRQzDhj/RAw4rVdlHXlU/B26sdyKZjEwE3GxVlYDdEBX4FiYYmoVuG3BwGWSefCw==} + cpu: [ia32] + os: [openbsd] + + '@koromix/koffi-openbsd-x64@3.0.2': + resolution: {integrity: sha512-o4QZwsKIQvlMSIJaCXsu8d2sz4bbKEJrZhmnCeT/SKEU1VEiegmDn/I6DYrg5mOMDC15GWlUP1qP6AsVlrxqXA==} + cpu: [x64] + os: [openbsd] + + '@koromix/koffi-win32-ia32@3.0.2': + resolution: {integrity: sha512-Yz/iP2xLaq8t3TFy5+f2Oww7rBpXgZ4YRexdYffSm2EwcnDeCtPudQSjIiKyEBdvDsU2vQetTrxyqDHStXXocw==} + cpu: [ia32] + os: [win32] + + '@koromix/koffi-win32-x64@3.0.2': + resolution: {integrity: sha512-zozoHzvSi61jch4sKqFi3ATsDC7lviFthoivzPKVk4VeJvFeSdqueKoYGHNXEzfhIXn9Y5K85l9a59nwfutkUA==} + cpu: [x64] + os: [win32] + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@mediapipe/tasks-vision@0.10.17': + resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} + + '@monogrid/gainmap-js@3.4.0': + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + 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-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-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + 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-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + 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-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + 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-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-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + 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-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + 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 + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + 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-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + 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/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@react-three/drei@10.7.7': + resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} + peerDependencies: + '@react-three/fiber': ^9.0.0 + react: ^19 + react-dom: ^19 + three: '>=0.159' + peerDependenciesMeta: + react-dom: + optional: true + + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.61.0': + resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.0': + resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.0': + resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.0': + resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.0': + resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.0': + resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.0': + resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.0': + resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.0': + resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.0': + resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.0': + resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.0': + resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.0': + resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.0': + resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==} + cpu: [x64] + os: [win32] + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + + '@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/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.2.16': + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.184.1': + resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==} + + '@types/verror@1.10.11': + resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.60.1': + resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.60.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.60.1': + resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==} + 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.60.1': + resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.60.1': + resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.60.1': + resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.60.1': + resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==} + 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.60.1': + resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.60.1': + resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.60.1': + resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} + 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.60.1': + resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + + '@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'} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + + 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-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + app-builder-bin@5.0.0-alpha.12: + resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} + + app-builder-lib@26.8.1: + resolution: {integrity: sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.8.1 + electron-builder-squirrel-windows: 26.8.1 + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + 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.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'} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + + 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} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + 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 + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + + builder-util@26.8.1: + resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=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-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camera-controls@3.1.2: + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} + engines: {node: '>=22.0.0', npm: '>=10.5.1'} + peerDependencies: + three: '>=0.126.1' + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + crc@3.8.0: + resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-binarytree@1.0.2: + resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-force-3d@3.0.6: + resolution: {integrity: sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-octree@1.1.0: + resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + 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@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + 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'} + + detect-gpu@5.0.70: + resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dmg-builder@26.8.1: + resolution: {integrity: sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==} + + dmg-license@1.0.11: + resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} + engines: {node: '>=8'} + os: [darwin] + hasBin: true + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.8.1: + resolution: {integrity: sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==} + + electron-builder@26.8.1: + resolution: {integrity: sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.8.1: + resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} + + electron-to-chromium@1.5.366: + resolution: {integrity: sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==} + + electron-vite@5.0.0: + resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@swc/core': ^1.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@swc/core': + optional: true + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@39.8.10: + resolution: {integrity: sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==} + engines: {node: '>= 12.20.55'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + 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-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + 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'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + 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-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} + 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@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + 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-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} + + 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'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + + 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-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + firebase@12.14.0: + resolution: {integrity: sha512-aEZ/lniDR1hOCYpx/x/V8Nrrqq9pepKDNkqP/4WGZFC69gTv6F59Z4/54W/SUP4L/hFlrRNmWj35aweQq+IHow==} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + 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'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + 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-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + 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@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 + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + 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'} + + glsl-noise@0.0.0: + resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.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.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + 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'} + + iconv-corefoundation@1.1.7: + resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} + engines: {node: ^8.11.2 || >=10} + os: [darwin] + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + 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'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + 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==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + 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.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + 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-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-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-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + 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-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'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: 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-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==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + 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==} + + koffi@3.0.2: + resolution: {integrity: sha512-YS4sGzlVMhFNNkKbkB3tcLKJxH9WGP3jHTxi4Eyx+ieMRKVz4K2i+6rebYr9ngHN36jhIW6YXnxkzBeOQ51lsw==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + maath@0.10.8: + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + meshline@3.3.1: + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==} + peerDependencies: + three: '>=0.137' + + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + + 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-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + 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==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-abi@4.31.0: + resolution: {integrity: sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==} + engines: {node: '>=22.12.0'} + + node-addon-api@1.7.2: + resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-gyp@12.3.0: + resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + 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.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onnxruntime-common@1.24.0-dev.20251116-b39e144322: + resolution: {integrity: sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==} + + onnxruntime-common@1.24.3: + resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} + + onnxruntime-node@1.24.3: + resolution: {integrity: sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==} + os: [win32, darwin, linux] + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + 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==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + 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@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + 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@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + promise-worker-transferable@1.0.4: + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.2: + resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==} + engines: {node: '>=12.0.0'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + 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-router-dom@7.16.0: + resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.16.0: + resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + 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-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + rollup@4.61.0: + resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + 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==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + 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.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + 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'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + 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'} + + 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'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.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.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + 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'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + engines: {node: '>=18'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + three-mesh-bvh@0.8.3: + resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==} + peerDependencies: + three: '>= 0.159.0' + + three-stdlib@2.36.1: + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} + peerDependencies: + three: '>=0.128.0' + + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + troika-three-text@0.52.4: + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} + peerDependencies: + three: '>=0.125.0' + + troika-three-utils@0.52.4: + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} + peerDependencies: + three: '>=0.125.0' + + troika-worker-utils@0.52.0: + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + 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-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + 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.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.60.1: + resolution: {integrity: sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==} + 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@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.26.0: + resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + engines: {node: '>=18.17'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + 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==} + + 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-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 + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + + webgl-constants@1.1.1: + resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} + + webgl-sdf-generator@1.1.1: + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} + + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + 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.21: + resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + 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==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + 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 + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + 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'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + 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 + +snapshots: + + 7zip-bin@5.2.0: {} + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@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.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@develar/schema-utils@2.6.5': + dependencies: + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + '@dimforge/rapier3d-compat@0.12.0': {} + + '@electron-toolkit/eslint-config-prettier@3.0.0(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.3)': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.3) + prettier: 3.8.3 + transitivePeerDependencies: + - '@types/eslint' + + '@electron-toolkit/eslint-config-ts@3.1.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint/js': 9.39.4 + eslint: 9.39.4(jiti@1.21.7) + globals: 16.5.0 + typescript-eslint: 8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@electron-toolkit/preload@3.0.2(electron@39.8.10)': + dependencies: + electron: 39.8.10 + + '@electron-toolkit/tsconfig@2.0.0(@types/node@22.19.19)': + dependencies: + '@types/node': 22.19.19 + + '@electron-toolkit/utils@4.0.0(electron@39.8.10)': + dependencies: + electron: 39.8.10 + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.0.4': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + node-abi: 4.31.0 + node-api-version: 0.2.1 + node-gyp: 12.3.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + dir-compare: 4.2.0 + fs-extra: 11.3.5 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3 + fs-extra: 11.3.5 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + 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.2.0 + 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 + + '@firebase/ai@2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/app-types': 0.9.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/analytics-compat@0.2.28(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/analytics': 0.10.22(@firebase/app@0.14.13) + '@firebase/analytics-types': 0.8.4 + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/analytics-types@0.8.4': {} + + '@firebase/analytics@0.10.22(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/app-check-compat@0.4.4(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-check': 0.11.4(@firebase/app@0.14.13) + '@firebase/app-check-types': 0.5.4 + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/app-check-interop-types@0.3.4': {} + + '@firebase/app-check-types@0.5.4': {} + + '@firebase/app-check@0.11.4(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/app-compat@0.5.13': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/app-types@0.9.5': + dependencies: + '@firebase/logger': 0.5.1 + + '@firebase/app@0.14.13': + dependencies: + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/auth-compat@0.6.7(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/auth': 1.13.2(@firebase/app@0.14.13) + '@firebase/auth-types': 0.13.1(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + - '@react-native-async-storage/async-storage' + + '@firebase/auth-interop-types@0.2.5': {} + + '@firebase/auth-types@0.13.1(@firebase/app-types@0.9.5)(@firebase/util@1.15.1)': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/auth@1.13.2(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/component@0.7.3': + dependencies: + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/data-connect@0.7.1(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/database-compat@2.1.4': + dependencies: + '@firebase/component': 0.7.3 + '@firebase/database': 1.1.3 + '@firebase/database-types': 1.0.20 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/database-types@1.0.20': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/database@1.1.3': + dependencies: + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/firestore-compat@0.4.10(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/firestore': 4.15.0(@firebase/app@0.14.13) + '@firebase/firestore-types': 3.0.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/firestore-types@3.0.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1)': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/firestore@4.15.0(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + '@firebase/webchannel-wrapper': 1.0.6 + '@grpc/grpc-js': 1.9.16 + '@grpc/proto-loader': 0.7.15 + tslib: 2.8.1 + + '@firebase/functions-compat@0.4.5(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/functions': 0.13.5(@firebase/app@0.14.13) + '@firebase/functions-types': 0.6.4 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/functions-types@0.6.4': {} + + '@firebase/functions@0.13.5(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/messaging-interop-types': 0.2.5 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/installations-compat@0.2.22(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations-types': 0.5.4(@firebase/app-types@0.9.5) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/installations-types@0.5.4(@firebase/app-types@0.9.5)': + dependencies: + '@firebase/app-types': 0.9.5 + + '@firebase/installations@0.6.22(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/logger@0.5.1': + dependencies: + tslib: 2.8.1 + + '@firebase/messaging-compat@0.2.27(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/messaging': 0.13.0(@firebase/app@0.14.13) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/messaging-interop-types@0.2.5': {} + + '@firebase/messaging@0.13.0(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/messaging-interop-types': 0.2.5 + '@firebase/util': 1.15.1 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/performance-compat@0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/performance': 0.7.12(@firebase/app@0.14.13) + '@firebase/performance-types': 0.2.4 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/performance-types@0.2.4': {} + + '@firebase/performance@0.7.12(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + web-vitals: 4.2.4 + + '@firebase/remote-config-compat@0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/remote-config': 0.8.4(@firebase/app@0.14.13) + '@firebase/remote-config-types': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/remote-config-types@0.5.1': {} + + '@firebase/remote-config@0.8.4(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/storage-compat@0.4.3(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/storage': 0.14.3(@firebase/app@0.14.13) + '@firebase/storage-types': 0.8.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/storage-types@0.8.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1)': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/storage@0.14.3(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/util@1.15.1': + dependencies: + tslib: 2.8.1 + + '@firebase/webchannel-wrapper@1.0.6': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@grpc/grpc-js@1.9.16': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@types/node': 22.19.19 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.2 + yargs: 17.7.2 + + '@huggingface/jinja@0.5.9': {} + + '@huggingface/tokenizers@0.1.3': {} + + '@huggingface/transformers@4.2.0': + dependencies: + '@huggingface/jinja': 0.5.9 + '@huggingface/tokenizers': 0.1.3 + onnxruntime-node: 1.24.3 + onnxruntime-web: 1.26.0-dev.20260416-b7804b056c + sharp: 0.34.5 + + '@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': {} + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@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/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@koromix/koffi-darwin-arm64@3.0.2': + optional: true + + '@koromix/koffi-darwin-x64@3.0.2': + optional: true + + '@koromix/koffi-freebsd-arm64@3.0.2': + optional: true + + '@koromix/koffi-freebsd-ia32@3.0.2': + optional: true + + '@koromix/koffi-freebsd-x64@3.0.2': + optional: true + + '@koromix/koffi-linux-arm64@3.0.2': + optional: true + + '@koromix/koffi-linux-ia32@3.0.2': + optional: true + + '@koromix/koffi-linux-loong64@3.0.2': + optional: true + + '@koromix/koffi-linux-riscv64@3.0.2': + optional: true + + '@koromix/koffi-linux-x64@3.0.2': + optional: true + + '@koromix/koffi-openbsd-ia32@3.0.2': + optional: true + + '@koromix/koffi-openbsd-x64@3.0.2': + optional: true + + '@koromix/koffi-win32-ia32@3.0.2': + optional: true + + '@koromix/koffi-win32-x64@3.0.2': + optional: true + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@mediapipe/tasks-vision@0.10.17': {} + + '@monogrid/gainmap-js@3.4.0(three@0.184.0)': + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.184.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@pkgr/core@0.3.6': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/rect': 1.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/rect@1.1.1': {} + + '@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0))(@types/react@19.2.16)(@types/three@0.184.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.184.0) + '@react-three/fiber': 9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) + '@use-gesture/react': 10.3.1(react@19.2.7) + camera-controls: 3.1.2(three@0.184.0) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.6.16 + maath: 0.10.8(@types/three@0.184.1)(three@0.184.0) + meshline: 3.3.1(three@0.184.0) + react: 19.2.7 + stats-gl: 2.4.2(@types/three@0.184.1)(three@0.184.0) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@19.2.7) + three: 0.184.0 + three-mesh-bvh: 0.8.3(three@0.184.0) + three-stdlib: 2.36.1(three@0.184.0) + troika-three-text: 0.52.4(three@0.184.0) + tunnel-rat: 0.1.2(@types/react@19.2.16)(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + utility-types: 3.11.0 + zustand: 5.0.14(@types/react@19.2.16)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer + + '@react-three/fiber@9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-use-measure: 2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.7) + three: 0.184.0 + use-sync-external-store: 1.6.0(react@19.2.7) + zustand: 5.0.14(@types/react@19.2.16)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.61.0': + optional: true + + '@rollup/rollup-android-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-x64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.0': + optional: true + + '@sindresorhus/is@4.6.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@tweenjs/tween.js@23.1.3': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@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.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.19.19 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.19.19 + '@types/responselike': 1.0.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-force@3.0.10': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/draco3d@1.4.10': {} + + '@types/estree@1.0.9': {} + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 22.19.19 + + '@types/http-cache-semantics@4.2.0': {} + + '@types/json-schema@7.0.15': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.19.19 + + '@types/ms@2.1.0': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/offscreencanvas@2019.7.3': {} + + '@types/plist@3.0.5': + dependencies: + '@types/node': 22.19.19 + xmlbuilder: 15.1.1 + optional: true + + '@types/react-dom@19.2.3(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + + '@types/react-reconciler@0.28.9(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + + '@types/react@19.2.16': + dependencies: + csstype: 3.2.3 + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.19.19 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.184.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/verror@1.10.11': + optional: true + + '@types/webxr@0.5.24': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.19 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.19.19 + optional: true + + '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/type-utils': 8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.60.1 + eslint: 9.39.4(jiti@1.21.7) + 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.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.60.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) + '@typescript-eslint/types': 8.60.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.60.1': + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + + '@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.60.1': {} + + '@typescript-eslint/typescript-estree@8.60.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.60.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.60.1': + dependencies: + '@typescript-eslint/types': 8.60.1 + eslint-visitor-keys: 5.0.1 + + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.2.7)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.2.7 + + '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.5(@types/node@22.19.19)(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.5(@types/node@22.19.19)(jiti@1.21.7) + + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': + optional: true + + abbrev@4.0.0: {} + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + adm-zip@0.5.17: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + 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 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + app-builder-bin@5.0.0-alpha.12: {} + + app-builder-lib@26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1): + dependencies: + '@develar/schema-utils': 2.6.5 + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.0.4 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@types/fs-extra': 9.0.13 + async-exit-hook: 2.0.1 + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3 + dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.8.1(dmg-builder@26.8.1) + electron-publish: 26.8.1 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.2.0 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.16 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + + arg@5.0.2: {} + + 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.2 + 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.2 + 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 + + assert-plus@1.0.0: + optional: true + + assertion-error@2.0.1: {} + + astral-regex@2.0.0: + optional: true + + async-exit-hook@2.0.1: {} + + async-function@1.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@1.17.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.33: {} + + better-sqlite3@12.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolean@3.2.0: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + 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.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.366 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@26.8.1: + dependencies: + 7zip-bin: 5.2.0 + '@types/debug': 4.1.13 + app-builder-bin: 5.0.0-alpha.12 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + js-yaml: 4.2.0 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + + cac@6.7.14: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + 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-css@2.0.1: {} + + camera-controls@3.1.2(three@0.184.0): + dependencies: + three: 0.184.0 + + caniuse-lite@1.0.30001793: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + 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 + + chownr@1.1.4: {} + + chownr@3.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + optional: true + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + commander@5.1.0: {} + + commander@9.5.0: + optional: true + + compare-version@0.1.2: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-util-is@1.0.2: + optional: true + + crc@3.8.0: + dependencies: + buffer: 5.7.1 + optional: true + + cross-dirname@0.1.0: + optional: true + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-binarytree@1.0.2: {} + + d3-dispatch@3.0.1: {} + + d3-force-3d@3.0.6: + dependencies: + d3-binarytree: 1.0.2 + d3-dispatch: 3.0.1 + d3-octree: 1.1.0 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-octree@1.1.0: {} + + d3-quadtree@3.0.1: {} + + d3-timer@3.0.1: {} + + 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@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.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: {} + + detect-gpu@5.0.70: + dependencies: + webgl-constants: 1.1.1 + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + detect-node@2.1.0: {} + + didyoumean@1.2.2: {} + + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dlv@1.1.3: {} + + dmg-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + fs-extra: 10.1.0 + iconv-lite: 0.6.3 + js-yaml: 4.2.0 + optionalDependencies: + dmg-license: 1.0.11 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + dmg-license@1.0.11: + dependencies: + '@types/plist': 3.0.5 + '@types/verror': 1.10.11 + ajv: 6.15.0 + crc: 3.8.0 + iconv-corefoundation: 1.1.7 + plist: 3.1.1 + smart-buffer: 4.2.0 + verror: 1.10.1 + optional: true + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + draco3d@1.5.7: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.8.1(dmg-builder@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.2 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.8.1: + dependencies: + '@types/fs-extra': 9.0.13 + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + form-data: 4.0.5 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-to-chromium@1.5.366: {} + + electron-vite@5.0.0(vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7)): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + cac: 6.7.14 + esbuild: 0.25.12 + magic-string: 0.30.21 + picocolors: 1.1.1 + vite: 7.3.5(@types/node@22.19.19)(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3 + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@39.8.10: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 22.19.19 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: {} + + err-code@2.0.3: {} + + 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.2 + 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.4 + 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.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.21 + + 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-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + 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.4 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es6-error@4.1.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + 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-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.3): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + prettier: 3.8.3 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@1.21.7)) + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 9.39.4(jiti@1.21.7) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): + 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(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.4 + 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.7 + 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-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@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.9 + 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 + optionalDependencies: + jiti: 1.21.7 + 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 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + exponential-backoff@3.1.3: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.4.1: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fflate@0.6.10: {} + + fflate@0.8.3: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-uri-to-path@1.0.0: {} + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + firebase@12.14.0: + dependencies: + '@firebase/ai': 2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/analytics': 0.10.22(@firebase/app@0.14.13) + '@firebase/analytics-compat': 0.2.28(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/app': 0.14.13 + '@firebase/app-check': 0.11.4(@firebase/app@0.14.13) + '@firebase/app-check-compat': 0.4.4(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/app-compat': 0.5.13 + '@firebase/app-types': 0.9.5 + '@firebase/auth': 1.13.2(@firebase/app@0.14.13) + '@firebase/auth-compat': 0.6.7(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/data-connect': 0.7.1(@firebase/app@0.14.13) + '@firebase/database': 1.1.3 + '@firebase/database-compat': 2.1.4 + '@firebase/firestore': 4.15.0(@firebase/app@0.14.13) + '@firebase/firestore-compat': 0.4.10(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/functions': 0.13.5(@firebase/app@0.14.13) + '@firebase/functions-compat': 0.4.5(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations-compat': 0.2.22(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/messaging': 0.13.0(@firebase/app@0.14.13) + '@firebase/messaging-compat': 0.2.27(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/performance': 0.7.12(@firebase/app@0.14.13) + '@firebase/performance-compat': 0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/remote-config': 0.8.4(@firebase/app@0.14.13) + '@firebase/remote-config-compat': 0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/storage': 0.14.3(@firebase/app@0.14.13) + '@firebase/storage-compat': 0.4.3(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/util': 1.15.1 + transitivePeerDependencies: + - '@react-native-async-storage/async-storage' + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatbuffers@25.9.23: {} + + flatted@3.4.2: {} + + follow-redirects@1.16.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.4 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + 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.4 + 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.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + 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 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.1 + serialize-error: 7.0.1 + + globals@14.0.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + glsl-noise@0.0.0: {} + + gopd@1.2.0: {} + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + guid-typescript@1.0.9: {} + + has-bigints@1.1.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.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hls.js@1.6.16: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + http-cache-semantics@4.2.0: {} + + http-parser-js@0.5.10: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + 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 + + iconv-corefoundation@1.1.7: + dependencies: + cli-truncate: 2.1.0 + node-addon-api: 1.7.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immediate@3.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + 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 + + 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.2: + dependencies: + hasown: 2.0.4 + + 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-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.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-promise@2.2.2: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + 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.21 + + 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 + + isarray@2.0.5: {} + + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + isexe@4.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + its-fine@2.0.0(@types/react@19.2.16)(react@19.2.7): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.16) + react: 19.2.7 + transitivePeerDependencies: + - '@types/react' + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jiti@1.21.7: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: {} + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + 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 + + koffi@3.0.2: + optionalDependencies: + '@koromix/koffi-darwin-arm64': 3.0.2 + '@koromix/koffi-darwin-x64': 3.0.2 + '@koromix/koffi-freebsd-arm64': 3.0.2 + '@koromix/koffi-freebsd-ia32': 3.0.2 + '@koromix/koffi-freebsd-x64': 3.0.2 + '@koromix/koffi-linux-arm64': 3.0.2 + '@koromix/koffi-linux-ia32': 3.0.2 + '@koromix/koffi-linux-loong64': 3.0.2 + '@koromix/koffi-linux-riscv64': 3.0.2 + '@koromix/koffi-linux-x64': 3.0.2 + '@koromix/koffi-openbsd-ia32': 3.0.2 + '@koromix/koffi-openbsd-x64': 3.0.2 + '@koromix/koffi-win32-ia32': 3.0.2 + '@koromix/koffi-win32-x64': 3.0.2 + + lazy-val@1.0.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.merge@4.6.2: {} + + lodash@4.18.1: {} + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lowercase-keys@2.0.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lucide-react@1.17.0(react@19.2.7): + dependencies: + react: 19.2.7 + + maath@0.10.8(@types/three@0.184.1)(three@0.184.0): + dependencies: + '@types/three': 0.184.1 + three: 0.184.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + meshline@3.3.1(three@0.184.0): + dependencies: + three: 0.184.0 + + meshoptimizer@1.1.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + napi-build-utils@2.0.0: {} + + natural-compare@1.4.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.1 + + node-abi@4.31.0: + dependencies: + semver: 7.8.1 + + node-addon-api@1.7.2: + optional: true + + node-api-version@0.2.1: + dependencies: + semver: 7.8.1 + + 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-gyp@12.3.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.1 + tar: 7.5.16 + tinyglobby: 0.2.17 + undici: 6.26.0 + which: 6.0.1 + + node-releases@2.0.47: {} + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-path@3.0.0: {} + + normalize-url@6.1.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + 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.2 + 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.2 + + 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.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.2 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onnxruntime-common@1.24.0-dev.20251116-b39e144322: {} + + onnxruntime-common@1.24.3: {} + + onnxruntime-node@1.24.3: + dependencies: + adm-zip: 0.5.17 + global-agent: 3.0.0 + onnxruntime-common: 1.24.3 + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + dependencies: + flatbuffers: 25.9.23 + guid-typescript: 1.0.9 + long: 5.3.2 + onnxruntime-common: 1.24.0-dev.20251116-b39e144322 + platform: 1.3.6 + protobufjs: 7.6.2 + + 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 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pe-library@0.4.1: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + platform@1.3.6: {} + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + optional: true + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.15 + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + optional: true + + potpack@1.0.2: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.8.3: {} + + proc-log@6.1.0: {} + + progress@2.0.3: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + promise-worker-transferable@1.0.4: + dependencies: + is-promise: 2.2.2 + lie: 3.3.0 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.2: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.19.19 + long: 5.3.2 + + proxy-from-env@2.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quick-lru@5.1.1: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-refresh@0.18.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + + react-router-dom@7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react-use-measure@2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + 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.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + 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 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + retry@0.12.0: {} + + reusify@1.1.0: {} + + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + + rollup@4.61.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.0 + '@rollup/rollup-android-arm64': 4.61.0 + '@rollup/rollup-darwin-arm64': 4.61.0 + '@rollup/rollup-darwin-x64': 4.61.0 + '@rollup/rollup-freebsd-arm64': 4.61.0 + '@rollup/rollup-freebsd-x64': 4.61.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.0 + '@rollup/rollup-linux-arm-musleabihf': 4.61.0 + '@rollup/rollup-linux-arm64-gnu': 4.61.0 + '@rollup/rollup-linux-arm64-musl': 4.61.0 + '@rollup/rollup-linux-loong64-gnu': 4.61.0 + '@rollup/rollup-linux-loong64-musl': 4.61.0 + '@rollup/rollup-linux-ppc64-gnu': 4.61.0 + '@rollup/rollup-linux-ppc64-musl': 4.61.0 + '@rollup/rollup-linux-riscv64-gnu': 4.61.0 + '@rollup/rollup-linux-riscv64-musl': 4.61.0 + '@rollup/rollup-linux-s390x-gnu': 4.61.0 + '@rollup/rollup-linux-x64-gnu': 4.61.0 + '@rollup/rollup-linux-x64-musl': 4.61.0 + '@rollup/rollup-openbsd-x64': 4.61.0 + '@rollup/rollup-openharmony-arm64': 4.61.0 + '@rollup/rollup-win32-arm64-msvc': 4.61.0 + '@rollup/rollup-win32-ia32-msvc': 4.61.0 + '@rollup/rollup-win32-x64-gnu': 4.61.0 + '@rollup/rollup-win32-x64-msvc': 4.61.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.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: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.0: {} + + scheduler@0.27.0: {} + + semver-compare@1.0.0: {} + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + semver@7.8.1: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + + set-cookie-parser@2.7.2: {} + + 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.2 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + 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 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.1 + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + optional: true + + smart-buffer@4.2.0: + optional: true + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.1.3: {} + + stackback@0.0.2: {} + + stat-mode@1.0.0: {} + + stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0): + dependencies: + '@types/three': 0.184.1 + three: 0.184.0 + + stats.js@0.17.0: {} + + std-env@3.10.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.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.2 + 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.2 + 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.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + suspend-react@0.1.3(react@19.2.7): + dependencies: + react: 19.2.7 + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + tailwind-merge@3.6.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.16: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + three-mesh-bvh@0.8.3(three@0.184.0): + dependencies: + three: 0.184.0 + + three-stdlib@2.36.1(three@0.184.0): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.24 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.184.0 + + three@0.184.0: {} + + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + troika-three-text@0.52.4(three@0.184.0): + dependencies: + bidi-js: 1.0.3 + three: 0.184.0 + troika-three-utils: 0.52.4(three@0.184.0) + troika-worker-utils: 0.52.0 + webgl-sdf-generator: 1.1.1 + + troika-three-utils@0.52.4(three@0.184.0): + dependencies: + three: 0.184.0 + + troika-worker-utils@0.52.0: {} + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tunnel-rat@0.1.2(@types/react@19.2.16)(react@19.2.7): + dependencies: + zustand: 4.5.7(@types/react@19.2.16)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + - react + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.13.1: {} + + 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.8: + 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-eslint@8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + 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@6.21.0: {} + + undici@6.26.0: {} + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + 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 + + use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + utf8-byte-length@1.0.5: {} + + util-deprecate@1.0.2: {} + + utility-types@3.11.0: {} + + verror@1.10.1: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + optional: true + + vite-node@3.2.4(@types/node@22.19.19)(jiti@1.21.7): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.5(@types/node@22.19.19)(jiti@1.21.7) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + jiti: 1.21.7 + + vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.5(@types/node@22.19.19)(jiti@1.21.7) + vite-node: 3.2.4(@types/node@22.19.19)(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.19.19 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + web-vitals@4.2.4: {} + + webgl-constants@1.1.1: {} + + webgl-sdf-generator@1.1.1: {} + + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + + 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.21 + + 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.21: + 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 + + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + 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: {} + + ws@8.21.0: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + 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 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zustand@4.5.7(@types/react@19.2.16)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + react: 19.2.7 + + zustand@5.0.14(@types/react@19.2.16)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.16 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/windows/pnpm-workspace.yaml b/windows/pnpm-workspace.yaml new file mode 100644 index 0000000000..4fe556ef64 --- /dev/null +++ b/windows/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +allowBuilds: + '@firebase/util': true + better-sqlite3: true + electron: true + electron-winstaller: true + esbuild: true + koffi: true + onnxruntime-node: true + protobufjs: true + sharp: true diff --git a/windows/postcss.config.js b/windows/postcss.config.js new file mode 100644 index 0000000000..33ad091d26 --- /dev/null +++ b/windows/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/windows/resources/icon.png b/windows/resources/icon.png new file mode 100644 index 0000000000..8bc156c95f Binary files /dev/null and b/windows/resources/icon.png differ diff --git a/windows/scripts/build-automation-helper.ps1 b/windows/scripts/build-automation-helper.ps1 new file mode 100644 index 0000000000..4d877412aa --- /dev/null +++ b/windows/scripts/build-automation-helper.ps1 @@ -0,0 +1,18 @@ +# Publishes win-automation-helper as a self-contained single-file exe into +# resources/win-automation-helper/ (gitignored). Ships via electron-builder's +# `asarUnpack: resources/**`, resolved at runtime by resolveHelperPath.ts. +$ErrorActionPreference = 'Stop' +$proj = Join-Path $PSScriptRoot '..\src\main\automation\helper' +$out = Join-Path $PSScriptRoot '..\resources\win-automation-helper' + +dotnet publish $proj ` + -c Release ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -o $out + +if (-not (Test-Path (Join-Path $out 'win-automation-helper.exe'))) { + throw 'build-automation-helper: win-automation-helper.exe was not produced' +} +Write-Host "build-automation-helper: published to $out" diff --git a/windows/scripts/build-ocr-helper.ps1 b/windows/scripts/build-ocr-helper.ps1 new file mode 100644 index 0000000000..efc63d40ca --- /dev/null +++ b/windows/scripts/build-ocr-helper.ps1 @@ -0,0 +1,18 @@ +# Publishes the win-ocr-helper as a self-contained single-file exe into +# resources/win-ocr-helper/ (gitignored). Ships via electron-builder's +# `asarUnpack: resources/**` and is resolved at runtime by resolveHelperPath.ts. +$ErrorActionPreference = 'Stop' +$proj = Join-Path $PSScriptRoot '..\src\main\ocr\win-ocr-helper' +$out = Join-Path $PSScriptRoot '..\resources\win-ocr-helper' + +dotnet publish $proj ` + -c Release ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -o $out + +if (-not (Test-Path (Join-Path $out 'win-ocr-helper.exe'))) { + throw 'build-ocr-helper: win-ocr-helper.exe was not produced' +} +Write-Host "build-ocr-helper: published to $out" diff --git a/windows/scripts/diag-listen-probe.mjs b/windows/scripts/diag-listen-probe.mjs new file mode 100644 index 0000000000..731e098f53 --- /dev/null +++ b/windows/scripts/diag-listen-probe.mjs @@ -0,0 +1,152 @@ +// Temp diagnostic: map which Omi host actually serves /v4/listen and what auth +// class it enforces. Delete after debugging. +// +// The production app authenticates with a **Firebase ID token** (Google sign-in +// → auth.currentUser.getIdToken()), NOT the omi_dev_ API key. To reproduce the +// app's real connect, give this script a token via one of: +// +// OMI_ID_TOKEN= one-off; ID tokens expire after ~1h +// OMI_REFRESH_TOKEN= script exchanges it for a fresh ID token each run +// +// Grab either from the RUNNING app's renderer DevTools console (the app stores +// both in IndexedDB under browserLocalPersistence): +// +// const db = await new Promise(r => { const q = indexedDB.open('firebaseLocalStorageDb'); q.onsuccess = () => r(q.result) }) +// const all = await new Promise(r => { const q = db.transaction('firebaseLocalStorage').objectStore('firebaseLocalStorage').getAll(); q.onsuccess = () => r(q.result) }) +// const u = all.find(x => x.fbase_key?.startsWith('firebase:authUser'))?.value +// console.log('ID TOKEN:', u.stsTokenManager.accessToken) +// console.log('REFRESH TOKEN:', u.stsTokenManager.refreshToken) +// +// Put it in .env (OMI_REFRESH_TOKEN=...) or pass inline: +// OMI_REFRESH_TOKEN=xxx node scripts/diag-listen-probe.mjs +import fs from 'node:fs' +import WebSocket from 'ws' + +const env = {} +for (const line of fs.readFileSync(new URL('../.env', import.meta.url), 'utf8').split('\n')) { + const m = line.match(/^([A-Z_]+)=(.*)$/) + if (m) env[m[1]] = m[2].trim() +} + +// Legacy probe params (uid in query) for the no-auth / dev-key paths. +const QS = 'language=en&sample_rate=16000&codec=pcm16&channels=1&uid=dummy-uid-test' +// The app's REAL connect params (src/main/ipc/omiListen.ts). With a Firebase +// token the backend derives uid from the token, so no uid query is sent. +const APP_QS = + 'language=en&sample_rate=16000&codec=linear16&channels=1' + + '&include_speech_profile=true&source=desktop&speaker_auto_assign=enabled' +const KEY = env.VITE_OMI_API_KEY + +const hosts = [ + ['api.omi.me (prod) ', 'wss://api.omi.me'], + // based-hardware-dev backend-listen service: serves /v4/listen, public invoke, + // verifies tokens against the PROD based-hardware Firebase project (same as the + // app), and its logs live in a project we can actually read. + ['backend-listen (dev) ', 'wss://backend-listen-dt5lrfkkoa-uc.a.run.app'], + ['backend (dev) ', 'wss://backend-dt5lrfkkoa-uc.a.run.app'], + ['desktop-backend ', 'wss://desktop-backend-hhibjajaja-uc.a.run.app'] +] + +function decodeJwt(tok) { + try { + const payload = tok.split('.')[1] + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) + } catch { + return null + } +} + +// Resolve a Firebase ID token from OMI_ID_TOKEN, or by exchanging +// OMI_REFRESH_TOKEN via the Firebase secure-token REST API. +async function resolveIdToken() { + const direct = process.env.OMI_ID_TOKEN || env.OMI_ID_TOKEN + if (direct) return { token: direct.trim(), source: 'OMI_ID_TOKEN' } + + const refresh = process.env.OMI_REFRESH_TOKEN || env.OMI_REFRESH_TOKEN + if (!refresh) return null + + const apiKey = env.VITE_FIREBASE_API_KEY + if (!apiKey) throw new Error('VITE_FIREBASE_API_KEY missing from .env') + const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refresh.trim() }) + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(`securetoken exchange failed: HTTP ${res.status} ${JSON.stringify(data).slice(0, 200)}`) + } + return { token: data.id_token, source: 'OMI_REFRESH_TOKEN → securetoken exchange' } +} + +// waitForClose: after a 101 OPEN, linger briefly to catch an immediate close +// (e.g. 1008 trial_expired / freemium_threshold_reached) so we can tell +// "auth OK, stays connected" from "auth OK but quota exhausted". +function probe(label, base, headers, tag, qs = QS, waitForClose = false) { + return new Promise((resolve) => { + const t0 = Date.now() + const ws = new WebSocket(`${base}/v4/listen?${qs}`, { headers }) + let opened = false + const done = (r) => { + try { ws.terminate() } catch {} + resolve(`${label}[${tag}] -> ${r} (${Date.now() - t0}ms)`) + } + ws.on('open', () => { + opened = true + if (!waitForClose) return done('OPEN ✅ (101)') + // Stay up to 2.5s; if no close arrives, the socket is healthy. + setTimeout(() => done('OPEN ✅ (101) — stayed connected 2.5s'), 2500) + }) + ws.on('unexpected-response', (_q, res) => { + let body = '' + res.on('data', (c) => { body += c }) + res.on('end', () => done(`HTTP ${res.statusCode} body=${JSON.stringify(body.slice(0, 150))}`)) + }) + ws.on('message', (data, isBinary) => { + if (isBinary || !waitForClose) return + const text = data.toString().trim() + if (text && text !== 'ping') console.log(` ${label}[${tag}] msg: ${text.slice(0, 160)}`) + }) + ws.on('close', (code, reasonBuf) => { + if (opened && waitForClose) done(`OPEN then CLOSED (${code}) ${reasonBuf.toString() || '(no reason)'}`) + }) + ws.on('error', (e) => done(`error: ${e.message}`)) + setTimeout(() => done('TIMEOUT'), 8000) + }) +} + +let firebase = null +try { + firebase = await resolveIdToken() +} catch (e) { + console.log(`\n⚠️ Firebase token unavailable: ${e.message}`) +} + +if (firebase) { + const claims = decodeJwt(firebase.token) + const expIn = claims?.exp ? Math.round(claims.exp - Date.now() / 1000) : null + console.log(`\n🔑 Firebase ID token via ${firebase.source}`) + if (claims) { + console.log(` uid=${claims.user_id || claims.sub} email=${claims.email || '(none)'} aud=${claims.aud}`) + console.log(` expires in ${expIn}s${expIn !== null && expIn <= 0 ? ' ⚠️ EXPIRED — refresh it' : ''}`) + } else { + console.log(' (could not decode JWT payload — token may be malformed)') + } +} else { + console.log('\nℹ️ No Firebase token provided (set OMI_ID_TOKEN or OMI_REFRESH_TOKEN) — skipping the app-faithful auth probe.') +} + +console.log('\n=== host map for /v4/listen ===') +for (const [label, base] of hosts) { + console.log(await probe(label, base, {}, 'no-auth')) + console.log(await probe(label, base, { Authorization: `Bearer ${KEY}` }, 'dev-key')) + if (firebase) { + // App-faithful: Firebase Bearer token + the app's real query params, and + // linger to surface an immediate quota close. + console.log( + await probe(label, base, { Authorization: `Bearer ${firebase.token}` }, 'fb-token', APP_QS, true) + ) + } +} +console.log('===============================\n') +process.exit(0) diff --git a/windows/scripts/ensure-ocr-helper.mjs b/windows/scripts/ensure-ocr-helper.mjs new file mode 100644 index 0000000000..ab8d2fcf91 --- /dev/null +++ b/windows/scripts/ensure-ocr-helper.mjs @@ -0,0 +1,32 @@ +// Postinstall step: on Windows, build win-ocr-helper.exe if it's missing, so a +// fresh clone or git worktree gets working screen-OCR out of the box (the binary is +// gitignored, like .env, so it never travels with the repo). Deliberately a no-op +// off-Windows or when the exe already exists, and NON-FATAL on any failure — it must +// never break `npm install`. If it can't build (e.g. no .NET SDK), the app still +// runs; OCR just stays disabled until `npm run build:ocr-helper` is run. +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { execSync } from 'node:child_process' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const exe = join(root, 'resources', 'win-ocr-helper', 'win-ocr-helper.exe') + +if (process.platform !== 'win32') { + console.log('[ensure-ocr-helper] not Windows — skipping (the OCR helper is Windows-only).') + process.exit(0) +} +if (existsSync(exe)) { + console.log('[ensure-ocr-helper] win-ocr-helper.exe already present — skipping.') + process.exit(0) +} +try { + console.log('[ensure-ocr-helper] win-ocr-helper.exe missing — building it (needs the .NET SDK)…') + execSync('npm run build:ocr-helper', { stdio: 'inherit', cwd: root }) +} catch { + console.warn( + '[ensure-ocr-helper] could NOT build the OCR helper (is the .NET SDK installed?). ' + + 'The app still works; screen-reading stays disabled until you run `npm run build:ocr-helper`.' + ) +} +process.exit(0) diff --git a/windows/scripts/folder-recency.py b/windows/scripts/folder-recency.py new file mode 100644 index 0000000000..ec1757bac2 --- /dev/null +++ b/windows/scripts/folder-recency.py @@ -0,0 +1,39 @@ +import sqlite3, os +from datetime import datetime, timezone + +DB = os.path.join(os.environ["APPDATA"], "omi-windows", "omi.db") +c = sqlite3.connect(DB).cursor() +now = datetime.now(timezone.utc).timestamp() * 1000 +DAY = 86_400_000 + + +def fmt(ms): + return datetime.fromtimestamp(ms / 1000, timezone.utc).strftime("%Y-%m-%d") + + +print("=== Top folders by FILE COUNT (what synthesis uses today) ===") +for folder, n, newest in c.execute( + """SELECT folder, COUNT(*) n, MAX(modified_at) newest + FROM indexed_files WHERE file_type!='application' + GROUP BY folder ORDER BY n DESC LIMIT 12""" +): + age = int((now - newest) / DAY) + print(f" {n:5d} files | newest {fmt(newest)} ({age:4d}d ago) | {folder}") + +print("\n=== Top folders by RECENCY (most recently touched) ===") +for folder, n, newest in c.execute( + """SELECT folder, COUNT(*) n, MAX(modified_at) newest + FROM indexed_files WHERE file_type!='application' + GROUP BY folder ORDER BY newest DESC LIMIT 12""" +): + age = int((now - newest) / DAY) + print(f" {n:5d} files | newest {fmt(newest)} ({age:4d}d ago) | {folder}") + +print("\n=== Folders active in the last 30 days (count of recently-modified files) ===") +for folder, recent in c.execute( + """SELECT folder, COUNT(*) recent FROM indexed_files + WHERE file_type!='application' AND modified_at > ? + GROUP BY folder ORDER BY recent DESC LIMIT 12""", + (now - 30 * DAY,), +): + print(f" {recent:5d} files modified <30d | {folder}") diff --git a/windows/scripts/inspect-kg.py b/windows/scripts/inspect-kg.py new file mode 100644 index 0000000000..2d91715965 --- /dev/null +++ b/windows/scripts/inspect-kg.py @@ -0,0 +1,52 @@ +import sqlite3, os, sys + +DB = os.path.join(os.environ["APPDATA"], "omi-windows", "omi.db") +d = sqlite3.connect(DB) +c = d.cursor() + + +def has_table(name): + return bool( + c.execute( + "select count(*) from sqlite_master where type='table' and name=?", (name,) + ).fetchone()[0] + ) + + +tables = sorted(r[0] for r in c.execute("select name from sqlite_master where type='table'")) +print("DB:", DB) +print("TABLES:", tables) + +if has_table("indexed_files"): + n = c.execute("select count(*) from indexed_files").fetchone()[0] + print("indexed_files:", n) + rows = c.execute( + "select extension, count(*) c from indexed_files where file_type!='application' and extension!='' group by extension order by c desc limit 15" + ).fetchall() + print("top extensions:", rows) +else: + print("indexed_files: NO TABLE") + +for t in ("local_kg_nodes", "local_kg_edges"): + if has_table(t): + print(f"{t}:", c.execute(f"select count(*) from {t}").fetchone()[0]) + else: + print(f"{t}: NO TABLE (expected until the new build runs once)") + +# Verification queries (only meaningful after synthesis runs) +if has_table("local_kg_nodes"): + print("\n--- technology nodes ---") + for r in c.execute( + "select label, summary from local_kg_nodes where node_type='technology' order by label" + ): + print(" ", r[0], "|", r[1]) + print("\n--- REGRESSION: phantom Flutter/Android/Dart nodes ---") + bad = c.execute( + "select label from local_kg_nodes where label in ('Dart','Android') or label like '%Flutter%'" + ).fetchall() + print(" rows (want 0):", bad) + print("\n--- sample project/interest nodes ---") + for r in c.execute( + "select node_type, label, summary from local_kg_nodes where node_type in ('project','interest','org','person') limit 20" + ): + print(" ", r[0], "|", r[1], "|", r[2]) diff --git a/windows/scripts/verify-screen-ambient.mjs b/windows/scripts/verify-screen-ambient.mjs new file mode 100644 index 0000000000..e79e50d18e --- /dev/null +++ b/windows/scripts/verify-screen-ambient.mjs @@ -0,0 +1,86 @@ +// One-off verification: does the always-on ambient screen block cause the backend +// to narrate the screen on UNRELATED messages? Hits the real /v2/messages with a +// Firebase token (env OMI_TOKEN), sending the exact framing readCurrentScreen() +// produces. Reads the SSE reply and reports whether it mentions the screen. +// +// OMI_TOKEN= node scripts/verify-screen-ambient.mjs +// +// Pass criteria: +// • UNRELATED message ("what is 17 * 23?") → reply must NOT mention the screen. +// • SCREEN message ("what's on my screen?") → reply SHOULD reflect the screen text. + +const OMI_BASE = process.env.VITE_OMI_API_BASE ?? 'https://api.omi.me' +const TOKEN = process.env.OMI_TOKEN +if (!TOKEN) { + console.error('Set OMI_TOKEN to a fresh Firebase ID token.') + process.exit(2) +} + +// A representative "what's on screen" OCR blob, framed EXACTLY as the app does. +const FAKE_OCR = [ + 'Visual Studio Code — invoice_parser.py', + 'def parse_invoice(path): total = 0 # TODO sum line items', + 'Terminal: pytest -k invoice ... 3 failed, 12 passed', + 'Slack — #billing "did the parser ship?"' +].join('\n') + +const screenBlock = `[Screen context — OCR of what is on the user's screen right now, provided as background only. Use it ONLY if the user's message is about what is on their screen. If it is not, ignore this completely: do not describe, summarize, or mention the screen.] +${FAKE_OCR}` + +async function ask(userMsg) { + const text = `${screenBlock}\n\n${userMsg}` + const res = await fetch(`${OMI_BASE}/v2/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ text }) + }) + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`) + // Same SSE parsing the app uses: strip `data:`/`done:`/`think:`, restore __CRLF__. + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let reply = '' + const consume = (line) => { + if (!line || line.startsWith('done:')) return + const content = line.startsWith('data:') ? line.slice(5).replace(/^ /, '') : line + if (content.startsWith('think:')) return + reply += content.replace(/__CRLF__/g, '\n') + } + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const l of lines) consume(l) + } + consume(buffer) + return reply.trim() +} + +// Heuristic: does the reply talk about the screen / the OCR'd content? +const SCREEN_WORDS = /\b(screen|on your display|invoice_parser|pytest|parse_invoice|slack|billing|terminal|vs ?code|visual studio code|line items)\b/i + +async function main() { + const cases = [ + { kind: 'UNRELATED', msg: 'What is 17 * 23?', wantScreen: false }, + { kind: 'UNRELATED', msg: 'Give me a one-sentence tip for staying focused.', wantScreen: false }, + { kind: 'SCREEN', msg: "What's on my screen right now?", wantScreen: true } + ] + let allPass = true + for (const c of cases) { + const reply = await ask(c.msg) + const mentioned = SCREEN_WORDS.test(reply) + const pass = mentioned === c.wantScreen + allPass &&= pass + console.log(`\n[${c.kind}] ${pass ? 'PASS' : 'FAIL'} (mentioned screen: ${mentioned}, wanted: ${c.wantScreen})`) + console.log(` Q: ${c.msg}`) + console.log(` A: ${reply.replace(/\n/g, ' ').slice(0, 300)}`) + } + console.log(`\n=== ${allPass ? 'ALL PASS — ambient framing holds' : 'FAIL — framing leaks narration, escalate'} ===`) + process.exit(allPass ? 0 : 1) +} +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/windows/src/main/automation/bridge.ts b/windows/src/main/automation/bridge.ts new file mode 100644 index 0000000000..0a19d4c7e3 --- /dev/null +++ b/windows/src/main/automation/bridge.ts @@ -0,0 +1,142 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' +import { resolveHelperPath } from './resolveHelperPath' +import { encodeRequest, FrameDecoder } from '../ocr/helperProtocol' +import { OP_SNAPSHOT, OP_STEP, OP_HELLO, PROTOCOL_VERSION } from './protocol' +import { validatePlan } from './capabilities' +import type { AutomationPlan, PlanRunResult, StepResult, UiSnapshot } from '../../shared/types' + +const REQUEST_TIMEOUT_MS = 8000 +const MAX_BACKOFF_MS = 10000 + +type Pending = { resolve: (json: string) => void; reject: (e: Error) => void; timer: NodeJS.Timeout } + +class AutomationBridge { + private child: ChildProcessWithoutNullStreams | null = null + private readonly queue: Pending[] = [] + private backoff = 500 + + private ensureStarted(): void { + if (this.child) return + const exe = resolveHelperPath() + const child = spawn(exe, [], { stdio: ['pipe', 'pipe', 'pipe'] }) + this.child = child + + const decoder = new FrameDecoder((json) => { + const pending = this.queue.shift() + if (!pending) return + clearTimeout(pending.timer) + pending.resolve(json) + }) + child.stdout.on('data', (chunk: Buffer) => decoder.push(chunk)) + child.stderr.on('data', (c: Buffer) => + console.log('[win-automation-helper]', c.toString().trim()) + ) + child.on('exit', (code) => { + console.warn(`[win-automation-helper] exited code=${code}`) + this.handleExit() + }) + child.on('error', (e) => { + console.error('[win-automation-helper] spawn error:', e.message) + this.handleExit() + }) + setTimeout(() => { + if (this.child === child) this.backoff = 500 + }, 2000) + + // Assert the helper speaks our protocol version. Fire-and-forget: queued + // before any real request (FIFO), so the version check resolves first. A + // mismatch means a stale helper build — log loudly; we don't recycle (that + // would loop) since a rebuild is the only fix. + void this.handshake() + } + + private async handshake(): Promise { + try { + const json = await this.request(OP_HELLO, '{}') + const { protocolVersion } = JSON.parse(json) as { protocolVersion?: number } + if (protocolVersion !== PROTOCOL_VERSION) { + console.error( + `[win-automation-helper] PROTOCOL MISMATCH: helper=${protocolVersion} expected=${PROTOCOL_VERSION} — rebuild the helper (pwsh scripts/build-automation-helper.ps1)` + ) + } + } catch (e) { + console.warn('[win-automation-helper] handshake failed:', (e as Error).message) + } + } + + private handleExit(): void { + this.child = null + while (this.queue.length) { + const p = this.queue.shift()! + clearTimeout(p.timer) + p.reject(new Error('helper exited')) + } + this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS) + } + + private recycle(): void { + if (this.child) { + try { + this.child.kill() + } catch { + /* already dead */ + } + } + this.handleExit() + } + + private request(opcode: number, payloadJson: string): Promise { + this.ensureStarted() + const child = this.child + if (!child) return Promise.reject(new Error('helper not available')) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.queue.findIndex((p) => p.timer === timer) + if (idx >= 0) this.queue.splice(idx, 1) + reject(new Error('helper request timed out')) + this.recycle() + }, REQUEST_TIMEOUT_MS) + this.queue.push({ resolve, reject, timer }) + child.stdin.write(encodeRequest(opcode, Buffer.from(payloadJson, 'utf8'))) + }) + } + + async snapshot(windowHandle?: string): Promise { + try { + const json = await this.request(OP_SNAPSHOT, JSON.stringify({ windowHandle: windowHandle ?? '' })) + return JSON.parse(json) as UiSnapshot + } catch (e) { + return { ok: false, code: 'HELPER_ERROR', message: (e as Error).message } + } + } + + // Validate, then run steps sequentially. `onStep` streams progress; the first + // failure halts the plan. Validation failure aborts before any step runs. + async run(plan: AutomationPlan, onStep: (r: StepResult) => void): Promise { + const check = validatePlan(plan) + if (!check.ok) return { planId: plan.id, ok: false, message: `rejected: ${check.reason}` } + + for (let i = 0; i < plan.steps.length; i++) { + onStep({ planId: plan.id, stepIndex: i, status: 'running' }) + let res: { ok: boolean; message?: string } + try { + const json = await this.request(OP_STEP, JSON.stringify(plan.steps[i])) + res = JSON.parse(json) as { ok: boolean; message?: string } + } catch (e) { + res = { ok: false, message: (e as Error).message } + } + if (!res.ok) { + onStep({ planId: plan.id, stepIndex: i, status: 'failed', detail: res.message }) + return { planId: plan.id, ok: false, failedStepIndex: i, message: res.message } + } + onStep({ planId: plan.id, stepIndex: i, status: 'ok' }) + } + return { planId: plan.id, ok: true } + } + + dispose(): void { + this.recycle() + } +} + +export const automationBridge = new AutomationBridge() diff --git a/windows/src/main/automation/capabilities.test.ts b/windows/src/main/automation/capabilities.test.ts new file mode 100644 index 0000000000..f08537d994 --- /dev/null +++ b/windows/src/main/automation/capabilities.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { validateStep, validatePlan } from './capabilities' +import type { AutomationPlan, AutomationStep } from '../../shared/types' + +const ok = (s: AutomationStep): void => expect(validateStep(s).ok).toBe(true) +const bad = (s: AutomationStep): void => expect(validateStep(s).ok).toBe(false) + +describe('validateStep', () => { + it('accepts allowed step types', () => { + ok({ type: 'focus_window', windowRef: 'Notepad' }) + ok({ type: 'invoke_element', elementRef: 'a:send' }) + ok({ type: 'set_value', elementRef: 'a:edit', value: 'hello' }) + ok({ type: 'wait_for', elementRef: 'a:x', timeoutMs: 1000 }) + }) + + it('rejects unknown step types', () => { + bad({ type: 'nuke' } as unknown as AutomationStep) + }) + + it('accepts plain text and whitelisted named keys in send_keys', () => { + ok({ type: 'send_keys', keys: 'hello world' }) + ok({ type: 'send_keys', keys: 'line one{ENTER}line two' }) + ok({ type: 'send_keys', keys: 'done{TAB}{ENTER}' }) + }) + + it('rejects modifier chords and OS-level keys in send_keys', () => { + bad({ type: 'send_keys', keys: '^r' }) // Ctrl+R + bad({ type: 'send_keys', keys: '%{F4}' }) // Alt+F4 + bad({ type: 'send_keys', keys: '#r' }) // Win+R + bad({ type: 'send_keys', keys: '+{TAB}' }) // Shift modifier syntax + bad({ type: 'send_keys', keys: '{WIN}' }) // unknown named key + }) + + it('rejects raw-coordinate click by default', () => { + bad({ type: 'click', point: { x: 10, y: 10 } }) + ok({ type: 'click', elementRef: 'a:btn' }) + }) + + it('rejects empty/whitespace value-bearing fields', () => { + bad({ type: 'invoke_element', elementRef: '' }) + bad({ type: 'focus_window', windowRef: ' ' }) + }) + + it('rejects set_value with an empty value', () => { + bad({ type: 'set_value', elementRef: 'a:edit', value: '' }) + bad({ type: 'set_value', elementRef: 'a:edit', value: ' ' }) + }) + + it('rejects fullwidth/unicode modifier lookalikes in send_keys', () => { + bad({ type: 'send_keys', keys: '+{TAB}' }) // U+FF0B fullwidth plus normalizes to '+' + }) + + it('enforces wait_for timeout bounds (capped below the bridge timeout)', () => { + bad({ type: 'wait_for', elementRef: 'a:x', timeoutMs: 0 }) + bad({ type: 'wait_for', elementRef: 'a:x', timeoutMs: 7001 }) + ok({ type: 'wait_for', elementRef: 'a:x', timeoutMs: 7000 }) + }) +}) + +describe('validatePlan', () => { + it('blocks plans targeting a blocklisted window', () => { + const plan: AutomationPlan = { + id: 'p', + summary: 's', + targetWindow: 'Windows Security', + steps: [{ type: 'invoke_element', elementRef: 'a:ok' }] + } + expect(validatePlan(plan).ok).toBe(false) + }) + + it('passes a clean plan', () => { + const plan: AutomationPlan = { + id: 'p', + summary: 's', + targetWindow: 'Notepad', + steps: [{ type: 'set_value', elementRef: 'a:edit', value: 'hi' }] + } + expect(validatePlan(plan).ok).toBe(true) + }) + + it('rejects an empty targetWindow', () => { + const plan: AutomationPlan = { + id: 'p', + summary: 's', + targetWindow: '', + steps: [{ type: 'invoke_element', elementRef: 'a:ok' }] + } + expect(validatePlan(plan).ok).toBe(false) + }) + + it('fails the plan if any step is invalid', () => { + const plan: AutomationPlan = { + id: 'p', + summary: 's', + targetWindow: 'Notepad', + steps: [ + { type: 'set_value', elementRef: 'a:edit', value: 'hi' }, + { type: 'send_keys', keys: '#r' } + ] + } + const r = validatePlan(plan) + expect(r.ok).toBe(false) + expect(r.ok ? '' : r.reason).toMatch(/step 1/) + }) +}) diff --git a/windows/src/main/automation/capabilities.ts b/windows/src/main/automation/capabilities.ts new file mode 100644 index 0000000000..96bf97c600 --- /dev/null +++ b/windows/src/main/automation/capabilities.ts @@ -0,0 +1,111 @@ +import type { AutomationPlan, AutomationStep } from '../../shared/types' + +export type ValidationResult = { ok: true } | { ok: false; reason: string } + +// Raw-coordinate clicking is brittle and unsafe; off by default in v1. +const ALLOW_RAW_COORDINATE_CLICK = false + +// Windows whose UI must never be driven (security prompts, lock screen, our own +// windows). Matched case-insensitively as a substring of the target title. +const BLOCKLISTED_WINDOW_SUBSTRINGS = [ + 'windows security', + 'user account control', + 'sign in', + 'lock screen', + 'credential', + 'task manager', + 'omi for windows' +] + +// Upper bound for wait_for. Kept BELOW the bridge's per-request timeout +// (REQUEST_TIMEOUT_MS = 8000) so a long wait can't silently trip the bridge +// into recycling the helper mid-wait. +const MAX_WAIT_FOR_MS = 7000 + +// send_keys grammar: printable text plus a small whitelist of named keys in +// {BRACES}. Modifier-chord syntax (^ % + #) is forbidden outright so no step +// can fire OS-level chords like Win+R or Alt+F4. +const ALLOWED_NAMED_KEYS = new Set([ + 'ENTER', + 'TAB', + 'ESC', + 'BACKSPACE', + 'DELETE', + 'UP', + 'DOWN', + 'LEFT', + 'RIGHT', + 'HOME', + 'END', + 'SPACE' +]) +const FORBIDDEN_MODIFIER_CHARS = ['^', '%', '+', '#'] + +function validateSendKeys(keys: string): ValidationResult { + keys = keys.normalize('NFKC') + for (const c of FORBIDDEN_MODIFIER_CHARS) { + if (keys.includes(c)) return { ok: false, reason: `modifier chord "${c}" not allowed` } + } + // Validate every {NAMED} token. + const tokenRe = /\{([^}]*)\}/g + let m: RegExpExecArray | null + while ((m = tokenRe.exec(keys)) !== null) { + if (!ALLOWED_NAMED_KEYS.has(m[1])) { + return { ok: false, reason: `named key {${m[1]}} not allowed` } + } + } + // Reject an unmatched '{' or '}'. + if ((keys.match(/\{/g)?.length ?? 0) !== (keys.match(/\}/g)?.length ?? 0)) { + return { ok: false, reason: 'unbalanced braces in send_keys' } + } + return { ok: true } +} + +function nonEmpty(s: string | undefined, field: string): ValidationResult { + return s && s.trim().length > 0 ? { ok: true } : { ok: false, reason: `${field} is empty` } +} + +export function validateStep(step: AutomationStep): ValidationResult { + switch (step.type) { + case 'focus_window': + return nonEmpty(step.windowRef, 'windowRef') + case 'invoke_element': + case 'select_item': + return nonEmpty(step.elementRef, 'elementRef') + case 'toggle': + return nonEmpty(step.elementRef, 'elementRef') + case 'set_value': { + const r = nonEmpty(step.elementRef, 'elementRef') + if (!r.ok) return r + return nonEmpty(step.value, 'value') + } + case 'wait_for': + if (!step.elementRef || !step.elementRef.trim()) + return { ok: false, reason: 'elementRef is empty' } + return step.timeoutMs > 0 && step.timeoutMs <= MAX_WAIT_FOR_MS + ? { ok: true } + : { ok: false, reason: 'timeoutMs out of range' } + case 'send_keys': + return validateSendKeys(step.keys) + case 'click': + if (step.elementRef && step.elementRef.trim()) return { ok: true } + if (step.point && ALLOW_RAW_COORDINATE_CLICK) return { ok: true } + return { ok: false, reason: 'click requires elementRef (raw-point click disabled)' } + default: + return { ok: false, reason: `unknown step type ${(step as { type: string }).type}` } + } +} + +export function validatePlan(plan: AutomationPlan): ValidationResult { + const t = nonEmpty(plan.targetWindow, 'targetWindow') + if (!t.ok) return t + const target = plan.targetWindow.toLowerCase() + for (const sub of BLOCKLISTED_WINDOW_SUBSTRINGS) { + if (target.includes(sub)) return { ok: false, reason: `target window "${plan.targetWindow}" is blocklisted` } + } + for (let i = 0; i < plan.steps.length; i++) { + const r = validateStep(plan.steps[i]) + if (!r.ok) return { ok: false, reason: `step ${i}: ${r.reason}` } + } + return { ok: true } +} diff --git a/windows/src/main/automation/foregroundTarget.ts b/windows/src/main/automation/foregroundTarget.ts new file mode 100644 index 0000000000..100acd10b7 --- /dev/null +++ b/windows/src/main/automation/foregroundTarget.ts @@ -0,0 +1,45 @@ +import { app } from 'electron' +import { getForegroundWindowInfo, subscribeForegroundChange } from '../usage/nativeForeground' +import { pickTarget } from './foregroundTargetLogic' + +// The desktop-automation planner needs the window the user actually wants to act +// on — but by the time they type into Omi and hit Enter, OMI itself is the +// foreground window (and it's blocklisted). So we track the most recent +// foreground window whose owning process ISN'T Omi, and hand that to the +// snapshot. Without this the chat path can only ever see Omi's own UI. + +let lastTargetHandle: string | null = null + +function record(): void { + try { + lastTargetHandle = pickTarget(getForegroundWindowInfo(), app.getPath('exe'), lastTargetHandle) + } catch { + /* never let tracking throw */ + } +} + +let unsubscribe: (() => void) | null = null +let pollTimer: NodeJS.Timeout | null = null + +// Start tracking the last non-Omi foreground window. Event-driven via the +// foreground hook, with a coarse poll as a backstop. No-op off-Windows / if +// already running. Safe at startup. +export function startAutomationTargetTracker(): void { + if (process.platform !== 'win32' || unsubscribe || pollTimer) return + record() + unsubscribe = subscribeForegroundChange(record) + pollTimer = setInterval(record, 5_000) +} + +export function stopAutomationTargetTracker(): void { + if (unsubscribe) unsubscribe() + if (pollTimer) clearInterval(pollTimer) + unsubscribe = null + pollTimer = null +} + +// The handle (decimal string) the planner should snapshot, or null to fall back +// to the live foreground window. +export function getAutomationTargetHandle(): string | null { + return lastTargetHandle +} diff --git a/windows/src/main/automation/foregroundTargetLogic.test.ts b/windows/src/main/automation/foregroundTargetLogic.test.ts new file mode 100644 index 0000000000..d0e2a164f6 --- /dev/null +++ b/windows/src/main/automation/foregroundTargetLogic.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { isSelfExe, isShellWindow, pickTarget } from './foregroundTargetLogic' + +const SELF = 'C:\\Apps\\Omi\\omi.exe' + +describe('isSelfExe', () => { + it('matches our own exe by basename, case-insensitively', () => { + expect(isSelfExe('D:\\other\\path\\OMI.EXE', SELF)).toBe(true) + }) + it('is false for a different app', () => { + expect(isSelfExe('C:\\Windows\\notepad.exe', SELF)).toBe(false) + }) + it('is false for a null path', () => { + expect(isSelfExe(null, SELF)).toBe(false) + }) +}) + +describe('pickTarget', () => { + it('adopts a non-self foreground window', () => { + expect(pickTarget({ handle: '111', exePath: 'C:\\x\\notepad.exe' }, SELF, null)).toBe('111') + }) + it('keeps the previous target when the foreground is our own window', () => { + expect(pickTarget({ handle: '999', exePath: SELF }, SELF, '111')).toBe('111') + }) + it('keeps the previous target when no handle could be read', () => { + expect(pickTarget({ handle: null, exePath: 'C:\\x\\notepad.exe' }, SELF, '111')).toBe('111') + }) + it('updates as the user moves between non-self apps', () => { + let t: string | null = null + t = pickTarget({ handle: '111', exePath: 'C:\\x\\notepad.exe' }, SELF, t) + t = pickTarget({ handle: '222', exePath: 'C:\\y\\slack.exe' }, SELF, t) + t = pickTarget({ handle: '333', exePath: SELF }, SELF, t) // clicked into Omi + expect(t).toBe('222') + }) + + it('keeps the previous target when the foreground is a bare shell surface', () => { + // The desktop, taskbar, etc. are explorer.exe windows with no actionable + // tree — adopting one left the planner snapshotting an empty window (B2 bug). + const taskbar = { handle: '65984', exePath: 'C:\\Windows\\explorer.exe', className: 'Shell_TrayWnd' } + expect(pickTarget(taskbar, SELF, '111')).toBe('111') + }) + + it('still adopts a real File Explorer folder window (CabinetWClass)', () => { + const folder = { handle: '777', exePath: 'C:\\Windows\\explorer.exe', className: 'CabinetWClass' } + expect(pickTarget(folder, SELF, '111')).toBe('777') + }) + + it('keeps the real app when a shell surface flashes between it and Omi', () => { + let t: string | null = null + t = pickTarget({ handle: '111', exePath: 'C:\\x\\chrome.exe', className: 'Chrome_WidgetWin_1' }, SELF, t) + t = pickTarget({ handle: '65984', exePath: 'C:\\Windows\\explorer.exe', className: 'WorkerW' }, SELF, t) // clicked desktop/taskbar + t = pickTarget({ handle: '333', exePath: SELF, className: 'Chrome_WidgetWin_1' }, SELF, t) // clicked into Omi + expect(t).toBe('111') + }) +}) + +describe('isShellWindow', () => { + it('flags desktop/taskbar/start shell classes, case-insensitively', () => { + for (const c of ['Progman', 'WorkerW', 'Shell_TrayWnd', 'Shell_SecondaryTrayWnd', 'Windows.UI.Core.CoreWindow']) { + expect(isShellWindow(c)).toBe(true) + } + }) + it('flags the Alt-Tab/window-switch transient surfaces (real B2 culprits)', () => { + // Observed live: switching browser->Omi flashed these explorer.exe windows + // as foreground, and the tracker latched onto an empty 0-element snapshot. + expect(isShellWindow('ForegroundStaging')).toBe(true) + expect(isShellWindow('XamlExplorerHostIslandWindow')).toBe(true) + }) + it('does not flag real app window classes', () => { + for (const c of ['Chrome_WidgetWin_1', 'CabinetWClass', 'Notepad', null, undefined, '']) { + expect(isShellWindow(c)).toBe(false) + } + }) +}) diff --git a/windows/src/main/automation/foregroundTargetLogic.ts b/windows/src/main/automation/foregroundTargetLogic.ts new file mode 100644 index 0000000000..95b0239900 --- /dev/null +++ b/windows/src/main/automation/foregroundTargetLogic.ts @@ -0,0 +1,49 @@ +import { basename } from 'path' + +// Pure target-selection logic, split out from foregroundTarget.ts (which pulls +// in electron + native koffi) so it can be unit-tested under node Vitest. + +// True when an exe path belongs to our own app (so we must NOT treat it as a +// target). Compared by basename, case-insensitively — in dev both Omi and the +// inspector share electron.exe, which is fine: we just won't target ourselves. +export function isSelfExe(exePath: string | null, selfExe: string): boolean { + if (!exePath) return false + return basename(exePath).toLowerCase() === basename(selfExe).toLowerCase() +} + +// Window classes of the Windows shell surfaces (desktop, taskbar, Start/search, +// tray overflow). These are owned by explorer.exe / shell hosts and briefly take +// the foreground when the user clicks the taskbar, Alt-Tabs, or opens Start — but +// they have no actionable UIA tree, so we must never adopt one as the automation +// target (doing so left the planner snapshotting an empty window). A real File +// Explorer FOLDER window is also explorer.exe but uses CabinetWClass, so this +// class-based filter keeps File Explorer while rejecting the bare shell. +const SHELL_WINDOW_CLASSES = new Set([ + 'progman', // desktop + 'workerw', // desktop wallpaper layer + 'shell_traywnd', // primary taskbar + 'shell_secondarytraywnd', // taskbar on additional monitors + 'notifyiconoverflowwindow', // tray overflow flyout + 'windows.ui.core.corewindow', // Start menu / search / action center + 'foregroundstaging', // transient window explorer creates mid foreground-switch + 'xamlexplorerhostislandwindow' // Alt-Tab switcher / Task View / virtual desktops +]) + +export function isShellWindow(className: string | null | undefined): boolean { + if (!className) return false + return SHELL_WINDOW_CLASSES.has(className.trim().toLowerCase()) +} + +// Decide the remembered target: keep the previous handle when the current +// foreground is our own window, a bare shell surface, or we couldn't read a +// handle; otherwise adopt it. +export function pickTarget( + current: { handle: string | null; exePath: string | null; className?: string | null }, + selfExe: string, + prev: string | null +): string | null { + if (!current.handle) return prev + if (isSelfExe(current.exePath, selfExe)) return prev + if (isShellWindow(current.className)) return prev + return current.handle +} diff --git a/windows/src/main/automation/helper/Program.cs b/windows/src/main/automation/helper/Program.cs new file mode 100644 index 0000000000..dd76b16ed1 --- /dev/null +++ b/windows/src/main/automation/helper/Program.cs @@ -0,0 +1,546 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; +using FlaUI.UIA3; + +// win-automation-helper — long-running stdio helper. +// Request frame: [uint32 LE length][1 byte opcode][UTF-8 JSON payload] +// opcode 1 = SNAPSHOT payload = {"windowHandle":""} +// opcode 2 = STEP payload = a single AutomationStep object +// Response frame: [uint32 LE length][UTF-8 JSON] +// SNAPSHOT: {"ok":true,"window":{...},"elements":[...]} | {"ok":false,"code","message"} +// STEP: {"ok":true} | {"ok":false,"message":"..."} + +internal static class Program +{ + private const byte OpSnapshot = 1; + private const byte OpStep = 2; + private const byte OpHello = 3; + // Must match PROTOCOL_VERSION in src/main/automation/protocol.ts. The bridge + // asserts a match on spawn and fails loudly on drift (e.g. a stale helper). + private const int ProtocolVersion = 1; + private const int MaxNodes = 400; + private const int MaxDepth = 12; + + private static readonly JsonSerializerOptions JsonOpts = + new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private static readonly UIA3Automation Automation = new(); + + private static async Task Main(string[] args) + { + if (args.Contains("--selftest")) return SelfTest(); + + var stdin = Console.OpenStandardInput(); + var stdout = Console.OpenStandardOutput(); + while (true) + { + var header = await ReadExactly(stdin, 4); + if (header is null) return 0; + var len = BitConverter.ToUInt32(header, 0); + if (len == 0) continue; + var body = await ReadExactly(stdin, (int)len); + if (body is null) return 0; + + var opcode = body[0]; + var payload = Encoding.UTF8.GetString(body, 1, body.Length - 1); + string json; + try + { + json = opcode switch + { + OpHello => Hello(), + OpSnapshot => Snapshot(payload), + OpStep => RunStep(payload), + _ => Err($"unknown opcode {opcode}") + }; + } + catch (Exception e) + { + json = Err(e.Message); + } + await WriteFrame(stdout, json); + } + } + + // ───────────────────────── snapshot ───────────────────────── + private static string Snapshot(string payload) + { + var handle = TryGetHandle(payload); + var hwnd = handle != IntPtr.Zero ? handle : GetForegroundWindow(); + if (hwnd == IntPtr.Zero) return Err("no foreground window", "NO_WINDOW"); + + var element = Automation.FromHandle(hwnd); + if (element is null) return Err("could not attach to window", "NO_WINDOW"); + + GetWindowThreadProcessId(hwnd, out var pid); + var procName = SafeProcName((int)pid); + var rect = SafeGet(() => element.BoundingRectangle, System.Drawing.Rectangle.Empty); + + var count = 0; + var root = BuildNode(element, 0, ref count); + + var window = new + { + handle = hwnd.ToInt64().ToString(), + title = SafeGet(() => element.Name ?? "", ""), + processName = procName, + rect = new { x = (double)rect.X, y = (double)rect.Y, w = (double)rect.Width, h = (double)rect.Height } + }; + return JsonSerializer.Serialize( + new { ok = true, window, elements = root?.children ?? new List() }, JsonOpts); + } + + private sealed class Node + { + public string ref_ = ""; + public string controlType = ""; + public string name = ""; + public string automationId = ""; + public object rect = new { x = 0.0, y = 0.0, w = 0.0, h = 0.0 }; + public List patterns = new(); + public bool enabled; + public List children = new(); + } + + private static Node? BuildNode(AutomationElement el, int depth, ref int count) + { + if (count >= MaxNodes || depth > MaxDepth) return null; + + // IsOffscreen and BoundingRectangle may throw PropertyNotSupportedException + // on some elements (e.g. raw UIA system items); treat those as on-screen. + bool offscreen = false; + try { offscreen = el.IsOffscreen; } catch { /* unsupported — treat on-screen */ } + System.Drawing.Rectangle r = System.Drawing.Rectangle.Empty; + bool sized = false; + try { r = el.BoundingRectangle; sized = r.Width > 0 && r.Height > 0; } catch { /* keep */ } + + // Recurse BEFORE deciding whether to keep this node. A window's real + // content usually hangs off a client-area container that itself reports a + // zero/odd bounding rect; pruning that container up front (as we used to) + // silently dropped the entire content subtree, leaving only the title bar. + var children = new List(); + foreach (var child in el.FindAllChildren()) + { + if (count >= MaxNodes) break; + var c = BuildNode(child, depth + 1, ref count); + if (c is not null) children.Add(SerializeNode(c)); + } + + // Keep a node if it's visible+sized itself, or it's a structural ancestor + // of something we kept. Drop only invisible/zero-size leaves. + if ((offscreen || !sized) && children.Count == 0) return null; + + count++; + var ct = SafeGet(() => el.ControlType.ToString(), "Unknown"); + var name = SafeGet(() => el.Name ?? "", ""); + var autoId = SafeGet(() => el.AutomationId ?? "", ""); + var enabled = SafeGet(() => el.IsEnabled, true); + return new Node + { + controlType = ct, + name = name, + automationId = autoId, + ref_ = autoId.Length > 0 ? $"a:{autoId}" : $"n:{ct}:{name}", + rect = new { x = (double)r.X, y = (double)r.Y, w = (double)r.Width, h = (double)r.Height }, + patterns = SupportedPatterns(el), + enabled = enabled, + children = children + }; + } + + // Emit camelCase keys with "ref" (reserved word avoided in the field name). + private static object SerializeNode(Node n) => new + { + @ref = n.ref_, + controlType = n.controlType, + name = n.name, + automationId = n.automationId, + rect = n.rect, + patterns = n.patterns, + enabled = n.enabled, + children = n.children + }; + + private static T SafeGet(Func getter, T fallback) + { + try { return getter(); } + catch { return fallback; } + } + + private static List SupportedPatterns(AutomationElement el) + { + var list = new List(); + if (el.Patterns.Invoke.IsSupported) list.Add("invoke"); + if (el.Patterns.Value.IsSupported) list.Add("value"); + if (el.Patterns.SelectionItem.IsSupported) list.Add("selectionItem"); + if (el.Patterns.Toggle.IsSupported) list.Add("toggle"); + return list; + } + + // ───────────────────────── step execution ───────────────────────── + private static string RunStep(string payload) + { + using var doc = JsonDocument.Parse(payload); + var root = doc.RootElement; + var type = root.GetProperty("type").GetString(); + + switch (type) + { + case "focus_window": + { + var hwnd = ResolveWindowHandle(root.GetProperty("windowRef").GetString() ?? ""); + if (hwnd == IntPtr.Zero) return Err("window not found"); + // Verify the window actually came to the foreground. A plain + // SetForeground from a background helper is silently no-op'd by + // Windows' foreground lock, which would leave a following + // send_keys typing into whatever else had focus. Fail loudly + // instead so the plan halts rather than acting on the wrong app. + if (!ForceForeground(hwnd)) + return Err("could not bring window to the foreground (it may be elevated or blocked)"); + return Ok(); + } + case "invoke_element": + { + var el = ResolveInActiveWindow(root.GetProperty("elementRef").GetString() ?? ""); + if (el is null) return Err("element not found"); + el.Patterns.Invoke.Pattern.Invoke(); + return Ok(); + } + case "set_value": + { + var el = ResolveInActiveWindow(root.GetProperty("elementRef").GetString() ?? ""); + if (el is null) return Err("element not found"); + el.Patterns.Value.Pattern.SetValue(root.GetProperty("value").GetString() ?? ""); + return Ok(); + } + case "select_item": + { + var el = ResolveInActiveWindow(root.GetProperty("elementRef").GetString() ?? ""); + if (el is null) return Err("element not found"); + el.Patterns.SelectionItem.Pattern.Select(); + return Ok(); + } + case "toggle": + { + var el = ResolveInActiveWindow(root.GetProperty("elementRef").GetString() ?? ""); + if (el is null) return Err("element not found"); + // Honor the desired state: only flip when not already there, so a + // re-run is idempotent rather than inverting a correct value. + var want = root.TryGetProperty("state", out var st) && st.ValueKind == JsonValueKind.True; + var toggle = el.Patterns.Toggle.Pattern; + var current = toggle.ToggleState.Value == ToggleState.On; + if (current != want) toggle.Toggle(); + return Ok(); + } + case "send_keys": + { + TypeKeys(root.GetProperty("keys").GetString() ?? ""); + return Ok(); + } + case "click": + { + var refStr = root.TryGetProperty("elementRef", out var er) ? er.GetString() : null; + if (string.IsNullOrEmpty(refStr)) return Err("click requires elementRef"); + var el = ResolveInActiveWindow(refStr); + if (el is null) return Err("element not found"); + el.Click(); + return Ok(); + } + case "wait_for": + { + var refStr = root.GetProperty("elementRef").GetString() ?? ""; + var timeout = root.GetProperty("timeoutMs").GetInt32(); + var deadline = DateTime.UtcNow.AddMilliseconds(timeout); + while (DateTime.UtcNow < deadline) + { + if (ResolveInActiveWindow(refStr) is not null) return Ok(); + Thread.Sleep(100); + } + return Err("wait_for timed out"); + } + default: + return Err($"unknown step type {type}"); + } + } + + // Plain text is sent as exact Unicode via SendInput (KEYEVENTF_UNICODE), which + // preserves case and shifted symbols regardless of keyboard layout — typing + // char-by-char through FlaUI dropped the shift state (e.g. "Hi!" → "hi"). + // Named keys ({ENTER}, {TAB}, …) still go through FlaUI's virtual-key path; + // modifier chords are rejected upstream by capabilities.ts. + private static void TypeKeys(string keys) + { + var i = 0; + while (i < keys.Length) + { + if (keys[i] == '{') + { + var end = keys.IndexOf('}', i); + if (end > i) + { + PressNamed(keys.Substring(i + 1, end - i - 1)); + i = end + 1; + continue; + } + } + SendUnicodeChar(keys[i]); + i++; + } + } + + // Emit one character as a Unicode key down+up pair. wVk=0 + KEYEVENTF_UNICODE + // tells Windows to deliver the literal code point, so case/symbols survive. + private static void SendUnicodeChar(char ch) + { + var inputs = new INPUT[2]; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].U.ki.wVk = 0; + inputs[0].U.ki.wScan = ch; + inputs[0].U.ki.dwFlags = KEYEVENTF_UNICODE; + inputs[1].type = INPUT_KEYBOARD; + inputs[1].U.ki.wVk = 0; + inputs[1].U.ki.wScan = ch; + inputs[1].U.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP; + var sent = SendInput(2, inputs, Marshal.SizeOf()); + if (sent != 2) + Console.Error.WriteLine($"[send_keys] SendInput sent={sent} err={Marshal.GetLastWin32Error()} cb={Marshal.SizeOf()}"); + } + + private static void PressNamed(string name) + { + var key = name switch + { + "ENTER" => VirtualKeyShort.ENTER, + "TAB" => VirtualKeyShort.TAB, + "ESC" => VirtualKeyShort.ESCAPE, + "BACKSPACE" => VirtualKeyShort.BACK, + "DELETE" => VirtualKeyShort.DELETE, + "UP" => VirtualKeyShort.UP, + "DOWN" => VirtualKeyShort.DOWN, + "LEFT" => VirtualKeyShort.LEFT, + "RIGHT" => VirtualKeyShort.RIGHT, + "HOME" => VirtualKeyShort.HOME, + "END" => VirtualKeyShort.END, + "SPACE" => VirtualKeyShort.SPACE, + _ => (VirtualKeyShort?)null + }; + if (key is not null) Keyboard.Press(key.Value); + } + + // ───────────────────────── element resolution ───────────────────────── + // Resolve a windowRef (numeric handle, or title substring) to an HWND. + private static IntPtr ResolveWindowHandle(string windowRef) + { + if (long.TryParse(windowRef, out var h) && h != 0) return new IntPtr(h); + var match = Automation.GetDesktop().FindAllChildren() + .FirstOrDefault(w => (w.Name ?? "").Contains(windowRef, StringComparison.OrdinalIgnoreCase)); + if (match is null) return IntPtr.Zero; + try { return match.Properties.NativeWindowHandle.Value; } + catch { return IntPtr.Zero; } + } + + // Bring a window to the foreground past Windows' foreground lock, which + // otherwise silently ignores SetForegroundWindow calls from a process that + // doesn't already own the foreground. We temporarily attach our input queue + // (and the current foreground thread's) to the target's so the call is + // honored, then confirm the switch actually took. + private static bool ForceForeground(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) return false; + if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE); + if (GetForegroundWindow() == hwnd) return true; + + var targetThread = GetWindowThreadProcessId(hwnd, out _); + var foreThread = GetWindowThreadProcessId(GetForegroundWindow(), out _); + var thisThread = GetCurrentThreadId(); + + var attachedFore = foreThread != targetThread && AttachThreadInput(foreThread, targetThread, true); + var attachedThis = thisThread != targetThread && AttachThreadInput(thisThread, targetThread, true); + try + { + BringWindowToTop(hwnd); + SetForegroundWindow(hwnd); + } + finally + { + if (attachedFore) AttachThreadInput(foreThread, targetThread, false); + if (attachedThis) AttachThreadInput(thisThread, targetThread, false); + } + + // Give the OS a moment to apply focus, then confirm it actually switched. + for (var i = 0; i < 10; i++) + { + if (GetForegroundWindow() == hwnd) return true; + Thread.Sleep(30); + } + return GetForegroundWindow() == hwnd; + } + + private static AutomationElement? ResolveInActiveWindow(string refStr) + { + var hwnd = GetForegroundWindow(); + if (hwnd == IntPtr.Zero) return null; + var root = Automation.FromHandle(hwnd); + if (root is null) return null; + + if (refStr.StartsWith("a:")) + { + var id = refStr.Substring(2); + return root.FindFirstDescendant(cf => cf.ByAutomationId(id)); + } + if (refStr.StartsWith("n:")) + { + var rest = refStr.Substring(2); + var sep = rest.IndexOf(':'); + if (sep < 0) return null; + var ct = rest.Substring(0, sep); + var name = rest.Substring(sep + 1); + return root.FindAllDescendants(cf => cf.ByName(name)) + .FirstOrDefault(e => e.ControlType.ToString() == ct); + } + return null; + } + + // ───────────────────────── stdio framing ───────────────────────── + private static async Task ReadExactly(Stream s, int n) + { + var buf = new byte[n]; + var read = 0; + while (read < n) + { + var r = await s.ReadAsync(buf.AsMemory(read, n - read)); + if (r == 0) return null; + read += r; + } + return buf; + } + + private static async Task WriteFrame(Stream s, string json) + { + var bytes = Encoding.UTF8.GetBytes(json); + await s.WriteAsync(BitConverter.GetBytes((uint)bytes.Length)); + await s.WriteAsync(bytes); + await s.FlushAsync(); + } + + private static string Hello() => + JsonSerializer.Serialize(new { ok = true, protocolVersion = ProtocolVersion }, JsonOpts); + + private static string Ok() => JsonSerializer.Serialize(new { ok = true }, JsonOpts); + private static string Err(string message, string code = "STEP_ERROR") => + JsonSerializer.Serialize(new { ok = false, code, message }, JsonOpts); + + private static IntPtr TryGetHandle(string payload) + { + try + { + using var doc = JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("windowHandle", out var h) && + long.TryParse(h.GetString(), out var v) && v != 0) + return new IntPtr(v); + } + catch { /* empty/absent payload → foreground window */ } + return IntPtr.Zero; + } + + private static string SafeProcName(int pid) + { + try { return System.Diagnostics.Process.GetProcessById(pid).ProcessName; } + catch { return ""; } + } + + private static int SelfTest() + { + var snap = Snapshot("{}"); + Console.Error.WriteLine($"[selftest] snapshot len={snap.Length} head={snap[..Math.Min(160, snap.Length)]}"); + return 0; + } + + private const int SW_RESTORE = 9; + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool BringWindowToTop(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + private const uint INPUT_KEYBOARD = 1; + private const uint KEYEVENTF_KEYUP = 0x0002; + private const uint KEYEVENTF_UNICODE = 0x0004; + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + + [StructLayout(LayoutKind.Sequential)] + private struct INPUT + { + public uint type; + public InputUnion U; + } + + [StructLayout(LayoutKind.Explicit)] + private struct InputUnion + { + [FieldOffset(0)] public MOUSEINPUT mi; + [FieldOffset(0)] public KEYBDINPUT ki; + [FieldOffset(0)] public HARDWAREINPUT hi; + } + + [StructLayout(LayoutKind.Sequential)] + private struct KEYBDINPUT + { + public ushort wVk; + public ushort wScan; + public uint dwFlags; + public uint time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MOUSEINPUT + { + public int dx; + public int dy; + public uint mouseData; + public uint dwFlags; + public uint time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct HARDWAREINPUT + { + public uint uMsg; + public ushort wParamL; + public ushort wParamH; + } +} diff --git a/windows/src/main/automation/helper/win-automation-helper.csproj b/windows/src/main/automation/helper/win-automation-helper.csproj new file mode 100644 index 0000000000..0494cd50c1 --- /dev/null +++ b/windows/src/main/automation/helper/win-automation-helper.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0-windows10.0.19041.0 + win-x64 + enable + latest + enable + true + true + win-automation-helper + true + + + + + diff --git a/windows/src/main/automation/planner.e2e.test.ts b/windows/src/main/automation/planner.e2e.test.ts new file mode 100644 index 0000000000..1c0ff0f610 --- /dev/null +++ b/windows/src/main/automation/planner.e2e.test.ts @@ -0,0 +1,269 @@ +/** + * End-to-end harness for the desktop-automation PLANNER path. + * + * The unit tests mock the LLM + snapshot, so the one thing they can't prove is + * the real round-trip: live UIA snapshot of a real window → real LLM call → + * structured plan → capability validation. This file exercises exactly that, + * reusing the SAME planner code the app runs (classifyIntent + planActions), + * just with node-side deps wired in place of Electron IPC / firebase auth. + * + * It is GATED on a Firebase ID token and skips entirely without one, so it never + * runs during a normal `npm test`. To run it: + * + * # token: in the running app's devtools → await auth.currentUser.getIdToken() + * $env:AUTOMATION_E2E_TOKEN="" # required (expires ~1h) + * $env:AUTOMATION_E2E_INSTRUCTION="type 'hello world' into the document" # optional + * $env:AUTOMATION_E2E_TARGET_PROC="notepad" # optional, launched if not running + * $env:AUTOMATION_E2E_EXECUTE="1" # optional: ALSO run the steps for real + * npx vitest run src/main/automation/planner.e2e.test.ts + * + * Default (no EXECUTE flag) is read-only: it plans + validates but does NOT + * touch the target app. Set AUTOMATION_E2E_EXECUTE=1 to close the loop and + * actually drive the window (bring the target to the foreground first). + */ +import { spawn, execSync, type ChildProcessWithoutNullStreams } from 'child_process' +import { join } from 'path' +import axios from 'axios' +import { describe, it, expect } from 'vitest' +import { encodeRequest, FrameDecoder } from '../ocr/helperProtocol' +import { OP_SNAPSHOT, OP_STEP } from './protocol' +import { validatePlan } from './capabilities' +import { looksLikeAction, planActions } from '../../renderer/src/lib/actionPlanner' +import { describePlanSteps } from '../../renderer/src/lib/automationPlan' +import { parseMessagesSse } from '../../renderer/src/lib/messagesSse' +import type { AutomationPlan, UiSnapshot } from '../../shared/types' + +const TOKEN = process.env.AUTOMATION_E2E_TOKEN ?? '' +const INSTRUCTION = + process.env.AUTOMATION_E2E_INSTRUCTION ?? "type 'hello world' into the document" +const TARGET_PROC = process.env.AUTOMATION_E2E_TARGET_PROC ?? 'notepad' +const EXECUTE = process.env.AUTOMATION_E2E_EXECUTE === '1' +// Token-free execution check: run a hand-built plan through the real helper to +// prove the C# step handlers + bridge run loop actually drive a window. Gated +// separately from the (LLM) planner test so it can run while the LLM is +// quota-blocked. Verified by a screenshot taken right after the run. +const EXEC = process.env.AUTOMATION_E2E_EXEC === '1' +// Mixed case + shifted symbols (but none of the ^ % + # chars capabilities.ts +// forbids) so the screenshot proves send_keys preserves case/symbols. +const EXEC_MARKER = 'OmiKbd-Verify!@2026' +const DESKTOP_BASE = + process.env.VITE_OMI_DESKTOP_API_BASE ?? 'https://desktop-backend-hhibjajaja-uc.a.run.app' +const AGENT_MODEL = 'claude-haiku-4-5-20251001' +const HELPER = join( + process.cwd(), + 'resources', + 'win-automation-helper', + 'win-automation-helper.exe' +) + +// Minimal persistent stdio bridge to the helper — a node-side mirror of +// bridge.ts (which can't be imported here: it pulls in electron). One process, +// one in-flight request at a time, length-prefixed frames. +class HelperClient { + private child: ChildProcessWithoutNullStreams + private readonly queue: Array<(json: string) => void> = [] + constructor() { + this.child = spawn(HELPER, [], { stdio: ['pipe', 'pipe', 'pipe'] }) + const decoder = new FrameDecoder((json) => this.queue.shift()?.(json)) + this.child.stdout.on('data', (c: Buffer) => decoder.push(c)) + this.child.stderr.on('data', (c: Buffer) => console.log('[helper]', c.toString().trim())) + } + request(opcode: number, payload: object): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('helper timed out')), 8000) + this.queue.push((json) => { + clearTimeout(timer) + resolve(json) + }) + this.child.stdin.write(encodeRequest(opcode, Buffer.from(JSON.stringify(payload), 'utf8'))) + }) + } + dispose(): void { + this.child.kill() + } +} + +// Find the target app's top-level window handle (decimal, as the helper parses +// it). Launch the process first if it isn't already running. +function resolveTargetHandle(proc: string): string { + const ps = (script: string): string => + execSync(`powershell -NoProfile -Command "${script}"`, { encoding: 'utf8' }).trim() + const query = `(Get-Process ${proc} -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle` + let handle = ps(query) + if (!handle || handle === '0') { + console.log(`[harness] launching ${proc}…`) + execSync(`powershell -NoProfile -Command "Start-Process ${proc}"`) + for (let i = 0; i < 20 && (!handle || handle === '0'); i++) { + execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 300"') + handle = ps(query) + } + } + if (!handle || handle === '0') throw new Error(`could not get a window handle for "${proc}"`) + return handle +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) +const OMI_BASE = process.env.VITE_OMI_API_BASE ?? 'https://api.omi.me' + +// Real LLM call — mirrors the app's agentLLM.ts (incl. its 429 fallback), with +// the supplied Firebase ID token instead of firebase-sdk auth. The desktop +// /v2/chat/completions endpoint is rate-limited per-account, so on 429 we fall +// back to /v2/messages (separate limit) exactly like the app now does. +async function callAgentLLM(prompt: string): Promise { + try { + const res = await axios.post( + `${DESKTOP_BASE}/v2/chat/completions`, + { model: AGENT_MODEL, stream: false, messages: [{ role: 'user', content: prompt }] }, + { headers: { Authorization: `Bearer ${TOKEN}` }, timeout: 20000 } + ) + return res.data?.choices?.[0]?.message?.content ?? '' + } catch (e) { + const status = axios.isAxiosError(e) ? e.response?.status : undefined + if (status === 429 || (axios.isAxiosError(e) && !e.response)) { + console.log('[harness] /v2/chat/completions 429 → falling back to /v2/messages') + const res = await axios.post( + `${OMI_BASE}/v2/messages`, + { text: prompt }, + { headers: { Authorization: `Bearer ${TOKEN}` }, responseType: 'text', timeout: 30000 } + ) + return parseMessagesSse(String(res.data ?? '')) + } + throw e + } +} + +describe.skipIf(!TOKEN)('automation planner e2e', () => { + it('snapshots a real window, plans via real LLM, and validates the plan', async () => { + const handle = resolveTargetHandle(TARGET_PROC) + console.log(`[harness] target "${TARGET_PROC}" handle=${handle}`) + const helper = new HelperClient() + try { + const getSnapshot = async (): Promise => { + const json = await helper.request(OP_SNAPSHOT, { windowHandle: handle }) + return JSON.parse(json) as UiSnapshot + } + + // 1. Snapshot — prove UIA reading works against a real window. + const snap = await getSnapshot() + console.log('\n=== SNAPSHOT ===') + if (!snap.ok) throw new Error(`snapshot failed: ${snap.message}`) + console.log(`window: "${snap.window.title}" (${snap.window.processName})`) + console.log(`elements: ${JSON.stringify(snap.elements).length} bytes of tree`) + expect(snap.ok).toBe(true) + + // 2. Intent gate — the free keyword pre-filter should flag our instruction. + console.log(`\n=== INTENT === "${INSTRUCTION}" → looksLikeAction=${looksLikeAction(INSTRUCTION)}`) + expect(looksLikeAction(INSTRUCTION)).toBe(true) + + // 3. Plan — the real LLM round-trip producing a structured plan. + const result = await planActions(INSTRUCTION, { getSnapshot, callLLM: callAgentLLM }) + console.log('\n=== PLAN ===') + if (!result.ok) throw new Error(`planning failed: ${result.reason}`) + console.log(`summary: ${result.plan.summary}`) + console.log(`targetWindow: ${result.plan.targetWindow}`) + console.log(describePlanSteps(result.plan.steps).join('\n')) + expect(result.ok).toBe(true) + + // 4. Validate — same capability gate the bridge runs before dispatch. + const check = validatePlan(result.plan) + console.log(`\n=== VALIDATION === ${check.ok ? 'OK' : 'REJECTED: ' + check.reason}`) + expect(check.ok).toBe(true) + + // 5. (opt-in) Execute — actually drive the window, streaming step status. + if (EXECUTE) { + console.log('\n=== EXECUTE ===') + for (let i = 0; i < result.plan.steps.length; i++) { + const json = await helper.request(OP_STEP, result.plan.steps[i]) + const r = JSON.parse(json) as { ok: boolean; message?: string } + console.log(`step ${i} (${result.plan.steps[i].type}): ${r.ok ? 'ok' : 'FAILED ' + r.message}`) + expect(r.ok).toBe(true) + } + } + } finally { + helper.dispose() + } + }, 360000) +}) + +describe.skipIf(!EXEC)('automation execution e2e (no LLM)', () => { + it('executes a hand-built plan against a real window', async () => { + // AUTOMATION_E2E_HANDLE lets a caller target a specific window (e.g. a + // readable WinForms TextBox) instead of launching TARGET_PROC by name. + const handle = process.env.AUTOMATION_E2E_HANDLE || resolveTargetHandle(TARGET_PROC) + console.log(`[harness] target "${TARGET_PROC}" handle=${handle}`) + // focus the real window by handle, then type a recognizable marker on its + // own line. Uses only allowlisted steps/keys (validates clean). + const plan: AutomationPlan = { + id: 'exec-e2e', + summary: `type ${EXEC_MARKER} into ${TARGET_PROC}`, + targetWindow: 'Notepad', + steps: [ + { type: 'focus_window', windowRef: handle }, + { type: 'send_keys', keys: `{ENTER}${EXEC_MARKER}{ENTER}` } + ] + } + + const check = validatePlan(plan) + console.log(`\n=== VALIDATION === ${check.ok ? 'OK' : 'REJECTED: ' + check.reason}`) + expect(check.ok).toBe(true) + + const helper = new HelperClient() + try { + console.log('\n=== EXECUTE ===') + for (let i = 0; i < plan.steps.length; i++) { + const json = await helper.request(OP_STEP, plan.steps[i]) + const r = JSON.parse(json) as { ok: boolean; message?: string } + console.log(`step ${i} (${plan.steps[i].type}): ${r.ok ? 'ok' : 'FAILED ' + r.message}`) + expect(r.ok).toBe(true) + await sleep(500) // let focus + keystrokes settle between steps + } + // Capture proof WHILE the target is still foregrounded by the run — once + // this test process exits, the shell regains focus and the evidence is + // gone. The marker should be visible in the captured window. + execSync( + `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.SystemInformation]::VirtualScreen; $bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height; $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size); $bmp.Save((Join-Path $env:TEMP 'omi-exec-proof.png')); $g.Dispose(); $bmp.Dispose()"` + ) + console.log(`\n=== PROOF === screenshot saved to %TEMP%\\omi-exec-proof.png (look for ${EXEC_MARKER})`) + + // Re-snapshot: best-effort log of the title (carries Notepad's first line). + const after = JSON.parse(await helper.request(OP_SNAPSHOT, { windowHandle: handle })) as UiSnapshot + if (after.ok) console.log(`=== POST-EXEC TITLE === ${after.window.title}`) + } finally { + helper.dispose() + } + }, 60000) +}) + +// Token-free snapshot characterization: dump the pruned UIA tree size + a sample +// of nodes for a target window, so we can see what real apps expose (e.g. WinUI +// apps returning an empty tree) before tuning the helper's pruning. +const SNAP = process.env.AUTOMATION_E2E_SNAPSHOT === '1' + +describe.skipIf(!SNAP)('automation snapshot characterization (no LLM)', () => { + it('snapshots a real window and reports the element tree', async () => { + const handle = process.env.AUTOMATION_E2E_HANDLE || resolveTargetHandle(TARGET_PROC) + const helper = new HelperClient() + try { + const snap = JSON.parse(await helper.request(OP_SNAPSHOT, { windowHandle: handle })) as UiSnapshot + if (!snap.ok) throw new Error(`snapshot failed: ${snap.message}`) + type N = (typeof snap.elements)[number] + let count = 0 + const sample: string[] = [] + const walk = (els: N[], depth: number): void => { + for (const el of els) { + count++ + if (sample.length < 40) { + sample.push(`${' '.repeat(depth)}${el.ref} [${el.controlType}] "${el.name}" {${el.patterns.join(',')}}`) + } + if (el.children) walk(el.children, depth + 1) + } + } + walk(snap.elements, 0) + console.log(`\n=== SNAPSHOT: "${snap.window.title}" (${snap.window.processName}) ===`) + console.log(`total elements: ${count}`) + console.log(sample.join('\n')) + } finally { + helper.dispose() + } + }, 60000) +}) diff --git a/windows/src/main/automation/protocol.test.ts b/windows/src/main/automation/protocol.test.ts new file mode 100644 index 0000000000..06fc1f1e26 --- /dev/null +++ b/windows/src/main/automation/protocol.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest' +import { + OP_SNAPSHOT, + OP_STEP, + PROTOCOL_VERSION, + encodeRef, + decodeRef, + parseAutomationPlan, + MAX_SNAPSHOT_NODES, + MAX_SNAPSHOT_DEPTH +} from './protocol' + +describe('refs', () => { + it('prefers automationId', () => { + expect(encodeRef({ automationId: 'sendBtn', controlType: 'Button', name: 'Send' })).toBe( + 'a:sendBtn' + ) + }) + it('falls back to controlType:name', () => { + expect(encodeRef({ automationId: '', controlType: 'Button', name: 'Send' })).toBe( + 'n:Button:Send' + ) + }) + it('decodes both forms', () => { + expect(decodeRef('a:sendBtn')).toEqual({ kind: 'automationId', value: 'sendBtn' }) + expect(decodeRef('n:Button:Send')).toEqual({ + kind: 'nameType', + controlType: 'Button', + name: 'Send' + }) + }) + it('decodes names that contain colons', () => { + expect(decodeRef('n:Edit:To: field')).toEqual({ + kind: 'nameType', + controlType: 'Edit', + name: 'To: field' + }) + }) + it('returns null for malformed refs', () => { + expect(decodeRef('garbage')).toBeNull() + }) +}) + +describe('parseAutomationPlan', () => { + it('parses a valid plan embedded in prose/fences', () => { + const text = + 'Sure!\n```json\n{"id":"p1","summary":"Type hi","targetWindow":"Notepad",' + + '"steps":[{"type":"set_value","elementRef":"a:edit","value":"hi"}]}\n```' + const plan = parseAutomationPlan(text) + expect(plan).not.toBeNull() + expect(plan!.steps).toHaveLength(1) + expect(plan!.steps[0]).toEqual({ type: 'set_value', elementRef: 'a:edit', value: 'hi' }) + }) + it('rejects a plan with an unknown step type', () => { + const text = '{"id":"p","summary":"x","targetWindow":"w","steps":[{"type":"format_disk"}]}' + expect(parseAutomationPlan(text)).toBeNull() + }) + it('rejects a plan with no steps', () => { + expect(parseAutomationPlan('{"id":"p","summary":"x","targetWindow":"w","steps":[]}')).toBeNull() + }) + it('returns null when there is no JSON object', () => { + expect(parseAutomationPlan('I cannot do that.')).toBeNull() + }) + it('normalizes underscore-dropped / camelCase step types to canonical form', () => { + // Models routinely emit "focuswindow"/"setValue" instead of snake_case. + const text = + '{"id":"p","summary":"x","targetWindow":"Chrome","steps":[' + + '{"type":"focuswindow","windowRef":"Chrome"},' + + '{"type":"setValue","elementRef":"a:edit","value":"hi"},' + + '{"type":"send_keys","keys":"{ENTER}"}]}' + const plan = parseAutomationPlan(text) + expect(plan).not.toBeNull() + expect(plan!.steps.map((s) => s.type)).toEqual(['focus_window', 'set_value', 'send_keys']) + }) +}) + +describe('constants', () => { + it('exposes opcodes and caps', () => { + expect(OP_SNAPSHOT).toBe(1) + expect(OP_STEP).toBe(2) + expect(PROTOCOL_VERSION).toBe(1) + expect(MAX_SNAPSHOT_NODES).toBeGreaterThan(0) + expect(MAX_SNAPSHOT_DEPTH).toBeGreaterThan(0) + }) +}) diff --git a/windows/src/main/automation/protocol.ts b/windows/src/main/automation/protocol.ts new file mode 100644 index 0000000000..533884f6d9 --- /dev/null +++ b/windows/src/main/automation/protocol.ts @@ -0,0 +1,120 @@ +import type { AutomationPlan, AutomationStep } from '../../shared/types' + +// Helper stdio opcodes (request frame: [uint32 LE len][1 byte opcode][JSON]). +export const OP_SNAPSHOT = 1 +export const OP_STEP = 2 +export const OP_HELLO = 3 + +// Bumped whenever the wire shape changes; bridge asserts a match on spawn. +export const PROTOCOL_VERSION = 1 + +// Snapshot prune caps — mirrored as constants in the C# helper. +export const MAX_SNAPSHOT_NODES = 400 +export const MAX_SNAPSHOT_DEPTH = 12 + +const STEP_TYPES = new Set([ + 'focus_window', + 'invoke_element', + 'set_value', + 'select_item', + 'toggle', + 'send_keys', + 'click', + 'wait_for' +]) + +// Stable element address. AutomationId is preferred (stable, app-assigned); +// otherwise controlType + visible name. Resolved live at execute time. +export function encodeRef(el: { automationId: string; controlType: string; name: string }): string { + if (el.automationId) return `a:${el.automationId}` + return `n:${el.controlType}:${el.name}` +} + +export type DecodedRef = + | { kind: 'automationId'; value: string } + | { kind: 'nameType'; controlType: string; name: string } + +export function decodeRef(ref: string): DecodedRef | null { + if (ref.startsWith('a:')) return { kind: 'automationId', value: ref.slice(2) } + if (ref.startsWith('n:')) { + const rest = ref.slice(2) + const sep = rest.indexOf(':') + if (sep === -1) return null + return { kind: 'nameType', controlType: rest.slice(0, sep), name: rest.slice(sep + 1) } + } + return null +} + +// Extract the first balanced JSON object from arbitrary model text (tolerates +// prose, ```fences```, and trailing chars). Mirrors localAgentProtocol's parser. +function firstJsonObject(text: string): string | null { + const start = text.indexOf('{') + if (start === -1) return null + let depth = 0 + let inStr = false + let esc = false + for (let i = start; i < text.length; i++) { + const ch = text[i] + if (inStr) { + if (esc) esc = false + else if (ch === '\\') esc = true + else if (ch === '"') inStr = false + continue + } + if (ch === '"') inStr = true + else if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) return text.slice(start, i + 1) + } + } + return null +} + +// Canonical step type keyed by its underscore-stripped, lowercased form. Models +// routinely emit "focuswindow"/"setValue" instead of "focus_window"/"set_value"; +// normalizing here turns an otherwise-valid plan into a usable one rather than +// silently rejecting it. +const CANONICAL_STEP_TYPE = new Map( + [...STEP_TYPES].map((t) => [t.replace(/_/g, ''), t]) +) + +function canonicalStepType(t: unknown): AutomationStep['type'] | null { + if (typeof t !== 'string') return null + return CANONICAL_STEP_TYPE.get(t.replace(/_/g, '').toLowerCase()) ?? null +} + +// Validate + normalize one step. Returns the step with a canonical `type`, or +// null if the type is unrecognized even after normalization. +function coerceStep(s: unknown): AutomationStep | null { + if (!s || typeof s !== 'object') return null + const type = canonicalStepType((s as { type?: unknown }).type) + if (!type) return null + return { ...(s as Record), type } as AutomationStep +} + +// Parse a plan from model output. Returns null on anything malformed: no JSON, +// missing fields, empty steps, or any unknown step type. Structural validation +// only — capability/allowlist checks live in capabilities.ts, ref existence in +// the planner (it alone holds the snapshot). +export function parseAutomationPlan(text: string): AutomationPlan | null { + const json = firstJsonObject(text) + if (!json) return null + let obj: unknown + try { + obj = JSON.parse(json) + } catch { + return null + } + const o = obj as Partial + if (typeof o.id !== 'string' || typeof o.summary !== 'string') return null + if (typeof o.targetWindow !== 'string') return null + if (!Array.isArray(o.steps) || o.steps.length === 0) return null + const steps: AutomationStep[] = [] + for (const raw of o.steps) { + const step = coerceStep(raw) + if (!step) return null + steps.push(step) + } + return { id: o.id, summary: o.summary, targetWindow: o.targetWindow, steps } +} diff --git a/windows/src/main/automation/resolveHelperPath.ts b/windows/src/main/automation/resolveHelperPath.ts new file mode 100644 index 0000000000..b47490844c --- /dev/null +++ b/windows/src/main/automation/resolveHelperPath.ts @@ -0,0 +1,21 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync } from 'fs' + +/** + * Resolve the on-disk path to the bundled win-automation-helper.exe. + * Mirrors src/main/ocr/resolveHelperPath.ts (packaged-unpacked, extraResources, + * dev). Returns the dev path last so the bridge surfaces a clear "not found". + */ +export function resolveHelperPath(): string { + const exe = 'win-automation-helper.exe' + const candidates = [ + join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'win-automation-helper', exe), + join(process.resourcesPath, 'win-automation-helper', exe), + join(app.getAppPath(), 'resources', 'win-automation-helper', exe) + ] + for (const c of candidates) { + if (existsSync(c)) return c + } + return candidates[candidates.length - 1] +} diff --git a/windows/src/main/env.d.ts b/windows/src/main/env.d.ts new file mode 100644 index 0000000000..5969224a39 --- /dev/null +++ b/windows/src/main/env.d.ts @@ -0,0 +1,28 @@ +/// + +interface ImportMetaEnv { + readonly MAIN_VITE_GOOGLE_CLIENT_ID?: string + readonly MAIN_VITE_GOOGLE_CLIENT_SECRET?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare namespace NodeJS { + interface ProcessEnv { + /** '1' enables bench mode: run the fixed workload after load, then quit. */ + OMI_BENCH?: string + /** '1' enables animation bench: record startup-animation jank, then quit + * (skips the DB/IPC workload so it can't pollute frame timing). */ + OMI_ANIM_BENCH?: string + /** Absolute path to the perf JSONL log. When unset, perf marks are no-ops. */ + OMI_PERF_LOG?: string + /** Override the SQLite file path (used to point at the throwaway bench DB). */ + OMI_DB_PATH?: string + /** The desktop-automation bridge (snapshot → plan → approve → execute real + * Windows UI actions) is ON by default. Set OMI_AUTOMATION='0' to disable it + * (kill-switch for builds that don't want the experimental feature). */ + OMI_AUTOMATION?: string + } +} diff --git a/windows/src/main/fileIndex/fileTypes.test.ts b/windows/src/main/fileIndex/fileTypes.test.ts new file mode 100644 index 0000000000..0ad524412f --- /dev/null +++ b/windows/src/main/fileIndex/fileTypes.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' +import { categorizeExtension } from './fileTypes' + +describe('categorizeExtension', () => { + it('maps known extensions to categories (case- and dot-insensitive)', () => { + expect(categorizeExtension('pdf')).toBe('document') + expect(categorizeExtension('.PDF')).toBe('document') + expect(categorizeExtension('ts')).toBe('code') + expect(categorizeExtension('png')).toBe('image') + expect(categorizeExtension('mp4')).toBe('media') + expect(categorizeExtension('zip')).toBe('archive') + expect(categorizeExtension('lnk')).toBe('application') + }) + it('falls back to "other" for unknown/empty', () => { + expect(categorizeExtension('xyz')).toBe('other') + expect(categorizeExtension('')).toBe('other') + }) +}) diff --git a/windows/src/main/fileIndex/fileTypes.ts b/windows/src/main/fileIndex/fileTypes.ts new file mode 100644 index 0000000000..8b15315f10 --- /dev/null +++ b/windows/src/main/fileIndex/fileTypes.ts @@ -0,0 +1,24 @@ +import type { IndexedFileType } from '../../shared/types' + +const BY_EXT: Record = { + pdf: 'document', doc: 'document', docx: 'document', txt: 'document', md: 'document', + rtf: 'document', odt: 'document', xls: 'document', xlsx: 'document', csv: 'document', + ppt: 'document', pptx: 'document', + ts: 'code', tsx: 'code', js: 'code', jsx: 'code', py: 'code', rs: 'code', go: 'code', + java: 'code', c: 'code', h: 'code', cpp: 'code', cs: 'code', rb: 'code', php: 'code', + swift: 'code', kt: 'code', sh: 'code', ps1: 'code', json: 'code', yaml: 'code', + yml: 'code', toml: 'code', html: 'code', css: 'code', sql: 'code', + png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image', + bmp: 'image', tiff: 'image', heic: 'image', ico: 'image', + mp4: 'media', mov: 'media', avi: 'media', mkv: 'media', webm: 'media', mp3: 'media', + wav: 'media', flac: 'media', m4a: 'media', aac: 'media', + zip: 'archive', rar: 'archive', '7z': 'archive', tar: 'archive', gz: 'archive', + exe: 'application', msi: 'application', lnk: 'application', appx: 'application' +} + +// Map a file extension (with or without leading dot, any case) to a category, +// mirroring the macOS FileIndexerService buckets. +export function categorizeExtension(extension: string): IndexedFileType { + const e = extension.replace(/^\./, '').toLowerCase() + return BY_EXT[e] ?? 'other' +} diff --git a/windows/src/main/fileIndex/indexer.ts b/windows/src/main/fileIndex/indexer.ts new file mode 100644 index 0000000000..f57c5c9d4b --- /dev/null +++ b/windows/src/main/fileIndex/indexer.ts @@ -0,0 +1,124 @@ +import { promises as fs, existsSync, type Dirent } from 'fs' +import { basename, extname, join } from 'path' +import { shell } from 'electron' +import { resolveScanRoots } from './scanRoots' +import { shouldVisitDir, shouldIndexFile, MAX_DEPTH } from './scanRules' +import { categorizeExtension } from './fileTypes' +import { replaceIndexedFiles, clearIndexedFiles, getFileIndexStats } from '../ipc/db' +import type { IndexedFileRecord, FileIndexStatus } from '../../shared/types' + +let running = false +let lastRunAt: number | null = null +let lastDurationMs: number | null = null + +async function readDir(dir: string): Promise { + try { + return await fs.readdir(dir, { withFileTypes: true }) + } catch { + return [] // unreadable/permission-denied dirs are skipped + } +} + +// Walk a 'files' root, recording metadata for each file within depth/size rules. +async function walkFiles(root: string, out: IndexedFileRecord[]): Promise { + const recurse = async (dir: string, depth: number): Promise => { + for (const ent of await readDir(dir)) { + const full = join(dir, ent.name) + if (ent.isDirectory()) { + if (shouldVisitDir(ent.name, depth + 1)) await recurse(full, depth + 1) + } else if (ent.isFile()) { + try { + const st = await fs.stat(full) + if (!shouldIndexFile(st.size)) continue + out.push({ + path: full, + filename: ent.name, + extension: extname(ent.name).replace(/^\./, '').toLowerCase(), + fileType: categorizeExtension(extname(ent.name)), + sizeBytes: st.size, + folder: dir, + depth, + createdAt: Math.round(st.birthtimeMs), + modifiedAt: Math.round(st.mtimeMs) + }) + } catch { + /* skip unreadable file */ + } + } + } + } + await recurse(root, 0) +} + +// Resolve a .lnk to its target executable. Best-effort: returns undefined when +// the shortcut can't be read (e.g. MSIX/UWP shortcuts have no file target). +function resolveShortcutTarget(lnkPath: string): string | undefined { + try { + const target = shell.readShortcutLink(lnkPath).target + return target && target.toLowerCase().endsWith('.exe') ? target : undefined + } catch { + return undefined + } +} + +// Walk a Start-Menu root: each .lnk shortcut is one installed app +// (the Windows analog of a macOS /Applications .app bundle). +async function walkApps(root: string, out: IndexedFileRecord[]): Promise { + const recurse = async (dir: string, depth: number): Promise => { + if (depth > MAX_DEPTH) return + for (const ent of await readDir(dir)) { + const full = join(dir, ent.name) + if (ent.isDirectory()) { + await recurse(full, depth + 1) + } else if (ent.isFile() && ent.name.toLowerCase().endsWith('.lnk')) { + try { + const st = await fs.stat(full) + out.push({ + path: full, + filename: basename(ent.name, '.lnk'), + extension: 'lnk', + fileType: 'application', + sizeBytes: st.size, + folder: dir, + depth, + createdAt: Math.round(st.birthtimeMs), + modifiedAt: Math.round(st.mtimeMs), + targetPath: resolveShortcutTarget(full) + }) + } catch { + /* skip */ + } + } + } + } + await recurse(root, 0) +} + +export function getStatus(): FileIndexStatus { + const { filesIndexed, byType } = getFileIndexStats() + return { filesIndexed, byType, lastRunAt, lastDurationMs, running } +} + +// Full re-scan: collect all records, then atomically replace the table. +export async function runFileIndex(): Promise { + if (running) return getStatus() + running = true + const t0 = Date.now() + try { + const records: IndexedFileRecord[] = [] + for (const r of resolveScanRoots( + { USERPROFILE: process.env.USERPROFILE, ProgramData: process.env.ProgramData, APPDATA: process.env.APPDATA }, + existsSync + )) { + if (r.kind === 'apps') await walkApps(r.path, records) + else await walkFiles(r.path, records) + } + clearIndexedFiles() + replaceIndexedFiles(records) + lastRunAt = Date.now() + lastDurationMs = lastRunAt - t0 + return getStatus() + } finally { + running = false + } +} diff --git a/windows/src/main/fileIndex/scanRoots.test.ts b/windows/src/main/fileIndex/scanRoots.test.ts new file mode 100644 index 0000000000..211231d9c6 --- /dev/null +++ b/windows/src/main/fileIndex/scanRoots.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { join } from 'path' +import { resolveScanRoots } from './scanRoots' + +describe('resolveScanRoots', () => { + const env = { + USERPROFILE: 'C:\\Users\\me', + ProgramData: 'C:\\ProgramData', + APPDATA: 'C:\\Users\\me\\AppData\\Roaming' + } + + it('includes existing doc + dev roots as files, Start-Menu as apps', () => { + const present = new Set([ + join('C:\\Users\\me', 'Downloads'), + join('C:\\Users\\me', 'Documents'), + join('C:\\Users\\me', 'source', 'repos'), + join('C:\\ProgramData', 'Microsoft', 'Windows', 'Start Menu', 'Programs'), + join('C:\\Users\\me\\AppData\\Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs') + ]) + const roots = resolveScanRoots(env, (p) => present.has(p)) + const files = roots.filter((r) => r.kind === 'files').map((r) => r.path) + const apps = roots.filter((r) => r.kind === 'apps').map((r) => r.path) + expect(files).toContain(join('C:\\Users\\me', 'Downloads')) + expect(files).toContain(join('C:\\Users\\me', 'source', 'repos')) + expect(files).not.toContain(join('C:\\Users\\me', 'Desktop')) // not present + expect(apps).toHaveLength(2) + }) + + it('returns nothing when env is empty', () => { + expect(resolveScanRoots({}, () => true)).toEqual([]) + }) +}) diff --git a/windows/src/main/fileIndex/scanRoots.ts b/windows/src/main/fileIndex/scanRoots.ts new file mode 100644 index 0000000000..a9d6ac9551 --- /dev/null +++ b/windows/src/main/fileIndex/scanRoots.ts @@ -0,0 +1,39 @@ +import { join } from 'path' + +export type ScanRoot = { path: string; kind: 'files' | 'apps' } + +export type ScanEnv = { + USERPROFILE?: string + ProgramData?: string + APPDATA?: string +} + +const DOC_DIRS = ['Downloads', 'Documents', 'Desktop'] +const DEV_DIRS = ['Developer', 'Projects', 'Code', 'src', 'repos', 'Sites'] +const START_MENU = join('Microsoft', 'Windows', 'Start Menu', 'Programs') + +// Resolve the Windows scan roots, keeping only paths that exist. Doc + dev +// folders are 'files'; the Start-Menu shortcut folders are the Windows analog +// of macOS /Applications and are tagged 'apps' (enumerated as .lnk installs). +export function resolveScanRoots(env: ScanEnv, exists: (p: string) => boolean): ScanRoot[] { + const roots: ScanRoot[] = [] + const home = env.USERPROFILE + if (home) { + for (const d of DOC_DIRS) { + const p = join(home, d) + if (exists(p)) roots.push({ path: p, kind: 'files' }) + } + const repos = join(home, 'source', 'repos') // Visual Studio default + if (exists(repos)) roots.push({ path: repos, kind: 'files' }) + for (const d of DEV_DIRS) { + const p = join(home, d) + if (exists(p)) roots.push({ path: p, kind: 'files' }) + } + } + for (const base of [env.ProgramData, env.APPDATA]) { + if (!base) continue + const p = join(base, START_MENU) + if (exists(p)) roots.push({ path: p, kind: 'apps' }) + } + return roots +} diff --git a/windows/src/main/fileIndex/scanRules.test.ts b/windows/src/main/fileIndex/scanRules.test.ts new file mode 100644 index 0000000000..76380ba6ad --- /dev/null +++ b/windows/src/main/fileIndex/scanRules.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { shouldVisitDir, shouldIndexFile, MAX_DEPTH, MAX_FILE_SIZE } from './scanRules' + +describe('shouldVisitDir', () => { + it('skips noise directories', () => { + expect(shouldVisitDir('node_modules', 1)).toBe(false) + expect(shouldVisitDir('.git', 1)).toBe(false) + expect(shouldVisitDir('__pycache__', 1)).toBe(false) + expect(shouldVisitDir('.Trash', 1)).toBe(false) + }) + it('visits normal dirs within depth', () => { + expect(shouldVisitDir('src', 1)).toBe(true) + expect(shouldVisitDir('src', MAX_DEPTH)).toBe(true) + }) + it('stops past max depth', () => { + expect(shouldVisitDir('src', MAX_DEPTH + 1)).toBe(false) + }) +}) + +describe('shouldIndexFile', () => { + it('accepts files up to the size cap', () => { + expect(shouldIndexFile(0)).toBe(true) + expect(shouldIndexFile(MAX_FILE_SIZE)).toBe(true) + }) + it('rejects oversized files', () => { + expect(shouldIndexFile(MAX_FILE_SIZE + 1)).toBe(false) + }) +}) diff --git a/windows/src/main/fileIndex/scanRules.ts b/windows/src/main/fileIndex/scanRules.ts new file mode 100644 index 0000000000..9b8f8d6810 --- /dev/null +++ b/windows/src/main/fileIndex/scanRules.ts @@ -0,0 +1,13 @@ +export const MAX_DEPTH = 3 +export const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500 MB, matching macOS +export const SKIP_DIRS = new Set(['.Trash', 'node_modules', '.git', '__pycache__']) + +// True when a subdirectory at `depth` should be descended into. +export function shouldVisitDir(name: string, depth: number): boolean { + return depth <= MAX_DEPTH && !SKIP_DIRS.has(name) +} + +// True when a file of `sizeBytes` should be recorded. +export function shouldIndexFile(sizeBytes: number): boolean { + return sizeBytes >= 0 && sizeBytes <= MAX_FILE_SIZE +} diff --git a/windows/src/main/index.ts b/windows/src/main/index.ts new file mode 100644 index 0000000000..7ed7cf646b --- /dev/null +++ b/windows/src/main/index.ts @@ -0,0 +1,436 @@ +import { + app, + shell, + BrowserWindow, + ipcMain, + session, + nativeImage, + desktopCapturer +} from 'electron' +import { join } from 'path' +import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import iconPath from '../../resources/icon.png?asset' +import { listCaptureSources } from './ipc/capture' +import { registerOmiListenHandlers } from './ipc/omiListen' +import { registerFileIndexHandlers } from './ipc/fileIndex' +import { registerMemoryImportHandlers } from './ipc/memoryImport' +import { registerMemoryExportHandlers } from './ipc/memoryExport' +import { registerKgHandlers } from './ipc/kg' +import { registerIntegrationsHandlers } from './ipc/integrations' +import { registerLocalGraphHandlers } from './ipc/localGraph' +import { registerUsageHandlers } from './ipc/usage' +import { registerMemoryCleanupHandlers } from './ipc/memoryCleanup' +import { startForegroundMonitor } from './usage/foregroundMonitor' +import { getOverlayWindow, toggleOverlay } from './overlay/window' +import { + registerOverlayShortcut, + unregisterOverlayShortcut, + OVERLAY_ACCELERATOR +} from './overlay/shortcut' +import { registerOverlayHandlers } from './overlay/ipc' +import { seedUserAssistOnce } from './usage/userAssistSeed' +import { registerRewindHandlers } from './ipc/rewind' +import { registerScreenHandlers } from './ipc/screen' +import { registerInsightHandlers } from './ipc/insight' +import { createInsightToastWindow } from './insight/toastWindow' +import { registerAutomationHandlers } from './ipc/automation' +import { automationBridge } from './automation/bridge' +import { + startAutomationTargetTracker, + stopAutomationTargetTracker +} from './automation/foregroundTarget' +import { registerScreenSynthHandlers } from './ipc/screenSynth' +import { startRewindCapture } from './rewind/captureService' +import { startRewindOcr } from './rewind/ocrService' +import { startRewindRetention } from './rewind/retentionRunner' +import { prewarmPrimarySourceId } from './rewind/sourceId' +import { perfMark, flushPerfMarks } from '../shared/perf' + +// Default the perf log to the user data dir so marks double as lightweight prod +// telemetry. The bench runner overrides OMI_PERF_LOG to point at .bench/. +// app.getPath('userData') is valid before app.whenReady(). +if (!process.env.OMI_PERF_LOG) { + process.env.OMI_PERF_LOG = join(app.getPath('userData'), 'perf.jsonl') +} +perfMark('app:start') + +// Opt-in sandbox isolation. By default Electron derives userData from the +// product name ("omi-windows"), which is the real user's data + signed-in +// Firebase session. Set OMI_SANDBOX to pin a throwaway userData dir instead, +// so a sandbox build can't share (and clobber) the production omi.db / +// local_kg schema. Must run before any DB open (db.ts resolves userData lazily +// on first IPC). NOTE: default = production data, so normal runs load memories. +// In bench mode (OMI_BENCH=1) we NEVER pin, so the runner's isolated +// --user-data-dir is honored (pinning here would override it and collide caches). +// +// The VALUE names the profile, so concurrent worktrees each get their OWN +// userData dir and never contend for the shared Chromium GPU/disk/quota caches +// (that contention crashes the WebGL brain map — the only GPU-backed surface — +// while the plain-DOM UI survives). Use a distinct OMI_SANDBOX= per +// worktree to run several at once. OMI_SANDBOX=1 keeps the original shared +// "…-sandbox-chat-kg" profile for backward compatibility (no re-login). +// +// This is intentionally OPT-IN, NOT auto-derived from the worktree folder: the +// user's real data + Firebase session + onboarding floor live in the DEFAULT +// profile, and Chromium can't safely share one profile across two live +// instances anyway. So the MAIN worktree must stay on the default (real data), +// and only the SECONDARY worktree(s) you run alongside it should set +// OMI_SANDBOX= to isolate. (An earlier auto-derive moved this worktree off +// the default profile and blanked the brain map's onboarding floor — never do +// that.) +// Desktop-automation bridge (real Windows UI actions). ON by default; set +// OMI_AUTOMATION='0' as a kill-switch to disable it. Gates both the IPC +// registration and the foreground-target tracker; the renderer reads the same +// flag (window.omi.automationEnabled) to skip its action-planner pre-step. +const AUTOMATION_ENABLED = process.env.OMI_AUTOMATION !== '0' + +const sandbox = process.env.OMI_SANDBOX +if (sandbox && process.env.OMI_BENCH !== '1') { + const suffix = sandbox === '1' ? 'chat-kg' : sandbox.replace(/[^a-zA-Z0-9._-]/g, '-') + app.setPath('userData', join(app.getPath('appData'), `omi-windows-sandbox-${suffix}`)) +} + +const icon = nativeImage.createFromPath(iconPath) +import { + remapConversationId, + insertLocalConversation, + getLocalConversation, + listLocalConversations, + deleteLocalConversation, + updateLocalConversationTitle +} from './ipc/db' + +function createWindow(): BrowserWindow { + // Create the browser window. 1280x820 gives the two-column Record layout + // (transcript + screen sidebar) room without overflow; min-size prevents the + // sidebar from clipping below a usable threshold. + const mainWindow = new BrowserWindow({ + title: 'omi', + width: 1280, + height: 820, + minWidth: 1024, + minHeight: 640, + show: false, + autoHideMenuBar: true, + frame: true, + transparent: false, + backgroundColor: '#121212', + icon, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + webSecurity: false, // Disabled to work around Omi API CORS preflight issues + // Keep renderer timers running at full rate when the window is minimized/ + // hidden, so Rewind's background screen capture keeps sampling instead of + // being throttled to ~once/minute by Chromium's background policy. + backgroundThrottling: false + } + }) + + // NOTE: the main window is intentionally NOT content-protected. We used to call + // setContentProtection(true) here (Windows WDA_EXCLUDEFROMCAPTURE) so Rewind/chat + // screenshots read only what's BEHIND Omi — but Omi's own window should appear in + // the Rewind timeline like any other app. The frame dedup hash still skips + // unchanged frames, and the foreground-window metadata records when Omi is + // frontmost. (The floating overlay keeps its own protection in overlay/window.ts.) + mainWindow.on('ready-to-show', () => { + mainWindow.show() + }) + perfMark('window:created') + + // Allow Firebase + Google OAuth popups to open as real Electron windows so + // signInWithPopup() can postMessage back to the opener. Everything else + // routes to the system browser. + mainWindow.webContents.setWindowOpenHandler((details) => { + const url = details.url + const isOAuth = + url.startsWith('https://accounts.google.com/') || + url.startsWith('https://based-hardware.firebaseapp.com/') || + url.includes('/__/auth/') || + url.includes('firebaseapp.com/__/auth') + if (isOAuth) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + width: 480, + height: 720, + autoHideMenuBar: true, + webPreferences: { nodeIntegration: false, contextIsolation: true } + } + } + } + shell.openExternal(url) + return { action: 'deny' } + }) + + // HMR for renderer base on electron-vite cli. + // Load the remote URL for development or the local html file for production. + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } + return mainWindow +} + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +// Some APIs can only be used after this event occurs. +app.whenReady().then(() => { + perfMark('main:ready') + // Set app user model id for windows + electronApp.setAppUserModelId('com.omiwindows.app') + + // Default open or close DevTools by F12 in development + // and ignore CommandOrControl + R in production. + // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + // Omi's API doesn't advertise http://localhost:5173 as a CORS-allowed origin. + // In Electron we control the network stack, so strip the Origin header on + // outgoing requests and inject permissive CORS response headers. Scoped to + // the specific upstreams — everything else flows normally. + const apiUrls = [ + 'https://api.omi.me/*', + 'https://desktop-backend-hhibjajaja-uc.a.run.app/*' + ] + session.defaultSession.webRequest.onBeforeSendHeaders({ urls: apiUrls }, (details, cb) => { + const headers = { ...details.requestHeaders } + delete headers.Origin + delete headers.origin + cb({ requestHeaders: headers }) + }) + session.defaultSession.webRequest.onHeadersReceived({ urls: apiUrls }, (details, cb) => { + cb({ + responseHeaders: { + ...details.responseHeaders, + 'access-control-allow-origin': ['*'], + 'access-control-allow-headers': ['*'], + 'access-control-allow-methods': ['GET, POST, PUT, PATCH, DELETE, OPTIONS'] + } + }) + }) + + // System-audio (loopback) capture for the Screen recording mode (which mixes + // mic + system audio). getDisplayMedia() in the renderer routes here; we hand back a screen + // video source plus 'loopback' audio (Windows WASAPI loopback). The renderer + // drops the unused video track and keeps only the system-audio track. This is + // separate from the screen-record picker, which uses getUserMedia with an + // explicit desktop source id and never hits this handler. + // + // NOTE: Electron ships no default getDisplayMedia picker — if this handler + // isn't registered, getDisplayMedia() rejects with "Not supported". Changes + // here only take effect after a FULL restart of the main process. + session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { + try { + const sources = await desktopCapturer.getSources({ types: ['screen'] }) + if (sources.length === 0) throw new Error('no screen sources available') + console.log('[main] display-media request → granting loopback audio') + callback({ video: sources[0], audio: 'loopback' }) + } catch (e) { + console.error('[main] display-media request failed:', e) + callback({}) + } + }) + console.log('[main] setDisplayMediaRequestHandler registered (system-audio loopback ready)') + + ipcMain.handle('capture:getSources', async () => listCaptureSources()) + // Renderer reports its first painted frame; recorded here so the startup mark + // uses the main process's monotonic clock (consistent with the other phases). + ipcMain.on('perf:firstPaint', () => perfMark('renderer:first-paint')) + // Generic renderer-side startup mark (e.g. 'renderer:eval' once the bundle has + // finished evaluating), recorded on the main clock to bisect startup phases. + ipcMain.on('perf:mark', (_e, name: string) => perfMark(String(name))) + // Trivial round-trip used to measure raw IPC overhead in bench mode. + ipcMain.handle('bench:echo', async (_e, x: number) => x) + ipcMain.handle('db:remapConversationId', async (_e, fromId: string, toId: string) => + remapConversationId(fromId, toId) + ) + ipcMain.handle('db:insertLocalConversation', async (_e, c) => insertLocalConversation(c)) + ipcMain.handle('db:getLocalConversation', async (_e, id: string) => getLocalConversation(id)) + ipcMain.handle('db:listLocalConversations', async () => listLocalConversations()) + ipcMain.handle('db:deleteLocalConversation', async (_e, id: string) => + deleteLocalConversation(id) + ) + ipcMain.handle('db:updateLocalConversationTitle', async (_e, id: string, title: string) => + updateLocalConversationTitle(id, title) + ) + registerOmiListenHandlers() + registerFileIndexHandlers() + registerLocalGraphHandlers() + registerMemoryImportHandlers() + registerMemoryExportHandlers() + registerKgHandlers() + registerIntegrationsHandlers() + registerUsageHandlers() + registerMemoryCleanupHandlers() + registerRewindHandlers() + registerScreenHandlers() + // Cross-window conversations refresh: any renderer that writes a local + // conversation (main window OR overlay) notifies here; rebroadcast to every + // window so each invalidates its own per-process conversations cache (e.g. an + // overlay chat shows up in the main window's chat tab without a relaunch). + ipcMain.on('conversations:notify-changed', () => { + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send('conversations:changed') + } + }) + registerInsightHandlers() + perfMark('main:handlers-registered') + // One-time cold-start seed: rank the first brain map by real historical app + // usage from the Windows UserAssist registry. No-op when disabled/off-Windows/ + // already seeded. Runs before the renderer's first KG build. + seedUserAssistOnce() + perfMark('main:userassist-seeded') + // Desktop automation: register the snapshot/plan/run IPC here (cheap — handler + // registration only). The foreground-window tracker is a service start, so it's + // deferred to ready-to-show below alongside the other background services. + // On by default; OMI_AUTOMATION='0' disables the "take real UI actions" bridge. + if (AUTOMATION_ENABLED) registerAutomationHandlers() + // Screen-activity synthesis IPC (cheap handler registration; the renderer drives + // cadence). Rewind handlers/services are already registered/deferred above + below. + registerScreenSynthHandlers() + + const mainWindow = createWindow() + + // Defer non-essential background services until the window is ready to show, so + // their synchronous setup (foreground-monitor koffi/user32 init ~60ms, rewind + // capture/OCR/retention loops, screen-source prewarm) runs AFTER first paint + // instead of delaying the window from appearing. None are needed before the UI + // is up; their IPC handlers are already registered above. + mainWindow.once('ready-to-show', () => { + // Foreground app-usage tracking. No-ops when disabled in Settings or off-Windows. + startForegroundMonitor() + // Track the last non-Omi foreground window so the automation planner snapshots + // the app the user was actually using (Omi is foreground once they click chat). + if (AUTOMATION_ENABLED) startAutomationTargetTracker() + // Load the user's persisted Rewind settings — capture is ON by default for a + // fresh install, and any change the user makes in Settings survives restarts. + // OCR/retention loops are cheap no-ops until frames exist. + startRewindCapture() + startRewindOcr() + startRewindRetention() + // Warm the (slow) screen-source-id cache a few seconds later, off the critical + // path, so enabling capture later is an instant cache hit. + setTimeout(() => prewarmPrimarySourceId(), 4000) + // Pre-create the (hidden) acrylic toast window so the first Omi insight shows instantly. + createInsightToastWindow() + }) + + // Overlay: wire IPC + global shortcut. The overlay window is created lazily on + // first summon (so it inherits the already signed-in Firebase session). + registerOverlayHandlers(() => { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.show() + mainWindow.focus() + }) + const shortcutOk = registerOverlayShortcut(OVERLAY_ACCELERATOR, toggleOverlay) + if (!shortcutOk) { + console.warn( + '[overlay] summon shortcut unavailable; overlay can still be opened via a future rebind UI' + ) + } + // Closing the main window must also tear down the always-alive (hidden) overlay + // window — otherwise it keeps a window open, 'window-all-closed' never fires, and + // the app lingers as an invisible background process (overlay has skipTaskbar). + mainWindow.on('closed', () => { + const overlay = getOverlayWindow() + if (overlay && !overlay.isDestroyed()) overlay.destroy() + }) + + // Bench mode: after the renderer has loaded, run the fixed DB + IPC workload, + // flush marks, and quit. Guarded entirely behind OMI_BENCH so prod is unaffected. + if (process.env.OMI_BENCH === '1') { + // Resolve when the renderer reports its first painted frame, so we can be + // sure the renderer:first-paint mark is recorded before we quit (the + // workload may otherwise finish first once seeding is fast). + const firstPaint = new Promise((resolve) => { + ipcMain.once('perf:firstPaint', () => resolve()) + }) + // Resolve when the AUTHENTICATED shell reports it has mounted+painted + // (renderer:app-ready). Only fires when signed in + onboarded; on the + // Login/unauthenticated path it never arrives, so callers must keep a + // fallback. The perf:mark handler above already records the mark on disk. + const appReady = new Promise((resolve) => { + const onMark = (_e: unknown, name: string): void => { + if (String(name) === 'renderer:app-ready') { + ipcMain.off('perf:mark', onMark) + resolve() + } + } + ipcMain.on('perf:mark', onMark) + }) + mainWindow.webContents.once('did-finish-load', async () => { + // Animation bench: just wait for the renderer probe's jank summary, record + // it, and quit. We deliberately DON'T run the DB/IPC workload here — its + // main-thread + IPC traffic would land during the recording window and + // pollute the frame-timing measurement. + if (process.env.OMI_ANIM_BENCH === '1') { + await new Promise((resolve) => { + ipcMain.once('perf:animResult', (_e, stats) => { + perfMark('anim:startup', stats as Record) + resolve() + }) + setTimeout(resolve, 15000) + }) + flushPerfMarks() + app.quit() + return + } + try { + // Wait for the authed shell to be ready BEFORE running the workload, so + // the bench measures the real authed-startup path. Fall back to + // first-paint (unauthenticated Login run) and a hard 30s cap so the + // bench always completes. The workload runs synchronously in main and + // would otherwise block main from processing these IPCs, back-dating the + // marks by the workload's duration (a measurement artifact). + // app-ready is preferred. On an authed run the spinner first-paints + // almost immediately while auth+onboarding+mount take a few more + // seconds, so the first-paint fallback gets an 8s grace to let app-ready + // win; only a genuinely unauthenticated run falls through to it. + await Promise.race([ + appReady, + firstPaint.then(() => new Promise((r) => setTimeout(r, 8000))), + new Promise((r) => setTimeout(r, 30000)) + ]) + // The DB/IPC workload (src/main/bench/workload.ts) was removed for the + // public release (commit dd1904d). Bench mode now only records the + // startup-timing marks captured above, then flushes and quits. + } catch (e) { + console.error('[bench] workload failed:', e) + } finally { + flushPerfMarks() + app.quit() + } + }) + } + + app.on('activate', function () { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +// Quit when all windows are closed, except on macOS. There, it's common +// for applications and their menu bar to stay active until the user quits +// explicitly with Cmd + Q. +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) + +// On a normal shutdown: flush buffered perf marks, release the overlay shortcut, +// and tear down the automation helper process + foreground-window hook. +app.on('will-quit', () => { + unregisterOverlayShortcut() + flushPerfMarks() + automationBridge.dispose() + stopAutomationTargetTracker() +}) + +// In this file you can include the rest of your app's specific main process +// code. You can also put them in separate files and require them here. diff --git a/windows/src/main/insight/notification.ts b/windows/src/main/insight/notification.ts new file mode 100644 index 0000000000..d9edffd8f1 --- /dev/null +++ b/windows/src/main/insight/notification.ts @@ -0,0 +1,17 @@ +// src/main/insight/notification.ts +import { Notification } from 'electron' +import type { InsightPayload } from '../../shared/types' + +/** Show an insight as a native Windows notification (also kept in the Action + * Center). Used when the user picks the "Windows notification" style. + * Best-effort; no-op if unsupported. */ +export function fireNativeInsight(p: InsightPayload): void { + try { + if (!Notification.isSupported()) return + const n = new Notification({ title: p.headline || 'Omi insight', body: p.advice }) + n.on('failed', (_e, e) => console.warn('[insight] native notification failed:', e)) + n.show() + } catch { + /* best-effort */ + } +} diff --git a/windows/src/main/insight/state.ts b/windows/src/main/insight/state.ts new file mode 100644 index 0000000000..0cf2ce9c07 --- /dev/null +++ b/windows/src/main/insight/state.ts @@ -0,0 +1,45 @@ +// src/main/insight/state.ts +import { app } from 'electron' +import { join } from 'path' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import type { InsightSettings } from '../../shared/types' + +const DEFAULTS: InsightSettings = { + enabled: true, + intervalMin: 15, + notificationStyle: 'omi', + denylist: [], + lastRunAt: null +} + +function statePath(): string { + return join(app.getPath('userData'), 'insights.json') +} + +let cache: InsightSettings | null = null + +export function getInsightSettings(): InsightSettings { + if (cache) return cache + try { + if (existsSync(statePath())) { + const raw = JSON.parse(readFileSync(statePath(), 'utf8')) as Partial + cache = { ...DEFAULTS, ...raw } + return cache + } + } catch { + /* corrupt → defaults */ + } + cache = { ...DEFAULTS } + return cache +} + +export function updateInsightSettings(patch: Partial): InsightSettings { + const next = { ...getInsightSettings(), ...patch } + cache = next + try { + writeFileSync(statePath(), JSON.stringify(next, null, 2)) + } catch { + /* best-effort */ + } + return next +} diff --git a/windows/src/main/insight/toastWindow.ts b/windows/src/main/insight/toastWindow.ts new file mode 100644 index 0000000000..f0aa81efd1 --- /dev/null +++ b/windows/src/main/insight/toastWindow.ts @@ -0,0 +1,121 @@ +// src/main/insight/toastWindow.ts +// Acrylic insight toast ("Omi notification"): frameless, transparent:false + +// setBackgroundMaterial('acrylic'→'mica'→none) — same DWM-backdrop approach as +// the overlay. Anchored bottom-right, shown WITHOUT stealing focus, auto-dismissed +// after a timeout (paused while hovered). +import { BrowserWindow, screen } from 'electron' +import { join } from 'path' +import { is } from '@electron-toolkit/utils' +import type { InsightPayload } from '../../shared/types' + +const WIDTH = 360 +const HEIGHT = 168 +const MARGIN = 16 +const AUTO_DISMISS_MS = 8000 + +let toastWindow: BrowserWindow | null = null +let dismissTimer: ReturnType | null = null + +function applyMaterial(win: BrowserWindow): void { + const w = win as BrowserWindow & { setBackgroundMaterial?: (m: string) => void } + if (process.platform !== 'win32' || typeof w.setBackgroundMaterial !== 'function') return + try { + w.setBackgroundMaterial('acrylic') + } catch { + try { + w.setBackgroundMaterial('mica') + } catch { + /* CSS-glass fallback in the renderer */ + } + } +} + +function ensureWindow(): BrowserWindow { + if (toastWindow && !toastWindow.isDestroyed()) return toastWindow + const win = new BrowserWindow({ + width: WIDTH, + height: HEIGHT, + show: false, + frame: false, + titleBarStyle: 'hidden', + resizable: false, + skipTaskbar: true, + alwaysOnTop: true, + // Must be focusable or Chromium won't route mouse input to it (the ✕ and + // hover-to-pause silently stop working). It still never steals focus when it + // appears because we show it via showInactive(). + focusable: true, + hasShadow: true, + backgroundColor: '#000000', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + webSecurity: false, + backgroundThrottling: false + } + }) + win.setAlwaysOnTop(true, 'screen-saver') + win.on('closed', () => { + toastWindow = null + }) + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/insight-toast`) + } else { + win.loadFile(join(__dirname, '../renderer/index.html'), { hash: 'insight-toast' }) + } + applyMaterial(win) + toastWindow = win + return win +} + +function position(win: BrowserWindow): void { + const wa = screen.getPrimaryDisplay().workArea + win.setBounds({ + x: wa.x + wa.width - WIDTH - MARGIN, + y: wa.y + wa.height - HEIGHT - MARGIN, + width: WIDTH, + height: HEIGHT + }) +} + +export function showInsightToast(payload: InsightPayload): void { + const win = ensureWindow() + position(win) + // showInactive: appear on top without taking focus from the user's current app. + win.showInactive() + const send = (): void => { + if (!win.isDestroyed()) win.webContents.send('insight:payload', payload) + } + if (win.webContents.isLoading()) win.webContents.once('did-finish-load', send) + else send() + if (dismissTimer) clearTimeout(dismissTimer) + dismissTimer = setTimeout(hideInsightToast, AUTO_DISMISS_MS) +} + +export function hideInsightToast(): void { + if (dismissTimer) { + clearTimeout(dismissTimer) + dismissTimer = null + } + if (toastWindow && !toastWindow.isDestroyed() && toastWindow.isVisible()) toastWindow.hide() +} + +/** Pause the auto-dismiss while the pointer is over the toast. */ +export function pauseInsightDismiss(): void { + if (dismissTimer) { + clearTimeout(dismissTimer) + dismissTimer = null + } +} + +/** Resume the auto-dismiss when the pointer leaves. No-op if already hidden. */ +export function resumeInsightDismiss(): void { + if (!toastWindow || toastWindow.isDestroyed() || !toastWindow.isVisible()) return + if (dismissTimer) clearTimeout(dismissTimer) + dismissTimer = setTimeout(hideInsightToast, AUTO_DISMISS_MS) +} + +/** Pre-create the (hidden) toast window so the first insight shows instantly. */ +export function createInsightToastWindow(): void { + ensureWindow() +} diff --git a/windows/src/main/integrations/google.ts b/windows/src/main/integrations/google.ts new file mode 100644 index 0000000000..a1aac1523c --- /dev/null +++ b/windows/src/main/integrations/google.ts @@ -0,0 +1,59 @@ +// Authenticated Gmail + Calendar REST reads (main). Reads Gmail metadata only +// (Subject/From/snippet — never the body) and the next 14 days of Calendar. +import { getAccessToken, invalidateAccessToken } from './oauth' +import { mapGmailMessage, mapCalendarEvent } from './googleMap' +import type { GmailMessageJson, CalEventJson } from './googleMap' +import type { GmailItem, CalendarItem } from '../../shared/types' + +const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' +const CAL_BASE = 'https://www.googleapis.com/calendar/v3' + +async function authedJson(url: string): Promise { + let token = await getAccessToken() + let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + if (res.status === 401) { + // Cached token rejected — force a refresh and retry once. + invalidateAccessToken() + token = await getAccessToken() + res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + } + if (!res.ok) throw new Error(`Google API ${res.status}: ${await res.text()}`) + return (await res.json()) as T +} + +export async function fetchGmail(): Promise { + const q = encodeURIComponent('in:inbox newer_than:7d') + const list = await authedJson<{ messages?: { id: string }[] }>( + `${GMAIL_BASE}/messages?q=${q}&maxResults=25` + ) + const ids = (list.messages ?? []).map((m) => m.id) + const items: GmailItem[] = [] + for (const id of ids) { + const msg = await authedJson( + `${GMAIL_BASE}/messages/${id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From` + ) + const item = mapGmailMessage(msg) + if (item) items.push(item) + } + return items +} + +export async function fetchCalendar(): Promise { + const now = new Date() + const params = new URLSearchParams({ + timeMin: now.toISOString(), + timeMax: new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString(), + singleEvents: 'true', + orderBy: 'startTime', + maxResults: '50' + }) + const data = await authedJson<{ items?: CalEventJson[] }>( + `${CAL_BASE}/calendars/primary/events?${params.toString()}` + ) + const items: CalendarItem[] = [] + for (const e of data.items ?? []) { + const item = mapCalendarEvent(e) + if (item) items.push(item) + } + return items +} diff --git a/windows/src/main/integrations/googleMap.test.ts b/windows/src/main/integrations/googleMap.test.ts new file mode 100644 index 0000000000..6ba3e32cbc --- /dev/null +++ b/windows/src/main/integrations/googleMap.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { mapGmailMessage, mapCalendarEvent } from './googleMap' + +describe('mapGmailMessage', () => { + it('extracts Subject/From headers, snippet, and numeric internalDate', () => { + const item = mapGmailMessage({ + id: 'm1', + snippet: 'Your order shipped', + internalDate: '1700000000000', + payload: { + headers: [ + { name: 'From', value: 'Shop ' }, + { name: 'Subject', value: 'Order #42' } + ] + } + }) + expect(item).toEqual({ + id: 'm1', + subject: 'Order #42', + from: 'Shop ', + snippet: 'Your order shipped', + internalDateMs: 1700000000000 + }) + }) + + it('header lookup is case-insensitive and tolerates missing fields', () => { + const item = mapGmailMessage({ id: 'm2', payload: { headers: [{ name: 'subject', value: 'Hi' }] } }) + expect(item).toEqual({ id: 'm2', subject: 'Hi', from: '', snippet: '', internalDateMs: 0 }) + }) + + it('returns null without an id', () => { + expect(mapGmailMessage({ snippet: 'x' })).toBeNull() + }) +}) + +describe('mapCalendarEvent', () => { + it('maps a timed event', () => { + const item = mapCalendarEvent({ + id: 'e1', + summary: 'Dentist', + location: 'Clinic', + updated: '2026-06-01T10:00:00Z', + start: { dateTime: '2026-06-10T09:00:00Z' }, + end: { dateTime: '2026-06-10T09:30:00Z' } + }) + expect(item?.id).toBe('e1') + expect(item?.title).toBe('Dentist') + expect(item?.location).toBe('Clinic') + expect(item?.startMs).toBe(Date.parse('2026-06-10T09:00:00Z')) + expect(item?.endMs).toBe(Date.parse('2026-06-10T09:30:00Z')) + }) + + it('handles all-day events (date, not dateTime) and missing summary', () => { + const item = mapCalendarEvent({ id: 'e2', start: { date: '2026-06-10' }, end: { date: '2026-06-11' } }) + expect(item?.title).toBe('(no title)') + expect(item?.startMs).toBe(Date.parse('2026-06-10')) + expect(item?.location).toBeUndefined() + }) + + it('returns null without an id', () => { + expect(mapCalendarEvent({ summary: 'x' })).toBeNull() + }) +}) diff --git a/windows/src/main/integrations/googleMap.ts b/windows/src/main/integrations/googleMap.ts new file mode 100644 index 0000000000..8ac64a586c --- /dev/null +++ b/windows/src/main/integrations/googleMap.ts @@ -0,0 +1,58 @@ +// Pure mappers: Google REST JSON → app item shapes. No IO; unit-testable. +import type { GmailItem, CalendarItem } from '../../shared/types' + +type GmailHeader = { name: string; value: string } +export type GmailMessageJson = { + id?: string + snippet?: string + internalDate?: string + payload?: { headers?: GmailHeader[] } +} + +function header(headers: GmailHeader[] | undefined, name: string): string { + const h = headers?.find((x) => x.name.toLowerCase() === name.toLowerCase()) + return h?.value ?? '' +} + +export function mapGmailMessage(m: GmailMessageJson): GmailItem | null { + if (!m.id) return null + const headers = m.payload?.headers + return { + id: m.id, + subject: header(headers, 'Subject'), + from: header(headers, 'From'), + snippet: m.snippet ?? '', + internalDateMs: m.internalDate ? Number(m.internalDate) || 0 : 0 + } +} + +type CalDateTime = { dateTime?: string; date?: string } +export type CalEventJson = { + id?: string + summary?: string + location?: string + description?: string + updated?: string + start?: CalDateTime + end?: CalDateTime +} + +function eventMs(d: CalDateTime | undefined): number { + const v = d?.dateTime ?? d?.date + if (!v) return 0 + const t = Date.parse(v) + return Number.isNaN(t) ? 0 : t +} + +export function mapCalendarEvent(e: CalEventJson): CalendarItem | null { + if (!e.id) return null + return { + id: e.id, + title: e.summary ?? '(no title)', + startMs: eventMs(e.start), + endMs: eventMs(e.end), + location: e.location || undefined, + description: e.description || undefined, + updatedMs: e.updated ? Date.parse(e.updated) || 0 : 0 + } +} diff --git a/windows/src/main/integrations/oauth.ts b/windows/src/main/integrations/oauth.ts new file mode 100644 index 0000000000..fcdc907296 --- /dev/null +++ b/windows/src/main/integrations/oauth.ts @@ -0,0 +1,265 @@ +// Google OAuth 2.0 PKCE loopback flow + token lifecycle (main process). +// The access token lives only in memory for this process run; the refresh token +// is persisted (encrypted) via tokenStore. +import { app, shell, BrowserWindow } from 'electron' +import { createServer, type Server } from 'http' +import type { AddressInfo } from 'net' +import { appendFileSync } from 'fs' +import { join } from 'path' +import { + generateVerifier, + challengeFromVerifier, + generateState, + buildAuthUrl, + isExpired +} from './oauthPkce' +import { saveRefreshToken, loadRefreshToken, clearRefreshToken } from './tokenStore' + +const TOKEN_URL = 'https://oauth2.googleapis.com/token' +const GMAIL_PROFILE_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/profile' + +// If the user never finishes the browser consent (e.g. stalls on Google's +// "unverified app" interstitial), Google never redirects to our loopback and +// the flow would otherwise hang forever. Fail loud after this long instead. +const LOOPBACK_TIMEOUT_MS = 5 * 60_000 + +// Diagnostics: main-process console.log only reaches the dev-server terminal, +// which is easy to miss. Also append to userData/google-oauth.log so the flow +// can be traced after the fact regardless of where the user is looking. +function oauthLog(msg: string, extra?: unknown): void { + const line = `[${new Date().toISOString()}] ${msg}${extra !== undefined ? ' ' + JSON.stringify(extra) : ''}` + console.log('[google-oauth]', line) + try { + appendFileSync(join(app.getPath('userData'), 'google-oauth.log'), line + '\n') + } catch { + /* best-effort logging only */ + } +} + +// Bring the Omi window back to the foreground after the OAuth callback lands so +// the user doesn't have to alt-tab back from the browser themselves. +function focusOmi(): void { + const win = BrowserWindow.getAllWindows()[0] + if (!win) return + if (win.isMinimized()) win.restore() + // Windows blocks a background app from stealing foreground focus from the + // browser — a plain focus() only flashes the taskbar. Briefly forcing the + // window above all others makes show()/focus() actually surface it. + win.setAlwaysOnTop(true) + win.show() + win.focus() + win.setAlwaysOnTop(false) + app.focus({ steal: true }) +} + +function clientId(): string { + const id = import.meta.env.MAIN_VITE_GOOGLE_CLIENT_ID + if (!id) throw new Error('Google client id not configured (set MAIN_VITE_GOOGLE_CLIENT_ID in .env)') + return id +} + +// Google's "Desktop app" OAuth clients are issued a client secret and require it +// at the token endpoint even with PKCE (omitting it yields invalid_client). The +// secret isn't truly confidential for an installed app; we keep it main-only and +// send it when configured. Left unset, the flow stays pure-PKCE for client types +// that don't need a secret. +function clientSecret(): string | undefined { + return import.meta.env.MAIN_VITE_GOOGLE_CLIENT_SECRET || undefined +} + +// Append client_secret to a token-request body when one is configured. +function withClientSecret(body: URLSearchParams): URLSearchParams { + const secret = clientSecret() + if (secret) body.set('client_secret', secret) + return body +} + +type TokenResponse = { access_token: string; refresh_token?: string; expires_in: number } + +// In-memory access token cache for this process run. +let accessToken: string | null = null +let accessExpiryMs = 0 + +/** Run the full PKCE loopback flow. Resolves with the connected account email. */ +export async function connect(): Promise<{ email: string }> { + oauthLog('connect() invoked', { hasClientSecret: !!clientSecret() }) + const verifier = generateVerifier() + const challenge = challengeFromVerifier(verifier) + const state = generateState() + + const { code, redirectUri } = await runLoopback(state, challenge) + const tokens = await exchangeCode(code, verifier, redirectUri) + oauthLog('token exchange ok', { hasRefresh: !!tokens.refresh_token }) + if (!tokens.refresh_token) { + throw new Error('Google did not return a refresh token — revoke prior access and retry') + } + accessToken = tokens.access_token + accessExpiryMs = Date.now() + tokens.expires_in * 1000 + const email = await fetchEmail(tokens.access_token) + saveRefreshToken(tokens.refresh_token, email) + oauthLog('connected', { email: email || '(email unavailable)' }) + return { email } +} + +function runLoopback( + state: string, + challenge: string +): Promise<{ code: string; redirectUri: string }> { + return new Promise((resolve, reject) => { + let settled = false + let timer: NodeJS.Timeout + const cleanup = (): void => { + clearTimeout(timer) + server.close() + } + const succeed = (v: { code: string; redirectUri: string }): void => { + if (settled) return + settled = true + cleanup() + resolve(v) + } + const fail = (e: Error): void => { + if (settled) return + settled = true + cleanup() + reject(e) + } + + const server: Server = createServer((req, res) => { + try { + const url = new URL(req.url ?? '', 'http://127.0.0.1') + if (!url.searchParams.has('code') && !url.searchParams.has('error')) { + res.writeHead(404).end() + return + } + const err = url.searchParams.get('error') + const code = url.searchParams.get('code') + const gotState = url.searchParams.get('state') + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end( + '' + + 'Connected to Omi. You can close this tab.' + + // Best-effort: browsers only honor this for script-opened tabs, so the + // text above is the fallback when the close is ignored. + '' + ) + oauthLog('callback received', { hasCode: !!code, error: err ?? undefined }) + focusOmi() + if (err) return fail(new Error(`Google authorization failed: ${err}`)) + if (gotState !== state) return fail(new Error('OAuth state mismatch')) + if (!code) return fail(new Error('No authorization code returned')) + const addr = server.address() as AddressInfo + succeed({ code, redirectUri: `http://127.0.0.1:${addr.port}` }) + } catch (e) { + fail(e as Error) + } + }) + server.on('error', fail) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo + const redirectUri = `http://127.0.0.1:${addr.port}` + oauthLog('loopback listening, opening consent', { redirectUri }) + const authUrl = buildAuthUrl({ clientId: clientId(), redirectUri, challenge, state }) + void shell.openExternal(authUrl) + timer = setTimeout(() => { + oauthLog('timed out waiting for the OAuth callback') + fail( + new Error( + 'Timed out waiting for Google. In the browser, finish the consent: ' + + 'Advanced → Go to Omi (unsafe) → Allow, then reconnect.' + ) + ) + }, LOOPBACK_TIMEOUT_MS) + }) + }) +} + +async function exchangeCode( + code: string, + verifier: string, + redirectUri: string +): Promise { + const body = withClientSecret( + new URLSearchParams({ + client_id: clientId(), + code, + code_verifier: verifier, + grant_type: 'authorization_code', + redirect_uri: redirectUri + }) + ) + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }) + if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`) + return (await res.json()) as TokenResponse +} + +/** A valid access token, refreshing if necessary. Throws 'not_connected' when no + * refresh token is stored, 'invalid_grant' when the grant was revoked. */ +export async function getAccessToken(): Promise { + if (accessToken && !isExpired(accessExpiryMs)) return accessToken + const stored = loadRefreshToken() + if (!stored) throw new Error('not_connected') + const body = withClientSecret( + new URLSearchParams({ + client_id: clientId(), + refresh_token: stored.refreshToken, + grant_type: 'refresh_token' + }) + ) + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }) + if (res.status === 400 || res.status === 401) { + const text = await res.text() + if (text.includes('invalid_grant')) { + clearRefreshToken() + accessToken = null + accessExpiryMs = 0 + throw new Error('invalid_grant') + } + throw new Error(`Token refresh failed: ${res.status} ${text}`) + } + if (!res.ok) throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`) + const tokens = (await res.json()) as TokenResponse + accessToken = tokens.access_token + accessExpiryMs = Date.now() + tokens.expires_in * 1000 + return accessToken +} + +/** Drop the cached access token so the next getAccessToken() forces a refresh. */ +export function invalidateAccessToken(): void { + accessToken = null + accessExpiryMs = 0 +} + +// The account email comes from the Gmail profile endpoint (covered by +// gmail.readonly) so we don't need to request an extra userinfo/email scope. +async function fetchEmail(token: string): Promise { + try { + const res = await fetch(GMAIL_PROFILE_URL, { headers: { Authorization: `Bearer ${token}` } }) + if (!res.ok) return '' + const j = (await res.json()) as { emailAddress?: string } + return j.emailAddress ?? '' + } catch { + return '' + } +} + +export function disconnect(): void { + clearRefreshToken() + invalidateAccessToken() +} + +export function isConnected(): boolean { + return loadRefreshToken() !== null +} + +export function connectedEmail(): string | undefined { + return loadRefreshToken()?.email +} diff --git a/windows/src/main/integrations/oauthPkce.test.ts b/windows/src/main/integrations/oauthPkce.test.ts new file mode 100644 index 0000000000..2c6fcb6780 --- /dev/null +++ b/windows/src/main/integrations/oauthPkce.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { + base64url, + challengeFromVerifier, + buildAuthUrl, + isExpired, + generateVerifier +} from './oauthPkce' + +describe('oauthPkce', () => { + it('base64url has no +, /, or = padding', () => { + const s = base64url(Buffer.from([0xfb, 0xff, 0xfe, 0x00])) + expect(s).not.toMatch(/[+/=]/) + }) + + it('challengeFromVerifier matches the RFC 7636 test vector', () => { + // RFC 7636 Appendix B + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + expect(challengeFromVerifier(verifier)).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + }) + + it('generateVerifier is 43+ chars, url-safe', () => { + const v = generateVerifier() + expect(v.length).toBeGreaterThanOrEqual(43) + expect(v).not.toMatch(/[+/=]/) + }) + + it('buildAuthUrl includes required params and both scopes', () => { + const url = buildAuthUrl({ + clientId: 'abc.apps.googleusercontent.com', + redirectUri: 'http://127.0.0.1:51000', + challenge: 'CHAL', + state: 'STATE' + }) + const u = new URL(url) + expect(u.origin + u.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth') + expect(u.searchParams.get('client_id')).toBe('abc.apps.googleusercontent.com') + expect(u.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:51000') + expect(u.searchParams.get('response_type')).toBe('code') + expect(u.searchParams.get('access_type')).toBe('offline') + expect(u.searchParams.get('prompt')).toBe('consent') + expect(u.searchParams.get('code_challenge')).toBe('CHAL') + expect(u.searchParams.get('code_challenge_method')).toBe('S256') + expect(u.searchParams.get('state')).toBe('STATE') + expect(u.searchParams.get('scope')).toContain('gmail.readonly') + expect(u.searchParams.get('scope')).toContain('calendar.readonly') + }) + + it('isExpired is true within the 60s skew window and false outside', () => { + const now = 1_000_000 + expect(isExpired(now + 30_000, now)).toBe(true) // 30s left → refresh + expect(isExpired(now + 120_000, now)).toBe(false) // 2m left → ok + }) +}) diff --git a/windows/src/main/integrations/oauthPkce.ts b/windows/src/main/integrations/oauthPkce.ts new file mode 100644 index 0000000000..c3fa4fa45d --- /dev/null +++ b/windows/src/main/integrations/oauthPkce.ts @@ -0,0 +1,52 @@ +// Pure PKCE + auth-URL helpers for the Google OAuth loopback flow (parity 3d). +// No Electron/IO imports here so it's unit-testable under node Vitest. +import { createHash, randomBytes } from 'crypto' + +const SCOPES = [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar.readonly' +] + +const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth' + +export function base64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** 43-char (256-bit) high-entropy code verifier. */ +export function generateVerifier(): string { + return base64url(randomBytes(32)) +} + +export function challengeFromVerifier(verifier: string): string { + return base64url(createHash('sha256').update(verifier).digest()) +} + +export function generateState(): string { + return base64url(randomBytes(16)) +} + +export function buildAuthUrl(params: { + clientId: string + redirectUri: string + challenge: string + state: string +}): string { + const q = new URLSearchParams({ + client_id: params.clientId, + redirect_uri: params.redirectUri, + response_type: 'code', + scope: SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + code_challenge: params.challenge, + code_challenge_method: 'S256', + state: params.state + }) + return `${AUTH_ENDPOINT}?${q.toString()}` +} + +/** True when the access token is within 60s of expiry (so we refresh early). */ +export function isExpired(expiryMs: number, now: number = Date.now()): boolean { + return now >= expiryMs - 60_000 +} diff --git a/windows/src/main/integrations/stickyNotes.ts b/windows/src/main/integrations/stickyNotes.ts new file mode 100644 index 0000000000..045cc329f8 --- /dev/null +++ b/windows/src/main/integrations/stickyNotes.ts @@ -0,0 +1,154 @@ +import Database from 'better-sqlite3' +import { app } from 'electron' +import { copyFileSync, existsSync, readdirSync, rmSync, statSync } from 'fs' +import { join } from 'path' +import { resolveStickyNotesDb } from './stickyNotesPath' +import { toStickyNotes, type RawNoteRow } from './stickyNotesText' +import type { StickyNotesReadResult } from '../../shared/types' + +// List subdirectories of `packages` newest-mtime first, so resolveStickyNotesDb +// picks the most recently used Sticky Notes install when several exist. +function listPackageDirsNewestFirst(packages: string): string[] { + try { + return readdirSync(packages, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => { + let mtime = 0 + try { + mtime = statSync(join(packages, e.name)).mtimeMs + } catch { + /* ignore */ + } + return { name: e.name, mtime } + }) + .sort((a, b) => b.mtime - a.mtime) + .map((e) => e.name) + } catch { + return [] + } +} + +// .NET DateTime ticks at the Unix epoch (100ns intervals since 0001-01-01). +const DOTNET_TICKS_AT_UNIX_EPOCH = 621355968000000000 +// Anything past this many ms epoch is well beyond any real note date (year +// ~5138), so a larger number must be .NET ticks rather than ms. +const MS_EPOCH_SANITY_MAX = 1e14 + +// Coerce a Sticky Notes timestamp to ms epoch. Newer Sticky Notes stores +// CreatedAt/UpdatedAt as .NET DateTime ticks (e.g. 638986500693000925); older +// data / other columns may be ISO strings or already-ms numbers. +function coerceMs(v: unknown): number { + if (typeof v === 'number' && Number.isFinite(v)) { + if (v > MS_EPOCH_SANITY_MAX) return Math.round((v - DOTNET_TICKS_AT_UNIX_EPOCH) / 10000) + return v + } + if (typeof v === 'string') { + const t = Date.parse(v) + if (!Number.isNaN(t)) return t + } + return 0 +} + +// Quote a SQLite identifier for safe interpolation (column names come from +// PRAGMA table_info, not user input, but we quote defensively). +function quoteIdent(id: string): string { + return '"' + id.replace(/"/g, '""') + '"' +} + +// Read the Note table from an already-openable db file. Introspects columns so +// it tolerates Sticky Notes schema drift across versions. +function readNoteRows(dbPath: string): RawNoteRow[] { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + const hasNote = db + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='Note'") + .get() + if (!hasNote) return [] + + const cols = db.prepare('PRAGMA table_info(Note)').all() as { name: string }[] + const names = cols.map((c) => c.name) + const textCol = names.find((c) => c === 'Text') ?? names.find((c) => /text/i.test(c)) + if (!textCol) return [] + const idCol = names.find((c) => c === 'Id') ?? names.find((c) => /^id$/i.test(c)) + const updatedCol = + names.find((c) => c === 'UpdatedAt') ?? names.find((c) => /updat/i.test(c)) + const deletedCol = + names.find((c) => /^(IsDeleted|DeletedAt)$/i.test(c)) ?? names.find((c) => /delet/i.test(c)) + + const select = [ + `${idCol ? quoteIdent(idCol) : 'rowid'} AS id`, + `${quoteIdent(textCol)} AS text`, + `${updatedCol ? quoteIdent(updatedCol) : '0'} AS updatedAt`, + `${deletedCol ? quoteIdent(deletedCol) : 'NULL'} AS deleted` + ].join(', ') + + const rows = db.prepare(`SELECT ${select} FROM Note`).all() as { + id: unknown + text: unknown + updatedAt: unknown + deleted: unknown + }[] + + return rows.map((r) => ({ + id: String(r.id), + text: typeof r.text === 'string' ? r.text : '', + updatedAt: coerceMs(r.updatedAt), + // truthy IsDeleted (1) or a non-null DeletedAt both mean deleted + deleted: r.deleted != null && r.deleted !== 0 && r.deleted !== '' + })) + } finally { + db.close() + } +} + +// Sticky Notes may hold a write lock on plum.sqlite. On a locked open, copy the +// db (and its -wal/-shm sidecars) to a temp dir, read the copy, then clean up. +function readViaTempCopy(dbPath: string): RawNoteRow[] { + const tmpBase = join(app.getPath('temp'), `omi-sticky-${Date.now()}`) + const tmpDb = `${tmpBase}.sqlite` + const copies: string[] = [] + try { + copyFileSync(dbPath, tmpDb) + copies.push(tmpDb) + for (const ext of ['-wal', '-shm']) { + const side = dbPath + ext + if (existsSync(side)) { + const tside = tmpDb + ext + copyFileSync(side, tside) + copies.push(tside) + } + } + return readNoteRows(tmpDb) + } finally { + for (const f of copies) { + try { + rmSync(f, { force: true }) + } catch { + /* best-effort cleanup */ + } + } + } +} + +// Locate + read Windows Sticky Notes, returning cleaned notes. Never throws: +// missing install → { available: false }, read failure → { available: true, error }. +export function readStickyNotes(): StickyNotesReadResult { + const dbPath = resolveStickyNotesDb( + { LOCALAPPDATA: process.env.LOCALAPPDATA }, + listPackageDirsNewestFirst, + existsSync + ) + if (!dbPath) return { available: false, notes: [] } + try { + let rows: RawNoteRow[] + try { + rows = readNoteRows(dbPath) + } catch { + // Likely SQLITE_BUSY / locked — fall back to a temp copy. + rows = readViaTempCopy(dbPath) + } + return { available: true, notes: toStickyNotes(rows) } + } catch (e) { + return { available: true, notes: [], error: (e as Error).message } + } +} diff --git a/windows/src/main/integrations/stickyNotesPath.test.ts b/windows/src/main/integrations/stickyNotesPath.test.ts new file mode 100644 index 0000000000..a2db521387 --- /dev/null +++ b/windows/src/main/integrations/stickyNotesPath.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { join } from 'path' +import { resolveStickyNotesDb } from './stickyNotesPath' + +describe('resolveStickyNotesDb', () => { + const local = 'C:\\Users\\me\\AppData\\Local' + const pkgDir = 'Microsoft.MicrosoftStickyNotes_8wekyb3d8bbwe' + const dbPath = join(local, 'Packages', pkgDir, 'LocalState', 'plum.sqlite') + + it('resolves the plum.sqlite path under the matching package dir', () => { + const got = resolveStickyNotesDb( + { LOCALAPPDATA: local }, + () => [pkgDir, 'Some.Other.Package_abc'], + (p) => p === dbPath + ) + expect(got).toBe(dbPath) + }) + + it('picks the first listed package whose db exists (caller pre-sorts newest-first)', () => { + const newer = 'Microsoft.MicrosoftStickyNotes_new' + const older = 'Microsoft.MicrosoftStickyNotes_old' + const olderDb = join(local, 'Packages', older, 'LocalState', 'plum.sqlite') + const got = resolveStickyNotesDb( + { LOCALAPPDATA: local }, + () => [newer, older], // newest-first + (p) => p === olderDb // only the older one actually has a db + ) + expect(got).toBe(olderDb) + }) + + it('returns null when no Sticky Notes package dir exists', () => { + expect( + resolveStickyNotesDb({ LOCALAPPDATA: local }, () => ['Some.Other_x'], () => true) + ).toBeNull() + }) + + it('returns null when the package exists but plum.sqlite does not', () => { + expect( + resolveStickyNotesDb({ LOCALAPPDATA: local }, () => [pkgDir], () => false) + ).toBeNull() + }) + + it('returns null when LOCALAPPDATA is unset', () => { + expect(resolveStickyNotesDb({}, () => [pkgDir], () => true)).toBeNull() + }) +}) diff --git a/windows/src/main/integrations/stickyNotesPath.ts b/windows/src/main/integrations/stickyNotesPath.ts new file mode 100644 index 0000000000..a7678d122c --- /dev/null +++ b/windows/src/main/integrations/stickyNotesPath.ts @@ -0,0 +1,26 @@ +import { join } from 'path' + +export type StickyNotesEnv = { LOCALAPPDATA?: string } + +const PACKAGE_PREFIX = 'Microsoft.MicrosoftStickyNotes_' + +// Pure: resolve the Sticky Notes plum.sqlite path. The UWP package dir carries a +// publisher-id suffix that varies per install, so we list %LOCALAPPDATA%\Packages +// and match the prefix. `listDirs` is expected to return entries newest-first; +// the first match whose plum.sqlite exists wins. Returns null when Sticky Notes +// isn't installed or has no database yet. +export function resolveStickyNotesDb( + env: StickyNotesEnv, + listDirs: (packagesDir: string) => string[], + exists: (p: string) => boolean +): string | null { + const local = env.LOCALAPPDATA + if (!local) return null + const packages = join(local, 'Packages') + for (const name of listDirs(packages)) { + if (!name.startsWith(PACKAGE_PREFIX)) continue + const candidate = join(packages, name, 'LocalState', 'plum.sqlite') + if (exists(candidate)) return candidate + } + return null +} diff --git a/windows/src/main/integrations/stickyNotesText.test.ts b/windows/src/main/integrations/stickyNotesText.test.ts new file mode 100644 index 0000000000..a70292734c Binary files /dev/null and b/windows/src/main/integrations/stickyNotesText.test.ts differ diff --git a/windows/src/main/integrations/stickyNotesText.ts b/windows/src/main/integrations/stickyNotesText.ts new file mode 100644 index 0000000000..ad7668220e --- /dev/null +++ b/windows/src/main/integrations/stickyNotesText.ts @@ -0,0 +1,42 @@ +// Pure helpers for turning raw Sticky Notes `Note` rows into importable notes. +// Sticky Notes stores bodies with lightweight markup + control chars; synthesis +// only needs the words, so we strip control characters and collapse whitespace +// rather than parsing the markup. +import type { StickyNote } from '../../shared/types' + +// Sticky Notes prefixes each content block with a `\id=` anchor; the real +// note text follows it (e.g. `\id= My favorite movie is ...`). Strip these +// anchors so only the human text reaches synthesis, turning each block boundary +// into a newline. +const BLOCK_ANCHOR = + /\\id=[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\s*/g + +export function cleanNoteText(raw: string): string { + if (!raw) return '' + return raw + .replace(BLOCK_ANCHOR, '\n') // drop block-id anchors, keep block boundaries + .replace(/\r\n?/g, '\n') // normalize line endings first + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, ' ') // strip control chars (keep tab + newline) + .replace(/[ \t]+/g, ' ') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +/** A raw row read from the Sticky Notes `Note` table (before cleaning). */ +export type RawNoteRow = { + id: string + text: string + updatedAt: number + deleted?: boolean +} + +// Clean + filter raw rows: drop deleted and empty/whitespace-only notes, newest +// (highest updatedAt) first. +export function toStickyNotes(rows: RawNoteRow[]): StickyNote[] { + return rows + .filter((r) => !r.deleted) + .map((r) => ({ id: r.id, text: cleanNoteText(r.text ?? ''), updatedAt: r.updatedAt })) + .filter((n) => n.text.length > 0) + .sort((a, b) => b.updatedAt - a.updatedAt) +} diff --git a/windows/src/main/integrations/syncState.ts b/windows/src/main/integrations/syncState.ts new file mode 100644 index 0000000000..714d7438c9 --- /dev/null +++ b/windows/src/main/integrations/syncState.ts @@ -0,0 +1,56 @@ +// Per-source sync state (lastSyncAt + processed IDs) in userData JSON. Wraps the +// pure syncStateLogic so the merge/dedup/bound rules stay unit-tested. +import { app } from 'electron' +import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { join } from 'path' +import { emptySourceState, recordProcessed, type SourceState } from './syncStateLogic' +import type { GoogleSource } from '../../shared/types' + +type SyncFile = { gmail: SourceState; calendar: SourceState } + +function file(): string { + return join(app.getPath('userData'), 'google-sync.json') +} + +function read(): SyncFile { + try { + if (existsSync(file())) { + const raw = JSON.parse(readFileSync(file(), 'utf8')) as Partial + return { + gmail: raw.gmail ?? emptySourceState(), + calendar: raw.calendar ?? emptySourceState() + } + } + } catch { + /* fall through to empty */ + } + return { gmail: emptySourceState(), calendar: emptySourceState() } +} + +function write(state: SyncFile): void { + writeFileSync(file(), JSON.stringify(state), 'utf8') +} + +export function getSourceState(source: GoogleSource): SourceState { + return read()[source] +} + +export function markProcessed(source: GoogleSource, ids: string[]): void { + const state = read() + state[source] = recordProcessed(state[source], ids, Date.now()) + write(state) +} + +/** Most recent successful sync across both sources (0 if never). */ +export function lastSyncAt(): number { + const s = read() + return Math.max(s.gmail.lastSyncAt, s.calendar.lastSyncAt) +} + +export function clearSyncState(): void { + try { + rmSync(file(), { force: true }) + } catch { + /* best-effort */ + } +} diff --git a/windows/src/main/integrations/syncStateLogic.test.ts b/windows/src/main/integrations/syncStateLogic.test.ts new file mode 100644 index 0000000000..464c4ec7c3 --- /dev/null +++ b/windows/src/main/integrations/syncStateLogic.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { + emptySourceState, + filterNew, + recordProcessed, + MAX_PROCESSED +} from './syncStateLogic' + +describe('filterNew', () => { + it('excludes items whose id is already processed', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + expect(filterNew(items, ['b']).map((i) => i.id)).toEqual(['a', 'c']) + }) + + it('returns all when nothing processed', () => { + expect(filterNew([{ id: 'a' }], []).map((i) => i.id)).toEqual(['a']) + }) +}) + +describe('recordProcessed', () => { + it('merges new ids, dedups, and advances lastSyncAt', () => { + const next = recordProcessed({ lastSyncAt: 0, processedIds: ['a'] }, ['a', 'b'], 1234) + expect(next.lastSyncAt).toBe(1234) + expect(next.processedIds).toEqual(['a', 'b']) + }) + + it('bounds the processed set to the newest MAX_PROCESSED ids', () => { + const start = emptySourceState() + const ids = Array.from({ length: MAX_PROCESSED + 5 }, (_, i) => `id${i}`) + const next = recordProcessed(start, ids, 1) + expect(next.processedIds.length).toBe(MAX_PROCESSED) + expect(next.processedIds[0]).toBe('id5') // oldest 5 dropped + expect(next.processedIds.at(-1)).toBe(`id${MAX_PROCESSED + 4}`) + }) +}) diff --git a/windows/src/main/integrations/syncStateLogic.ts b/windows/src/main/integrations/syncStateLogic.ts new file mode 100644 index 0000000000..993f1c4f47 --- /dev/null +++ b/windows/src/main/integrations/syncStateLogic.ts @@ -0,0 +1,34 @@ +// Pure idempotency logic for Google sync. No IO; unit-testable. + +export type SourceState = { + lastSyncAt: number + processedIds: string[] +} + +/** Cap on retained processed IDs per source (bounded memory; old IDs age out). */ +export const MAX_PROCESSED = 1000 + +export function emptySourceState(): SourceState { + return { lastSyncAt: 0, processedIds: [] } +} + +/** Items whose id is NOT already in the processed set. */ +export function filterNew(items: T[], processedIds: string[]): T[] { + const seen = new Set(processedIds) + return items.filter((it) => !seen.has(it.id)) +} + +/** Merge ids into state (dedup, newest appended last, bounded), set lastSyncAt=now. */ +export function recordProcessed(state: SourceState, ids: string[], now: number): SourceState { + const merged = [...state.processedIds] + const have = new Set(merged) + for (const id of ids) { + if (!have.has(id)) { + merged.push(id) + have.add(id) + } + } + const bounded = + merged.length > MAX_PROCESSED ? merged.slice(merged.length - MAX_PROCESSED) : merged + return { lastSyncAt: now, processedIds: bounded } +} diff --git a/windows/src/main/integrations/tokenStore.ts b/windows/src/main/integrations/tokenStore.ts new file mode 100644 index 0000000000..92f20edc5e --- /dev/null +++ b/windows/src/main/integrations/tokenStore.ts @@ -0,0 +1,40 @@ +// Persist the Google refresh token encrypted at rest via Electron safeStorage +// (DPAPI on Windows). The access token is NOT persisted (kept in oauth.ts memory). +import { app, safeStorage } from 'electron' +import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { join } from 'path' + +type StoredFile = { refreshToken: string; email?: string } + +function file(): string { + return join(app.getPath('userData'), 'google-tokens.json') +} + +export function saveRefreshToken(refreshToken: string, email?: string): void { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('Secure storage is unavailable on this system') + } + const enc = safeStorage.encryptString(refreshToken).toString('base64') + writeFileSync(file(), JSON.stringify({ refreshToken: enc, email } satisfies StoredFile), 'utf8') +} + +export function loadRefreshToken(): { refreshToken: string; email?: string } | null { + const f = file() + if (!existsSync(f)) return null + try { + const raw = JSON.parse(readFileSync(f, 'utf8')) as StoredFile + if (!raw.refreshToken) return null + const dec = safeStorage.decryptString(Buffer.from(raw.refreshToken, 'base64')) + return { refreshToken: dec, email: raw.email } + } catch { + return null + } +} + +export function clearRefreshToken(): void { + try { + rmSync(file(), { force: true }) + } catch { + /* best-effort */ + } +} diff --git a/windows/src/main/ipc/automation.ts b/windows/src/main/ipc/automation.ts new file mode 100644 index 0000000000..76d18ccf0b --- /dev/null +++ b/windows/src/main/ipc/automation.ts @@ -0,0 +1,85 @@ +import { ipcMain, dialog, BrowserWindow, type WebContents } from 'electron' +import { automationBridge } from '../automation/bridge' +import { getAutomationTargetHandle } from '../automation/foregroundTarget' +import type { AutomationPlan, AutomationStep, PlanRunResult, UiSnapshot } from '../../shared/types' + +// Result of the native-dialog confirm flow. `canceled` distinguishes a user +// "Cancel" from an execution failure. +export type ConfirmRunResult = { ok: boolean; canceled?: boolean; message?: string } + +// Human-readable, consent-relevant summary of a step for the native dialog. +// Built in MAIN from the real plan (not renderer-supplied text) so what the user +// approves is what runs. Element refs aren't human-friendly, so clicks are +// described generically; the typed value / keys (what matters for consent) shown. +function describeStepForDialog(step: AutomationStep, i: number): string { + const n = `${i + 1}. ` + switch (step.type) { + case 'focus_window': + return `${n}Bring the target window to the front` + case 'set_value': + return `${n}Type “${step.value}”` + case 'send_keys': + return `${n}Press keys: ${step.keys}` + case 'invoke_element': + case 'click': + return `${n}Click an element` + case 'select_item': + return `${n}Select an item` + case 'toggle': + return `${n}Turn a setting ${step.state ? 'on' : 'off'}` + case 'wait_for': + return `${n}Wait for an element` + default: + return `${n}${(step as { type: string }).type}` + } +} + +export function registerAutomationHandlers(): void { + ipcMain.handle('automation:snapshot', async (_e, windowHandle?: string): Promise => { + return automationBridge.snapshot(windowHandle) + }) + + // The last non-Omi foreground window the planner should target (null → caller + // falls back to the live foreground window). + ipcMain.handle('automation:targetWindow', async (): Promise => { + return getAutomationTargetHandle() + }) + + ipcMain.handle('automation:run', async (e, plan: AutomationPlan): Promise => { + const wc: WebContents = e.sender + return automationBridge.run(plan, (r) => { + if (!wc.isDestroyed()) wc.send('automation:step', r) + }) + }) + + // Consent gate as a NATIVE Windows dialog (works identically from the main + // window and the floating overlay, since it lives here in main). Shows the plan + // and only runs it on explicit approval. + ipcMain.handle( + 'automation:confirmRun', + async (e, plan: AutomationPlan): Promise => { + const parent = BrowserWindow.fromWebContents(e.sender) + const detail = [ + `In “${plan.targetWindow}”:`, + '', + ...plan.steps.map((s, i) => describeStepForDialog(s, i)) + ].join('\n') + const opts = { + type: 'question' as const, + title: 'Omi — approve action', + message: plan.summary || `Omi wants to do something in “${plan.targetWindow}”`, + detail, + buttons: ['Approve & run', 'Cancel'], + defaultId: 0, + cancelId: 1, + noLink: true + } + const { response } = parent + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts) + if (response !== 0) return { ok: false, canceled: true } + const result = await automationBridge.run(plan, () => {}) + return { ok: result.ok, message: result.message } + } + ) +} diff --git a/windows/src/main/ipc/capture.ts b/windows/src/main/ipc/capture.ts new file mode 100644 index 0000000000..ca497c680d --- /dev/null +++ b/windows/src/main/ipc/capture.ts @@ -0,0 +1,14 @@ +import { desktopCapturer } from 'electron' +import type { CaptureSource } from '../../shared/types' + +export async function listCaptureSources(): Promise { + const sources = await desktopCapturer.getSources({ + types: ['window', 'screen'], + thumbnailSize: { width: 320, height: 180 } + }) + return sources.map((s) => ({ + id: s.id, + name: s.name, + thumbnailDataUrl: s.thumbnail.toDataURL() + })) +} diff --git a/windows/src/main/ipc/db.ts b/windows/src/main/ipc/db.ts new file mode 100644 index 0000000000..6d6d989b11 --- /dev/null +++ b/windows/src/main/ipc/db.ts @@ -0,0 +1,807 @@ +import Database from 'better-sqlite3' +import { app } from 'electron' +import { basename, join } from 'path' +import { categorize } from '../usage/category' +import { isNewLocalDay } from '../usage/usageDay' +import type { + AppUsageRecord, + ChatMessage, + FileIndexDigest, + IndexedAppRecord, + IndexedFileRecord, + InsightPayload, + InsightRecord, + KgSqlResult, + KnowledgeGraph, + LocalConversation, + LocalKGStatus, + LocalKnowledgeGraph, + OnboardingGraphNode, + OnboardingGraphEdge, + RewindFrame, + UsageCategory +} from '../../shared/types' +import { perfMark } from '../../shared/perf' + +// Time a synchronous DB helper and emit a perf mark with its duration in ms. +// Always-on (perfMark is a no-op unless OMI_PERF_LOG is set), so the bench can +// measure DB read throughput without affecting normal runs. +function timed(name: string, fn: () => T): T { + const t = performance.now() + try { + return fn() + } finally { + perfMark(`db:${name}`, { ms: performance.now() - t }) + } +} + +let db: Database.Database | null = null +let roDb: Database.Database | null = null + +// Add a column only if it doesn't already exist, so existing databases (which +// predate the `kind`/`messages` columns) migrate forward without data loss. +function ensureColumn(d: Database.Database, table: string, col: string, decl: string): void { + const cols = d.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[] + if (!cols.some((c) => c.name === col)) { + d.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`) + } +} + +// Drop a table whose on-disk schema predates the current one (detected by a +// missing expected column), so the CREATE TABLE IF NOT EXISTS below can recreate +// it fresh. Used for the local_kg_* tables: an abandoned experiment left an +// incompatible schema (node_id/edge_id PKs, no summary/source columns) that +// silently broke every INSERT. These tables are a derived cache with no user +// data worth migrating, so recreating them is safe. +function dropIfMissingColumn(d: Database.Database, table: string, col: string): void { + const exists = d + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?") + .get(table) + if (!exists) return + const cols = d.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[] + if (!cols.some((c) => c.name === col)) d.exec(`DROP TABLE ${table}`) +} + +function get(): Database.Database { + if (db) return db + // OMI_DB_PATH lets the bench harness point at a throwaway DB so benchmarking + // never reads or writes the user's real omi.db. + const file = process.env.OMI_DB_PATH ?? join(app.getPath('userData'), 'omi.db') + db = new Database(file) + // For the throwaway bench DB only, relax durability so seeding ~7k rows isn't + // dominated by a per-insert fsync (otherwise it swamps the startup measurement). + if (process.env.OMI_DB_PATH) { + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + } + // Migrate away the incompatible local_kg_* schema from the parked KG experiment. + dropIfMissingColumn(db, 'local_kg_nodes', 'summary') + dropIfMissingColumn(db, 'local_kg_edges', 'id') + db.exec(` + CREATE TABLE IF NOT EXISTS caption_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + ts INTEGER NOT NULL, + caption TEXT NOT NULL, + ocr_text TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_caption_convo ON caption_event(conversation_id, ts); + + CREATE TABLE IF NOT EXISTS local_conversation ( + id TEXT PRIMARY KEY, + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + transcript TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind TEXT NOT NULL DEFAULT 'recording', + messages TEXT, + title TEXT + ); + + CREATE TABLE IF NOT EXISTS indexed_files ( + path TEXT PRIMARY KEY, + filename TEXT NOT NULL, + extension TEXT NOT NULL, + file_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + folder TEXT NOT NULL, + depth INTEGER NOT NULL, + created_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + indexed_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_indexed_files_type ON indexed_files(file_type); + + CREATE TABLE IF NOT EXISTS local_kg_nodes ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + node_type TEXT NOT NULL, + summary TEXT NOT NULL, + source TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_local_kg_nodes_label ON local_kg_nodes(label); + CREATE INDEX IF NOT EXISTS idx_local_kg_nodes_type ON local_kg_nodes(node_type); + + CREATE TABLE IF NOT EXISTS local_kg_edges ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + label TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + -- Onboarding brain-map graph (sandbox/ui). Separate tables from the chat-KG + -- local_kg_* above; disposable progressive-reveal data only. + CREATE TABLE IF NOT EXISTS onboarding_kg_nodes ( + node_id TEXT PRIMARY KEY, + label TEXT NOT NULL, + node_type TEXT NOT NULL, + aliases_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS onboarding_kg_edges ( + edge_id TEXT PRIMARY KEY, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + label TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS app_usage ( + exe_path TEXT PRIMARY KEY, + exe_name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'other', + total_seconds INTEGER NOT NULL DEFAULT 0, + last_used INTEGER NOT NULL DEFAULT 0, + distinct_days INTEGER NOT NULL DEFAULT 0, + first_seen INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS rewind_frames ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + app TEXT NOT NULL DEFAULT '', + window_title TEXT NOT NULL DEFAULT '', + process_name TEXT NOT NULL DEFAULT '', + ocr_text TEXT NOT NULL DEFAULT '', + image_path TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + indexed INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_rewind_frames_ts ON rewind_frames(ts); + CREATE INDEX IF NOT EXISTS idx_rewind_frames_indexed ON rewind_frames(indexed); + + CREATE TABLE IF NOT EXISTS insights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + headline TEXT NOT NULL, + advice TEXT NOT NULL, + reasoning TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT 'other', + source_app TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 0, + dismissed INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_insights_ts ON insights(ts); + `) + // Migrate older databases that have local_conversation without these columns. + ensureColumn(db, 'local_conversation', 'kind', "TEXT NOT NULL DEFAULT 'recording'") + ensureColumn(db, 'local_conversation', 'messages', 'TEXT') + ensureColumn(db, 'local_conversation', 'title', 'TEXT') + // Node provenance for the LLM-synthesized graph (additive). + ensureColumn(db, 'local_kg_nodes', 'aliases_json', 'TEXT') + ensureColumn(db, 'local_kg_nodes', 'source_refs', 'TEXT') + // Resolved .lnk target exe, for joining indexed apps to app_usage (additive). + ensureColumn(db, 'indexed_files', 'target_path', 'TEXT') + return db +} + +type LocalConversationRow = { + id: string + startedAt: number + endedAt: number + transcript: string + createdAt: number + kind: string | null + messages: string | null + title: string | null +} + +function mapLocalConversation(row: LocalConversationRow): LocalConversation { + return { + id: row.id, + startedAt: row.startedAt, + endedAt: row.endedAt, + transcript: row.transcript, + createdAt: row.createdAt, + kind: row.kind === 'chat' ? 'chat' : 'recording', + messages: row.messages ? (JSON.parse(row.messages) as ChatMessage[]) : undefined, + title: row.title ?? null + } +} + +const LOCAL_CONVERSATION_COLUMNS = + 'id, started_at AS startedAt, ended_at AS endedAt, transcript, created_at AS createdAt, kind, messages, title' + +export function insertLocalConversation(c: LocalConversation): void { + get() + .prepare( + 'INSERT OR REPLACE INTO local_conversation (id, started_at, ended_at, transcript, created_at, kind, messages, title) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ) + .run( + c.id, + c.startedAt, + c.endedAt, + c.transcript, + c.createdAt, + c.kind ?? 'recording', + c.messages ? JSON.stringify(c.messages) : null, + c.title ?? null + ) +} + +export function updateLocalConversationTitle(id: string, title: string): void { + get() + .prepare('UPDATE local_conversation SET title = ? WHERE id = ?') + .run(title.trim() || null, id) +} + +export function getLocalConversation(id: string): LocalConversation | null { + return timed('getLocalConversation', () => { + const row = get() + .prepare(`SELECT ${LOCAL_CONVERSATION_COLUMNS} FROM local_conversation WHERE id = ?`) + .get(id) as LocalConversationRow | undefined + return row ? mapLocalConversation(row) : null + }) +} + +export function listLocalConversations(): LocalConversation[] { + return timed('listLocalConversations', () => { + const rows = get() + .prepare(`SELECT ${LOCAL_CONVERSATION_COLUMNS} FROM local_conversation ORDER BY created_at DESC`) + .all() as LocalConversationRow[] + return rows.map(mapLocalConversation) + }) +} + +export function deleteLocalConversation(id: string): void { + get().prepare('DELETE FROM local_conversation WHERE id = ?').run(id) +} + + +export function remapConversationId(fromId: string, toId: string): number { + const r = get() + .prepare('UPDATE caption_event SET conversation_id = ? WHERE conversation_id = ?') + .run(toId, fromId) + return r.changes +} + +// Replace the whole index in batches of 500 (matches macOS commit cadence), +// wrapped per batch in a transaction for speed. +export function replaceIndexedFiles(records: IndexedFileRecord[]): void { + const d = get() + const insert = d.prepare( + `INSERT OR REPLACE INTO indexed_files + (path, filename, extension, file_type, size_bytes, folder, depth, created_at, modified_at, target_path, indexed_at) + VALUES (@path, @filename, @extension, @fileType, @sizeBytes, @folder, @depth, @createdAt, @modifiedAt, @targetPath, @indexedAt)` + ) + const indexedAt = Date.now() + const writeBatch = d.transaction((rows: IndexedFileRecord[]) => { + // Default the optional field so better-sqlite3 never sees `undefined`. + for (const r of rows) insert.run({ ...r, targetPath: r.targetPath ?? null, indexedAt }) + }) + for (let i = 0; i < records.length; i += 500) writeBatch(records.slice(i, i + 500)) +} + +export function clearIndexedFiles(): void { + get().prepare('DELETE FROM indexed_files').run() +} + +export function getFileIndexStats(): { filesIndexed: number; byType: Record } { + const total = get().prepare('SELECT COUNT(*) AS n FROM indexed_files').get() as { n: number } + const rows = get() + .prepare('SELECT file_type AS t, COUNT(*) AS n FROM indexed_files GROUP BY file_type') + .all() as { t: string; n: number }[] + const byType: Record = {} + for (const r of rows) byType[r.t] = r.n + return { filesIndexed: total.n, byType } +} + +// The indexed installed apps (Start-Menu .lnk shortcuts captured as +// file_type='application'), newest-modified first. Used by the renderer to +// synthesize "Uses " memories. modified_at is the .lnk mtime — an +// imperfect usage proxy (see appSelection.rankApps). +type IndexedAppRow = { name: string; path: string; modifiedAt: number; targetPath: string | null } + +export function getIndexedApps(limit = 200): IndexedAppRecord[] { + // Installed apps come ONLY from Start-Menu shortcuts (.lnk) — the Windows + // analog of /Applications. file_type='application' also covers loose .exe/.msi + // (installers in Downloads, venv script-shims, firmware updaters), which are + // NOT installed apps and otherwise dominate by recency. Restrict to .lnk. + const rows = get() + .prepare( + `SELECT filename AS name, path, modified_at AS modifiedAt, target_path AS targetPath + FROM indexed_files + WHERE file_type = 'application' AND extension = 'lnk' + ORDER BY modified_at DESC + LIMIT ?` + ) + .all(limit) as IndexedAppRow[] + return rows.map((r) => ({ + name: r.name, + path: r.path, + modifiedAt: r.modifiedAt, + targetPath: r.targetPath ?? undefined + })) +} + +// --- App usage (foreground-time tracking) --- + +// Add `seconds` of foreground time to an app, creating the row if needed and +// bumping distinct_days when `at` falls on a new local day. Called from the +// foreground monitor's flush loop. +export function addAppUsage(exePath: string, seconds: number, at: number): void { + if (seconds <= 0) return + const d = get() + const existing = d + .prepare( + 'SELECT total_seconds AS totalSeconds, last_used AS lastUsed, distinct_days AS distinctDays FROM app_usage WHERE exe_path = ?' + ) + .get(exePath) as { totalSeconds: number; lastUsed: number; distinctDays: number } | undefined + const exeName = basename(exePath) + const category: UsageCategory = categorize(exeName) + if (!existing) { + d.prepare( + `INSERT INTO app_usage (exe_path, exe_name, category, total_seconds, last_used, distinct_days, first_seen) + VALUES (?, ?, ?, ?, ?, 1, ?)` + ).run(exePath, exeName, category, Math.round(seconds), at, at) + return + } + const days = existing.distinctDays + (isNewLocalDay(existing.lastUsed, at) ? 1 : 0) + d.prepare( + 'UPDATE app_usage SET total_seconds = ?, last_used = ?, distinct_days = ?, category = ? WHERE exe_path = ?' + ).run(existing.totalSeconds + Math.round(seconds), at, days, category, exePath) +} + +// Seed a single app_usage row from historical UserAssist data at onboarding, so +// the first brain-map build ranks by REAL past foreground time (not install +// recency). Keyed by a synthetic `userassist:` exe_path so it never +// collides with live monitor rows (which key by the real exe path), and carries +// the friendly app NAME in exe_name (rankApps matches that to the indexed app). +// `at` is stamped as last_used/first_seen so retention keeps the snapshot for the +// full window. INSERT OR IGNORE: never clobber an existing (e.g. already-seeded) +// row. See usage/userAssist.ts. +export function seedAppUsage(name: string, seconds: number, at: number): void { + if (seconds <= 0 || !name.trim()) return + get() + .prepare( + `INSERT OR IGNORE INTO app_usage (exe_path, exe_name, category, total_seconds, last_used, distinct_days, first_seen) + VALUES (?, ?, ?, ?, ?, 1, ?)` + ) + .run(`userassist:${name}`, name, categorize(name), Math.round(seconds), at, at) +} + +export function listAppUsage(): AppUsageRecord[] { + return get() + .prepare( + `SELECT exe_path AS exePath, exe_name AS exeName, category, total_seconds AS totalSeconds, + last_used AS lastUsed, distinct_days AS distinctDays + FROM app_usage ORDER BY total_seconds DESC` + ) + .all() as AppUsageRecord[] +} + +// Drop app_usage rows last foregrounded before `cutoff` (ms epoch). Bounds table +// growth and stops long-unused apps from influencing the ranking. Returns the +// number of rows removed. +export function pruneAppUsage(cutoff: number): number { + return get().prepare('DELETE FROM app_usage WHERE last_used < ?').run(cutoff).changes +} + +// --- Local knowledge graph (M2) --- + +// Full-replace the local graph: clear both tables and batch-insert in a single +// transaction (matches replaceIndexedFiles cadence). 500-row batches keep +// large graphs off a single mega-statement. +export function replaceLocalGraph(graph: LocalKnowledgeGraph): void { + const d = get() + const insertNode = d.prepare( + `INSERT OR REPLACE INTO local_kg_nodes (id, label, node_type, summary, source, created_at, aliases_json, source_refs) + VALUES (@id, @label, @nodeType, @summary, @source, @createdAt, @aliasesJson, @sourceRefs)` + ) + const insertEdge = d.prepare( + `INSERT OR REPLACE INTO local_kg_edges (id, source_id, target_id, label, created_at) + VALUES (@id, @sourceId, @targetId, @label, @createdAt)` + ) + const write = d.transaction((g: LocalKnowledgeGraph) => { + d.prepare('DELETE FROM local_kg_edges').run() + d.prepare('DELETE FROM local_kg_nodes').run() + // Map each node to bind params: aliases/sourceRefs are arrays (not bindable), + // so JSON-encode them (or null). Avoids passing extra object keys too, which + // better-sqlite3 rejects. + for (const n of g.nodes) { + insertNode.run({ + id: n.id, + label: n.label, + nodeType: n.nodeType, + summary: n.summary, + source: n.source, + createdAt: n.createdAt, + aliasesJson: n.aliases?.length ? JSON.stringify(n.aliases) : null, + sourceRefs: n.sourceRefs?.length ? JSON.stringify(n.sourceRefs) : null + }) + } + for (const e of g.edges) insertEdge.run(e) + }) + write(graph) +} + +export function getLocalKGStatus(): LocalKGStatus { + const d = get() + const nodes = d.prepare('SELECT COUNT(*) AS n FROM local_kg_nodes').get() as { n: number } + const edges = d.prepare('SELECT COUNT(*) AS n FROM local_kg_edges').get() as { n: number } + const last = d.prepare('SELECT MAX(created_at) AS t FROM local_kg_nodes').get() as { + t: number | null + } + return { nodeCount: nodes.n, edgeCount: edges.n, lastBuiltAt: last.t ?? null } +} + +// Separate connection opened read-only so the chat agent's execute_sql tool +// physically cannot mutate the DB (defense in depth behind sqlGuard). Lazily +// created; reuses the same omi.db file. ensureSchema runs on the writable +// connection first (get()) so the file/tables exist before we open it. +function getReadonly(): Database.Database { + if (roDb) return roDb + get() // ensure the db file + schema exist before opening read-only + roDb = new Database(join(app.getPath('userData'), 'omi.db'), { readonly: true }) + return roDb +} + +// Run a single SELECT (caller MUST pass sqlGuard-validated SQL) and return +// columns + row objects. Throws on a non-SELECT or SQL error; callers treat that +// as "no context". The readonly connection makes writes impossible at the driver. +export function execSafeSelect(sql: string): KgSqlResult { + const stmt = getReadonly().prepare(sql) + const rows = stmt.all() as Record[] + const columns = rows.length + ? Object.keys(rows[0]) + : (stmt.columns().map((c) => c.name) ?? []) + return { columns, rows } +} + +type LocalKGNodeRow = { + id: string + label: string + nodeType: string + summary: string + source: string + createdAt: number + aliasesJson: string | null + sourceRefs: string | null +} + +// Nodes whose label/summary match q, plus every edge incident to a matched +// node. The query is tokenized on whitespace and matched as OR-of-LIKE per +// token, so a multi-word agent query ("projects work tasks") matches a node +// whose label/summary contains ANY token — not only the whole phrase. An empty +// query returns the most recent nodes (used by the chat fallback snapshot). +const SELECT_KG_NODE = + 'SELECT id, label, node_type AS nodeType, summary, source, created_at AS createdAt, aliases_json AS aliasesJson, source_refs AS sourceRefs FROM local_kg_nodes' + +// Parse a JSON string[] column, tolerating null/garbage. +function parseJsonArray(s: string | null): string[] | undefined { + if (!s) return undefined + try { + const v = JSON.parse(s) + return Array.isArray(v) ? (v as string[]) : undefined + } catch { + return undefined + } +} + +export function queryKgNodes(q: string, limit = 12): LocalKnowledgeGraph { + const d = get() + const tokens = q + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length >= 2) + let nodeRows: LocalKGNodeRow[] + if (tokens.length === 0) { + nodeRows = d + .prepare(`${SELECT_KG_NODE} ORDER BY created_at DESC LIMIT ?`) + .all(limit) as LocalKGNodeRow[] + } else { + const clause = tokens.map(() => '(label LIKE ? OR summary LIKE ?)').join(' OR ') + const params: unknown[] = [] + for (const t of tokens) params.push(`%${t}%`, `%${t}%`) + params.push(limit) + nodeRows = d + .prepare(`${SELECT_KG_NODE} WHERE ${clause} ORDER BY created_at DESC LIMIT ?`) + .all(...params) as LocalKGNodeRow[] + } + const nodes = nodeRows.map((r) => ({ + id: r.id, + label: r.label, + nodeType: r.nodeType as LocalKnowledgeGraph['nodes'][number]['nodeType'], + summary: r.summary, + source: r.source as LocalKnowledgeGraph['nodes'][number]['source'], + createdAt: r.createdAt, + aliases: parseJsonArray(r.aliasesJson), + sourceRefs: parseJsonArray(r.sourceRefs) + })) + if (nodes.length === 0) return { nodes: [], edges: [] } + const ids = nodes.map((n) => n.id) + const placeholders = ids.map(() => '?').join(',') + const edges = d + .prepare( + `SELECT id, source_id AS sourceId, target_id AS targetId, label, created_at AS createdAt + FROM local_kg_edges + WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders})` + ) + .all(...ids, ...ids) as LocalKnowledgeGraph['edges'] + return { nodes, edges } +} + +// indexed_files whose filename/folder match q. Excludes apps (file_type +// 'application') unless explicitly requested via fileType. +export function searchIndexedFiles( + q: string, + fileType?: string, + limit = 20 +): IndexedFileRecord[] { + const like = `%${q}%` + const cols = + 'path, filename, extension, file_type AS fileType, size_bytes AS sizeBytes, folder, depth, created_at AS createdAt, modified_at AS modifiedAt' + const d = get() + if (fileType) { + return d + .prepare( + `SELECT ${cols} FROM indexed_files + WHERE (filename LIKE ? OR folder LIKE ?) AND file_type = ? + ORDER BY modified_at DESC LIMIT ?` + ) + .all(like, like, fileType, limit) as IndexedFileRecord[] + } + return d + .prepare( + `SELECT ${cols} FROM indexed_files + WHERE (filename LIKE ? OR folder LIKE ?) AND file_type != 'application' + ORDER BY modified_at DESC LIMIT ?` + ) + .all(like, like, limit) as IndexedFileRecord[] +} + +// Aggregate indexed_files into a synthesis digest. Files exclude apps; apps are +// listed separately via getIndexedApps. +export function getFileIndexDigest(): FileIndexDigest { + const d = get() + const total = d + .prepare("SELECT COUNT(*) AS n FROM indexed_files WHERE file_type != 'application'") + .get() as { n: number } + const typeRows = d + .prepare( + "SELECT file_type AS t, COUNT(*) AS n FROM indexed_files WHERE file_type != 'application' GROUP BY file_type" + ) + .all() as { t: string; n: number }[] + const extRows = d + .prepare( + "SELECT extension AS e, COUNT(*) AS n FROM indexed_files WHERE file_type != 'application' AND extension != '' GROUP BY extension" + ) + .all() as { e: string; n: number }[] + const folderRows = d + .prepare( + `SELECT folder, COUNT(*) AS count FROM indexed_files + WHERE file_type != 'application' + GROUP BY folder ORDER BY count DESC LIMIT 15` + ) + .all() as { folder: string; count: number }[] + const sampleRows = d + .prepare( + `SELECT filename FROM indexed_files + WHERE file_type != 'application' + ORDER BY modified_at DESC LIMIT 20` + ) + .all() as { filename: string }[] + // Recently-active WORKING folders: the macOS-style "what are you working on + // now" signal. Only folders whose CODE/DOCUMENT files were modified in the + // last 30 days count, which filters out stale game/media folders (their + // recent files are config/other, not code/docs). Future-dated files + // (modified_at > now — bad mtimes like a 2050 stamp) are excluded so they + // can't masquerade as "recent". + const now = Date.now() + const since = now - 30 * 86_400_000 + const activeRows = d + .prepare( + `SELECT folder, COUNT(*) AS recentCount, MAX(modified_at) AS lastModified + FROM indexed_files + WHERE file_type IN ('code', 'document') + AND modified_at <= ? AND modified_at > ? + GROUP BY folder + ORDER BY recentCount DESC, lastModified DESC + LIMIT 15` + ) + .all(now, since) as { folder: string; recentCount: number; lastModified: number }[] + const byType: Record = {} + for (const r of typeRows) byType[r.t] = r.n + const byExtension: Record = {} + for (const r of extRows) byExtension[r.e] = r.n + return { + totalFiles: total.n, + byType, + byExtension, + topFolders: folderRows, + activeFolders: activeRows, + apps: getIndexedApps(100).map((a) => a.name), + sampleFiles: sampleRows.map((r) => r.filename) + } +} + +// --- Onboarding brain-map graph (sandbox/ui; mirrors macOS KnowledgeGraphStorage) --- +// Separate onboarding_kg_* tables from the chat-KG local_kg_* above. Returns the +// server-shaped KnowledgeGraph (memoryIds: []) so the brain-map renderer can +// consume it with the same shape as the backend graph. + +export function loadLocalGraph(): KnowledgeGraph { + const d = get() + const nodeRows = d + .prepare('SELECT node_id, label, node_type, aliases_json FROM onboarding_kg_nodes') + .all() as { node_id: string; label: string; node_type: string; aliases_json: string | null }[] + const edgeRows = d + .prepare('SELECT edge_id, source_id, target_id, label FROM onboarding_kg_edges') + .all() as { edge_id: string; source_id: string; target_id: string; label: string }[] + return { + nodes: nodeRows.map((r) => ({ + id: r.node_id, + label: r.label, + nodeType: r.node_type, + aliases: r.aliases_json ? (JSON.parse(r.aliases_json) as string[]) : [], + memoryIds: [] + })), + edges: edgeRows.map((r) => ({ + id: r.edge_id, + sourceId: r.source_id, + targetId: r.target_id, + label: r.label, + memoryIds: [] + })) + } +} + +// Idempotent upsert by id. Returns the full graph after writing so the renderer +// can update in one round-trip. +export function upsertLocalGraph( + nodes: OnboardingGraphNode[], + edges: OnboardingGraphEdge[] +): KnowledgeGraph { + const d = get() + const now = Date.now() + const insertNode = d.prepare( + `INSERT INTO onboarding_kg_nodes (node_id, label, node_type, aliases_json, created_at, updated_at) + VALUES (@id, @label, @nodeType, @aliasesJson, @now, @now) + ON CONFLICT(node_id) DO UPDATE SET label=@label, node_type=@nodeType, aliases_json=@aliasesJson, updated_at=@now` + ) + const insertEdge = d.prepare( + `INSERT INTO onboarding_kg_edges (edge_id, source_id, target_id, label, created_at) + VALUES (@id, @sourceId, @targetId, @label, @now) + ON CONFLICT(edge_id) DO UPDATE SET source_id=@sourceId, target_id=@targetId, label=@label` + ) + const write = d.transaction(() => { + for (const n of nodes) { + insertNode.run({ + id: n.id, + label: n.label, + nodeType: n.nodeType, + aliasesJson: n.aliases && n.aliases.length ? JSON.stringify(n.aliases) : null, + now + }) + } + for (const e of edges) { + insertEdge.run({ id: e.id, sourceId: e.sourceId, targetId: e.targetId, label: e.label, now }) + } + }) + write() + return loadLocalGraph() +} + +export function clearLocalGraph(): void { + const d = get() + d.prepare('DELETE FROM onboarding_kg_edges').run() + d.prepare('DELETE FROM onboarding_kg_nodes').run() +} + +// --- Rewind: screen-history timeline --- + +const REWIND_COLUMNS = + 'id, ts, app, window_title AS windowTitle, process_name AS processName, ocr_text AS ocrText, image_path AS imagePath, width, height, indexed' + +export function insertRewindFrame(f: Omit): number { + const r = get() + .prepare( + `INSERT INTO rewind_frames (ts, app, window_title, process_name, ocr_text, image_path, width, height, indexed) + VALUES (@ts, @app, @windowTitle, @processName, @ocrText, @imagePath, @width, @height, @indexed)` + ) + .run(f) + return r.lastInsertRowid as number +} + +export function listRewindFrames(from: number, to: number): RewindFrame[] { + return timed('listRewindFrames', () => + get() + .prepare(`SELECT ${REWIND_COLUMNS} FROM rewind_frames WHERE ts BETWEEN ? AND ? ORDER BY ts`) + .all(from, to) as RewindFrame[] + ) +} + +export function searchRewindFrames(query: string, limit = 500): RewindFrame[] { + return timed('searchRewindFrames', () => { + const like = `%${query}%` + return get() + .prepare( + `SELECT ${REWIND_COLUMNS} FROM rewind_frames + WHERE ocr_text LIKE ? OR window_title LIKE ? OR app LIKE ? + ORDER BY ts DESC LIMIT ?` + ) + .all(like, like, like, limit) as RewindFrame[] + }) +} + +export function rewindDayBounds(): { min: number; max: number } | null { + const row = get() + .prepare('SELECT MIN(ts) AS min, MAX(ts) AS max FROM rewind_frames') + .get() as { min: number | null; max: number | null } + return row.min == null || row.max == null ? null : { min: row.min, max: row.max } +} + +/** The single most-recent captured frame (Omi's own windows are never captured), + * used by the chat to read "what's on screen right now". null if none yet. */ +export function latestRewindFrame(): RewindFrame | null { + const row = get() + .prepare(`SELECT ${REWIND_COLUMNS} FROM rewind_frames ORDER BY ts DESC LIMIT 1`) + .get() as RewindFrame | undefined + return row ?? null +} + +export function unindexedRewindFrames(limit = 20): RewindFrame[] { + return get() + .prepare(`SELECT ${REWIND_COLUMNS} FROM rewind_frames WHERE indexed = 0 ORDER BY ts LIMIT ?`) + .all(limit) as RewindFrame[] +} + +export function setRewindFrameOcr(id: number, ocrText: string): void { + get().prepare('UPDATE rewind_frames SET ocr_text = ?, indexed = 1 WHERE id = ?').run(ocrText, id) +} + +export function deleteRewindFramesOlderThan(cutoffTs: number): RewindFrame[] { + const d = get() + const select = d.prepare(`SELECT ${REWIND_COLUMNS} FROM rewind_frames WHERE ts < ?`) + const del = d.prepare('DELETE FROM rewind_frames WHERE ts < ?') + const pruneOlderThan = d.transaction((cutoff: number) => { + const doomed = select.all(cutoff) as RewindFrame[] + del.run(cutoff) + return doomed // caller deletes the image files + }) + return pruneOlderThan(cutoffTs) +} + +// --- Proactive Insights --- + +const INSIGHT_COLUMNS = + 'id, ts, headline, advice, reasoning, category AS category, source_app AS sourceApp, confidence, dismissed' + +export function insertInsight(p: InsightPayload): number { + const info = get() + .prepare( + `INSERT INTO insights (ts, headline, advice, reasoning, category, source_app, confidence) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run(Date.now(), p.headline, p.advice, p.reasoning, p.category, p.sourceApp, p.confidence) + return info.lastInsertRowid as number +} + +export function recentInsights(limit = 30): InsightRecord[] { + return get() + .prepare(`SELECT ${INSIGHT_COLUMNS} FROM insights ORDER BY ts DESC LIMIT ?`) + .all(limit) as InsightRecord[] +} diff --git a/windows/src/main/ipc/fileIndex.ts b/windows/src/main/ipc/fileIndex.ts new file mode 100644 index 0000000000..047a68a950 --- /dev/null +++ b/windows/src/main/ipc/fileIndex.ts @@ -0,0 +1,9 @@ +import { ipcMain } from 'electron' +import { runFileIndex, getStatus } from '../fileIndex/indexer' +import { getIndexedApps } from './db' + +export function registerFileIndexHandlers(): void { + ipcMain.handle('fileIndex:scan', async () => runFileIndex()) + ipcMain.handle('fileIndex:status', async () => getStatus()) + ipcMain.handle('fileIndex:apps', async (_e, limit?: number) => getIndexedApps(limit)) +} diff --git a/windows/src/main/ipc/insight.ts b/windows/src/main/ipc/insight.ts new file mode 100644 index 0000000000..2fdcdcdcbe --- /dev/null +++ b/windows/src/main/ipc/insight.ts @@ -0,0 +1,45 @@ +// src/main/ipc/insight.ts +import { ipcMain } from 'electron' +import { getInsightSettings, updateInsightSettings } from '../insight/state' +import { + showInsightToast, + hideInsightToast, + pauseInsightDismiss, + resumeInsightDismiss +} from '../insight/toastWindow' +import { fireNativeInsight } from '../insight/notification' +import { insertInsight, recentInsights } from './db' +import type { InsightPayload, InsightSettings } from '../../shared/types' + +// Show an insight using the user's chosen style: the in-app acrylic toast +// ('omi') or a native Windows notification ('native'). +function deliverInsight(p: InsightPayload): void { + if (getInsightSettings().notificationStyle === 'native') fireNativeInsight(p) + else showInsightToast(p) +} + +export function registerInsightHandlers(): void { + ipcMain.handle('insight:getSettings', async () => getInsightSettings()) + ipcMain.handle('insight:setSettings', async (_e, patch: Partial) => + updateInsightSettings(patch) + ) + ipcMain.handle('insight:add', async (_e, p: InsightPayload) => { + insertInsight(p) + }) + ipcMain.handle('insight:recent', async (_e, limit: number) => recentInsights(limit)) + ipcMain.on('insight:show', (_e, p: InsightPayload) => deliverInsight(p)) + ipcMain.on('insight:dismiss', () => hideInsightToast()) + ipcMain.on('insight:hoverStart', () => pauseInsightDismiss()) + ipcMain.on('insight:hoverEnd', () => resumeInsightDismiss()) + // Settings "test notification": show an example in the user's chosen style. + ipcMain.on('insight:test', () => + deliverInsight({ + headline: 'Test notification', + advice: 'If you can see this, Omi notifications are working.', + reasoning: 'Triggered from Settings.', + category: 'other', + sourceApp: 'Omi', + confidence: 1 + }) + ) +} diff --git a/windows/src/main/ipc/integrations.ts b/windows/src/main/ipc/integrations.ts new file mode 100644 index 0000000000..28f8d7f2e8 --- /dev/null +++ b/windows/src/main/ipc/integrations.ts @@ -0,0 +1,79 @@ +import { ipcMain } from 'electron' +import { readStickyNotes } from '../integrations/stickyNotes' +import { connect, disconnect, isConnected, connectedEmail } from '../integrations/oauth' +import { fetchGmail, fetchCalendar } from '../integrations/google' +import { + getSourceState, + markProcessed, + lastSyncAt, + clearSyncState +} from '../integrations/syncState' +import { filterNew } from '../integrations/syncStateLogic' +import type { + GoogleStatus, + GoogleSource, + FetchNewResult, + GmailItem, + CalendarItem +} from '../../shared/types' + +// All integrations IPC lives here (3e Sticky Notes + 3d Gmail/Calendar) so +// concurrent chat/KG work doesn't conflict in index.ts. +function googleStatus(): GoogleStatus { + const connected = isConnected() + return { + connected, + email: connected ? connectedEmail() : undefined, + lastSyncAt: connected ? lastSyncAt() || undefined : undefined + } +} + +export function registerIntegrationsHandlers(): void { + ipcMain.handle('integrations:stickyNotes:read', async () => readStickyNotes()) + + ipcMain.handle('integrations:google:connect', async (): Promise => { + await connect() + return googleStatus() + }) + + ipcMain.handle('integrations:google:disconnect', async (): Promise => { + disconnect() + clearSyncState() + return googleStatus() + }) + + ipcMain.handle('integrations:google:status', async (): Promise => googleStatus()) + + ipcMain.handle( + 'integrations:google:gmailFetchNew', + async (): Promise> => { + if (!isConnected()) return { ok: false, items: [], error: 'not_connected' } + try { + const all = await fetchGmail() + return { ok: true, items: filterNew(all, getSourceState('gmail').processedIds) } + } catch (e) { + return { ok: false, items: [], error: (e as Error).message } + } + } + ) + + ipcMain.handle( + 'integrations:google:calendarFetchNew', + async (): Promise> => { + if (!isConnected()) return { ok: false, items: [], error: 'not_connected' } + try { + const all = await fetchCalendar() + return { ok: true, items: filterNew(all, getSourceState('calendar').processedIds) } + } catch (e) { + return { ok: false, items: [], error: (e as Error).message } + } + } + ) + + ipcMain.handle( + 'integrations:google:markProcessed', + async (_e, source: GoogleSource, ids: string[]): Promise => { + markProcessed(source, ids) + } + ) +} diff --git a/windows/src/main/ipc/kg.ts b/windows/src/main/ipc/kg.ts new file mode 100644 index 0000000000..a2a9800d25 --- /dev/null +++ b/windows/src/main/ipc/kg.ts @@ -0,0 +1,29 @@ +import { ipcMain } from 'electron' +import { + execSafeSelect, + getFileIndexDigest, + getLocalKGStatus, + queryKgNodes, + replaceLocalGraph, + searchIndexedFiles +} from './db' +import { guardSelect } from '../../shared/sqlGuard' +import type { LocalKnowledgeGraph } from '../../shared/types' + +// All local-knowledge-graph IPC. Kept in this dedicated module so registration +// is a single append in index.ts (conflict discipline with the concurrent +// integrations/Settings work). +export function registerKgHandlers(): void { + ipcMain.handle('kg:fileIndexDigest', async () => getFileIndexDigest()) + ipcMain.handle('kg:saveGraph', async (_e, graph: LocalKnowledgeGraph) => replaceLocalGraph(graph)) + ipcMain.handle('kg:status', async () => getLocalKGStatus()) + ipcMain.handle('kg:queryNodes', async (_e, q: string, limit?: number) => queryKgNodes(q, limit)) + ipcMain.handle('kg:searchFiles', async (_e, q: string, fileType?: string, limit?: number) => + searchIndexedFiles(q, fileType, limit) + ) + // The chat agent writes SQL here. guardSelect validates read-only + single + // statement; execSafeSelect runs it on a readonly connection (defense in depth). + ipcMain.handle('kg:executeSql', async (_e, sql: string) => + execSafeSelect(guardSelect(String(sql ?? ''))) + ) +} diff --git a/windows/src/main/ipc/localGraph.ts b/windows/src/main/ipc/localGraph.ts new file mode 100644 index 0000000000..81c667faa4 --- /dev/null +++ b/windows/src/main/ipc/localGraph.ts @@ -0,0 +1,11 @@ +import { ipcMain } from 'electron' +import { loadLocalGraph, upsertLocalGraph, clearLocalGraph } from './db' +import type { OnboardingGraphNode, OnboardingGraphEdge } from '../../shared/types' + +export function registerLocalGraphHandlers(): void { + ipcMain.handle('localGraph:load', async () => loadLocalGraph()) + ipcMain.handle('localGraph:upsert', async (_e, nodes: OnboardingGraphNode[], edges: OnboardingGraphEdge[]) => + upsertLocalGraph(nodes ?? [], edges ?? []) + ) + ipcMain.handle('localGraph:clear', async () => clearLocalGraph()) +} diff --git a/windows/src/main/ipc/memoryCleanup.ts b/windows/src/main/ipc/memoryCleanup.ts new file mode 100644 index 0000000000..cf33027c61 --- /dev/null +++ b/windows/src/main/ipc/memoryCleanup.ts @@ -0,0 +1,102 @@ +import { ipcMain, net, type IpcMainInvokeEvent } from 'electron' +import { classifyStatus, backoffMs, type DeleteOutcome } from '../memoryCleanup/bulkDelete' + +// Bulk-delete memories from the main process so the job survives renderer +// navigation / reloads and never blocks the UI thread. The renderer passes the +// API base + a fresh Firebase token + the ids to remove; we drain them with a +// small worker pool, retrying 429/5xx with backoff, and stream progress back. + +export type BulkDeleteArgs = { baseURL: string; token: string; ids: string[] } +export type BulkDeleteResult = { deleted: number; failed: number; firstError?: string } + +const CONCURRENCY = 4 +const MAX_ATTEMPTS = 6 +const PROGRESS_EVERY = 25 +const REQUEST_TIMEOUT_MS = 15_000 + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +// Result of one delete, with a human-readable reason when it failed (so a wall +// of 0-deleted has an explanation — wrong status, expired token, network). +type DeleteResult = { outcome: DeleteOutcome; reason?: string } + +async function deleteOne(baseURL: string, token: string, id: string): Promise { + let lastReason = '' + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + let outcome: DeleteOutcome + let retryAfter: string | null = null + // Electron's net.fetch uses Chromium's network stack (proxy/TLS aware) — the + // same path the renderer's axios uses successfully. Node's global fetch + // stalled here. AbortController caps a hung request so it can't block a worker. + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS) + try { + const res = await net.fetch(`${baseURL}/v3/memories/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + signal: ctrl.signal + }) + outcome = classifyStatus(res.status) + retryAfter = res.headers.get('retry-after') + if (outcome !== 'ok' && outcome !== 'gone') { + const body = await res.text().catch(() => '') + lastReason = `HTTP ${res.status} ${body.slice(0, 160)}`.trim() + } + } catch (e) { + outcome = 'retry' + lastReason = `network: ${(e as Error).message}` + } finally { + clearTimeout(timer) + } + if (outcome === 'ok' || outcome === 'gone') return { outcome } + if (outcome === 'fail') return { outcome, reason: lastReason } + if (attempt < MAX_ATTEMPTS) await sleep(backoffMs(attempt, retryAfter)) + } + return { outcome: 'fail', reason: lastReason } +} + +export function registerMemoryCleanupHandlers(): void { + ipcMain.handle( + 'memories:bulkDelete', + async (e: IpcMainInvokeEvent, args: BulkDeleteArgs): Promise => { + const { baseURL, token } = args + // Defensive dedupe: if the analyze pagination ever repeats a page, the id + // list can contain duplicates that would inflate the "total". + const ids = [...new Set(args.ids)] + let deleted = 0 + let failed = 0 + let cursor = 0 + let firstError = '' + + const emit = (done = false): void => { + if (!e.sender.isDestroyed()) { + e.sender.send('memories:deleteProgress', { deleted, failed, total: ids.length, done }) + } + } + + const worker = async (): Promise => { + while (cursor < ids.length) { + const id = ids[cursor++] + const { outcome, reason } = await deleteOne(baseURL, token, id) + if (outcome === 'ok' || outcome === 'gone') deleted++ + else { + failed++ + if (!firstError && reason) { + firstError = reason + // Surface the very first failure reason loudly in the terminal — + // far easier to copy than a toast when diagnosing "0 deleted". + console.error(`[memcleanup] delete FAILED for id=${id}: ${reason}`) + } + } + if ((deleted + failed) % PROGRESS_EVERY === 0) emit() + } + } + + console.log(`[memcleanup] starting: ${ids.length} ids, baseURL=${baseURL}, token.len=${token.length}`) + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())) + console.log(`[memcleanup] done: deleted=${deleted} failed=${failed}${firstError ? ` firstError="${firstError}"` : ''}`) + emit(true) + return { deleted, failed, firstError: firstError || undefined } + } + ) +} diff --git a/windows/src/main/ipc/memoryExport.ts b/windows/src/main/ipc/memoryExport.ts new file mode 100644 index 0000000000..481f309d43 --- /dev/null +++ b/windows/src/main/ipc/memoryExport.ts @@ -0,0 +1,47 @@ +import { dialog, ipcMain } from 'electron' +import { exportToObsidian } from '../memoryExport/obsidian' +import { exportToFile } from '../memoryExport/plainFile' +import { exportToNotion } from '../memoryExport/notion' +import type { ExportMemory, MemoryExportResult } from '../../shared/types' + +// The renderer fetches memories (it owns the API token) and hands them here for +// the file-writing / Notion targets, which need main-process fs + network. +export function registerMemoryExportHandlers(): void { + ipcMain.handle( + 'memoryExport:obsidian', + async (_e, memories: ExportMemory[]): Promise => { + const r = await dialog.showOpenDialog({ + title: 'Choose your Obsidian vault folder', + properties: ['openDirectory'] + }) + if (r.canceled || r.filePaths.length === 0) return { canceled: true, count: 0 } + const location = await exportToObsidian(r.filePaths[0], memories) + return { count: memories.length, location } + } + ) + + ipcMain.handle( + 'memoryExport:file', + async (_e, memories: ExportMemory[]): Promise => { + const r = await dialog.showSaveDialog({ + title: 'Export memories', + defaultPath: 'Omi-Memories.md', + filters: [{ name: 'Markdown', extensions: ['md'] }] + }) + if (r.canceled || !r.filePath) return { canceled: true, count: 0 } + const location = await exportToFile(r.filePath, memories) + return { count: memories.length, location } + } + ) + + ipcMain.handle( + 'memoryExport:notion', + async ( + _e, + args: { token: string; parentPageId: string; memories: ExportMemory[] } + ): Promise => { + const location = await exportToNotion(args.token, args.parentPageId, args.memories) + return { count: args.memories.length, location } + } + ) +} diff --git a/windows/src/main/ipc/memoryImport.ts b/windows/src/main/ipc/memoryImport.ts new file mode 100644 index 0000000000..f3da460679 --- /dev/null +++ b/windows/src/main/ipc/memoryImport.ts @@ -0,0 +1,8 @@ +import { ipcMain } from 'electron' +import { parseMemoryDump } from '../memoryImport/parse' + +// Parsing only: the renderer owns the Firebase token, so it does the actual +// POST /v3/memories with the strings returned here. +export function registerMemoryImportHandlers(): void { + ipcMain.handle('memoryImport:parse', async (_e, dump: string) => parseMemoryDump(dump)) +} diff --git a/windows/src/main/ipc/ocr.ts b/windows/src/main/ipc/ocr.ts new file mode 100644 index 0000000000..e5c00acd64 --- /dev/null +++ b/windows/src/main/ipc/ocr.ts @@ -0,0 +1,7 @@ +import { helperProcess } from '../ocr/helperProcess' +import type { OcrResult } from '../../shared/types' + +/** Run Windows OCR on a JPEG frame. `jpeg` arrives as an ArrayBuffer over IPC. */ +export async function ocrRecognize(jpeg: ArrayBuffer): Promise { + return helperProcess.ocr(Buffer.from(jpeg)) +} diff --git a/windows/src/main/ipc/omiListen.ts b/windows/src/main/ipc/omiListen.ts new file mode 100644 index 0000000000..ab5671230c --- /dev/null +++ b/windows/src/main/ipc/omiListen.ts @@ -0,0 +1,145 @@ +import { ipcMain, WebContents, webContents } from 'electron' +import WebSocket from 'ws' +import type { + BackendSegment, + ListenEvent, + ListenMessage, + ListenStartArgs +} from '../../shared/types' + +function buildEndpoint(language: string): string { + return ( + 'wss://api.omi.me/v4/listen' + + `?language=${encodeURIComponent(language || 'en')}` + + '&sample_rate=16000' + + '&codec=pcm16' + + '&channels=1' + + '&include_speech_profile=true' + + '&source=desktop' + + '&speaker_auto_assign=enabled' + ) +} + +type Session = { + ws: WebSocket + ownerId: number // webContents id for routing replies back + source: 'mic' | 'system' + closed: boolean +} + +const sessions = new Map() + +function emit(ownerId: number, msg: ListenMessage): void { + const wc = webContents.fromId(ownerId) + if (wc && !wc.isDestroyed()) { + wc.send('omi-listen:message', msg) + } +} + +function startSession(args: ListenStartArgs, owner: WebContents): void { + const existing = sessions.get(args.sessionId) + if (existing) { + // Already running — caller bug. Tear the old one down to avoid leaks. + try { existing.ws.close() } catch { /* ignore */ } + sessions.delete(args.sessionId) + } + // Decode (not verify) the JWT to derive the uid for the query param; the + // backend verifies the token from the Authorization header. + let uid = '' + try { + const payload = JSON.parse( + Buffer.from(args.token.split('.')[1] ?? '', 'base64').toString('utf8') + ) + uid = payload.user_id ?? payload.sub ?? '' + } catch { + // Token not decodable; uid stays empty (the backend also reads the + // Authorization header). + } + // Official docs require `uid` as a query param; backend source also reads the + // Firebase token from the Authorization header. Send both. + const base = buildEndpoint(args.language) + const url = uid ? `${base}&uid=${encodeURIComponent(uid)}` : base + const ws = new WebSocket(url, { + headers: { Authorization: `Bearer ${args.token}` } + }) + ws.binaryType = 'arraybuffer' + const session: Session = { ws, ownerId: owner.id, source: args.source, closed: false } + sessions.set(args.sessionId, session) + + ws.on('open', () => { + emit(session.ownerId, { sessionId: args.sessionId, kind: 'connected' }) + }) + + ws.on('message', (data, isBinary) => { + if (isBinary) return // v4/listen sends text only; ignore stray binary + const text = data.toString().trim() + if (text === 'ping' || text === '') return + let json: unknown + try { + json = JSON.parse(text) + } catch { + return + } + if (Array.isArray(json)) { + emit(session.ownerId, { + sessionId: args.sessionId, + kind: 'segments', + segments: json as BackendSegment[] + }) + return + } + if (json && typeof json === 'object' && 'type' in (json as object)) { + const obj = json as Record + const event: ListenEvent = { type: String(obj.type), raw: obj } + emit(session.ownerId, { sessionId: args.sessionId, kind: 'event', event }) + } + }) + + ws.on('error', (err) => { + emit(session.ownerId, { + sessionId: args.sessionId, + kind: 'error', + message: err.message, + fatal: ws.readyState !== WebSocket.OPEN + }) + }) + + ws.on('close', (code, reasonBuf) => { + if (session.closed) return + session.closed = true + sessions.delete(args.sessionId) + emit(session.ownerId, { + sessionId: args.sessionId, + kind: 'closed', + code, + reason: reasonBuf.toString() + }) + }) +} + +function feedSession(sessionId: string, pcm: ArrayBuffer): void { + const s = sessions.get(sessionId) + if (!s || s.ws.readyState !== WebSocket.OPEN) return + s.ws.send(pcm) +} + +function stopSession(sessionId: string): void { + const s = sessions.get(sessionId) + if (!s) return + s.closed = true + sessions.delete(sessionId) + try { s.ws.close() } catch { /* ignore */ } +} + +export function registerOmiListenHandlers(): void { + ipcMain.handle('omi-listen:start', (e, args: ListenStartArgs) => { + startSession(args, e.sender) + }) + ipcMain.handle('omi-listen:stop', (_e, sessionId: string) => { + stopSession(sessionId) + }) + // `on` (not `handle`) — feed is fire-and-forget to keep audio throughput cheap. + ipcMain.on('omi-listen:feed', (_e, sessionId: string, pcm: ArrayBuffer) => { + feedSession(sessionId, pcm) + }) +} diff --git a/windows/src/main/ipc/rewind.ts b/windows/src/main/ipc/rewind.ts new file mode 100644 index 0000000000..abbe537156 --- /dev/null +++ b/windows/src/main/ipc/rewind.ts @@ -0,0 +1,58 @@ +import { ipcMain, BrowserWindow } from 'electron' +import { readFile } from 'fs/promises' +import { resolve, sep } from 'path' +import { getPrimarySourceId } from '../rewind/sourceId' +import { + listRewindFrames, + searchRewindFrames, + rewindDayBounds +} from './db' +import { groupFrames } from '../rewind/rewindGrouping' +import { + getRewindSettings, + updateRewindSettings, + ingestRewindFrame +} from '../rewind/captureService' +import { pruneRewindOnce } from '../rewind/retentionRunner' +import { rewindRoot } from '../rewind/paths' +import type { RewindSettings } from '../../shared/types' + +export function registerRewindHandlers(): void { + ipcMain.handle('rewind:frames', async (_e, from: number, to: number) => listRewindFrames(from, to)) + ipcMain.handle('rewind:dayBounds', async () => rewindDayBounds()) + ipcMain.handle('rewind:search', async (_e, query: string) => { + const q = query.trim() + if (!q) return [] + return groupFrames(searchRewindFrames(q), q) + }) + ipcMain.handle('rewind:frameImage', async (_e, imagePath: string) => { + const root = resolve(rewindRoot()) + const full = resolve(imagePath) + if (full !== root && !full.startsWith(root + sep)) { + throw new Error('invalid frame path') + } + const buf = await readFile(full) + return `data:image/jpeg;base64,${buf.toString('base64')}` + }) + ipcMain.handle('rewind:getSettings', async () => getRewindSettings()) + ipcMain.handle('rewind:setSettings', async (_e, next: RewindSettings) => { + updateRewindSettings(next) + const current = getRewindSettings() + // Notify the renderer capture host so it can start/stop the stream and + // re-pace immediately, without waiting for a re-mount or a poll. + for (const w of BrowserWindow.getAllWindows()) { + w.webContents.send('rewind:settings', current) + } + return current + }) + ipcMain.handle('rewind:pruneNow', async () => pruneRewindOnce()) + // Cached primary-screen id. The underlying desktopCapturer.getSources() can + // take several seconds on some machines, so it's prewarmed at startup; this + // is an instant cache hit in the normal case. + ipcMain.handle('rewind:primarySourceId', async () => getPrimarySourceId()) + // Receive a sampled JPEG frame from the renderer capture host and store it + // (after foreground-window metadata + idle/lock/dup gating). + ipcMain.handle('rewind:saveFrame', async (_e, data: Uint8Array) => + ingestRewindFrame(Buffer.from(data)) + ) +} diff --git a/windows/src/main/ipc/screen.ts b/windows/src/main/ipc/screen.ts new file mode 100644 index 0000000000..3823954edf --- /dev/null +++ b/windows/src/main/ipc/screen.ts @@ -0,0 +1,84 @@ +import { ipcMain, desktopCapturer } from 'electron' +import { readFile } from 'fs/promises' +import { helperProcess } from '../ocr/helperProcess' +import { getPrimarySourceId } from '../rewind/sourceId' +import { getCurrentScreen, screenCacheFresh } from '../rewind/currentScreen' +import { latestRewindFrame } from './db' + +// Overall cap for a single read. The fast path (latest Rewind frame) is near- +// instant; this is the backstop for the desktopCapturer fallback so a wedged +// capture can never hang the chat send. +const READ_TIMEOUT_MS = 4500 + +// Last-resort: capture the primary screen via desktopCapturer and OCR it. Slow +// (getSources can take seconds) and would include Omi's own window, so it's only +// used when Rewind has no frame yet (capture just enabled / disabled). +async function desktopCapturerOcr(): Promise { + try { + const primaryId = await getPrimarySourceId().catch(() => null) + const sources = await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 1920, height: 1080 } + }) + const source = (primaryId ? sources.find((s) => s.id === primaryId) : undefined) ?? sources[0] + if (!source || source.thumbnail.isEmpty()) return '' + const res = await helperProcess.ocr(source.thumbnail.toJPEG(80)) + return res.ok ? res.fullText : '' + } catch { + return '' + } +} + +// Read "what's on screen right now" as OCR text. The FAST path is the hot in-memory +// cache (currentScreen), kept ~1s fresh in the background by the capture pipeline — +// an instant read, which is what makes the chat feel like it's looking live. The +// cold-start fallbacks below only run before the cache has been seeded (capture just +// enabled / app just started): the latest stored frame's OCR, then a one-off +// desktopCapturer grab as a last resort. +async function readScreenText(): Promise { + const cached = getCurrentScreen() + if (cached.text && cached.text.trim() && screenCacheFresh(Date.now())) { + console.log( + `[screen:readNow] cache hit ${Math.round((Date.now() - cached.ts) / 1000)}s old, ${cached.text.length} chars` + ) + return cached.text + } + // Seeded this session but stale (capture paused on idle/lock/excluded-app) — don't + // pass off old text as the screen "right now"; send nothing this message. + if (cached.ts !== 0) { + console.log( + `[screen:readNow] stale cache ${Math.round((Date.now() - cached.ts) / 1000)}s old; skipping (capture paused)` + ) + return '' + } + + // Cold cache — never seeded this session — seed from the most-recent stored frame's OCR if present. + const frame = latestRewindFrame() + if (frame?.ocrText && frame.ocrText.trim()) { + console.log(`[screen:readNow] cold cache; latest frame OCR ${frame.ocrText.length} chars`) + return frame.ocrText + } + // The stored frame exists but isn't OCR'd yet — OCR it once to bootstrap. + if (frame) { + try { + const jpeg = await readFile(frame.imagePath) + const res = await helperProcess.ocr(jpeg) + if (res.ok) return res.fullText + } catch { + /* fall through to desktopCapturer */ + } + } + // No usable Rewind frame at all (capture off / just started) — last resort. + const text = await desktopCapturerOcr() + console.log(`[screen:readNow] cold cache; desktopCapturer fallback ${text.length} chars`) + return text +} + +export function registerScreenHandlers(): void { + ipcMain.handle('screen:readNow', async () => + Promise.race([ + readScreenText(), + new Promise((resolve) => setTimeout(() => resolve(''), READ_TIMEOUT_MS)) + ]) + ) +} diff --git a/windows/src/main/ipc/screenSynth.ts b/windows/src/main/ipc/screenSynth.ts new file mode 100644 index 0000000000..bbb6571283 --- /dev/null +++ b/windows/src/main/ipc/screenSynth.ts @@ -0,0 +1,36 @@ +// src/main/ipc/screenSynth.ts +import { ipcMain } from 'electron' +import { listRewindFrames } from './db' +import { + getScreenSynthState, + updateScreenSynthState, + advanceWatermark, + recordRun +} from '../screenSynth/state' +import type { ScreenFrameLite, ScreenSynthState, ScreenSynthRun } from '../../shared/types' + +export function registerScreenSynthHandlers(): void { + // Frames since the watermark, stripped to the fields synthesis needs (no image bytes). + ipcMain.handle('screenSynth:framesSince', async (): Promise => { + const { watermarkTs } = getScreenSynthState() + // +1 so we never re-emit the exact watermark frame. + const frames = listRewindFrames(watermarkTs + 1, Date.now()) + return frames.map((f) => ({ + ts: f.ts, + app: f.app, + windowTitle: f.windowTitle, + processName: f.processName, + ocrText: f.ocrText + })) + }) + ipcMain.handle('screenSynth:getState', async () => getScreenSynthState()) + ipcMain.handle('screenSynth:setState', async (_e, patch: Partial) => + updateScreenSynthState(patch) + ) + ipcMain.handle('screenSynth:advanceWatermark', async (_e, ts: number) => { + if (typeof ts === 'number' && ts > 0) advanceWatermark(ts) + }) + ipcMain.handle('screenSynth:recordRun', async (_e, run: ScreenSynthRun) => + recordRun(run.lastRunAt, run.lastCount) + ) +} diff --git a/windows/src/main/ipc/usage.ts b/windows/src/main/ipc/usage.ts new file mode 100644 index 0000000000..82446d9309 --- /dev/null +++ b/windows/src/main/ipc/usage.ts @@ -0,0 +1,35 @@ +import { ipcMain } from 'electron' +import { listAppUsage } from './db' +import { getUsageSettings, setUsageSettings } from '../usage/usageSettings' +import { + flushForegroundMonitor, + pruneUsageNow, + startForegroundMonitor, + stopForegroundMonitor +} from '../usage/foregroundMonitor' +import { seedUserAssistOnce } from '../usage/userAssistSeed' +import type { UsageSettings } from '../../shared/types' + +export function registerUsageHandlers(): void { + ipcMain.handle('usage:list', async () => listAppUsage()) + // Force an immediate flush of the in-memory tally, then return the fresh rows. + ipcMain.handle('usage:flush', async () => { + flushForegroundMonitor() + return listAppUsage() + }) + ipcMain.handle('usage:getSettings', async () => getUsageSettings()) + ipcMain.handle('usage:setSettings', async (_e, next: UsageSettings) => { + const saved = setUsageSettings(next) + if (saved.enabled) { + // First time tracking is turned on, seed historical usage (once-guarded). + seedUserAssistOnce() + startForegroundMonitor() + // Apply a changed retention window now (startForegroundMonitor no-ops when + // already running, so it wouldn't re-prune on its own). + pruneUsageNow() + } else { + stopForegroundMonitor() + } + return saved + }) +} diff --git a/windows/src/main/memoryCleanup/bulkDelete.test.ts b/windows/src/main/memoryCleanup/bulkDelete.test.ts new file mode 100644 index 0000000000..b71cb1e3a4 --- /dev/null +++ b/windows/src/main/memoryCleanup/bulkDelete.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { classifyStatus, backoffMs } from './bulkDelete' + +describe('classifyStatus', () => { + it('treats 2xx as ok', () => { + expect(classifyStatus(200)).toBe('ok') + expect(classifyStatus(204)).toBe('ok') + }) + it('treats 404 as gone (idempotent success)', () => { + expect(classifyStatus(404)).toBe('gone') + }) + it('treats 429 and 5xx as retry', () => { + expect(classifyStatus(429)).toBe('retry') + expect(classifyStatus(500)).toBe('retry') + expect(classifyStatus(503)).toBe('retry') + }) + it('treats auth/client errors as fail', () => { + expect(classifyStatus(401)).toBe('fail') + expect(classifyStatus(400)).toBe('fail') + }) +}) + +describe('backoffMs', () => { + it('honors a numeric Retry-After header (seconds -> ms, capped)', () => { + expect(backoffMs(1, '2')).toBe(2000) + expect(backoffMs(5, '120')).toBe(60_000) // capped + }) + it('falls back to exponential backoff with jitter when no header', () => { + expect(backoffMs(1)).toBeGreaterThanOrEqual(1000) + expect(backoffMs(1)).toBeLessThan(1400) + expect(backoffMs(3)).toBeGreaterThanOrEqual(4000) + expect(backoffMs(99)).toBeLessThan(16_400) // capped at 16s + jitter + }) + it('ignores a non-numeric Retry-After', () => { + expect(backoffMs(1, 'soon')).toBeGreaterThanOrEqual(1000) + }) +}) diff --git a/windows/src/main/memoryCleanup/bulkDelete.ts b/windows/src/main/memoryCleanup/bulkDelete.ts new file mode 100644 index 0000000000..7cdd31b035 --- /dev/null +++ b/windows/src/main/memoryCleanup/bulkDelete.ts @@ -0,0 +1,26 @@ +// Pure helpers for the bulk memory-delete job. The HTTP loop lives in the IPC +// handler (ipc/memoryCleanup.ts); these decision functions are unit-tested. + +export type DeleteOutcome = 'ok' | 'gone' | 'retry' | 'fail' + +// Classify a DELETE /v3/memories/:id response. +// - 2xx -> ok (deleted) +// - 404 -> gone (already deleted; idempotent success, not a failure) +// - 429/5xx -> retry (rate-limited / transient) +// - everything else (401 expired token, 400, …) -> fail (don't spin on it) +export function classifyStatus(status: number): DeleteOutcome { + if (status >= 200 && status < 300) return 'ok' + if (status === 404) return 'gone' + if (status === 429 || (status >= 500 && status < 600)) return 'retry' + return 'fail' +} + +// Milliseconds to wait before retry `attempt` (1-based). Honors a numeric +// Retry-After (seconds) header when the server sends one; otherwise exponential +// backoff capped at 16s with jitter so a fleet of workers doesn't resynchronize. +export function backoffMs(attempt: number, retryAfter?: string | null): number { + const ra = Number(retryAfter) + if (Number.isFinite(ra) && ra > 0) return Math.min(ra * 1000, 60_000) + const base = Math.min(1000 * 2 ** Math.max(0, attempt - 1), 16_000) + return base + Math.floor(Math.random() * 400) +} diff --git a/windows/src/main/memoryExport/format.test.ts b/windows/src/main/memoryExport/format.test.ts new file mode 100644 index 0000000000..dddde106d5 --- /dev/null +++ b/windows/src/main/memoryExport/format.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { formatMemoriesMarkdown } from './format' + +describe('formatMemoriesMarkdown', () => { + const at = new Date('2026-06-03T12:00:00Z') + + it('renders a title, export stamp, and category groups', () => { + const md = formatMemoriesMarkdown( + [ + { content: 'Has two cats', category: 'Personal' }, + { content: 'Prefers TypeScript', category: 'Work' }, + { content: 'Lives in Seattle', category: 'Personal' } + ], + at + ) + expect(md).toBe( + `# Omi Memories + +_Exported 2026-06-03 · 3 memories_ + +## Personal + +- Has two cats +- Lives in Seattle + +## Work + +- Prefers TypeScript +` + ) + }) + + it('falls back to "Other" when category is missing', () => { + const md = formatMemoriesMarkdown([{ content: 'No category here' }], at) + expect(md).toContain('## Other') + expect(md).toContain('- No category here') + expect(md).toContain('· 1 memory_') + }) + + it('collapses newlines inside a memory into one bullet', () => { + const md = formatMemoriesMarkdown([{ content: 'line one\n line two' }], at) + expect(md).toContain('- line one line two') + }) +}) diff --git a/windows/src/main/memoryExport/format.ts b/windows/src/main/memoryExport/format.ts new file mode 100644 index 0000000000..d61fde949b --- /dev/null +++ b/windows/src/main/memoryExport/format.ts @@ -0,0 +1,27 @@ +import type { ExportMemory } from '../../shared/types' + +// Render memories as a single Markdown document, grouped by category, used by +// the Obsidian and plain-file targets (Notion builds its own block payload). +// Mirrors the macOS MemoryExportService layout: a title, an export stamp, then +// one bullet per memory under a category heading. +export function formatMemoriesMarkdown(memories: ExportMemory[], now = new Date()): string { + const date = now.toISOString().slice(0, 10) + const noun = memories.length === 1 ? 'memory' : 'memories' + const lines: string[] = ['# Omi Memories', '', `_Exported ${date} · ${memories.length} ${noun}_`, ''] + + const groups = new Map() + for (const m of memories) { + const cat = (m.category ?? '').trim() || 'Other' + const arr = groups.get(cat) ?? [] + arr.push(m) + groups.set(cat, arr) + } + + for (const [cat, items] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`## ${cat}`, '') + for (const m of items) lines.push(`- ${m.content.replace(/\s*\n\s*/g, ' ').trim()}`) + lines.push('') + } + + return lines.join('\n').trimEnd() + '\n' +} diff --git a/windows/src/main/memoryExport/io.test.ts b/windows/src/main/memoryExport/io.test.ts new file mode 100644 index 0000000000..2de51695c3 --- /dev/null +++ b/windows/src/main/memoryExport/io.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { promises as fs } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { exportToObsidian } from './obsidian' +import { exportToFile } from './plainFile' + +// Real-filesystem checks for the file-writing export targets (no auth needed). +const work = join(tmpdir(), `omi-export-test-${Date.now()}`) +const memories = [ + { content: 'Has two cats', category: 'Personal' }, + { content: 'Prefers TypeScript', category: 'Work' } +] + +afterAll(async () => { + await fs.rm(work, { recursive: true, force: true }) +}) + +describe('export file I/O (real disk)', () => { + it('exportToObsidian writes /Omi/Memories.md', async () => { + const vault = join(work, 'vault') + const file = await exportToObsidian(vault, memories) + expect(file).toBe(join(vault, 'Omi', 'Memories.md')) + const text = await fs.readFile(file, 'utf8') + expect(text).toContain('# Omi Memories') + expect(text).toContain('## Personal') + expect(text).toContain('- Has two cats') + expect(text).toContain('## Work') + }) + + it('exportToFile writes Markdown to the given path', async () => { + const target = join(work, 'memories.md') + await fs.mkdir(work, { recursive: true }) + const file = await exportToFile(target, memories) + expect(file).toBe(target) + const text = await fs.readFile(file, 'utf8') + expect(text).toContain('- Prefers TypeScript') + expect(text).toContain('· 2 memories_') + }) +}) diff --git a/windows/src/main/memoryExport/notion.ts b/windows/src/main/memoryExport/notion.ts new file mode 100644 index 0000000000..040117e038 --- /dev/null +++ b/windows/src/main/memoryExport/notion.ts @@ -0,0 +1,60 @@ +import type { ExportMemory } from '../../shared/types' + +// Pinned Notion REST API version (required header). Matches the macOS client. +const NOTION_VERSION = '2022-06-28' +// Notion caps a page-create / append call at 100 child blocks. +const MAX_BLOCKS = 100 + +// One bulleted-list block per memory. Notion rich_text content is capped at +// 2000 chars per item, so long memories are truncated to stay within the limit. +function toBlocks(memories: ExportMemory[]): unknown[] { + return memories.map((m) => ({ + object: 'block', + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [{ type: 'text', text: { content: m.content.slice(0, 2000) } }] + } + })) +} + +// Create an "Omi Memories" page under `parentPageId` and append every memory as +// a bullet, batching at Notion's 100-block ceiling. Returns the new page URL. +// Uses the official Notion REST API with the user's internal-integration token. +export async function exportToNotion( + token: string, + parentPageId: string, + memories: ExportMemory[] +): Promise { + const headers = { + Authorization: `Bearer ${token}`, + 'Notion-Version': NOTION_VERSION, + 'Content-Type': 'application/json' + } + + const createRes = await fetch('https://api.notion.com/v1/pages', { + method: 'POST', + headers, + body: JSON.stringify({ + parent: { page_id: parentPageId }, + properties: { title: { title: [{ text: { content: 'Omi Memories' } }] } }, + children: toBlocks(memories.slice(0, MAX_BLOCKS)) + }) + }) + if (!createRes.ok) { + throw new Error(`Notion create failed (${createRes.status}): ${await createRes.text()}`) + } + const page = (await createRes.json()) as { id: string; url?: string } + + for (let i = MAX_BLOCKS; i < memories.length; i += MAX_BLOCKS) { + const res = await fetch(`https://api.notion.com/v1/blocks/${page.id}/children`, { + method: 'PATCH', + headers, + body: JSON.stringify({ children: toBlocks(memories.slice(i, i + MAX_BLOCKS)) }) + }) + if (!res.ok) { + throw new Error(`Notion append failed (${res.status}): ${await res.text()}`) + } + } + + return page.url ?? `https://notion.so/${page.id.replace(/-/g, '')}` +} diff --git a/windows/src/main/memoryExport/obsidian.ts b/windows/src/main/memoryExport/obsidian.ts new file mode 100644 index 0000000000..e980ebbcfc --- /dev/null +++ b/windows/src/main/memoryExport/obsidian.ts @@ -0,0 +1,17 @@ +import { promises as fs } from 'fs' +import { join } from 'path' +import type { ExportMemory } from '../../shared/types' +import { formatMemoriesMarkdown } from './format' + +// Write memories to /Omi/Memories.md, mirroring the macOS Obsidian +// target. This is a full export, so the file is overwritten each time. +export async function exportToObsidian( + vaultPath: string, + memories: ExportMemory[] +): Promise { + const dir = join(vaultPath, 'Omi') + await fs.mkdir(dir, { recursive: true }) + const file = join(dir, 'Memories.md') + await fs.writeFile(file, formatMemoriesMarkdown(memories), 'utf8') + return file +} diff --git a/windows/src/main/memoryExport/plainFile.ts b/windows/src/main/memoryExport/plainFile.ts new file mode 100644 index 0000000000..26e80c2a2c --- /dev/null +++ b/windows/src/main/memoryExport/plainFile.ts @@ -0,0 +1,10 @@ +import { promises as fs } from 'fs' +import type { ExportMemory } from '../../shared/types' +import { formatMemoriesMarkdown } from './format' + +// Write memories as Markdown to an arbitrary file path the user picked. The +// "plain file" target — no app integration, just a portable .md export. +export async function exportToFile(filePath: string, memories: ExportMemory[]): Promise { + await fs.writeFile(filePath, formatMemoriesMarkdown(memories), 'utf8') + return filePath +} diff --git a/windows/src/main/memoryImport/parse.test.ts b/windows/src/main/memoryImport/parse.test.ts new file mode 100644 index 0000000000..618cec0daf --- /dev/null +++ b/windows/src/main/memoryImport/parse.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { parseMemoryDump } from './parse' + +describe('parseMemoryDump', () => { + it('extracts bulleted memories and strips markers', () => { + const dump = `- Has two cats named Mochi and Pip +* Prefers concise answers +• Works as a software engineer` + expect(parseMemoryDump(dump)).toEqual([ + 'Has two cats named Mochi and Pip', + 'Prefers concise answers', + 'Works as a software engineer' + ]) + }) + + it('handles numbered lists', () => { + const dump = `1. Lives in Seattle\n2) Is learning Spanish` + expect(parseMemoryDump(dump)).toEqual(['Lives in Seattle', 'Is learning Spanish']) + }) + + it('drops conversational scaffolding but keeps real memories', () => { + const dump = `Sure! Here are your saved memories: +- Has a dog +That's everything I have.` + expect(parseMemoryDump(dump)).toEqual(['Has a dog']) + }) + + it('dedupes case-insensitively', () => { + const dump = `- Likes coffee\n- likes coffee\n- Likes tea` + expect(parseMemoryDump(dump)).toEqual(['Likes coffee', 'Likes tea']) + }) + + it('strips markdown emphasis and headings', () => { + const dump = `## Profile\n**Enjoys hiking**\n_Vegetarian_` + expect(parseMemoryDump(dump)).toEqual(['Profile', 'Enjoys hiking', 'Vegetarian']) + }) + + it('returns nothing for empty input', () => { + expect(parseMemoryDump('')).toEqual([]) + expect(parseMemoryDump('\n\n \n')).toEqual([]) + }) +}) diff --git a/windows/src/main/memoryImport/parse.ts b/windows/src/main/memoryImport/parse.ts new file mode 100644 index 0000000000..1388398a73 --- /dev/null +++ b/windows/src/main/memoryImport/parse.ts @@ -0,0 +1,50 @@ +// Parse a pasted ChatGPT/Claude "memory dump" — the assistant's full response +// when asked to list everything it remembers — into individual memory strings. +// Mirrors the macOS import path: strip list markup + conversational scaffolding, +// dedupe, and keep the rest. The Settings UI shows the result for review before +// anything is sent to the backend, so erring toward keeping a line is safe. + +// Opener/closer scaffolding lines that are not memories themselves. Kept tight +// so real third-person memory lines ("Has two cats", "Prefers concise answers") +// are never dropped. +const SCAFFOLDING = [ + /^sure[,!. ]/i, + /^here(?:'s| is| are)\b.*\bmemor/i, + /^below (?:is|are)\b/i, + /^these are\b.*\bmemor/i, + /^(?:your |the )?saved memories\b/i, + /^(?:here are )?the (?:things|details) (?:i|that i) (?:remember|know)/i, + /^let me know\b/i, + /^(?:and )?that'?s (?:all|everything)\b/i, + /^is there anything\b/i, + /^i (?:currently )?(?:remember|have stored|don'?t have)\b.*(?:memor|the following|about you)/i +] + +// Remove a single leading list marker: -, *, •, –, or "1." / "1)". +function stripMarker(line: string): string { + return line.replace(/^\s*(?:[-*•–]\s+|\d+[.)]\s+)/, '') +} + +// Strip surrounding markdown emphasis / heading syntax. +function stripFormatting(s: string): string { + let t = s.trim() + t = t.replace(/^#{1,6}\s+/, '') // markdown heading + t = t.replace(/^\*\*([\s\S]*)\*\*$/, '$1') // **bold** + t = t.replace(/^_([\s\S]*)_$/, '$1') // _italic_ + return t.trim() +} + +export function parseMemoryDump(dump: string): string[] { + const out: string[] = [] + const seen = new Set() + for (const raw of dump.split(/\r?\n/)) { + const stripped = stripFormatting(stripMarker(raw)) + if (stripped.length < 3) continue // blank lines, stray numbering/punctuation + if (SCAFFOLDING.some((re) => re.test(stripped))) continue + const key = stripped.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(stripped) + } + return out +} diff --git a/windows/src/main/ocr/helperProcess.ts b/windows/src/main/ocr/helperProcess.ts new file mode 100644 index 0000000000..10377e6c04 --- /dev/null +++ b/windows/src/main/ocr/helperProcess.ts @@ -0,0 +1,131 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' +import { resolveHelperPath } from './resolveHelperPath' +import { encodeRequest, FrameDecoder, OP_OCR, OP_WINDOW } from './helperProtocol' +import type { OcrResult, WindowInfo } from '../../shared/types' + +const REQUEST_TIMEOUT_MS = 5000 +const MAX_BACKOFF_MS = 10000 + +type Pending = { + resolve: (json: string) => void + reject: (e: Error) => void + timer: NodeJS.Timeout +} + +/** + * One supervised, long-running helper process shared by OCR + window-info. + * Lazy start; capped-backoff restart on crash; single-flight FIFO request queue + * (the helper processes one frame at a time, so we serialize). Per-request + * timeout recycles the process to avoid a wedged pipe blocking forever. + */ +class HelperProcess { + private child: ChildProcessWithoutNullStreams | null = null + private readonly queue: Pending[] = [] + private backoff = 500 + private starting = false + // Set once the helper binary is confirmed missing (spawn ENOENT). Without this, + // every OCR/window request re-spawns the missing exe, failing forever — flooding + // the log and stalling each caller on a doomed spawn. Once unavailable, fail fast. + private unavailable = false + + private ensureStarted(): void { + if (this.child || this.starting || this.unavailable) return + this.starting = true + const exe = resolveHelperPath() + const child = spawn(exe, [], { stdio: ['pipe', 'pipe', 'pipe'] }) + this.child = child + this.starting = false + + const decoder = new FrameDecoder((json) => { + const pending = this.queue.shift() + if (!pending) return + clearTimeout(pending.timer) + pending.resolve(json) + }) + child.stdout.on('data', (chunk: Buffer) => decoder.push(chunk)) + child.stderr.on('data', (c: Buffer) => console.log('[win-ocr-helper]', c.toString().trim())) + child.on('exit', (code) => { + console.warn(`[win-ocr-helper] exited code=${code}`) + this.handleExit() + }) + child.on('error', (e) => { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + if (!this.unavailable) { + console.error( + '[win-ocr-helper] binary not found — OCR / screen-reading is DISABLED. ' + + 'Build it once with: pnpm run build:ocr-helper (needs .NET SDK). ' + + `(${e.message})` + ) + } + this.unavailable = true + } else { + console.error('[win-ocr-helper] spawn error:', e.message) + } + this.handleExit() + }) + // Successful start — reset backoff after a short grace period. + setTimeout(() => { + if (this.child === child) this.backoff = 500 + }, 2000) + } + + private handleExit(): void { + this.child = null + // Fail every in-flight request; the helper restarts lazily on next request. + while (this.queue.length) { + const p = this.queue.shift()! + clearTimeout(p.timer) + p.reject(new Error('helper exited')) + } + this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS) + } + + private recycle(): void { + if (this.child) { + try { + this.child.kill() + } catch { + /* already dead */ + } + } + this.handleExit() + } + + private request(opcode: number, payload: Buffer): Promise { + if (this.unavailable) return Promise.reject(new Error('helper unavailable (binary missing)')) + this.ensureStarted() + const child = this.child + if (!child) return Promise.reject(new Error('helper not available')) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // Drop the wedged request and recycle the process. + const idx = this.queue.findIndex((p) => p.timer === timer) + if (idx >= 0) this.queue.splice(idx, 1) + reject(new Error('helper request timed out')) + this.recycle() + }, REQUEST_TIMEOUT_MS) + this.queue.push({ resolve, reject, timer }) + child.stdin.write(encodeRequest(opcode, payload)) + }) + } + + async ocr(jpeg: Buffer): Promise { + try { + const json = await this.request(OP_OCR, jpeg) + return JSON.parse(json) as OcrResult + } catch (e) { + return { ok: false, code: 'HELPER_ERROR', message: (e as Error).message } + } + } + + async windowInfo(): Promise { + const json = await this.request(OP_WINDOW, Buffer.alloc(0)) + return JSON.parse(json) as WindowInfo + } + + dispose(): void { + this.recycle() + } +} + +export const helperProcess = new HelperProcess() diff --git a/windows/src/main/ocr/helperProtocol.test.ts b/windows/src/main/ocr/helperProtocol.test.ts new file mode 100644 index 0000000000..d97406b59a --- /dev/null +++ b/windows/src/main/ocr/helperProtocol.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { encodeRequest, FrameDecoder, OP_OCR, OP_WINDOW } from './helperProtocol' + +describe('encodeRequest', () => { + it('prefixes length and opcode for an OCR request', () => { + const frame = encodeRequest(OP_OCR, Buffer.from([1, 2, 3])) + // 4-byte LE length (1 opcode + 3 payload = 4), then opcode, then payload. + expect(frame.readUInt32LE(0)).toBe(4) + expect(frame[4]).toBe(OP_OCR) + expect([...frame.subarray(5)]).toEqual([1, 2, 3]) + }) + + it('encodes an empty-payload window request', () => { + const frame = encodeRequest(OP_WINDOW, Buffer.alloc(0)) + expect(frame.readUInt32LE(0)).toBe(1) + expect(frame[4]).toBe(OP_WINDOW) + expect(frame.length).toBe(5) + }) +}) + +describe('FrameDecoder', () => { + it('reassembles a response split across chunks', () => { + const json = JSON.stringify({ ok: true }) + const body = Buffer.from(json, 'utf8') + const header = Buffer.alloc(4) + header.writeUInt32LE(body.length, 0) + const full = Buffer.concat([header, body]) + + const seen: string[] = [] + const dec = new FrameDecoder((s) => seen.push(s)) + dec.push(full.subarray(0, 2)) + dec.push(full.subarray(2, 6)) + dec.push(full.subarray(6)) + expect(seen).toEqual([json]) + }) + + it('handles two frames in one chunk', () => { + const mk = (o: object): Buffer => { + const b = Buffer.from(JSON.stringify(o)) + const h = Buffer.alloc(4) + h.writeUInt32LE(b.length, 0) + return Buffer.concat([h, b]) + } + const seen: string[] = [] + const dec = new FrameDecoder((s) => seen.push(s)) + dec.push(Buffer.concat([mk({ a: 1 }), mk({ b: 2 })])) + expect(seen).toEqual(['{"a":1}', '{"b":2}']) + }) +}) diff --git a/windows/src/main/ocr/helperProtocol.ts b/windows/src/main/ocr/helperProtocol.ts new file mode 100644 index 0000000000..ec50f13fa3 --- /dev/null +++ b/windows/src/main/ocr/helperProtocol.ts @@ -0,0 +1,32 @@ +// Framing for the win-ocr-helper stdio protocol. +// Request : [uint32 LE length][1 byte opcode][payload] +// Response: [uint32 LE length][UTF-8 JSON] + +export const OP_OCR = 1 +export const OP_WINDOW = 2 + +/** Build a length-prefixed, opcode-tagged request frame. */ +export function encodeRequest(opcode: number, payload: Buffer): Buffer { + const header = Buffer.alloc(4) + header.writeUInt32LE(payload.length + 1, 0) + return Buffer.concat([header, Buffer.from([opcode]), payload]) +} + +/** Streaming decoder for length-prefixed JSON response frames. Buffers partial + * chunks and invokes `onFrame(jsonString)` once per complete frame. */ +export class FrameDecoder { + private buf = Buffer.alloc(0) + constructor(private readonly onFrame: (json: string) => void) {} + + push(chunk: Buffer): void { + this.buf = Buffer.concat([this.buf, chunk]) + for (;;) { + if (this.buf.length < 4) return + const len = this.buf.readUInt32LE(0) + if (this.buf.length < 4 + len) return + const json = this.buf.subarray(4, 4 + len).toString('utf8') + this.buf = this.buf.subarray(4 + len) + this.onFrame(json) + } + } +} diff --git a/windows/src/main/ocr/resolveHelperPath.ts b/windows/src/main/ocr/resolveHelperPath.ts new file mode 100644 index 0000000000..e7a47d7707 --- /dev/null +++ b/windows/src/main/ocr/resolveHelperPath.ts @@ -0,0 +1,28 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync } from 'fs' + +/** + * Resolve the on-disk path to the bundled win-ocr-helper.exe. + * + * Locations, in priority order: + * 1. Packaged via `asarUnpack: resources/**`: + * `/app.asar.unpacked/resources/win-ocr-helper/win-ocr-helper.exe` + * 2. Packaged via extraResources: + * `/win-ocr-helper/win-ocr-helper.exe` + * 3. Dev (electron-vite): `/resources/win-ocr-helper/win-ocr-helper.exe` + */ +export function resolveHelperPath(): string { + const exe = 'win-ocr-helper.exe' + const candidates = [ + join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'win-ocr-helper', exe), + join(process.resourcesPath, 'win-ocr-helper', exe), + join(app.getAppPath(), 'resources', 'win-ocr-helper', exe) + ] + for (const c of candidates) { + if (existsSync(c)) return c + } + // Return the dev path so the supervisor surfaces a clear "helper not found" + // error rather than spawning a nonexistent path. + return candidates[candidates.length - 1] +} diff --git a/windows/src/main/ocr/win-ocr-helper/Program.cs b/windows/src/main/ocr/win-ocr-helper/Program.cs new file mode 100644 index 0000000000..890480d445 --- /dev/null +++ b/windows/src/main/ocr/win-ocr-helper/Program.cs @@ -0,0 +1,233 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Windows.Globalization; +using Windows.Graphics.Imaging; +using Windows.Media.Ocr; +using Windows.Storage.Streams; + +// win-ocr-helper — long-running stdio helper. +// Request frame: [uint32 LE length][1 byte opcode][payload...] +// opcode 1 = OCR payload = JPEG bytes +// opcode 2 = WINDOW payload = empty +// Response frame: [uint32 LE length][UTF-8 JSON] +// OCR: {"ok":true,"fullText":"...","lines":[{text,x,y,w,h,confidence}]} +// or {"ok":false,"code":"NO_LANGUAGE|DECODE_FAILED|HELPER_ERROR","message":"..."} +// WINDOW: {"app":"...","title":"...","pid":123,"processName":"..."} + +internal static class Program +{ + private const byte OpOcr = 1; + private const byte OpWindow = 2; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static async Task Main(string[] args) + { + if (args.Contains("--selftest")) + { + return await SelfTest(); + } + + var stdin = Console.OpenStandardInput(); + var stdout = Console.OpenStandardOutput(); + + while (true) + { + var header = await ReadExactly(stdin, 4); + if (header is null) return 0; // EOF — parent closed the pipe. + var len = BitConverter.ToUInt32(header, 0); + if (len == 0) continue; + var body = await ReadExactly(stdin, (int)len); + if (body is null) return 0; + + var opcode = body[0]; + var payload = new byte[body.Length - 1]; + Array.Copy(body, 1, payload, 0, payload.Length); + + string json; + try + { + json = opcode switch + { + OpOcr => await RunOcr(payload), + OpWindow => RunWindowInfo(), + _ => ErrorJson("HELPER_ERROR", $"unknown opcode {opcode}") + }; + } + catch (Exception e) + { + json = ErrorJson("HELPER_ERROR", e.Message); + } + + await WriteFrame(stdout, json); + } + } + + private static async Task ReadExactly(Stream s, int n) + { + var buf = new byte[n]; + var read = 0; + while (read < n) + { + var r = await s.ReadAsync(buf.AsMemory(read, n - read)); + if (r == 0) return null; // EOF + read += r; + } + return buf; + } + + private static async Task WriteFrame(Stream s, string json) + { + var bytes = Encoding.UTF8.GetBytes(json); + var header = BitConverter.GetBytes((uint)bytes.Length); + await s.WriteAsync(header); + await s.WriteAsync(bytes); + await s.FlushAsync(); + } + + private static string ErrorJson(string code, string message) => + JsonSerializer.Serialize(new { ok = false, code, message }, JsonOpts); + + private static async Task RunOcr(byte[] jpeg) + { + var engine = OcrEngine.TryCreateFromUserProfileLanguages(); + if (engine is null) + { + return ErrorJson("NO_LANGUAGE", + "No Windows OCR language pack is installed."); + } + + SoftwareBitmap bitmap; + try + { + using var stream = new InMemoryRandomAccessStream(); + var writer = new DataWriter(stream); + writer.WriteBytes(jpeg); + await writer.StoreAsync(); + writer.DetachStream(); + stream.Seek(0); + var decoder = await BitmapDecoder.CreateAsync(stream); + bitmap = await decoder.GetSoftwareBitmapAsync(); + } + catch (Exception e) + { + return ErrorJson("DECODE_FAILED", e.Message); + } + + var width = bitmap.PixelWidth; + var height = bitmap.PixelHeight; + var result = await engine.RecognizeAsync(bitmap); + bitmap.Dispose(); + + var lines = result.Lines.Select(line => + { + // Bounding rect = union of word rects (OcrLine has no rect of its own). + double minX = double.MaxValue, minY = double.MaxValue, maxX = 0, maxY = 0; + double confSum = 0; + var count = 0; + foreach (var word in line.Words) + { + var r = word.BoundingRect; + minX = Math.Min(minX, r.X); + minY = Math.Min(minY, r.Y); + maxX = Math.Max(maxX, r.X + r.Width); + maxY = Math.Max(maxY, r.Y + r.Height); + confSum += 1.0; // Windows OCR exposes no per-word confidence; report 1.0. + count++; + } + if (count == 0) { minX = minY = 0; } + return new + { + text = line.Text, + x = minX / width, + y = minY / height, + w = (maxX - minX) / width, + h = (maxY - minY) / height, + confidence = count > 0 ? confSum / count : 0.0 + }; + }).ToArray(); + + var fullText = string.Join("\n", result.Lines.Select(l => l.Text)); + return JsonSerializer.Serialize(new { ok = true, fullText, lines }, JsonOpts); + } + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + private static string RunWindowInfo() + { + var hwnd = GetForegroundWindow(); + var sb = new StringBuilder(512); + GetWindowText(hwnd, sb, sb.Capacity); + var title = sb.ToString(); + + GetWindowThreadProcessId(hwnd, out var pid); + var processName = ""; + var app = ""; + try + { + var proc = Process.GetProcessById((int)pid); + processName = proc.ProcessName; + app = string.IsNullOrWhiteSpace(proc.MainModule?.ModuleName) + ? processName + : Path.GetFileNameWithoutExtension(proc.MainModule!.ModuleName); + } + catch + { + // Access-denied (elevated/foreign process) — keep what we have. + } + + return JsonSerializer.Serialize( + new { app = string.IsNullOrEmpty(app) ? processName : app, title, pid = (int)pid, processName }, + JsonOpts); + } + + private static async Task SelfTest() + { + var win = RunWindowInfo(); + Console.Error.WriteLine($"[selftest] window: {win}"); + + var engine = OcrEngine.TryCreateFromUserProfileLanguages(); + Console.Error.WriteLine(engine is null + ? "[selftest] OCR: NO_LANGUAGE (no language pack installed)" + : $"[selftest] OCR: engine ok ({engine.RecognizerLanguage.DisplayName})"); + + if (engine is not null) + { + var jpeg = MakeWhiteJpeg(64, 32); + var ocr = await RunOcr(jpeg); + Console.Error.WriteLine($"[selftest] ocr round-trip: {ocr[..Math.Min(120, ocr.Length)]}"); + } + await Task.CompletedTask; + return 0; + } + + private static byte[] MakeWhiteJpeg(int w, int h) + { + using var stream = new InMemoryRandomAccessStream(); + var encoder = BitmapEncoder.CreateAsync(BitmapEncoder.JpegEncoderId, stream) + .AsTask().GetAwaiter().GetResult(); + var pixels = new byte[w * h * 4]; + Array.Fill(pixels, (byte)255); + encoder.SetPixelData(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Ignore, + (uint)w, (uint)h, 96, 96, pixels); + encoder.FlushAsync().AsTask().GetAwaiter().GetResult(); + stream.Seek(0); + var bytes = new byte[stream.Size]; + var reader = new DataReader(stream); + reader.LoadAsync((uint)stream.Size).AsTask().GetAwaiter().GetResult(); + reader.ReadBytes(bytes); + return bytes; + } +} diff --git a/windows/src/main/ocr/win-ocr-helper/win-ocr-helper.csproj b/windows/src/main/ocr/win-ocr-helper/win-ocr-helper.csproj new file mode 100644 index 0000000000..a9e2f4a775 --- /dev/null +++ b/windows/src/main/ocr/win-ocr-helper/win-ocr-helper.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0-windows10.0.19041.0 + win-x64 + enable + latest + enable + true + true + win-ocr-helper + + diff --git a/windows/src/main/overlay/bounds.test.ts b/windows/src/main/overlay/bounds.test.ts new file mode 100644 index 0000000000..07b7ccf252 --- /dev/null +++ b/windows/src/main/overlay/bounds.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { computeOverlayBounds, OVERLAY_WIDTH, TOP_MARGIN, MAX_HEIGHT_FRACTION } from './bounds' + +// A primary display whose work area starts at the origin. +const primary = { x: 0, y: 0, width: 1920, height: 1080 } + +describe('computeOverlayBounds', () => { + it('centers the panel horizontally within the work area', () => { + const b = computeOverlayBounds(primary, 300) + expect(b.width).toBe(OVERLAY_WIDTH) + expect(b.x).toBe(Math.round(primary.x + (primary.width - OVERLAY_WIDTH) / 2)) + }) + + it('anchors the top near the top of the work area (fixed margin)', () => { + const b = computeOverlayBounds(primary, 300) + expect(b.y).toBe(Math.round(primary.y + TOP_MARGIN)) + }) + + it('uses the requested content height when below the clamp', () => { + const b = computeOverlayBounds(primary, 300) + expect(b.height).toBe(300) + }) + + it('clamps height to the max fraction of work-area height', () => { + const tall = 5000 + const b = computeOverlayBounds(primary, tall) + expect(b.height).toBe(Math.round(primary.height * MAX_HEIGHT_FRACTION)) + }) + + it('offsets onto a secondary monitor by its work-area origin', () => { + const secondary = { x: 1920, y: 0, width: 1280, height: 1024 } + const b = computeOverlayBounds(secondary, 300) + expect(b.x).toBe(Math.round(secondary.x + (secondary.width - OVERLAY_WIDTH) / 2)) + }) + + it('max-height-clamped panel stays within the work-area bottom edge', () => { + const shortDisplay = { x: 0, y: 0, width: 1920, height: 500 } + const b = computeOverlayBounds(shortDisplay, 400) + expect(b.y + b.height).toBeLessThanOrEqual(shortDisplay.y + shortDisplay.height) + expect(b.y).toBeGreaterThanOrEqual(shortDisplay.y) + }) + + it('overflow nudge stays structurally satisfied: TOP_MARGIN + maxHeight(0.70) stays on screen', () => { + // TOP_MARGIN (px) + maxHeight(0.70 * height) <= height for any realistic display, + // so the panel never runs off the bottom. Locks the invariant so a future constant + // change re-triggers an on-screen audit. + const displays = [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 0, y: 0, width: 1280, height: 720 }, + { x: 1920, y: -200, width: 1440, height: 900 } + ] + for (const d of displays) { + const b = computeOverlayBounds(d, Math.round(d.height * MAX_HEIGHT_FRACTION)) + expect(b.y + b.height).toBeLessThanOrEqual(d.y + d.height) + } + }) +}) diff --git a/windows/src/main/overlay/bounds.ts b/windows/src/main/overlay/bounds.ts new file mode 100644 index 0000000000..b4fe8aa3c6 --- /dev/null +++ b/windows/src/main/overlay/bounds.ts @@ -0,0 +1,36 @@ +/** Fixed window width in px. The renderer renders the panel at 70% scale + * (overlay.css `.overlay-zoom` lays out at 480px then zooms to 0.7), so the window + * is 0.7 × 480 ≈ 336px and the panel fills it edge-to-edge. Keep this == 480×zoom. */ +export const OVERLAY_WIDTH = 336 + +/** Top edge sits this many px below the top of the work area — the overlay spawns + * at the top of the screen (just clear of the work-area edge / taskbar). */ +export const TOP_MARGIN = 12 + +/** Window height never exceeds this fraction of the work area; past it the + * renderer's reply area scrolls internally. */ +export const MAX_HEIGHT_FRACTION = 0.7 + +export type WorkArea = { x: number; y: number; width: number; height: number } +export type Bounds = { x: number; y: number; width: number; height: number } + +/** + * Compute the overlay window rect for a given display work area and the + * renderer's current measured content height. Centered horizontally, anchored + * near the top (TOP_MARGIN px down), height clamped to 70% of the work area, and + * nudged up so it never runs off the bottom edge of the display. + */ +export function computeOverlayBounds(workArea: WorkArea, contentHeight: number): Bounds { + const width = OVERLAY_WIDTH + const x = Math.round(workArea.x + (workArea.width - width) / 2) + + const maxHeight = Math.round(workArea.height * MAX_HEIGHT_FRACTION) + const height = Math.max(1, Math.min(Math.round(contentHeight), maxHeight)) + + let y = Math.round(workArea.y + TOP_MARGIN) + const bottomLimit = workArea.y + workArea.height + if (y + height > bottomLimit) y = bottomLimit - height + if (y < workArea.y) y = workArea.y + + return { x, y, width, height } +} diff --git a/windows/src/main/overlay/heightTween.test.ts b/windows/src/main/overlay/heightTween.test.ts new file mode 100644 index 0000000000..cf596c57b0 --- /dev/null +++ b/windows/src/main/overlay/heightTween.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { tweenHeights } from './heightTween' + +describe('tweenHeights', () => { + it('returns a sequence ending exactly at the target', () => { + const seq = tweenHeights(100, 300, 8) + expect(seq[seq.length - 1]).toBe(300) + }) + + it('starts strictly after the source (does not re-emit the current height)', () => { + const seq = tweenHeights(100, 300, 8) + expect(seq[0]).toBeGreaterThan(100) + }) + + it('is monotonically increasing when growing', () => { + const seq = tweenHeights(100, 300, 8) + for (let i = 1; i < seq.length; i++) expect(seq[i]).toBeGreaterThanOrEqual(seq[i - 1]) + }) + + it('is monotonically decreasing when shrinking', () => { + const seq = tweenHeights(400, 120, 8) + for (let i = 1; i < seq.length; i++) expect(seq[i]).toBeLessThanOrEqual(seq[i - 1]) + expect(seq[seq.length - 1]).toBe(120) + }) + + it('returns a single target step when from === to', () => { + expect(tweenHeights(200, 200, 8)).toEqual([200]) + }) + + it('returns just the target when steps <= 1', () => { + expect(tweenHeights(100, 300, 1)).toEqual([300]) + }) + + it('produces integer heights only', () => { + const seq = tweenHeights(100, 333, 6) + for (const h of seq) expect(Number.isInteger(h)).toBe(true) + }) +}) diff --git a/windows/src/main/overlay/heightTween.ts b/windows/src/main/overlay/heightTween.ts new file mode 100644 index 0000000000..469b53e5bf --- /dev/null +++ b/windows/src/main/overlay/heightTween.ts @@ -0,0 +1,22 @@ +/** easeOutCubic — fast start, gentle settle, matching the macOS feel. */ +function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3) +} + +/** + * Build an eased sequence of integer heights from `from` to `to` over `steps` + * frames. The sequence excludes the starting height (we are already there) and + * always lands exactly on `to`. Monotonic in whichever direction we're moving. + */ +export function tweenHeights(from: number, to: number, steps: number): number[] { + if (from === to) return [to] + if (steps <= 1) return [to] + + const out: number[] = [] + for (let i = 1; i <= steps; i++) { + const t = easeOutCubic(i / steps) + out.push(Math.round(from + (to - from) * t)) + } + out[out.length - 1] = to + return out +} diff --git a/windows/src/main/overlay/ipc.ts b/windows/src/main/overlay/ipc.ts new file mode 100644 index 0000000000..972df65fd2 --- /dev/null +++ b/windows/src/main/overlay/ipc.ts @@ -0,0 +1,52 @@ +import { ipcMain, BrowserWindow } from 'electron' +import { hideOverlay, setOverlayHeight, setOverlayEnabled } from './window' +import { + setOverlayAccelerator, + suspendOverlayShortcut, + resumeOverlayShortcut +} from './shortcut' + +/** + * Wire the overlay IPC channels. Renderer → main: hide, setHeight, focusMain, + * setEnabled, plus shortcut rebinding (setAccelerator) and suspend/resume used + * while the onboarding step records a custom shortcut. Main → renderer + * 'overlay:shown'/'overlay:summoned' are sent directly from window.ts. + */ +export function registerOverlayHandlers(focusMain: () => void): void { + ipcMain.on('overlay:hide', () => hideOverlay()) + ipcMain.on('overlay:setEnabled', (_e, enabled: boolean) => setOverlayEnabled(!!enabled)) + ipcMain.on('overlay:setHeight', (_e, px: number) => { + if (typeof px === 'number' && px > 0) setOverlayHeight(px) + }) + ipcMain.on('overlay:focusMain', () => { + hideOverlay() + focusMain() + }) + + // Rebind the global summon accelerator. Returns whether the new accelerator + // was claimed (false → it's taken; main rolled back to the previous binding). + ipcMain.handle('overlay:setAccelerator', (_e, accelerator: string): boolean => { + if (typeof accelerator !== 'string' || !accelerator.trim()) return false + return setOverlayAccelerator(accelerator) + }) + // Release/re-claim the accelerator so the renderer can read raw keys while + // recording a custom shortcut (otherwise the registered combo is swallowed). + ipcMain.on('overlay:suspendShortcut', () => suspendOverlayShortcut()) + ipcMain.handle('overlay:resumeShortcut', (): boolean => resumeOverlayShortcut()) + + // The overlay reports a captured push-to-talk transcript; relay it to every + // window so the onboarding voice step knows the user completed a voice ask. + ipcMain.on('overlay:voiceCaptured', () => { + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send('overlay:voiceCaptured') + } + }) + + // The overlay reports any message sent (typed or spoken); relay it so the + // onboarding demo step knows the user asked something in the bar. + ipcMain.on('overlay:asked', () => { + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send('overlay:asked') + } + }) +} diff --git a/windows/src/main/overlay/shortcut.behavior.test.ts b/windows/src/main/overlay/shortcut.behavior.test.ts new file mode 100644 index 0000000000..2dc1c4d220 --- /dev/null +++ b/windows/src/main/overlay/shortcut.behavior.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// In-memory globalShortcut stand-in. `taken` simulates accelerators another app +// already owns (register returns false for those). +const registered = new Set() +const taken = new Set() + +vi.mock('electron', () => ({ + globalShortcut: { + register: (accel: string): boolean => { + if (taken.has(accel)) return false + registered.add(accel) + return true + }, + unregister: (accel: string): void => { + registered.delete(accel) + }, + isRegistered: (accel: string): boolean => registered.has(accel) + } +})) + +import { + registerOverlayShortcut, + setOverlayAccelerator, + suspendOverlayShortcut, + resumeOverlayShortcut, + getOverlayAccelerator, + OVERLAY_ACCELERATOR +} from './shortcut' + +describe('overlay shortcut manager', () => { + beforeEach(() => { + registered.clear() + taken.clear() + }) + + it('claims the default accelerator on register', () => { + expect(registerOverlayShortcut(OVERLAY_ACCELERATOR, () => {})).toBe(true) + expect(registered.has('Shift+Space')).toBe(true) + expect(getOverlayAccelerator()).toBe('Shift+Space') + }) + + it('rebinds: releases the old accelerator and claims the new one', () => { + registerOverlayShortcut('Shift+Space', () => {}) + expect(setOverlayAccelerator('CommandOrControl+J')).toBe(true) + expect(registered.has('Shift+Space')).toBe(false) + expect(registered.has('CommandOrControl+J')).toBe(true) + expect(getOverlayAccelerator()).toBe('CommandOrControl+J') + }) + + it('rolls back to the previous binding when the new accelerator is taken', () => { + registerOverlayShortcut('CommandOrControl+J', () => {}) + taken.add('CommandOrControl+O') // owned by another app + expect(setOverlayAccelerator('CommandOrControl+O')).toBe(false) + // Previous binding restored and still registered. + expect(getOverlayAccelerator()).toBe('CommandOrControl+J') + expect(registered.has('CommandOrControl+J')).toBe(true) + expect(registered.has('CommandOrControl+O')).toBe(false) + }) + + it('suspend releases the current accelerator; resume re-claims it', () => { + registerOverlayShortcut('Shift+Space', () => {}) + suspendOverlayShortcut() + expect(registered.has('Shift+Space')).toBe(false) + expect(resumeOverlayShortcut()).toBe(true) + expect(registered.has('Shift+Space')).toBe(true) + }) +}) diff --git a/windows/src/main/overlay/shortcut.test.ts b/windows/src/main/overlay/shortcut.test.ts new file mode 100644 index 0000000000..6cc343c540 --- /dev/null +++ b/windows/src/main/overlay/shortcut.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest' +import { OVERLAY_ACCELERATOR } from './shortcut' + +describe('OVERLAY_ACCELERATOR', () => { + it('defaults to Shift+Space', () => { + expect(OVERLAY_ACCELERATOR).toBe('Shift+Space') + }) +}) diff --git a/windows/src/main/overlay/shortcut.ts b/windows/src/main/overlay/shortcut.ts new file mode 100644 index 0000000000..290fcb8bd0 --- /dev/null +++ b/windows/src/main/overlay/shortcut.ts @@ -0,0 +1,76 @@ +import { globalShortcut } from 'electron' + +/** Default summon shortcut. User-rebindable during onboarding (and persisted). */ +export const OVERLAY_ACCELERATOR = 'Shift+Space' + +// The accelerator currently claimed (so suspend/resume and rebinding can release +// the right one). Updated by registerOverlayShortcut / setOverlayAccelerator. +let currentAccelerator = OVERLAY_ACCELERATOR +// The toggle callback, kept so we can re-register after a suspend or a rebind. +let toggleHandler: (() => void) | null = null + +/** + * Register the overlay summon shortcut. Returns false if the accelerator is + * already taken by another app (Electron's register() returns false / the key + * is reported unregistered). The app keeps running either way. + */ +export function registerOverlayShortcut(accelerator: string, onToggle: () => void): boolean { + toggleHandler = onToggle + return tryRegister(accelerator) +} + +function tryRegister(accelerator: string): boolean { + if (!toggleHandler) return false + try { + const ok = globalShortcut.register(accelerator, toggleHandler) + if (!ok || !globalShortcut.isRegistered(accelerator)) { + console.warn(`[overlay] shortcut "${accelerator}" is unavailable (already in use?)`) + return false + } + currentAccelerator = accelerator + return true + } catch (e) { + console.warn(`[overlay] failed to register shortcut "${accelerator}":`, e) + return false + } +} + +export function unregisterOverlayShortcut(accelerator: string = currentAccelerator): void { + try { + globalShortcut.unregister(accelerator) + } catch { + // ignore — unregistering an unregistered accelerator is a no-op + } +} + +/** + * Rebind the summon shortcut: release the current accelerator and claim the new + * one. On failure the previous accelerator is restored so the user is never left + * with no working shortcut. Returns whether the new accelerator was claimed. + */ +export function setOverlayAccelerator(accelerator: string): boolean { + const previous = currentAccelerator + if (accelerator === previous && globalShortcut.isRegistered(previous)) return true + unregisterOverlayShortcut(previous) + if (tryRegister(accelerator)) return true + // Roll back to the previous binding so summoning still works. + tryRegister(previous) + return false +} + +/** Temporarily release the global shortcut so the renderer can read raw keys + * (used while recording a custom shortcut). Idempotent. */ +export function suspendOverlayShortcut(): void { + unregisterOverlayShortcut(currentAccelerator) +} + +/** Re-claim the current accelerator after a suspend. */ +export function resumeOverlayShortcut(): boolean { + if (globalShortcut.isRegistered(currentAccelerator)) return true + return tryRegister(currentAccelerator) +} + +/** The accelerator currently claimed (exposed for tests/diagnostics). */ +export function getOverlayAccelerator(): string { + return currentAccelerator +} diff --git a/windows/src/main/overlay/window.ts b/windows/src/main/overlay/window.ts new file mode 100644 index 0000000000..e61911518b --- /dev/null +++ b/windows/src/main/overlay/window.ts @@ -0,0 +1,400 @@ +// WINDOW MATERIAL: non-transparent, frameless window + setBackgroundMaterial +// ('acrylic'→'mica'→none) = the Win11 DWM system backdrop. That backdrop is the +// only acrylic that actually renders TRANSLUCENT on modern Win11 builds +// (ACCENT_ENABLE_ACRYLICBLURBEHIND, the custom-color path, now paints opaque on +// 22H2+/build 26200). The backdrop is OS-theme-tinted, so a thin CSS black wash +// in the renderer (overlay.css) recolors it toward black while staying +// translucent. transparent:false lets the backdrop composite and DWM auto-round +// the corners. Visual confirmation is manual GUI. +import { app, BrowserWindow, screen } from 'electron' +import { join } from 'path' +import { is } from '@electron-toolkit/utils' +import { computeOverlayBounds, OVERLAY_WIDTH } from './bounds' + +let overlayWindow: BrowserWindow | null = null + +// Distinguishes a real app shutdown from the user clicking the overlay's native +// close button: on quit we let the window actually close; otherwise we hide it. +let isQuitting = false +app.on('before-quit', () => { + isQuitting = true +}) + +export function getOverlayWindow(): BrowserWindow | null { + return overlayWindow +} + +/** + * Create the overlay window, hidden. Loads the existing renderer bundle at the + * `#/overlay` hash route so it shares the default session (Firebase auth + + * useChat work unchanged). Kept alive for the app lifetime so summoning is + * instant and the auth/session stay warm. + */ +export function createOverlayWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: OVERLAY_WIDTH, + height: 200, + show: false, + // Hidden title bar, NO native caption buttons (no minimize/close). The window + // is moved via a CSS drag region in the renderer and dismissed via the global + // shortcut / Esc only. titleBarStyle 'hidden' keeps the window frame, so Win11 + // still rounds the corners and the Mica/acrylic material renders. + titleBarStyle: 'hidden', + resizable: false, + skipTaskbar: true, + alwaysOnTop: true, + hasShadow: true, + focusable: true, + // Pure-black base so any pre-paint frame and the acrylic's inactive fallback + // read black, not grey. The DWM backdrop composites over this where the + // renderer is transparent/translucent. + backgroundColor: '#000000', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + webSecurity: false, // match main window (Omi API CORS workaround) + backgroundThrottling: false + } + }) + + // Float above fullscreen-ish apps. 'screen-saver' is the highest standard + // level; if it proves too aggressive over the taskbar/Start, drop to 'pop-up-menu'. + win.setAlwaysOnTop(true, 'screen-saver') + + // Exclude the overlay from screen capture (Windows WDA_EXCLUDEFROMCAPTURE). The + // chat's "what's on my screen" feature grabs a screenshot at send time; without + // this, the floating bar itself would appear in that capture (and in the user's + // own screenshots/recordings). It's a transient HUD, so hiding it from capture is + // what you want. + win.setContentProtection(true) + + // Tell the renderer when the window gains/loses focus so it can darken the wash + // in rest mode. Win11 renders the acrylic backdrop with a brighter luminosity + // layer when the window is inactive; a darker inactive wash (overlay.css) + // cancels that brightening so the panel stays dark + translucent, instead of + // flashing the OS's bright inactive tint. BrowserWindow focus/blur is reliable + // for this, unlike DOM window focus/blur. + win.on('focus', () => { + if (!win.isDestroyed()) win.webContents.send('overlay:active', true) + broadcastOverlayState() + }) + win.on('blur', () => { + if (!win.isDestroyed()) win.webContents.send('overlay:active', false) + broadcastOverlayState() + }) + + // The native close button should HIDE the summon overlay (so the global + // shortcut can re-open it), not destroy it. Real teardown uses destroy() + // (main-window close / quit), which bypasses this 'close' handler. + win.on('close', (e) => { + if (!isQuitting) { + e.preventDefault() + hideOverlay() + } + }) + + win.on('closed', () => { + overlayWindow = null + }) + + // Surface overlay load failures (e.g. a bad renderer URL) instead of silently + // leaving an empty window. + win.webContents.on('did-fail-load', (_e, code, desc, url) => + console.error('[overlay] did-fail-load', code, desc, url) + ) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/overlay`) + } else { + win.loadFile(join(__dirname, '../renderer/index.html'), { hash: 'overlay' }) + } + + applyOverlayMaterial(win) + overlayWindow = win + return win +} + +/** + * Apply the Win11 DWM system backdrop: acrylic → mica → none. This is the only + * acrylic that renders TRANSLUCENT on modern Win11 (the custom-color + * SetWindowCompositionAttribute path paints opaque on build 26200). The backdrop + * is OS-theme-tinted; overlay.css adds a thin black wash to push it toward black. + * Wrapped in try/catch because setBackgroundMaterial throws on unsupported + * platforms/builds (Win10, old Electron). + */ +export function applyOverlayMaterial(win: BrowserWindow): 'acrylic' | 'mica' | 'none' { + const trySet = (material: 'acrylic' | 'mica'): boolean => { + try { + // setBackgroundMaterial exists on Win; guard for type/platform safety. + const w = win as BrowserWindow & { + setBackgroundMaterial?: (m: string) => void + } + if (typeof w.setBackgroundMaterial !== 'function') return false + w.setBackgroundMaterial(material) + return true + } catch { + return false + } + } + + if (process.platform === 'win32') { + if (trySet('acrylic')) return 'acrylic' + if (trySet('mica')) return 'mica' + } + return 'none' +} + +// --- Summon / dismiss ------------------------------------------------------- + +const TWEEN_FRAME_MS = 20 +// Per-frame fraction of the remaining gap to close (exponential ease). Follows a +// MOVING target smoothly instead of restarting a fresh tween on each report — +// which is what made a live voice transcript's growth lurch. +const TWEEN_EASE = 0.28 +const INITIAL_HEIGHT = 200 // BrowserWindow's initial (hidden) height; real size comes from the renderer +const SETTLE_MS = 250 // after an open, snap (don't tween) height reports for this long +let tweenTimer: ReturnType | null = null +// The (continuously updated) height goal the tween eases toward, plus the anchor +// it holds while doing so. A live voice transcript retargets these many times a +// second; the single running loop just follows the latest values (no restart). +let tweenTargetH = INITIAL_HEIGHT +let tweenW = 0 +let lastToggle = 0 +// True once the renderer has mounted and reported its content height at least +// once. Until then the window holds an unmeasured, wrongly-sized empty frame, so +// the first summon is DEFERRED (pendingSummon) rather than flashing that frame. +let overlayReady = false +let pendingSummon = false +// Last content height the renderer reported. The window opens at this height (no +// flash from a fixed placeholder size), and it persists across hide/show. +let lastContentHeight = INITIAL_HEIGHT +// Until this timestamp, height reports snap instantly instead of tweening, so +// post-open layout settling doesn't animate a resize against the entrance fade. +let snapUntil = 0 +// Work area of the display captured at summon, reused by the height tween so a +// mid-stream cursor move to another monitor can't yank the panel across screens. +let activeWorkArea: { x: number; y: number; width: number; height: number } | null = null +// Whether the summon shortcut may open the overlay. Off until onboarding completes +// (the renderer reports the flag via 'overlay:setEnabled'); the overlay's own +// shortcut-setup step ships later. +let overlayEnabled = false + +/** Enable/disable summoning. Disabling also hides the overlay if it's open. */ +export function setOverlayEnabled(enabled: boolean): void { + overlayEnabled = enabled + if (!enabled) { + hideOverlay() + return + } + // Pre-warm: create + load the overlay (hidden) as soon as it's enabled — i.e. + // right after sign-in/onboarding — so the FIRST summon is instant instead of + // paying for window creation + bundle load + React/Firebase mount on activation. + // Created here (post-sign-in) rather than at cold startup so its Firebase still + // reads the already-persisted session. The renderer reports its height when it + // mounts, flipping overlayReady, so a summon during warm-up is handled by the + // existing pendingSummon path. + ensureOverlayWindow() +} + +/** + * Get the overlay window, creating it lazily on first summon. Creating it AFTER + * sign-in (instead of eagerly at startup) means its Firebase reads the + * already-persisted session, so the overlay opens authenticated — and we avoid + * the expensive per-summon reload we'd otherwise need to refresh auth. The + * window then stays warm, so later summons are instant. + */ +function ensureOverlayWindow(): BrowserWindow { + if (overlayWindow && !overlayWindow.isDestroyed()) return overlayWindow + return createOverlayWindow() +} + +/** + * Summon the overlay on the display under the cursor. If the renderer hasn't + * mounted/measured yet (first ever summon), DEFER the actual show until the first + * height report — otherwise we'd flash an empty, wrongly-sized acrylic frame + * during the heavy first bundle load. Once warm, this presents instantly. + */ +export function showOverlay(): void { + const win = ensureOverlayWindow() + + // Capture the display under the cursor ONCE at summon; the height tween reuses + // it so the panel stays on the display it opened on. + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + activeWorkArea = display.workArea + + if (!overlayReady) { + pendingSummon = true + return + } + presentOverlay(win) +} + +/** Position at the last measured height, show + focus, and tell the renderer to + * play the entrance animation. Chat history is preserved across summons. */ +function presentOverlay(win: BrowserWindow): void { + if (!activeWorkArea) { + activeWorkArea = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea + } + win.setBounds(computeOverlayBounds(activeWorkArea, lastContentHeight)) + snapUntil = Date.now() + SETTLE_MS + win.show() + win.focus() + win.webContents.send('overlay:shown') + broadcastOverlayState() +} + +export function hideOverlay(): void { + const win = overlayWindow + if (!win || win.isDestroyed()) return + if (tweenTimer) { + clearInterval(tweenTimer) + tweenTimer = null + } + // Let the renderer pre-stage its panel to opacity 0 BEFORE we hide, so the next + // summon fades in cleanly instead of flashing the fully-opaque panel for a frame. + if (win.isVisible()) win.webContents.send('overlay:willHide') + win.hide() + broadcastOverlayState() +} + +/** + * Broadcast that the summon shortcut fired, so any window (e.g. the onboarding + * shortcut-setup step) can give "it works" feedback. Sent on every accepted + * toggle — globalShortcut swallows the key globally and focus jumps to the + * overlay, so a renderer can't observe the press itself. + */ +function broadcastSummoned(): void { + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send('overlay:summoned') + } +} + +/** + * Broadcast the overlay's open/focused state to every window so the onboarding + * voice step can switch between "press the hotkey" and "hold Space". `active` + * requires the overlay to be both visible and focused (you can only hold-Space + * when it has focus). + */ +function broadcastOverlayState(): void { + const win = overlayWindow + const open = !!(win && !win.isDestroyed() && win.isVisible()) + const active = open && !!win && win.isFocused() + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send('overlay:visibility', { open, active }) + } +} + +/** Debounced toggle so a rapid double-press doesn't flicker. */ +export function toggleOverlay(): void { + // Gated until the onboarding shortcut step enables it — the shortcut stays + // registered (so it's claimed) but does nothing until then. + if (!overlayEnabled) return + const now = Date.now() + if (now - lastToggle < 150) return + lastToggle = now + broadcastSummoned() + const win = overlayWindow + const visible = !!(win && !win.isDestroyed() && win.isVisible()) + // Hide only if it exists and is visible; otherwise summon — showOverlay lazily + // creates the window on first use (so a null window must NOT short-circuit here). + if (visible) { + hideOverlay() + } else { + showOverlay() + } +} + +/** + * Ease the window height toward `contentHeight` (clamped to the display via + * computeOverlayBounds). Manual tween because Electron ignores setBounds' + * animate flag on Windows. Width/x/y stay anchored; only height grows downward. + */ +export function setOverlayHeight(contentHeight: number): void { + lastContentHeight = contentHeight + + // First report ever: the renderer has now mounted + measured. Mark ready, and if + // a summon was waiting on that, present at the real height now. + if (!overlayReady) { + overlayReady = true + if (pendingSummon) { + pendingSummon = false + const w = overlayWindow + if (w && !w.isDestroyed()) presentOverlay(w) + } + return + } + + const win = overlayWindow + if (!win || win.isDestroyed() || !win.isVisible()) return + + // Reuse the display captured at summon (fall back to the cursor's display if + // somehow unset) so the tween never repositions onto a different monitor. + const workArea = + activeWorkArea ?? screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea + const target = computeOverlayBounds(workArea, contentHeight) + const current = win.getBounds() + + // Retarget the (single, continuous) tween toward the latest goal. Updating these + // every report — instead of cancelling and starting a new tween — is what keeps a + // fast-growing voice transcript smooth rather than lurchy. Width only (x/y are + // read LIVE per frame in applyTweenHeight so a drag is never fought). + tweenTargetH = target.height + tweenW = target.width + + if (current.height === target.height) return + + // Snap instantly (no tween) during the post-open settle window OR on a large + // GROW. The big one is the first message: it inserts the whole message list above + // the input, so the window must grow a lot at once. Tweening that leaves the + // window shorter than the content for a few hundred ms, clipping the input row off + // the bottom until the tween catches up (it "vanishes, then comes back"). Snapping + // grows the window in one step so the input is never clipped. Small streaming + // grows (and shrinks) still tween for smoothness. + const bigGrow = target.height - current.height > 40 + if (Date.now() < snapUntil || bigGrow) { + if (tweenTimer) { + clearInterval(tweenTimer) + tweenTimer = null + } + applyTweenHeight(win, tweenTargetH) + return + } + + // A tween is already easing toward the (now-updated) goal — let it keep running + // instead of restarting it. Otherwise start one: each frame closes a fraction of + // the remaining gap toward the latest tweenTargetH (exponential ease), so a moving + // target stays smooth and it always lands exactly on the goal. + if (tweenTimer) return + tweenTimer = setInterval(() => { + const w = overlayWindow + if (!w || w.isDestroyed()) { + if (tweenTimer) clearInterval(tweenTimer) + tweenTimer = null + return + } + const cur = w.getBounds().height + const diff = tweenTargetH - cur + if (Math.abs(diff) <= 1) { + applyTweenHeight(w, tweenTargetH) + if (tweenTimer) clearInterval(tweenTimer) + tweenTimer = null + return + } + applyTweenHeight(w, Math.round(cur + diff * TWEEN_EASE)) + }, TWEEN_FRAME_MS) +} + +// Apply a new window HEIGHT while preserving the window's LIVE x/y, so the resize +// only ever changes height and never rewrites position. This is what stops the +// tween from fighting an in-progress user DRAG: the old code wrote a captured +// x/y every 20ms, so dragging the window while it was resizing (e.g. during a +// streaming reply) yanked it back to the pre-drag spot each frame — the glitch. +// We still nudge up if growing would run off the bottom of the captured work +// area, so a tall reply stays on-screen. +function applyTweenHeight(win: BrowserWindow, height: number): void { + const b = win.getBounds() + let y = b.y + const wa = activeWorkArea + if (wa && y + height > wa.y + wa.height) y = Math.max(wa.y, wa.y + wa.height - height) + win.setBounds({ x: b.x, y, width: tweenW, height }) +} diff --git a/windows/src/main/rewind/captureDecision.test.ts b/windows/src/main/rewind/captureDecision.test.ts new file mode 100644 index 0000000000..3d1664502d --- /dev/null +++ b/windows/src/main/rewind/captureDecision.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { shouldCaptureFrame, DUP_HAMMING_THRESHOLD } from './captureDecision' + +const base = { + locked: false, + idleSeconds: 0, + idleThresholdSeconds: 60, + busy: false, + appName: 'Code.exe', + excludedApps: [] as string[], + hash: '1111000011110000', + lastHash: '0000111100001111' // very different +} + +describe('shouldCaptureFrame', () => { + it('captures a normal, changed frame', () => { + expect(shouldCaptureFrame(base)).toEqual({ capture: true }) + }) + it('skips when the screen is locked', () => { + expect(shouldCaptureFrame({ ...base, locked: true })).toEqual({ capture: false, reason: 'locked' }) + }) + it('skips when the user is idle past the threshold', () => { + expect(shouldCaptureFrame({ ...base, idleSeconds: 120 })).toEqual({ capture: false, reason: 'idle' }) + }) + it('skips when a previous frame is still processing', () => { + expect(shouldCaptureFrame({ ...base, busy: true })).toEqual({ capture: false, reason: 'busy' }) + }) + it('skips when the focused app is excluded (case-insensitive)', () => { + expect(shouldCaptureFrame({ ...base, excludedApps: ['code.exe'] })).toEqual({ + capture: false, + reason: 'excluded' + }) + }) + it('excludes by case-insensitive substring of the app name', () => { + expect( + shouldCaptureFrame({ ...base, appName: 'Google Chrome', excludedApps: ['chrome'] }) + ).toEqual({ capture: false, reason: 'excluded' }) + }) + it('excludes by substring of the process name', () => { + expect( + shouldCaptureFrame({ + ...base, + appName: 'Some App', + processName: 'chrome', + excludedApps: ['chrome'] + }) + ).toEqual({ capture: false, reason: 'excluded' }) + }) + it('ignores empty/whitespace exclusion entries', () => { + expect(shouldCaptureFrame({ ...base, excludedApps: ['', ' '] })).toEqual({ capture: true }) + }) + it('does not exclude an unrelated app', () => { + expect( + shouldCaptureFrame({ ...base, appName: 'Notepad', excludedApps: ['chrome'] }) + ).toEqual({ capture: true }) + }) + it('skips a login page by window title (sensitive)', () => { + expect( + shouldCaptureFrame({ ...base, appName: 'Google Chrome', windowTitle: 'Sign in - Google Accounts' }) + ).toEqual({ capture: false, reason: 'sensitive' }) + }) + it('skips an incognito window by title (sensitive)', () => { + expect( + shouldCaptureFrame({ ...base, appName: 'Google Chrome', windowTitle: 'New Tab - Google Chrome (Incognito)' }) + ).toEqual({ capture: false, reason: 'sensitive' }) + }) + it('skips a password page by title (sensitive)', () => { + expect( + shouldCaptureFrame({ ...base, appName: 'Firefox', windowTitle: 'Change your password' }) + ).toEqual({ capture: false, reason: 'sensitive' }) + }) + it('captures a normal browser tab', () => { + expect( + shouldCaptureFrame({ ...base, appName: 'Google Chrome', windowTitle: 'Wikipedia — Octopus' }) + ).toEqual({ capture: true }) + }) + it('skips a near-duplicate of the last frame', () => { + expect(shouldCaptureFrame({ ...base, lastHash: base.hash })).toEqual({ + capture: false, + reason: 'duplicate' + }) + }) + it('captures when difference exceeds the dedup threshold', () => { + // flip more than DUP_HAMMING_THRESHOLD bits + const flipped = base.hash.split('') + for (let i = 0; i <= DUP_HAMMING_THRESHOLD; i++) flipped[i] = flipped[i] === '1' ? '0' : '1' + expect(shouldCaptureFrame({ ...base, lastHash: flipped.join('') })).toEqual({ capture: true }) + }) +}) diff --git a/windows/src/main/rewind/captureDecision.ts b/windows/src/main/rewind/captureDecision.ts new file mode 100644 index 0000000000..fe9f7df809 --- /dev/null +++ b/windows/src/main/rewind/captureDecision.ts @@ -0,0 +1,59 @@ +import { hammingDistance } from './frameHash' +import { SENSITIVE_WINDOW_MARKERS } from '../../shared/rewindExclusions' + +/** Max bit difference for two frames to count as "the same screen" → skip. */ +export const DUP_HAMMING_THRESHOLD = 4 + +export type CaptureState = { + locked: boolean + idleSeconds: number + idleThresholdSeconds: number + busy: boolean + appName: string + /** Foreground process name (e.g. "chrome"); matched alongside appName. */ + processName?: string + /** Foreground window title; matched against sensitive markers (login/private). */ + windowTitle?: string + excludedApps: string[] + hash: string + lastHash: string | null +} + +export type CaptureDecision = + | { capture: true } + | { capture: false; reason: 'locked' | 'idle' | 'busy' | 'excluded' | 'sensitive' | 'duplicate' } + +// Case-insensitive substring match so a user entry like "chrome" excludes +// "Google Chrome" (and the "chrome" process). Matched against the friendly app +// name and the process name; empty entries never match. +function isExcluded(appName: string, processName: string, excludedApps: string[]): boolean { + const haystack = `${appName} ${processName}`.toLowerCase() + return excludedApps.some((e) => { + const needle = e.trim().toLowerCase() + return needle.length > 0 && haystack.includes(needle) + }) +} + +// True when the window title looks like a login / password / private-browsing +// screen — so Rewind skips it even when the app itself isn't excluded (e.g. a +// login page in a normal browser). +function isSensitiveTitle(windowTitle: string): boolean { + const t = windowTitle.toLowerCase() + return SENSITIVE_WINDOW_MARKERS.some((m) => t.includes(m)) +} + +export function shouldCaptureFrame(s: CaptureState): CaptureDecision { + if (s.locked) return { capture: false, reason: 'locked' } + if (s.busy) return { capture: false, reason: 'busy' } + if (s.idleSeconds >= s.idleThresholdSeconds) return { capture: false, reason: 'idle' } + if (isExcluded(s.appName, s.processName ?? '', s.excludedApps)) { + return { capture: false, reason: 'excluded' } + } + if (isSensitiveTitle(s.windowTitle ?? '')) { + return { capture: false, reason: 'sensitive' } + } + if (s.lastHash && hammingDistance(s.hash, s.lastHash) <= DUP_HAMMING_THRESHOLD) { + return { capture: false, reason: 'duplicate' } + } + return { capture: true } +} diff --git a/windows/src/main/rewind/captureService.ts b/windows/src/main/rewind/captureService.ts new file mode 100644 index 0000000000..fb38db696e --- /dev/null +++ b/windows/src/main/rewind/captureService.ts @@ -0,0 +1,178 @@ +import { powerMonitor, nativeImage } from 'electron' +import { writeFileSync } from 'fs' +import { basename } from 'path' +import { getForegroundExePath, getForegroundWindowTitle } from '../usage/nativeForeground' +import { averageHash } from './frameHash' +import { shouldCaptureFrame } from './captureDecision' +import { rewindFramePath } from './paths' +import { helperProcess } from '../ocr/helperProcess' +import { insertRewindFrame, setRewindFrameOcr } from '../ipc/db' +import { setCurrentScreen } from './currentScreen' +import { getPersistedRewindSettings, persistRewindSettings } from './rewindSettings' +import { BUILT_IN_EXCLUDED_APPS } from '../../shared/rewindExclusions' +import type { RewindSettings } from '../../shared/types' + +const HASH_W = 16 +const HASH_H = 9 +const IDLE_THRESHOLD_SECONDS = 60 + +let locked = false +let lastHash: string | null = null +let powerListenersBound = false +// In-memory mirror of the persisted settings. startRewindCapture() loads the +// saved value (defaulting to capture-on) at startup; updateRewindSettings() +// keeps this and the on-disk copy in sync. Defaults to capture-on for any +// pre-startup getRewindSettings() read. +let settings: RewindSettings = { + captureEnabled: true, + intervalMs: 1000, + retentionDays: 14, + excludedApps: [] +} + +function bindPowerListeners(): void { + if (powerListenersBound) return + powerMonitor.on('lock-screen', () => (locked = true)) + powerMonitor.on('unlock-screen', () => (locked = false)) + powerListenersBound = true +} + +export type IngestResult = { captured: boolean; reason?: string } + +// Single-flight guard so the background "current screen" OCR never stacks: the +// helper processes one frame at a time, and a captured frame arrives ~every second. +// If an OCR is already running we skip this frame — the cache stays ~1-2s fresh, +// which is plenty for the chat's instant read. +let screenOcrInFlight = false + +/** + * Keep the chat's hot "current screen" cache fresh: OCR a just-captured frame in + * the background and store the text in {@link setCurrentScreen}, so the chat reads + * it with zero latency. Also persists the OCR onto the frame so the slower + * backfiller doesn't re-OCR it. Best-effort and NEVER awaited by the capture path. + */ +async function refreshCurrentScreen(frameId: number, jpeg: Buffer): Promise { + if (screenOcrInFlight) return + screenOcrInFlight = true + try { + const res = await helperProcess.ocr(jpeg) + if (res.ok) { + setCurrentScreen(res.fullText) + setRewindFrameOcr(frameId, res.fullText) + } + } catch { + /* best-effort: keep the last good cached value */ + } finally { + screenOcrInFlight = false + } +} + +/** + * Ingest one screen frame (JPEG bytes) sampled by the renderer's capture host + * (a getUserMedia desktop stream → canvas, the app's proven efficient path). + * Capture *acquisition* deliberately lives in the renderer so it never touches + * Electron's heavyweight `desktopCapturer` full-resolution thumbnail path, + * which froze the whole system when polled. The main process keeps the cheap + * parts: foreground-window metadata, idle/lock/dup gating, and storage. + */ +export async function ingestRewindFrame(jpeg: Buffer): Promise { + if (!settings.captureEnabled) return { captured: false, reason: 'disabled' } + + // NOTE: we don't skip capture while an Omi window is focused. The main window is + // no longer content-protected, so Omi appears in the Rewind timeline like any + // other app; the timeline keeps filling (and the chat's screen cache stays fresh) + // even while Omi is focused. The dedup hash below still skips unchanged frames. + + let win = { app: '', title: '', processName: '' } + try { + const info = await helperProcess.windowInfo() + // Prefer the friendly app name ("Google Chrome") over the exe ("chrome"); + // keep the raw process name in its own field. + win = { app: info.app || info.processName, title: info.title, processName: info.processName } + } catch { + /* helper unavailable; fall back below */ + } + // The C# helper isn't always running (OCR is shelved), so windowInfo() often + // yields nothing → every frame would read "Unknown app". Fall back to the + // always-available koffi/user32 foreground reader (same source app-usage uses) + // and derive a name from the foreground exe. + if (!win.app) { + const exe = getForegroundExePath() + if (exe) { + const proc = basename(exe).replace(/\.exe$/i, '') + win = { + app: proc ? proc.charAt(0).toUpperCase() + proc.slice(1) : '', + title: win.title, + processName: win.processName || proc + } + } + } + // The helper rarely runs (OCR shelved), so the title is usually empty — but the + // window title is what catches login/private-browsing screens in a normal + // browser. Read it directly from user32 (GetWindowTextW) as a fallback. + if (!win.title) win.title = getForegroundWindowTitle() ?? '' + + const image = nativeImage.createFromBuffer(jpeg) + if (image.isEmpty()) return { captured: false, reason: 'decode-failed' } + + const small = image.resize({ width: HASH_W, height: HASH_H }) + const hash = averageHash(small.toBitmap(), HASH_W * HASH_H) + + const decision = shouldCaptureFrame({ + locked, + idleSeconds: powerMonitor.getSystemIdleTime(), + idleThresholdSeconds: IDLE_THRESHOLD_SECONDS, + busy: false, + appName: win.app, + processName: win.processName, + windowTitle: win.title, + excludedApps: [...BUILT_IN_EXCLUDED_APPS, ...settings.excludedApps], + hash, + lastHash + }) + if (!decision.capture) return { captured: false, reason: decision.reason } + + try { + const ts = Date.now() + const path = rewindFramePath(ts) + writeFileSync(path, jpeg) + const { width, height } = image.getSize() + const id = insertRewindFrame({ + ts, + app: win.app, + windowTitle: win.title, + processName: win.processName, + ocrText: '', + imagePath: path, + width, + height, + indexed: 0 + }) + lastHash = hash + // Update the chat's hot "current screen" cache from this fresh frame, in the + // background (single-flight). Not awaited: capture cadence must not wait on OCR. + void refreshCurrentScreen(id, jpeg) + return { captured: true } + } catch (e) { + console.error('[rewind] capture failed:', (e as Error).message) + return { captured: false, reason: 'write-failed' } + } +} + +/** + * Load the user's persisted settings (capture-on by default for a fresh + * install) + bind power listeners. The renderer drives cadence. + */ +export function startRewindCapture(): void { + bindPowerListeners() + settings = getPersistedRewindSettings() +} + +/** Update the live settings and persist them so the choice survives restarts. */ +export function updateRewindSettings(next: RewindSettings): void { + settings = persistRewindSettings(next) +} + +export function getRewindSettings(): RewindSettings { + return settings +} diff --git a/windows/src/main/rewind/currentScreen.test.ts b/windows/src/main/rewind/currentScreen.test.ts new file mode 100644 index 0000000000..c0b0f8bd94 --- /dev/null +++ b/windows/src/main/rewind/currentScreen.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { + setCurrentScreen, + getCurrentScreen, + currentScreenAgeMs, + screenCacheFresh, + CACHE_FRESH_MS +} from './currentScreen' + +describe('currentScreen cache', () => { + beforeEach(() => { + vi.useFakeTimers() + // Reset to a known state for each test. + setCurrentScreen('') + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('stores and returns the latest text', () => { + setCurrentScreen('hello world') + expect(getCurrentScreen().text).toBe('hello world') + }) + + it('overwrites with the newest text', () => { + setCurrentScreen('first') + setCurrentScreen('second') + expect(getCurrentScreen().text).toBe('second') + }) + + it('stamps the time on set, so age reflects how stale the text is', () => { + vi.setSystemTime(new Date('2026-06-09T00:00:00Z')) + setCurrentScreen('on screen') + vi.advanceTimersByTime(1500) + expect(currentScreenAgeMs()).toBe(1500) + }) +}) + +describe('screenCacheFresh', () => { + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + }) + + it('is false before any setCurrentScreen (ts === 0)', async () => { + // Fresh module so the cache has never been seeded this "session" (ts === 0). + vi.resetModules() + const mod = await import('./currentScreen') + expect(mod.screenCacheFresh(Date.now())).toBe(false) + }) + + it('is true right after setCurrentScreen', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-09T00:00:00Z')) + setCurrentScreen('on screen') + expect(screenCacheFresh(Date.now())).toBe(true) + }) + + it('is false once the age exceeds CACHE_FRESH_MS', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-09T00:00:00Z')) + setCurrentScreen('on screen') + vi.advanceTimersByTime(CACHE_FRESH_MS + 1) + expect(screenCacheFresh(Date.now())).toBe(false) + }) + + it('is true exactly at the CACHE_FRESH_MS boundary', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-09T00:00:00Z')) + setCurrentScreen('on screen') + vi.advanceTimersByTime(CACHE_FRESH_MS) + expect(screenCacheFresh(Date.now())).toBe(true) + }) +}) diff --git a/windows/src/main/rewind/currentScreen.ts b/windows/src/main/rewind/currentScreen.ts new file mode 100644 index 0000000000..08a4e091fa --- /dev/null +++ b/windows/src/main/rewind/currentScreen.ts @@ -0,0 +1,36 @@ +// In-memory "what's on screen right now": the latest OCR'd screen text, kept hot +// by the Rewind capture pipeline so the chat can read it with ZERO latency at send +// time — no DB scan, no on-demand OCR, no desktopCapturer. Frames captured by the +// Rewind host already exclude Omi's own windows, so this is the user's actual work, +// not Omi's UI. Freshness is ~1s (the capture cadence); the chat accepts that in +// exchange for an instant, always-ready answer. + +let text = '' +let ts = 0 + +// The capture loop refreshes the cache ~every 1s while active, but pauses on idle +// (60s)/lock/excluded-app — beyond this window the cached text is no longer +// trustworthy as "right now", so the chat must not send it. +export const CACHE_FRESH_MS = 30000 + +/** + * Pure freshness predicate (no db import, so it's unit-testable under node vitest): + * true iff the cache has been seeded (ts !== 0) AND it's within CACHE_FRESH_MS of now. + */ +export function screenCacheFresh(now: number): boolean { + return ts !== 0 && now - ts <= CACHE_FRESH_MS +} + +export function setCurrentScreen(t: string): void { + text = t + ts = Date.now() +} + +export function getCurrentScreen(): { text: string; ts: number } { + return { text, ts } +} + +/** Age of the cached text in ms; Infinity if never set. For diagnostics/staleness. */ +export function currentScreenAgeMs(): number { + return ts === 0 ? Infinity : Date.now() - ts +} diff --git a/windows/src/main/rewind/frameHash.test.ts b/windows/src/main/rewind/frameHash.test.ts new file mode 100644 index 0000000000..1fbce98997 --- /dev/null +++ b/windows/src/main/rewind/frameHash.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { averageHash, hammingDistance } from './frameHash' + +// A 2x2 "bitmap" in Electron BGRA order (4 bytes/pixel). +// Pixel luminance ~ (r+g+b)/3; build dark vs light pixels. +const px = (v: number): number[] => [v, v, v, 255] // B,G,R,A +function bitmap(vals: number[]): Buffer { + return Buffer.from(vals.flatMap(px)) +} + +describe('averageHash', () => { + it('produces a bit per pixel: 1 when above the frame average', () => { + // values 0,0,255,255 -> average 127.5 -> bits 0,0,1,1 + expect(averageHash(bitmap([0, 0, 255, 255]), 4)).toBe('0011') + }) + it('is stable for identical input', () => { + const b = bitmap([10, 200, 30, 240]) + expect(averageHash(b, 4)).toBe(averageHash(bitmap([10, 200, 30, 240]), 4)) + }) +}) + +describe('hammingDistance', () => { + it('counts differing bits', () => { + expect(hammingDistance('0011', '0001')).toBe(1) + expect(hammingDistance('0011', '0011')).toBe(0) + expect(hammingDistance('1111', '0000')).toBe(4) + }) + it('treats length mismatch as maximally different', () => { + expect(hammingDistance('001', '0011')).toBe(Number.POSITIVE_INFINITY) + }) +}) diff --git a/windows/src/main/rewind/frameHash.ts b/windows/src/main/rewind/frameHash.ts new file mode 100644 index 0000000000..b363fc3b4a --- /dev/null +++ b/windows/src/main/rewind/frameHash.ts @@ -0,0 +1,24 @@ +// Average-hash (aHash) over a small BGRA bitmap (Electron NativeImage.toBitmap()). +// Pure: takes raw bytes + pixel count, returns a bit string. No image decoding here — +// the caller resizes the NativeImage to a tiny size first (e.g. 16x9 = 144 px). + +/** @param bgra BGRA bytes, 4 per pixel. @param pixelCount number of pixels. */ +export function averageHash(bgra: Buffer, pixelCount: number): string { + const lum = new Array(pixelCount) + for (let i = 0; i < pixelCount; i++) { + const o = i * 4 + lum[i] = (bgra[o] + bgra[o + 1] + bgra[o + 2]) / 3 + } + const avg = lum.reduce((a, b) => a + b, 0) / pixelCount + let bits = '' + for (let i = 0; i < pixelCount; i++) bits += lum[i] > avg ? '1' : '0' + return bits +} + +/** Bit difference between two equal-length hash strings; Infinity if lengths differ. */ +export function hammingDistance(a: string, b: string): number { + if (a.length !== b.length) return Number.POSITIVE_INFINITY + let d = 0 + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) d++ + return d +} diff --git a/windows/src/main/rewind/ocrService.ts b/windows/src/main/rewind/ocrService.ts new file mode 100644 index 0000000000..c4463e69e4 --- /dev/null +++ b/windows/src/main/rewind/ocrService.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'fs' +import { helperProcess } from '../ocr/helperProcess' +import { unindexedRewindFrames, setRewindFrameOcr } from '../ipc/db' + +const BACKFILL_INTERVAL_MS = 4000 +const BATCH = 5 + +let timer: NodeJS.Timeout | null = null +let running = false + +async function backfill(): Promise { + if (running) return + running = true + try { + const frames = unindexedRewindFrames(BATCH) + for (const f of frames) { + if (f.id == null) continue + let jpeg: Buffer + try { + jpeg = readFileSync(f.imagePath) + } catch { + setRewindFrameOcr(f.id, '') // image gone; mark indexed so we stop retrying + continue + } + const result = await helperProcess.ocr(jpeg) + setRewindFrameOcr(f.id, result.ok ? result.fullText : '') + } + } finally { + running = false + } +} + +export function startRewindOcr(): void { + if (timer) clearInterval(timer) + timer = setInterval(() => void backfill(), BACKFILL_INTERVAL_MS) +} diff --git a/windows/src/main/rewind/paths.ts b/windows/src/main/rewind/paths.ts new file mode 100644 index 0000000000..df129cb969 --- /dev/null +++ b/windows/src/main/rewind/paths.ts @@ -0,0 +1,24 @@ +import { app } from 'electron' +import { join } from 'path' +import { mkdirSync } from 'fs' + +/** Root dir for rewind JPEGs: /rewind */ +export function rewindRoot(): string { + return join(app.getPath('userData'), 'rewind') +} + +/** Per-day subdir (YYYY-MM-DD), created if missing. */ +export function rewindDayDir(tsMs: number): string { + const d = new Date(tsMs) + const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate() + ).padStart(2, '0')}` + const dir = join(rewindRoot(), day) + mkdirSync(dir, { recursive: true }) + return dir +} + +/** Absolute path for a frame's JPEG: //.jpg */ +export function rewindFramePath(tsMs: number): string { + return join(rewindDayDir(tsMs), `${tsMs}.jpg`) +} diff --git a/windows/src/main/rewind/retentionRunner.ts b/windows/src/main/rewind/retentionRunner.ts new file mode 100644 index 0000000000..917433e120 --- /dev/null +++ b/windows/src/main/rewind/retentionRunner.ts @@ -0,0 +1,20 @@ +import { unlink } from 'fs/promises' +import { retentionCutoff } from './retentionSelection' +import { deleteRewindFramesOlderThan } from '../ipc/db' +import { getRewindSettings } from './captureService' + +const PRUNE_INTERVAL_MS = 60 * 60 * 1000 // hourly + +export async function pruneRewindOnce(): Promise { + const { retentionDays } = getRewindSettings() + const cutoff = retentionCutoff(Date.now(), retentionDays) + const removed = deleteRewindFramesOlderThan(cutoff) + await Promise.all( + removed.map((f) => unlink(f.imagePath).catch(() => undefined)) // file may already be gone + ) + return removed.length +} + +export function startRewindRetention(): void { + setInterval(() => void pruneRewindOnce(), PRUNE_INTERVAL_MS) +} diff --git a/windows/src/main/rewind/retentionSelection.test.ts b/windows/src/main/rewind/retentionSelection.test.ts new file mode 100644 index 0000000000..55e641dca7 --- /dev/null +++ b/windows/src/main/rewind/retentionSelection.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest' +import { retentionCutoff } from './retentionSelection' + +describe('retentionCutoff', () => { + const now = 10_000_000_000 // fixed epoch ms + it('returns now minus N days in ms', () => { + expect(retentionCutoff(now, 7)).toBe(now - 7 * 24 * 60 * 60 * 1000) + }) + it('treats 0 or negative retention as "keep nothing in the past" (cutoff = now)', () => { + expect(retentionCutoff(now, 0)).toBe(now) + expect(retentionCutoff(now, -5)).toBe(now) + }) +}) diff --git a/windows/src/main/rewind/retentionSelection.ts b/windows/src/main/rewind/retentionSelection.ts new file mode 100644 index 0000000000..72637b4910 --- /dev/null +++ b/windows/src/main/rewind/retentionSelection.ts @@ -0,0 +1,7 @@ +const DAY_MS = 24 * 60 * 60 * 1000 + +/** Frames with ts < the returned cutoff are eligible for pruning. */ +export function retentionCutoff(nowMs: number, retentionDays: number): number { + if (retentionDays <= 0) return nowMs + return nowMs - retentionDays * DAY_MS +} diff --git a/windows/src/main/rewind/rewindGrouping.test.ts b/windows/src/main/rewind/rewindGrouping.test.ts new file mode 100644 index 0000000000..97562722dc --- /dev/null +++ b/windows/src/main/rewind/rewindGrouping.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { groupFrames, GROUP_WINDOW_MS } from './rewindGrouping' +import type { RewindFrame } from '../../shared/types' + +function frame(over: Partial): RewindFrame { + return { + id: 1, + ts: 0, + app: 'Code.exe', + windowTitle: 'a.ts', + processName: 'Code', + ocrText: 'hello world', + imagePath: '/x.jpg', + width: 0, + height: 0, + indexed: 1, + ...over + } +} + +describe('groupFrames', () => { + it('clusters frames within the time window AND same app+window into one group', () => { + const frames = [ + frame({ id: 1, ts: 1000 }), + frame({ id: 2, ts: 1000 + GROUP_WINDOW_MS - 1 }), + frame({ id: 3, ts: 1000 + GROUP_WINDOW_MS + 5000 }) // new group (gap > window) + ] + const groups = groupFrames(frames, 'hello') + expect(groups).toHaveLength(2) + // newest group (frame 3) sorts first; oldest group (frames 1,2) sorts second + expect(groups[0].frames.map((f) => f.id)).toEqual([3]) + expect(groups[1].frames.map((f) => f.id)).toEqual([1, 2]) + }) + it('splits when app/window changes even within the window', () => { + const groups = groupFrames( + [frame({ id: 1, ts: 0, app: 'A' }), frame({ id: 2, ts: 10, app: 'B' })], + 'hello' + ) + expect(groups).toHaveLength(2) + }) + it('sorts groups newest-first and sets startTs/endTs/representative/snippet', () => { + const groups = groupFrames([frame({ id: 1, ts: 0 }), frame({ id: 2, ts: 9_000_000 })], 'world') + expect(groups[0].startTs).toBe(9_000_000) // newest group first + expect(groups[0].representative.id).toBe(2) + expect(groups[0].matchSnippet.toLowerCase()).toContain('world') + }) +}) diff --git a/windows/src/main/rewind/rewindGrouping.ts b/windows/src/main/rewind/rewindGrouping.ts new file mode 100644 index 0000000000..6c6d9ec34b --- /dev/null +++ b/windows/src/main/rewind/rewindGrouping.ts @@ -0,0 +1,58 @@ +import type { RewindFrame, RewindSearchGroup } from '../../shared/types' + +/** Temporal window for clustering consecutive frames (matches macOS 30s). */ +export const GROUP_WINDOW_MS = 30_000 + +function snippet(text: string, query: string): string { + const idx = text.toLowerCase().indexOf(query.toLowerCase()) + if (idx < 0) return text.slice(0, 80) + const start = Math.max(0, idx - 30) + return (start > 0 ? '…' : '') + text.slice(start, idx + query.length + 30).trim() + '…' +} + +/** + * Cluster a flat frame list into groups: consecutive frames within + * GROUP_WINDOW_MS of the group's start that share the same app + window title. + * Input order is irrelevant (sorted ascending internally); output is newest group first. + */ +export function groupFrames(frames: RewindFrame[], query: string): RewindSearchGroup[] { + const sorted = [...frames].sort((a, b) => a.ts - b.ts) + const groups: RewindSearchGroup[] = [] + let current: RewindFrame[] = [] + + const flush = (): void => { + if (current.length === 0) return + const first = current[0] + const last = current[current.length - 1] + const rep = current.find((f) => f.ocrText.toLowerCase().includes(query.toLowerCase())) ?? last + groups.push({ + id: `${first.app}-${first.ts}`, + app: first.app, + windowTitle: first.windowTitle, + startTs: first.ts, + endTs: last.ts, + frames: [...current], + representative: rep, + matchSnippet: snippet(rep.ocrText, query) + }) + current = [] + } + + for (const f of sorted) { + if (current.length === 0) { + current.push(f) + continue + } + const first = current[0] + const prev = current[current.length - 1] + const sameContext = prev.app === f.app && prev.windowTitle === f.windowTitle + const withinWindow = f.ts - first.ts <= GROUP_WINDOW_MS + if (sameContext && withinWindow) current.push(f) + else { + flush() + current.push(f) + } + } + flush() + return groups.sort((a, b) => b.startTs - a.startTs) +} diff --git a/windows/src/main/rewind/rewindSettings.ts b/windows/src/main/rewind/rewindSettings.ts new file mode 100644 index 0000000000..453a30798f --- /dev/null +++ b/windows/src/main/rewind/rewindSettings.ts @@ -0,0 +1,67 @@ +import { app } from 'electron' +import { join } from 'path' +import { readFileSync, writeFileSync } from 'fs' +import type { RewindSettings } from '../../shared/types' + +// Rewind capture is ON by default — screen history is a core feature, so a fresh +// install (no settings file yet) starts capturing. Once the user changes a +// setting it is persisted and these defaults no longer apply. excludedApps holds +// only USER additions; the built-in screenshot-tool exclusions live in +// shared/rewindExclusions and are merged in at capture time. +const DEFAULTS: RewindSettings = { + captureEnabled: true, + intervalMs: 1000, + retentionDays: 14, + excludedApps: [] +} + +function file(): string { + return join(app.getPath('userData'), 'rewind-settings.json') +} + +// Coerce a partial/untrusted settings object into a fully-valid one. +function sanitize(raw: Partial): RewindSettings { + const intervalMs = + typeof raw.intervalMs === 'number' && Number.isFinite(raw.intervalMs) && raw.intervalMs > 0 + ? raw.intervalMs + : DEFAULTS.intervalMs + const retentionDays = + typeof raw.retentionDays === 'number' && + Number.isFinite(raw.retentionDays) && + raw.retentionDays >= 1 + ? Math.floor(raw.retentionDays) + : DEFAULTS.retentionDays + const excludedApps = Array.isArray(raw.excludedApps) + ? raw.excludedApps + .filter((s): s is string => typeof s === 'string') + .map((s) => s.trim()) + .filter(Boolean) + : [] + return { + // Default ON: only an explicit `false` disables capture. + captureEnabled: raw.captureEnabled !== false, + intervalMs, + retentionDays, + excludedApps + } +} + +// Read the persisted settings, defaulting to capture-on. Never throws — a +// missing/corrupt file yields the defaults. +export function getPersistedRewindSettings(): RewindSettings { + try { + return sanitize(JSON.parse(readFileSync(file(), 'utf-8')) as Partial) + } catch { + return { ...DEFAULTS } + } +} + +export function persistRewindSettings(next: RewindSettings): RewindSettings { + const value = sanitize(next) + try { + writeFileSync(file(), JSON.stringify(value), 'utf-8') + } catch (e) { + console.warn('[rewind] failed to persist settings:', e) + } + return value +} diff --git a/windows/src/main/rewind/sourceId.ts b/windows/src/main/rewind/sourceId.ts new file mode 100644 index 0000000000..b862c9acc4 --- /dev/null +++ b/windows/src/main/rewind/sourceId.ts @@ -0,0 +1,55 @@ +import { desktopCapturer, screen } from 'electron' + +// desktopCapturer.getSources() is pathologically slow on some machines (multiple +// seconds even with thumbnails disabled), and it's the dominant cost of enabling +// Rewind capture. The primary screen's source id is stable for a session, so we +// fetch it once, cache it, and reuse it. The cache is invalidated when the +// display layout changes. A single-flight promise dedupes concurrent callers +// (e.g. the startup prewarm racing the user's first enable). + +let cached: string | null = null +let inflight: Promise | null = null + +async function fetchPrimarySourceId(): Promise { + const sources = await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 0, height: 0 } // ids only — no screen bitmap + }) + return sources[0]?.id ?? null +} + +/** Cached primary-screen source id; computes it (slowly) once, then reuses it. */ +export async function getPrimarySourceId(): Promise { + if (cached) return cached + if (!inflight) { + inflight = fetchPrimarySourceId() + .then((id) => { + cached = id + return id + }) + .finally(() => { + inflight = null + }) + } + return inflight +} + +let invalidatorBound = false + +/** + * Kick off the slow getSources() once at startup-idle so the cache is warm + * before the user enables capture — turning the multi-second enable hitch into + * an instant cache hit. Also binds display-change listeners that drop the cache. + */ +export function prewarmPrimarySourceId(): void { + if (!invalidatorBound) { + const invalidate = (): void => { + cached = null + } + screen.on('display-added', invalidate) + screen.on('display-removed', invalidate) + screen.on('display-metrics-changed', invalidate) + invalidatorBound = true + } + void getPrimarySourceId() +} diff --git a/windows/src/main/screenSynth/state.ts b/windows/src/main/screenSynth/state.ts new file mode 100644 index 0000000000..2ea371e1d6 --- /dev/null +++ b/windows/src/main/screenSynth/state.ts @@ -0,0 +1,57 @@ +// src/main/screenSynth/state.ts +import { app } from 'electron' +import { join } from 'path' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import type { ScreenSynthState } from '../../shared/types' + +const DEFAULTS: ScreenSynthState = { + enabled: false, // opt-in: writes screen-derived content to the cloud account + watermarkTs: 0, + lastRunAt: null, + lastCount: 0, + denylist: [] +} + +function statePath(): string { + return join(app.getPath('userData'), 'screen-synth.json') +} + +let cache: ScreenSynthState | null = null + +export function getScreenSynthState(): ScreenSynthState { + if (cache) return cache + try { + if (existsSync(statePath())) { + const raw = JSON.parse(readFileSync(statePath(), 'utf8')) as Partial + cache = { ...DEFAULTS, ...raw } + return cache + } + } catch { + /* corrupt file → fall back to defaults */ + } + cache = { ...DEFAULTS } + return cache +} + +function persist(next: ScreenSynthState): ScreenSynthState { + cache = next + try { + writeFileSync(statePath(), JSON.stringify(next, null, 2)) + } catch { + /* best-effort; in-memory cache still holds for this session */ + } + return next +} + +export function updateScreenSynthState(patch: Partial): ScreenSynthState { + return persist({ ...getScreenSynthState(), ...patch }) +} + +export function advanceWatermark(ts: number): void { + const cur = getScreenSynthState() + if (ts > cur.watermarkTs) persist({ ...cur, watermarkTs: ts }) +} + +export function recordRun(lastRunAt: number, lastCount: number): void { + persist({ ...getScreenSynthState(), lastRunAt, lastCount }) +} diff --git a/windows/src/main/usage/category.test.ts b/windows/src/main/usage/category.test.ts new file mode 100644 index 0000000000..268cd7d379 --- /dev/null +++ b/windows/src/main/usage/category.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { categorize } from './category' + +describe('categorize', () => { + it('maps known browsers', () => { + expect(categorize('chrome.exe')).toBe('browser') + expect(categorize('msedge.exe')).toBe('browser') + expect(categorize('firefox.exe')).toBe('browser') + }) + it('maps editors and comms', () => { + expect(categorize('Code.exe')).toBe('editor') + expect(categorize('devenv.exe')).toBe('editor') + expect(categorize('slack.exe')).toBe('comms') + expect(categorize('Discord.exe')).toBe('comms') + }) + it('maps media', () => { + expect(categorize('spotify.exe')).toBe('media') + expect(categorize('vlc.exe')).toBe('media') + }) + it('is case-insensitive and defaults to other', () => { + expect(categorize('CHROME.EXE')).toBe('browser') + expect(categorize('some-random-tool.exe')).toBe('other') + expect(categorize('')).toBe('other') + }) +}) diff --git a/windows/src/main/usage/category.ts b/windows/src/main/usage/category.ts new file mode 100644 index 0000000000..97bbc194a0 --- /dev/null +++ b/windows/src/main/usage/category.ts @@ -0,0 +1,20 @@ +import type { UsageCategory } from '../../shared/types' + +// Deterministic exe-basename → coarse category map. Matched against the lowercased +// basename without extension. Substring match so variants ("chrome", "chrome_proxy") +// land in the same bucket. Unknown apps fall through to 'other'. +const RULES: ReadonlyArray<[UsageCategory, readonly string[]]> = [ + ['browser', ['chrome', 'msedge', 'firefox', 'opera', 'brave', 'arc', 'vivaldi']], + ['editor', ['code', 'devenv', 'idea', 'pycharm', 'webstorm', 'sublime', 'notepad++', 'rider', 'cursor']], + ['comms', ['slack', 'discord', 'teams', 'zoom', 'telegram', 'whatsapp', 'outlook']], + ['media', ['spotify', 'vlc', 'wmplayer', 'itunes', 'foobar2000']] +] + +export function categorize(exeName: string): UsageCategory { + const base = exeName.toLowerCase().replace(/\.exe$/, '') + if (!base) return 'other' + for (const [cat, keys] of RULES) { + if (keys.some((k) => base.includes(k))) return cat + } + return 'other' +} diff --git a/windows/src/main/usage/foregroundMonitor.ts b/windows/src/main/usage/foregroundMonitor.ts new file mode 100644 index 0000000000..fa7a1538dc --- /dev/null +++ b/windows/src/main/usage/foregroundMonitor.ts @@ -0,0 +1,87 @@ +import { getForegroundExePath, subscribeForegroundChange } from './nativeForeground' +import { UsageAccumulator } from './usageAccumulator' +import { addAppUsage, pruneAppUsage } from '../ipc/db' +import { getUsageSettings } from './usageSettings' +import { usageCutoff } from './usageRetention' + +// Switch boundaries are now captured precisely by the WinEvent hook, so the poll +// only needs to bank elapsed time for a long-running single app and cap idle +// gaps — it can be much coarser than before, cutting idle wakeups (closer to +// macOS's event-driven WindowMonitor). +const POLL_MS = 15_000 +const FLUSH_MS = 60_000 +// Cap a single attributed gap at 3 poll intervals so a stalled timer, sleep, or +// lock doesn't dump minutes onto whatever app happened to be foreground. +const MAX_GAP_MS = POLL_MS * 3 + +let pollTimer: NodeJS.Timeout | null = null +let flushTimer: NodeJS.Timeout | null = null +let unsubscribeForeground: (() => void) | null = null +let accumulator: UsageAccumulator | null = null + +function flush(): void { + if (!accumulator) return + const now = Date.now() + for (const { exePath, ms } of accumulator.drain()) { + try { + addAppUsage(exePath, ms / 1000, now) + } catch (e) { + console.warn('[usage] flush failed for', exePath, e) + } + } +} + +// Start polling the foreground window. No-op when disabled by setting or when +// already running. Safe to call at app startup. +// Drop app_usage rows older than the user's configured retention window. Safe to +// call any time (startup, or right after the window is changed in Settings). +export function pruneUsageNow(): void { + try { + pruneAppUsage(usageCutoff(Date.now(), getUsageSettings().retentionDays)) + } catch (e) { + console.warn('[usage] prune failed:', e) + } +} + +export function startForegroundMonitor(): void { + if (pollTimer) return + if (!getUsageSettings().enabled) return + if (process.platform !== 'win32') return + // Bound the table: drop apps not foregrounded within the retention window. + pruneUsageNow() + accumulator = new UsageAccumulator(MAX_GAP_MS) + const sample = (): void => { + try { + accumulator?.addSample(getForegroundExePath(), Date.now()) + } catch (e) { + console.warn('[usage] sample failed:', e) + } + } + // Event-driven: credit the outgoing app the instant the foreground changes, so + // switch boundaries are precise regardless of poll cadence. + unsubscribeForeground = subscribeForegroundChange(sample) + // Coarse poll: bank elapsed time for the current app and cap idle gaps. + pollTimer = setInterval(sample, POLL_MS) + flushTimer = setInterval(flush, FLUSH_MS) + console.log('[usage] foreground monitor started') +} + +// Force the in-memory accumulator to persist immediately, instead of waiting for +// the next FLUSH_MS tick. Used by the usage:flush IPC so the running tally is +// visible right away (the periodic flush only writes every 60s). No-op when the +// monitor isn't running. +export function flushForegroundMonitor(): void { + flush() +} + +export function stopForegroundMonitor(): void { + if (pollTimer) clearInterval(pollTimer) + if (flushTimer) clearInterval(flushTimer) + if (unsubscribeForeground) unsubscribeForeground() + pollTimer = null + flushTimer = null + unsubscribeForeground = null + flush() // persist whatever was pending + accumulator = null + console.log('[usage] foreground monitor stopped') +} diff --git a/windows/src/main/usage/nativeForeground.ts b/windows/src/main/usage/nativeForeground.ts new file mode 100644 index 0000000000..62b75cb948 --- /dev/null +++ b/windows/src/main/usage/nativeForeground.ts @@ -0,0 +1,238 @@ +import koffi from 'koffi' + +// PROCESS_QUERY_LIMITED_INFORMATION — enough to read the image path, and works +// for processes at higher integrity than ours (unlike QUERY_INFORMATION). +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + +// Foreground-window-changed accessibility event + flags for an out-of-context +// (callback-on-our-thread) hook. OBJID_WINDOW filters out caret/menu/child noise. +const EVENT_SYSTEM_FOREGROUND = 0x0003 +const WINEVENT_OUTOFCONTEXT = 0x0000 +const OBJID_WINDOW = 0 + +export type ForegroundWindowInfo = { + handle: string | null + exePath: string | null + // Win32 window class — lets callers distinguish a real app window from a bare + // shell surface (desktop/taskbar/Start), which share explorer.exe. + className: string | null +} + +type Win32 = { + getForegroundExePath: () => string | null + // Foreground window's HWND (as a decimal string the C# helper can parse) plus + // its owning exe path, read from a single GetForegroundWindow() call. + getForegroundWindowInfo: () => ForegroundWindowInfo + // Foreground window's title text (GetWindowTextW). Lets Rewind detect + // login/private-browsing screens without the C# helper running. + getForegroundWindowTitle: () => string | null + // Fire `cb` whenever the foreground window changes. Returns an unsubscribe. + subscribeForegroundChange: (cb: () => void) => () => void +} + +let cached: Win32 | null = null +let loadFailed = false + +function load(): Win32 | null { + if (cached) return cached + if (loadFailed) return null + try { + const user32 = koffi.load('user32.dll') + const kernel32 = koffi.load('kernel32.dll') + + const GetForegroundWindow = user32.func('void* GetForegroundWindow()') + const GetWindowThreadProcessId = user32.func( + 'uint32 GetWindowThreadProcessId(void* hWnd, _Out_ uint32* lpdwProcessId)' + ) + const OpenProcess = kernel32.func( + 'void* OpenProcess(uint32 dwDesiredAccess, bool bInheritHandle, uint32 dwProcessId)' + ) + const QueryFullProcessImageNameW = kernel32.func( + 'bool QueryFullProcessImageNameW(void* hProcess, uint32 dwFlags, _Out_ uint16* lpExeName, _Inout_ uint32* lpdwSize)' + ) + const CloseHandle = kernel32.func('bool CloseHandle(void* hObject)') + const GetClassNameW = user32.func( + 'int32 GetClassNameW(void* hWnd, _Out_ uint16* lpClassName, int32 nMaxCount)' + ) + const GetWindowTextW = user32.func( + 'int32 GetWindowTextW(void* hWnd, _Out_ uint16* lpString, int32 nMaxCount)' + ) + + // Read an HWND's title text. Returns null on any edge. Titles can be long + // (browser tabs include the page name), so allow 512 UTF-16 chars. + const titleFromHwnd = (hwnd: unknown): string | null => { + const buf = Buffer.alloc(1024) + const n = GetWindowTextW(hwnd, buf, 512) + if (!n || n <= 0) return null + return buf.toString('utf16le', 0, n * 2) + } + + // Read an HWND's Win32 window class (e.g. "Shell_TrayWnd", "CabinetWClass"). + // Returns null on any edge. Class names are capped at 256 chars by Windows. + const classNameFromHwnd = (hwnd: unknown): string | null => { + const buf = Buffer.alloc(512) // 256 UTF-16 chars + const n = GetClassNameW(hwnd, buf, 256) + if (!n || n <= 0) return null + return buf.toString('utf16le', 0, n * 2) + } + + // Resolve an HWND to its owning process's image path. Shared by the + // exe-path and window-info readers. Returns null on any permission edge. + const exePathFromHwnd = (hwnd: unknown): string | null => { + const pidBox: [number] = [0] + GetWindowThreadProcessId(hwnd, pidBox) + const pid = pidBox[0] + if (!pid) return null + const handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if (!handle) return null + try { + const buf = Buffer.alloc(520) // 260 UTF-16 chars + const sizeBox: [number] = [260] + const ok = QueryFullProcessImageNameW(handle, 0, buf, sizeBox) + if (!ok || sizeBox[0] <= 0) return null + return buf.toString('utf16le', 0, sizeBox[0] * 2) + } finally { + CloseHandle(handle) + } + } + + // WINEVENTPROC callback prototype (CALLBACK == __stdcall; ignored on x64 but + // correct on x86). Registered per-subscription via koffi.register. + const WinEventProc = koffi.proto( + 'void __stdcall WinEventProc(void* hHook, uint32 event, void* hwnd, int32 idObject, int32 idChild, uint32 idThread, uint32 dwmsEventTime)' + ) + const SetWinEventHook = user32.func( + 'void* SetWinEventHook(uint32 eventMin, uint32 eventMax, void* hmodWinEventProc, void* lpfnWinEventProc, uint32 idProcess, uint32 idThread, uint32 dwFlags)' + ) + const UnhookWinEvent = user32.func('bool UnhookWinEvent(void* hWinEventHook)') + + cached = { + subscribeForegroundChange(cb: () => void): () => void { + let hook: unknown = null + let registered: bigint | null = null + try { + const onEvent = ( + _hHook: unknown, + _event: number, + _hwnd: unknown, + idObject: number, + idChild: number + ): void => { + // Only top-level window foreground changes — skip caret/menu/child objects. + if (idObject !== OBJID_WINDOW || idChild !== 0) return + try { + cb() + } catch { + // Never let a JS callback throw back into the native dispatcher. + } + } + registered = koffi.register(onEvent, koffi.pointer(WinEventProc)) + hook = SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_FOREGROUND, + null, + registered, + 0, + 0, + WINEVENT_OUTOFCONTEXT + ) + } catch (e) { + console.warn('[usage] SetWinEventHook failed; relying on poll only:', e) + } + return () => { + try { + if (hook) UnhookWinEvent(hook) + } catch { + // ignore + } + try { + if (registered) koffi.unregister(registered) + } catch { + // ignore + } + hook = null + registered = null + } + }, + getForegroundExePath(): string | null { + const hwnd = GetForegroundWindow() + if (!hwnd) return null + return exePathFromHwnd(hwnd) + }, + getForegroundWindowInfo(): ForegroundWindowInfo { + const hwnd = GetForegroundWindow() + if (!hwnd) return { handle: null, exePath: null, className: null } + let handle: string | null = null + try { + // koffi.address gives the pointer's numeric address; the C# helper + // parses windowHandle as a decimal long. + handle = koffi.address(hwnd).toString() + } catch { + handle = null + } + return { handle, exePath: exePathFromHwnd(hwnd), className: classNameFromHwnd(hwnd) } + }, + getForegroundWindowTitle(): string | null { + const hwnd = GetForegroundWindow() + if (!hwnd) return null + return titleFromHwnd(hwnd) + } + } + return cached + } catch (e) { + console.warn('[usage] koffi/user32 unavailable, foreground monitor disabled:', e) + loadFailed = true + return null + } +} + +// Returns the absolute exe path of the current foreground window, or null when +// unavailable (no foreground window, permission edge, or koffi failed to load). +// Never throws. +export function getForegroundExePath(): string | null { + if (process.platform !== 'win32') return null + try { + return load()?.getForegroundExePath() ?? null + } catch (e) { + console.warn('[usage] getForegroundExePath failed:', e) + return null + } +} + +// Returns the current foreground window's HWND (decimal string) + owning exe +// path, or nulls when unavailable. Never throws. +export function getForegroundWindowInfo(): ForegroundWindowInfo { + if (process.platform !== 'win32') return { handle: null, exePath: null, className: null } + try { + return load()?.getForegroundWindowInfo() ?? { handle: null, exePath: null, className: null } + } catch (e) { + console.warn('[usage] getForegroundWindowInfo failed:', e) + return { handle: null, exePath: null, className: null } + } +} + +// Returns the current foreground window's title text, or null when unavailable. +// Never throws. +export function getForegroundWindowTitle(): string | null { + if (process.platform !== 'win32') return null + try { + return load()?.getForegroundWindowTitle() ?? null + } catch (e) { + console.warn('[usage] getForegroundWindowTitle failed:', e) + return null + } +} + +// Subscribe to foreground-window changes (event-driven, like macOS's NSWorkspace +// activation notification). Returns an unsubscribe. A no-op unsubscribe when +// unavailable (off-Windows, koffi failed, or the hook couldn't be installed) — +// in that case the caller's poll loop remains the sole signal. Never throws. +export function subscribeForegroundChange(cb: () => void): () => void { + if (process.platform !== 'win32') return () => {} + try { + return load()?.subscribeForegroundChange(cb) ?? (() => {}) + } catch (e) { + console.warn('[usage] subscribeForegroundChange failed:', e) + return () => {} + } +} diff --git a/windows/src/main/usage/usageAccumulator.test.ts b/windows/src/main/usage/usageAccumulator.test.ts new file mode 100644 index 0000000000..4ef84e3084 --- /dev/null +++ b/windows/src/main/usage/usageAccumulator.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { UsageAccumulator } from './usageAccumulator' + +const MAX_GAP = 10_000 + +describe('UsageAccumulator', () => { + it('credits the earlier sample with the elapsed gap', () => { + const a = new UsageAccumulator(MAX_GAP) + a.addSample('C:\\a\\chrome.exe', 1000) + a.addSample('C:\\a\\chrome.exe', 5000) // +4000ms to chrome + a.addSample('C:\\b\\code.exe', 6000) // +1000ms to chrome + const drained = a.drain() + expect(drained).toEqual([{ exePath: 'C:\\a\\chrome.exe', ms: 5000 }]) + }) + + it('caps gaps larger than maxGap (idle/sleep)', () => { + const a = new UsageAccumulator(MAX_GAP) + a.addSample('C:\\a\\chrome.exe', 1000) + a.addSample('C:\\a\\chrome.exe', 1_000_000) // huge gap → not credited + expect(a.drain()).toEqual([]) + }) + + it('ignores null/empty foreground samples', () => { + const a = new UsageAccumulator(MAX_GAP) + a.addSample(null, 1000) + a.addSample('C:\\a\\chrome.exe', 2000) + a.addSample('C:\\a\\chrome.exe', 4000) // +2000ms + expect(a.drain()).toEqual([{ exePath: 'C:\\a\\chrome.exe', ms: 2000 }]) + }) + + it('drain clears accumulated totals', () => { + const a = new UsageAccumulator(MAX_GAP) + a.addSample('C:\\a\\chrome.exe', 1000) + a.addSample('C:\\a\\chrome.exe', 3000) + a.drain() + expect(a.drain()).toEqual([]) + }) +}) diff --git a/windows/src/main/usage/usageAccumulator.ts b/windows/src/main/usage/usageAccumulator.ts new file mode 100644 index 0000000000..58312524d2 --- /dev/null +++ b/windows/src/main/usage/usageAccumulator.ts @@ -0,0 +1,29 @@ +export type UsageDelta = { exePath: string; ms: number } + +// Accepts one foreground sample per poll tick and credits the time between +// consecutive samples to the EARLIER sample's app. Gaps beyond maxGapMs (sleep, +// lock, monitor stall) are dropped so suspended time isn't counted. Null/empty +// samples reset the cursor without crediting anything. +export class UsageAccumulator { + private totals = new Map() + private prev: { exePath: string; ts: number } | null = null + + constructor(private readonly maxGapMs: number) {} + + addSample(exePath: string | null, ts: number): void { + if (this.prev) { + const delta = ts - this.prev.ts + if (delta > 0 && delta <= this.maxGapMs) { + this.totals.set(this.prev.exePath, (this.totals.get(this.prev.exePath) ?? 0) + delta) + } + } + this.prev = exePath ? { exePath, ts } : null + } + + drain(): UsageDelta[] { + const out: UsageDelta[] = [] + for (const [exePath, ms] of this.totals) out.push({ exePath, ms }) + this.totals.clear() + return out + } +} diff --git a/windows/src/main/usage/usageDay.test.ts b/windows/src/main/usage/usageDay.test.ts new file mode 100644 index 0000000000..898cde39fe --- /dev/null +++ b/windows/src/main/usage/usageDay.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { isNewLocalDay } from './usageDay' + +describe('isNewLocalDay', () => { + const d = (s: string) => new Date(s).getTime() + it('is true when there is no previous timestamp', () => { + expect(isNewLocalDay(null, d('2026-06-05T10:00:00'))).toBe(true) + }) + it('is false within the same local day', () => { + expect(isNewLocalDay(d('2026-06-05T01:00:00'), d('2026-06-05T23:00:00'))).toBe(false) + }) + it('is true across local-day boundaries', () => { + expect(isNewLocalDay(d('2026-06-05T23:59:00'), d('2026-06-06T00:01:00'))).toBe(true) + }) +}) diff --git a/windows/src/main/usage/usageDay.ts b/windows/src/main/usage/usageDay.ts new file mode 100644 index 0000000000..41ddf78c1a --- /dev/null +++ b/windows/src/main/usage/usageDay.ts @@ -0,0 +1,11 @@ +function localDayKey(ts: number): string { + const d = new Date(ts) + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` +} + +// True when `now` falls on a different local calendar day than `prevLastUsed` +// (or when there is no previous timestamp). Drives the distinct_days counter. +export function isNewLocalDay(prevLastUsed: number | null, now: number): boolean { + if (prevLastUsed == null) return true + return localDayKey(prevLastUsed) !== localDayKey(now) +} diff --git a/windows/src/main/usage/usageRetention.test.ts b/windows/src/main/usage/usageRetention.test.ts new file mode 100644 index 0000000000..0988158052 --- /dev/null +++ b/windows/src/main/usage/usageRetention.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { + usageCutoff, + normalizeRetentionDays, + DEFAULT_RETENTION_DAYS, + MIN_RETENTION_DAYS, + MAX_RETENTION_DAYS, + RETENTION_PRESETS +} from './usageRetention' + +const DAY_MS = 86_400_000 + +describe('usageCutoff', () => { + it('returns the timestamp DEFAULT_RETENTION_DAYS before now by default', () => { + const now = 1_000_000_000_000 + expect(usageCutoff(now)).toBe(now - DEFAULT_RETENTION_DAYS * DAY_MS) + }) + + it('respects a custom retention window', () => { + const now = 1_000_000_000_000 + expect(usageCutoff(now, 7)).toBe(now - 7 * DAY_MS) + }) +}) + +describe('normalizeRetentionDays', () => { + it('keeps a valid in-range integer', () => { + expect(normalizeRetentionDays(30)).toBe(30) + expect(normalizeRetentionDays(90)).toBe(90) + }) + + it('rounds fractional values', () => { + expect(normalizeRetentionDays(45.4)).toBe(45) + }) + + it('clamps below the minimum', () => { + expect(normalizeRetentionDays(1)).toBe(MIN_RETENTION_DAYS) + }) + + it('clamps above the maximum', () => { + expect(normalizeRetentionDays(100_000)).toBe(MAX_RETENTION_DAYS) + }) + + it('falls back to the default for non-finite / non-numeric input', () => { + expect(normalizeRetentionDays(NaN)).toBe(DEFAULT_RETENTION_DAYS) + expect(normalizeRetentionDays(undefined)).toBe(DEFAULT_RETENTION_DAYS) + expect(normalizeRetentionDays('45')).toBe(DEFAULT_RETENTION_DAYS) + }) + + it('exposes sane bounds and presets', () => { + expect(DEFAULT_RETENTION_DAYS).toBeGreaterThanOrEqual(30) + expect(MIN_RETENTION_DAYS).toBeLessThan(DEFAULT_RETENTION_DAYS) + expect(MAX_RETENTION_DAYS).toBeGreaterThan(DEFAULT_RETENTION_DAYS) + expect(RETENTION_PRESETS).toContain(DEFAULT_RETENTION_DAYS) + // Every preset must survive normalization unchanged. + for (const p of RETENTION_PRESETS) expect(normalizeRetentionDays(p)).toBe(p) + }) +}) diff --git a/windows/src/main/usage/usageRetention.ts b/windows/src/main/usage/usageRetention.ts new file mode 100644 index 0000000000..fa2ffabca9 --- /dev/null +++ b/windows/src/main/usage/usageRetention.ts @@ -0,0 +1,31 @@ +const DAY_MS = 86_400_000 + +// Apps not foregrounded within the retention window are pruned from app_usage. +// macOS keeps no usage history at all; we keep a bounded, user-selectable window +// so the table can't grow unbounded and stale apps stop influencing the ranking. +// Because total_seconds is cumulative and never decays, this window is the +// feature's only recency control — hence it's exposed in Settings. +export const DEFAULT_RETENTION_DAYS = 45 +export const MIN_RETENTION_DAYS = 7 +export const MAX_RETENTION_DAYS = 365 + +// Presets surfaced in the Settings dropdown. Any value persists (clamped), these +// are just the convenient choices. +export const RETENTION_PRESETS: readonly number[] = [30, 45, 60, 90, 180] + +// Timestamp (ms epoch) before which app_usage rows are considered stale. A row +// whose last_used is < this should be pruned. +export function usageCutoff(now: number, retentionDays = DEFAULT_RETENTION_DAYS): number { + return now - retentionDays * DAY_MS +} + +// Coerce an arbitrary persisted/UI value into a valid retention window: rounds to +// whole days, clamps to [MIN, MAX], and falls back to the default for anything +// non-numeric. Single source of truth for both the settings store and the prune. +export function normalizeRetentionDays(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_RETENTION_DAYS + const r = Math.round(value) + if (r < MIN_RETENTION_DAYS) return MIN_RETENTION_DAYS + if (r > MAX_RETENTION_DAYS) return MAX_RETENTION_DAYS + return r +} diff --git a/windows/src/main/usage/usageSettings.ts b/windows/src/main/usage/usageSettings.ts new file mode 100644 index 0000000000..7eeb7989b1 --- /dev/null +++ b/windows/src/main/usage/usageSettings.ts @@ -0,0 +1,39 @@ +import { app } from 'electron' +import { join } from 'path' +import { readFileSync, writeFileSync } from 'fs' +import type { UsageSettings } from '../../shared/types' +import { DEFAULT_RETENTION_DAYS, normalizeRetentionDays } from './usageRetention' + +const DEFAULTS: UsageSettings = { enabled: true, retentionDays: DEFAULT_RETENTION_DAYS } + +function file(): string { + return join(app.getPath('userData'), 'usage-settings.json') +} + +// Coerce a partial/untrusted settings object into a fully-valid one. +function sanitize(raw: Partial): UsageSettings { + return { + enabled: raw.enabled !== false, + retentionDays: normalizeRetentionDays(raw.retentionDays) + } +} + +// Read the persisted settings, defaulting to enabled with the default retention. +// Never throws — a missing/corrupt file yields the defaults. +export function getUsageSettings(): UsageSettings { + try { + return sanitize(JSON.parse(readFileSync(file(), 'utf-8')) as Partial) + } catch { + return { ...DEFAULTS } + } +} + +export function setUsageSettings(next: UsageSettings): UsageSettings { + const value = sanitize(next) + try { + writeFileSync(file(), JSON.stringify(value), 'utf-8') + } catch (e) { + console.warn('[usage] failed to persist settings:', e) + } + return value +} diff --git a/windows/src/main/usage/userAssist.test.ts b/windows/src/main/usage/userAssist.test.ts new file mode 100644 index 0000000000..1805d6026a --- /dev/null +++ b/windows/src/main/usage/userAssist.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { rot13, parseUserAssistData, friendlyAppName, aggregateUserAssist } from './userAssist' + +// Build a 72-byte Win7+ UserAssist Count blob with the fields we read. +function blob(opts: { + runCount?: number + focusCount?: number + focusMs?: number + lastUsedMs?: number + len?: number +}): Buffer { + const b = Buffer.alloc(opts.len ?? 72) + if (b.length >= 8) b.writeInt32LE(opts.runCount ?? 0, 4) + if (b.length >= 12) b.writeInt32LE(opts.focusCount ?? 0, 8) + if (b.length >= 16) b.writeInt32LE(opts.focusMs ?? 0, 12) + if (b.length >= 68 && opts.lastUsedMs != null) { + // ms epoch -> Windows FILETIME (100ns ticks since 1601-01-01) + const ticks = (BigInt(opts.lastUsedMs) + 11644473600000n) * 10000n + b.writeBigUInt64LE(ticks, 60) + } + return b +} + +describe('rot13', () => { + it('decodes UserAssist value names', () => { + expect(rot13('Puebzr')).toBe('Chrome') + expect(rot13('qri.jnec.Jnec')).toBe('dev.warp.Warp') + }) + it('is its own inverse and leaves non-letters untouched', () => { + expect(rot13(rot13('C:\\Users\\a.b_1!App'))).toBe('C:\\Users\\a.b_1!App') + }) +}) + +describe('parseUserAssistData', () => { + it('reads run count, focus count and focus time (ms -> seconds)', () => { + const p = parseUserAssistData(blob({ runCount: 22, focusCount: 757, focusMs: 43_146_781 })) + expect(p).not.toBeNull() + expect(p!.runCount).toBe(22) + expect(p!.focusCount).toBe(757) + expect(p!.focusSeconds).toBe(43_147) // rounded + }) + it('reads last-used from the FILETIME at offset 60', () => { + const when = Date.UTC(2026, 5, 3, 12, 0, 0) + const p = parseUserAssistData(blob({ focusMs: 1000, lastUsedMs: when })) + expect(p!.lastUsed).toBe(when) + }) + it('returns 0 last-used when the FILETIME is empty', () => { + expect(parseUserAssistData(blob({ focusMs: 1000 }))!.lastUsed).toBe(0) + }) + it('returns null for a blob too short to hold focus time', () => { + expect(parseUserAssistData(Buffer.alloc(8))).toBeNull() + }) +}) + +describe('friendlyAppName', () => { + it('drops UEME_ control entries', () => { + expect(friendlyAppName('UEME_CTLSESSION')).toBeNull() + expect(friendlyAppName('UEME_CTLCUACount:ctor')).toBeNull() + }) + it('takes the last segment of a dotted AppUserModelID', () => { + expect(friendlyAppName('dev.warp.Warp')).toBe('Warp') + expect(friendlyAppName('Microsoft.VisualStudioCode')).toBe('VisualStudioCode') + expect(friendlyAppName('Telegram.TelegramDesktop')).toBe('TelegramDesktop') + }) + it('strips the package-family hash and !App suffix from packaged AUMIDs', () => { + expect(friendlyAppName('Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic')).toBe('ZuneMusic') + expect(friendlyAppName('5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App')).toBe('WhatsAppDesktop') + // Uses the package-name segment, not the !Activatable id. "SpotifyMusic" + // still matches an indexed "Spotify" via rankApps' containment rule. + expect(friendlyAppName('SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify')).toBe('SpotifyMusic') + }) + it('keeps a bare pseudo-name as-is', () => { + expect(friendlyAppName('Chrome')).toBe('Chrome') + }) + it('uses the exe basename (without .exe) for full paths', () => { + expect(friendlyAppName('C:\\Users\\me\\AppData\\Local\\Programs\\Warp\\Warp.exe')).toBe('Warp') + expect(friendlyAppName('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe')).toBe('chrome') + }) + it('ignores a leading KNOWNFOLDERID GUID segment in a path', () => { + expect( + friendlyAppName('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe') + ).toBe('powershell') + }) + it('returns null for empty / GUID-only names', () => { + expect(friendlyAppName('')).toBeNull() + expect(friendlyAppName('{9E04CAB2-CC14-11DF-BB8C-A2F1DED72085}')).toBeNull() + }) +}) + +describe('aggregateUserAssist', () => { + it('decodes, drops control entries, and sums focus time per friendly name', () => { + const raw = [ + { name: rot13('UEME_CTLSESSION'), data: blob({ focusMs: 999_999 }) }, + { name: rot13('dev.warp.Warp'), data: blob({ focusMs: 60_000, runCount: 3, lastUsedMs: 100 }) }, + // same friendly name via a full path -> merged + { name: rot13('C:\\x\\Warp\\Warp.exe'), data: blob({ focusMs: 30_000, runCount: 2, lastUsedMs: 200 }) }, + { name: rot13('Chrome'), data: blob({ focusMs: 120_000, lastUsedMs: 50 }) } + ] + const out = aggregateUserAssist(raw) + const warp = out.find((a) => a.name === 'Warp')! + const chrome = out.find((a) => a.name === 'Chrome')! + expect(out.some((a) => a.name.startsWith('UEME'))).toBe(false) + expect(warp.focusSeconds).toBe(90) // 60s + 30s merged + expect(warp.runCount).toBe(5) + expect(warp.lastUsed).toBe(200) // max + expect(chrome.focusSeconds).toBe(120) + }) + it('sorts by focus time descending', () => { + const raw = [ + { name: rot13('Small'), data: blob({ focusMs: 1000 }) }, + { name: rot13('Big'), data: blob({ focusMs: 500_000 }) } + ] + expect(aggregateUserAssist(raw).map((a) => a.name)).toEqual(['Big', 'Small']) + }) +}) diff --git a/windows/src/main/usage/userAssist.ts b/windows/src/main/usage/userAssist.ts new file mode 100644 index 0000000000..0199980cb9 --- /dev/null +++ b/windows/src/main/usage/userAssist.ts @@ -0,0 +1,123 @@ +// Pure parsing of Windows UserAssist registry data. The registry READ (native, +// per-machine) lives in userAssistRegistry.ts; everything here is deterministic +// and unit-tested so the fiddly bits (ROT13, the binary blob layout, AUMID -> +// friendly name) are covered without a Windows box. +// +// UserAssist records per-user, historical app usage under +// HKCU\...\Explorer\UserAssist\{GUID}\Count +// with ROT13-encoded value names (an exe path or an AppUserModelID) and a binary +// blob carrying run count + focus count + focus time. We use it ONCE at +// onboarding to seed app_usage so the first brain-map build ranks apps by REAL +// historical foreground time instead of install-recency noise (see the +// spike findings in the app-usage-ranking work). + +// Offsets into the Win7+ Count blob (72 bytes). Earlier fields exist on shorter +// blobs; the FILETIME only on full-length ones. +const OFF_RUN_COUNT = 4 +const OFF_FOCUS_COUNT = 8 +const OFF_FOCUS_MS = 12 +const OFF_LAST_USED_FILETIME = 60 +// 100ns ticks between 1601-01-01 (FILETIME epoch) and 1970-01-01 (Unix epoch). +const FILETIME_UNIX_OFFSET_MS = 11_644_473_600_000n + +export type ParsedUserAssist = { + runCount: number + focusCount: number + focusSeconds: number + // ms epoch of last execution, or 0 when absent/zeroed. + lastUsed: number +} + +export type UserAssistApp = { + // Friendly app token (e.g. "Warp", "Chrome", "VisualStudioCode"). Matched + // against indexed Start-Menu app names by appSelection.rankApps. + name: string + focusSeconds: number + runCount: number + lastUsed: number +} + +// Caesar-shift by 13. Letters only; everything else (digits, '.', '\', '!', ':') +// passes through unchanged — exactly how Windows encodes UserAssist names. +export function rot13(s: string): string { + return s.replace(/[a-zA-Z]/g, (ch) => { + const base = ch <= 'Z' ? 65 : 97 + return String.fromCharCode(((ch.charCodeAt(0) - base + 13) % 26) + base) + }) +} + +// Parse a Count value's binary blob. Returns null when it's too short to even +// hold the focus-time field (control/sentinel entries can be tiny). +export function parseUserAssistData(data: Buffer): ParsedUserAssist | null { + if (data.length < OFF_FOCUS_MS + 4) return null + const focusMs = data.readInt32LE(OFF_FOCUS_MS) + let lastUsed = 0 + if (data.length >= OFF_LAST_USED_FILETIME + 8) { + const ticks = data.readBigUInt64LE(OFF_LAST_USED_FILETIME) + if (ticks > 0n) lastUsed = Number(ticks / 10_000n - FILETIME_UNIX_OFFSET_MS) + } + return { + runCount: data.readInt32LE(OFF_RUN_COUNT), + focusCount: data.readInt32LE(OFF_FOCUS_COUNT), + focusSeconds: Math.round(focusMs / 1000), + lastUsed + } +} + +function looksLikeGuid(s: string): boolean { + return /^\{[0-9a-f-]{36}\}$/i.test(s) +} + +// Reduce a decoded UserAssist value name to a friendly app token, or null when +// it isn't an app (UEME_ control entries, empty/GUID-only names). +// +// Three shapes occur in the wild (confirmed by spike): +// - full/known-folder path: C:\...\Warp.exe or {GUID}\...\powershell.exe +// - packaged AUMID: Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic +// - bare pseudo-name: Chrome +export function friendlyAppName(rawName: string): string | null { + const name = rawName.trim() + if (!name) return null + if (name.startsWith('UEME_')) return null + + // Path (incl. KNOWNFOLDERID-prefixed): take the basename, drop a .exe suffix. + if (name.includes('\\') || name.includes('/')) { + const base = name.split(/[\\/]/).pop() ?? '' + const stem = base.replace(/\.exe$/i, '').trim() + return stem && !looksLikeGuid(stem) ? stem : null + } + + // AUMID / pseudo-name: drop the !Activatable suffix, strip the package-family + // hash (_8wekyb3d8bbwe), then take the last dotted segment. + const beforeBang = name.split('!')[0] + const noHash = beforeBang.replace(/_[a-z0-9]+$/i, '') + const seg = noHash.split('.').filter(Boolean).pop() ?? '' + return seg && !looksLikeGuid(seg) ? seg : null +} + +// Decode raw {name, data} registry pairs into per-app usage, merged by friendly +// name (Windows can list the same app under both an exe path and an AUMID) and +// sorted by focus time desc. +export function aggregateUserAssist(raw: { name: string; data: Buffer }[]): UserAssistApp[] { + const byName = new Map() + for (const { name, data } of raw) { + const friendly = friendlyAppName(rot13(name)) + if (!friendly) continue + const parsed = parseUserAssistData(data) + if (!parsed) continue + const existing = byName.get(friendly) + if (existing) { + existing.focusSeconds += parsed.focusSeconds + existing.runCount += parsed.runCount + existing.lastUsed = Math.max(existing.lastUsed, parsed.lastUsed) + } else { + byName.set(friendly, { + name: friendly, + focusSeconds: parsed.focusSeconds, + runCount: parsed.runCount, + lastUsed: parsed.lastUsed + }) + } + } + return [...byName.values()].sort((a, b) => b.focusSeconds - a.focusSeconds) +} diff --git a/windows/src/main/usage/userAssistRegistry.ts b/windows/src/main/usage/userAssistRegistry.ts new file mode 100644 index 0000000000..1faef30d4f --- /dev/null +++ b/windows/src/main/usage/userAssistRegistry.ts @@ -0,0 +1,111 @@ +import koffi from 'koffi' + +// Native read of the UserAssist Count values from HKCU. Thin and untested by +// design — all decoding/parsing lives in the pure userAssist.ts. Returns the raw +// (still ROT13-encoded) value names plus their binary blobs; never throws, and +// yields [] off-Windows or if advapi32 can't be reached. + +const HKEY_CURRENT_USER = 0x80000001 +const KEY_READ = 0x20019 +const ERROR_SUCCESS = 0 +const ERROR_MORE_DATA = 234 +const ERROR_NO_MORE_ITEMS = 259 + +// The "executable" UserAssist key: the only one carrying real focus time (the +// sibling shortcut key {F4E57C4B...} records launches with zero focus time). +const COUNT_SUBKEY = + 'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\UserAssist\\' + + '{CEBFF5CD-ACE2-4F4F-9178-9926F41749EA}\\Count' + +// Generous caps: value names (ROT13 paths/AUMIDs) and blobs are small; the +// largest known entry (UEME_CTLSESSION) is ~1.6 KB. +const MAX_NAME_CHARS = 16384 +const INITIAL_DATA_BYTES = 8192 + +type RawEntry = { name: string; data: Buffer } + +type Advapi = { + RegOpenKeyExW: (h: number, sub: string, o: number, sam: number, out: [unknown]) => number + RegEnumValueW: ( + h: unknown, + i: number, + name: Buffer, + nameLen: [number], + reserved: unknown, + type: [number], + data: Buffer, + dataLen: [number] + ) => number + RegCloseKey: (h: unknown) => number +} +let advapi: Advapi | null = null +let loadFailed = false + +function load(): Advapi | null { + if (advapi) return advapi + if (loadFailed) return null + try { + const lib = koffi.load('advapi32.dll') + advapi = { + // HKEY passed as an integer (predefined HKEY_CURRENT_USER); result HKEY is a + // real pointer we thread into RegEnumValueW / RegCloseKey. + RegOpenKeyExW: lib.func( + 'long __stdcall RegOpenKeyExW(size_t hKey, str16 lpSubKey, uint32 ulOptions, uint32 samDesired, _Out_ void **phkResult)' + ), + RegEnumValueW: lib.func( + 'long __stdcall RegEnumValueW(void *hKey, uint32 dwIndex, _Out_ uint16 *lpValueName, _Inout_ uint32 *lpcchValueName, void *lpReserved, _Out_ uint32 *lpType, _Out_ uint8 *lpData, _Inout_ uint32 *lpcbData)' + ), + RegCloseKey: lib.func('long __stdcall RegCloseKey(void *hKey)') + } + return advapi + } catch (e) { + console.warn('[usage] advapi32 unavailable; UserAssist seed skipped:', e) + loadFailed = true + return null + } +} + +// Read every Count value. Returns raw ROT13 names + blobs. Never throws. +export function readUserAssistRaw(): RawEntry[] { + if (process.platform !== 'win32') return [] + const api = load() + if (!api) return [] + const hkeyBox: [unknown] = [null] + if (api.RegOpenKeyExW(HKEY_CURRENT_USER, COUNT_SUBKEY, 0, KEY_READ, hkeyBox) !== ERROR_SUCCESS) { + return [] + } + const hkey = hkeyBox[0] + const out: RawEntry[] = [] + try { + const nameBuf = Buffer.alloc(MAX_NAME_CHARS * 2) + let dataBuf = Buffer.alloc(INITIAL_DATA_BYTES) + for (let i = 0; i < 100000; i++) { + // Reset the in/out sizes to the current buffer capacities each iteration. + const nameLen: [number] = [MAX_NAME_CHARS] + const dataLen: [number] = [dataBuf.length] + const type: [number] = [0] + let rc = api.RegEnumValueW(hkey, i, nameBuf, nameLen, null, type, dataBuf, dataLen) + if (rc === ERROR_MORE_DATA) { + // Blob bigger than our buffer — grow and retry this same index. + dataBuf = Buffer.alloc(Math.max(dataBuf.length * 2, dataLen[0] || dataBuf.length * 2)) + nameLen[0] = MAX_NAME_CHARS + dataLen[0] = dataBuf.length + type[0] = 0 + rc = api.RegEnumValueW(hkey, i, nameBuf, nameLen, null, type, dataBuf, dataLen) + } + if (rc === ERROR_NO_MORE_ITEMS) break + if (rc !== ERROR_SUCCESS) break + const name = nameBuf.toString('utf16le', 0, nameLen[0] * 2) + out.push({ name, data: Buffer.from(dataBuf.subarray(0, dataLen[0])) }) + } + } catch (e) { + console.warn('[usage] RegEnumValue loop failed:', e) + } finally { + try { + api.RegCloseKey(hkey) + } catch { + // ignore + } + } + return out +} diff --git a/windows/src/main/usage/userAssistSeed.ts b/windows/src/main/usage/userAssistSeed.ts new file mode 100644 index 0000000000..8ed74d698b --- /dev/null +++ b/windows/src/main/usage/userAssistSeed.ts @@ -0,0 +1,45 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync, writeFileSync } from 'fs' +import { readUserAssistRaw } from './userAssistRegistry' +import { aggregateUserAssist } from './userAssist' +import { getUsageSettings } from './usageSettings' +import { seedAppUsage } from '../ipc/db' + +// Ignore apps with negligible historical focus time — drops sub-minute noise the +// name-join would otherwise let through (e.g. a 12-second Telegram launch, MSI +// "Creator Center"). 1 minute is well below any app the user actually relies on. +const MIN_FOCUS_SECONDS = 60 + +// One-shot marker so the seed runs exactly once. Absent until a successful seed, +// so if tracking was OFF at first launch we still seed the first time it's ON. +function markerFile(): string { + return join(app.getPath('userData'), 'userassist-seeded.json') +} + +// Seed app_usage from the per-user UserAssist registry history, ONCE. No-op when: +// off-Windows, app-usage tracking is disabled (opt-out), already seeded, or the +// registry can't be read. Never throws. Stamps `now` as last_used so the snapshot +// survives the retention window. Call at startup before the first brain-map build. +export function seedUserAssistOnce(): void { + try { + if (process.platform !== 'win32') return + if (!getUsageSettings().enabled) return + if (existsSync(markerFile())) return + + const apps = aggregateUserAssist(readUserAssistRaw()) + const now = Date.now() + let seeded = 0 + for (const a of apps) { + if (a.focusSeconds < MIN_FOCUS_SECONDS) continue + seedAppUsage(a.name, a.focusSeconds, now) + seeded++ + } + // Only mark done once we've actually read the registry and seeded (or found + // nothing to seed) — a load failure above throws past this and retries later. + writeFileSync(markerFile(), JSON.stringify({ at: now, seeded }), 'utf-8') + console.log(`[usage] UserAssist seed complete: ${seeded} app(s)`) + } catch (e) { + console.warn('[usage] UserAssist seed failed:', e) + } +} diff --git a/windows/src/preload/index.d.ts b/windows/src/preload/index.d.ts new file mode 100644 index 0000000000..0950fbde5c --- /dev/null +++ b/windows/src/preload/index.d.ts @@ -0,0 +1,10 @@ +import { ElectronAPI } from '@electron-toolkit/preload' +import type { OmiBridgeApi, OmiOverlayApi } from '../shared/types' + +declare global { + interface Window { + electron: ElectronAPI + omi: OmiBridgeApi + omiOverlay: OmiOverlayApi + } +} diff --git a/windows/src/preload/index.ts b/windows/src/preload/index.ts new file mode 100644 index 0000000000..4a53e9c135 --- /dev/null +++ b/windows/src/preload/index.ts @@ -0,0 +1,216 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { electronAPI } from '@electron-toolkit/preload' +import type { + OmiBridgeApi, + OmiOverlayApi, + LocalConversation, + CaptureChoice, + ListenStartArgs, + ListenMessage, + ExportMemory, + GoogleSource, + KnowledgeGraph, + OnboardingGraphNode, + OnboardingGraphEdge, + UsageSettings, + RewindSettings, + InsightPayload, + AutomationPlan, + StepResult +} from '../shared/types' + +const omi: OmiBridgeApi = { + getCaptureSources: () => ipcRenderer.invoke('capture:getSources'), + remapConversationId: (fromId: string, toId: string) => + ipcRenderer.invoke('db:remapConversationId', fromId, toId), + insertLocalConversation: (c: LocalConversation) => + ipcRenderer.invoke('db:insertLocalConversation', c), + getLocalConversation: (id: string) => ipcRenderer.invoke('db:getLocalConversation', id), + listLocalConversations: () => ipcRenderer.invoke('db:listLocalConversations'), + deleteLocalConversation: (id: string) => ipcRenderer.invoke('db:deleteLocalConversation', id), + updateLocalConversationTitle: (id: string, title: string) => + ipcRenderer.invoke('db:updateLocalConversationTitle', id, title), + onRecordHotkey: (cb: (choice: CaptureChoice) => void) => { + const listener = (_e: Electron.IpcRendererEvent, choice: CaptureChoice): void => cb(choice) + ipcRenderer.on('recorder:hotkey', listener) + return () => ipcRenderer.removeListener('recorder:hotkey', listener) + }, + listenStart: (args: ListenStartArgs) => ipcRenderer.invoke('omi-listen:start', args), + listenStop: (sessionId: string) => ipcRenderer.invoke('omi-listen:stop', sessionId), + listenFeed: (sessionId: string, pcm: ArrayBuffer) => { + ipcRenderer.send('omi-listen:feed', sessionId, pcm) + }, + onListenMessage: (cb: (msg: ListenMessage) => void) => { + const listener = (_e: Electron.IpcRendererEvent, msg: ListenMessage): void => cb(msg) + ipcRenderer.on('omi-listen:message', listener) + return () => ipcRenderer.removeListener('omi-listen:message', listener) + }, + indexFilesScan: () => ipcRenderer.invoke('fileIndex:scan'), + indexFilesStatus: () => ipcRenderer.invoke('fileIndex:status'), + indexFilesApps: (limit?: number) => ipcRenderer.invoke('fileIndex:apps', limit), + localGraphLoad: () => ipcRenderer.invoke('localGraph:load') as Promise, + localGraphUpsert: (nodes: OnboardingGraphNode[], edges: OnboardingGraphEdge[]) => + ipcRenderer.invoke('localGraph:upsert', nodes, edges) as Promise, + localGraphClear: () => ipcRenderer.invoke('localGraph:clear') as Promise, + getAppUsage: () => ipcRenderer.invoke('usage:list'), + usageFlush: () => ipcRenderer.invoke('usage:flush'), + usageGetSettings: () => ipcRenderer.invoke('usage:getSettings'), + usageSetSettings: (next: UsageSettings) => ipcRenderer.invoke('usage:setSettings', next), + memoryImportParse: (dump: string) => ipcRenderer.invoke('memoryImport:parse', dump), + memoryExportObsidian: (memories: ExportMemory[]) => + ipcRenderer.invoke('memoryExport:obsidian', memories), + memoryExportFile: (memories: ExportMemory[]) => ipcRenderer.invoke('memoryExport:file', memories), + memoryExportNotion: (args: { token: string; parentPageId: string; memories: ExportMemory[] }) => + ipcRenderer.invoke('memoryExport:notion', args), + kgFileIndexDigest: () => ipcRenderer.invoke('kg:fileIndexDigest'), + kgSaveGraph: (graph) => ipcRenderer.invoke('kg:saveGraph', graph), + kgStatus: () => ipcRenderer.invoke('kg:status'), + kgQueryNodes: (q, limit?) => ipcRenderer.invoke('kg:queryNodes', q, limit), + kgSearchFiles: (q, fileType?, limit?) => ipcRenderer.invoke('kg:searchFiles', q, fileType, limit), + kgExecuteSql: (sql) => ipcRenderer.invoke('kg:executeSql', sql), + readStickyNotes: () => ipcRenderer.invoke('integrations:stickyNotes:read'), + googleConnect: () => ipcRenderer.invoke('integrations:google:connect'), + googleDisconnect: () => ipcRenderer.invoke('integrations:google:disconnect'), + googleStatus: () => ipcRenderer.invoke('integrations:google:status'), + googleGmailFetchNew: () => ipcRenderer.invoke('integrations:google:gmailFetchNew'), + googleCalendarFetchNew: () => ipcRenderer.invoke('integrations:google:calendarFetchNew'), + googleMarkProcessed: (source: GoogleSource, ids: string[]) => + ipcRenderer.invoke('integrations:google:markProcessed', source, ids), + memoriesBulkDelete: (args: { baseURL: string; token: string; ids: string[] }) => + ipcRenderer.invoke('memories:bulkDelete', args), + onMemoriesDeleteProgress: ( + cb: (p: { deleted: number; failed: number; total: number; done: boolean }) => void + ) => { + const listener = ( + _e: Electron.IpcRendererEvent, + p: { deleted: number; failed: number; total: number; done: boolean } + ): void => cb(p) + ipcRenderer.on('memories:deleteProgress', listener) + return () => ipcRenderer.removeListener('memories:deleteProgress', listener) + }, + rewindFrames: (from: number, to: number) => ipcRenderer.invoke('rewind:frames', from, to), + rewindDayBounds: () => ipcRenderer.invoke('rewind:dayBounds'), + rewindSearch: (query: string) => ipcRenderer.invoke('rewind:search', query), + rewindFrameImage: (imagePath: string) => ipcRenderer.invoke('rewind:frameImage', imagePath), + rewindGetSettings: () => ipcRenderer.invoke('rewind:getSettings'), + rewindSetSettings: (next: RewindSettings) => ipcRenderer.invoke('rewind:setSettings', next), + rewindPruneNow: () => ipcRenderer.invoke('rewind:pruneNow'), + rewindPrimarySourceId: () => ipcRenderer.invoke('rewind:primarySourceId'), + rewindSaveFrame: (data: Uint8Array) => ipcRenderer.invoke('rewind:saveFrame', data), + screenReadText: () => ipcRenderer.invoke('screen:readNow'), + screenSynthFramesSince: () => ipcRenderer.invoke('screenSynth:framesSince'), + screenSynthGetState: () => ipcRenderer.invoke('screenSynth:getState'), + screenSynthSetState: (patch) => ipcRenderer.invoke('screenSynth:setState', patch), + screenSynthAdvanceWatermark: (ts) => ipcRenderer.invoke('screenSynth:advanceWatermark', ts), + screenSynthRecordRun: (run) => ipcRenderer.invoke('screenSynth:recordRun', run), + onRewindSettings: (cb: (s: RewindSettings) => void) => { + const listener = (_e: unknown, s: RewindSettings): void => cb(s) + ipcRenderer.on('rewind:settings', listener) + return () => ipcRenderer.removeListener('rewind:settings', listener) + }, + insightGetSettings: () => ipcRenderer.invoke('insight:getSettings'), + insightSetSettings: (patch) => ipcRenderer.invoke('insight:setSettings', patch), + insightAdd: (p) => ipcRenderer.invoke('insight:add', p), + insightRecent: (limit) => ipcRenderer.invoke('insight:recent', limit), + insightShow: (p) => ipcRenderer.send('insight:show', p), + insightDismiss: () => ipcRenderer.send('insight:dismiss'), + insightHoverStart: () => ipcRenderer.send('insight:hoverStart'), + insightHoverEnd: () => ipcRenderer.send('insight:hoverEnd'), + insightTest: () => ipcRenderer.send('insight:test'), + onInsightShow: (cb) => { + const listener = (_e: Electron.IpcRendererEvent, p: InsightPayload): void => cb(p) + ipcRenderer.on('insight:payload', listener) + return () => ipcRenderer.removeListener('insight:payload', listener) + }, + perfFirstPaint: () => ipcRenderer.send('perf:firstPaint'), + perfMark: (name: string) => ipcRenderer.send('perf:mark', name), + perfAnimResult: (stats: Record) => + ipcRenderer.send('perf:animResult', stats), + isAnimBench: process.env.OMI_ANIM_BENCH === '1', + benchEcho: (x: number) => ipcRenderer.invoke('bench:echo', x), + isBench: process.env.OMI_BENCH === '1', + // Desktop automation bridge. ON by default; OMI_AUTOMATION='0' disables it. + // The renderer checks `automationEnabled` before its planner pre-step. + automationEnabled: process.env.OMI_AUTOMATION !== '0', + automationSnapshot: (windowHandle?: string) => + ipcRenderer.invoke('automation:snapshot', windowHandle), + automationTargetWindow: () => ipcRenderer.invoke('automation:targetWindow'), + automationRun: (plan: AutomationPlan) => ipcRenderer.invoke('automation:run', plan), + automationConfirmRun: (plan: AutomationPlan) => ipcRenderer.invoke('automation:confirmRun', plan), + onAutomationStep: (cb: (r: StepResult) => void) => { + const listener = (_e: unknown, r: StepResult): void => cb(r) + ipcRenderer.on('automation:step', listener) + return () => ipcRenderer.removeListener('automation:step', listener) + }, + notifyConversationsChanged: () => ipcRenderer.send('conversations:notify-changed'), + onConversationsChanged: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('conversations:changed', listener) + return () => ipcRenderer.removeListener('conversations:changed', listener) + } +} + +const omiOverlay: OmiOverlayApi = { + onShown: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('overlay:shown', listener) + return () => ipcRenderer.removeListener('overlay:shown', listener) + }, + hide: () => ipcRenderer.send('overlay:hide'), + setEnabled: (enabled: boolean) => ipcRenderer.send('overlay:setEnabled', enabled), + setHeight: (px: number) => ipcRenderer.send('overlay:setHeight', px), + focusMain: () => ipcRenderer.send('overlay:focusMain'), + onActiveChange: (cb: (active: boolean) => void) => { + const listener = (_e: Electron.IpcRendererEvent, active: boolean): void => cb(active) + ipcRenderer.on('overlay:active', listener) + return () => ipcRenderer.removeListener('overlay:active', listener) + }, + onWillHide: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('overlay:willHide', listener) + return () => ipcRenderer.removeListener('overlay:willHide', listener) + }, + onSummoned: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('overlay:summoned', listener) + return () => ipcRenderer.removeListener('overlay:summoned', listener) + }, + setAccelerator: (accelerator: string) => ipcRenderer.invoke('overlay:setAccelerator', accelerator), + suspendShortcut: () => ipcRenderer.send('overlay:suspendShortcut'), + resumeShortcut: () => ipcRenderer.invoke('overlay:resumeShortcut'), + onVisibilityChange: (cb: (state: { open: boolean; active: boolean }) => void) => { + const listener = (_e: Electron.IpcRendererEvent, state: { open: boolean; active: boolean }): void => + cb(state) + ipcRenderer.on('overlay:visibility', listener) + return () => ipcRenderer.removeListener('overlay:visibility', listener) + }, + notifyVoiceCaptured: () => ipcRenderer.send('overlay:voiceCaptured'), + onVoiceCaptured: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('overlay:voiceCaptured', listener) + return () => ipcRenderer.removeListener('overlay:voiceCaptured', listener) + }, + notifyAsked: () => ipcRenderer.send('overlay:asked'), + onAsked: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('overlay:asked', listener) + return () => ipcRenderer.removeListener('overlay:asked', listener) + } +} + +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('omi', omi) + contextBridge.exposeInMainWorld('omiOverlay', omiOverlay) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore (define in dts) + window.electron = electronAPI + // @ts-ignore (define in dts) + window.omi = omi + // @ts-ignore (define in dts) + window.omiOverlay = omiOverlay +} diff --git a/windows/src/renderer/index.html b/windows/src/renderer/index.html new file mode 100644 index 0000000000..d4443027cc --- /dev/null +++ b/windows/src/renderer/index.html @@ -0,0 +1,40 @@ + + + + + omi + + + + + +
+ + + diff --git a/windows/src/renderer/src/App.tsx b/windows/src/renderer/src/App.tsx new file mode 100644 index 0000000000..a4da0ece6c --- /dev/null +++ b/windows/src/renderer/src/App.tsx @@ -0,0 +1,185 @@ +import { useEffect } from 'react' +import { HashRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom' +import { useAuth } from './hooks/useAuth' +import { Login } from './pages/Login' +import { Sidebar } from './components/layout/Sidebar' +import { MainViews } from './components/layout/MainViews' +import { Spinner } from './components/ui/Spinner' +import { purgeAppMemoriesOnce } from './lib/appMemories' +import { AppStateProvider, useAppState } from './state/AppStateProvider' +import { SourcePicker } from './components/SourcePicker' +// Imported DIRECTLY (NOT lazy/Suspense). Code-splitting the Onboarding page +// (commit c226cac, for the three.js bundle win) repeatedly blanked the onboarding +// brain map — wrapping the page in Suspense breaks the BrainGraph render. The +// direct import keeps the map reliable; the bundle-size win is not worth it. +import { Onboarding } from './pages/Onboarding' +import { consumePendingRoute } from './lib/preferences' +import { useOnboardingComplete } from './hooks/useOnboardingComplete' +import { getPreferences } from './lib/preferences' +import { SandboxBadge } from './components/SandboxBadge' +import { OverlayApp } from './components/overlay/OverlayApp' +import { RewindCaptureHost } from './components/rewind/RewindCaptureHost' +import { ContinuousRecordingHost } from './components/recording/ContinuousRecordingHost' +import { invalidateConversationsCache } from './lib/pageCache' +import { runAnimBench } from './lib/animBench' +import { InsightToast } from './components/insight/InsightToast' + +function AppShellInner(): React.JSX.Element { + const { recorder, pickerOpen, setPickerOpen } = useAppState() + // Settings is a full-screen view with its own tab rail + Back button, so the + // main app sidebar is hidden there. + const { pathname } = useLocation() + const navigate = useNavigate() + const hideSidebar = pathname === '/settings' + + // Honor a one-shot destination requested by onboarding (e.g. the final + // "Take me to my tasks" button). The shell mounts at /home after the + // onboarding gate redirects; we consume the pending route here and jump to it. + useEffect(() => { + const dest = consumePendingRoute() + if (dest) navigate(dest, { replace: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Startup-phase mark for the AUTHENTICATED path: the heavy authed shell + // (MainViews + sidebar + providers) has now mounted, so a double-rAF lands + // after its first painted frame. The bench (OMI_BENCH) waits for this before + // measuring/quitting, so the loop can target real authed-startup cost rather + // than the lightweight Login screen. No-op on prod (perfMark is buffered). + useEffect(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => window.omi?.perfMark('renderer:app-ready')) + }) + // Start the animation-jank probe (no-op unless OMI_ANIM_BENCH). Runs as the + // entrance animations (sidebar slide, content fade) play. + runAnimBench() + }, []) + + // The overlay is a separate window with its own conversations cache, so when it + // saves a chat it can't invalidate ours directly. Main rebroadcasts the change + // here so this window's Conversations tab refreshes without a relaunch. + useEffect(() => window.omi.onConversationsChanged(() => invalidateConversationsCache()), []) + + return ( +
+ {!hideSidebar && } +
+ +
+ {/* Hidden video sink for screen-capture recording mode. Invisible, but + mounted app-wide so the screen stream has a render target regardless of + which tab is active. */} +
+ ) +} + +function AppShell(): React.JSX.Element { + // One-time cleanup of legacy "Uses " memories (macOS parity — app data + // lives in the local KG, not in memories). Guarded internally so it runs at + // most once per install; best-effort, never blocks the UI. + useEffect(() => { + void purgeAppMemoriesOnce() + }, []) + + return ( + + + + ) +} + +function App(): React.JSX.Element { + const { user, loading } = useAuth() + // Under the perf bench, treat the user as already onboarded so the authed + // shell mounts (a returning user always is). The onboarding flag lives in + // origin-scoped localStorage, which the file:// bench profile can't inherit + // from the dev session, so without this the bench would stall on the wizard. + const onboarded = useOnboardingComplete() || !!window.omi?.isBench + + // Tell main whether the summon shortcut may open the overlay. Enabled once + // onboarding is complete; during onboarding the shortcut-setup step enables it + // early (and warms the overlay) so the user can test the press there. This + // effect never disables what that step turned on, since `onboarded` only + // transitions false→true. + useEffect(() => { + if (onboarded) window.omiOverlay?.setEnabled(true) + }, [onboarded]) + + // Push the user's saved summon shortcut to main on startup so their choice + // survives restarts (main registers its default at launch; this re-applies the + // persisted accelerator once the renderer mounts). + useEffect(() => { + const accel = getPreferences().overlayShortcut + if (accel) void window.omiOverlay?.setAccelerator(accel) + }, []) + + if (loading) { + return ( +
+ + +
+ ) + } + + return ( + + + + } /> + } /> + : } /> + + ) : onboarded ? ( + + ) : ( + <> + {/* Run screen capture during onboarding too, so the hot + currentScreen cache is seeded and chat can read the screen + while the user is still in the wizard. Post-onboarding this + host is mounted by AppShell; routes are mutually exclusive so + only one host is ever live (no double getUserMedia stream). */} + + {/* Onboarding is imported DIRECTLY (no lazy/Suspense) so the + BrainGraph map renders reliably — see the import comment. */} + + + ) + } + /> + + ) : !onboarded ? ( + + ) : ( + + ) + } + /> + + + ) +} + +export default App diff --git a/windows/src/renderer/src/assets/base.css b/windows/src/renderer/src/assets/base.css new file mode 100644 index 0000000000..5ed6406a34 --- /dev/null +++ b/windows/src/renderer/src/assets/base.css @@ -0,0 +1,67 @@ +:root { + --ev-c-white: #ffffff; + --ev-c-white-soft: #f8f8f8; + --ev-c-white-mute: #f2f2f2; + + --ev-c-black: #1b1b1f; + --ev-c-black-soft: #222222; + --ev-c-black-mute: #282828; + + --ev-c-gray-1: #515c67; + --ev-c-gray-2: #414853; + --ev-c-gray-3: #32363f; + + --ev-c-text-1: rgba(255, 255, 245, 0.86); + --ev-c-text-2: rgba(235, 235, 245, 0.6); + --ev-c-text-3: rgba(235, 235, 245, 0.38); + + --ev-button-alt-border: transparent; + --ev-button-alt-text: var(--ev-c-text-1); + --ev-button-alt-bg: var(--ev-c-gray-3); + --ev-button-alt-hover-border: transparent; + --ev-button-alt-hover-text: var(--ev-c-text-1); + --ev-button-alt-hover-bg: var(--ev-c-gray-2); +} + +:root { + --color-background: var(--ev-c-black); + --color-background-soft: var(--ev-c-black-soft); + --color-background-mute: var(--ev-c-black-mute); + + --color-text: var(--ev-c-text-1); +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +ul { + list-style: none; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/windows/src/renderer/src/assets/electron.svg b/windows/src/renderer/src/assets/electron.svg new file mode 100644 index 0000000000..45ef09cf4f --- /dev/null +++ b/windows/src/renderer/src/assets/electron.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/windows/src/renderer/src/assets/macs.png b/windows/src/renderer/src/assets/macs.png new file mode 100644 index 0000000000..f9b7809c90 Binary files /dev/null and b/windows/src/renderer/src/assets/macs.png differ diff --git a/windows/src/renderer/src/assets/main.css b/windows/src/renderer/src/assets/main.css new file mode 100644 index 0000000000..0179fc4c27 --- /dev/null +++ b/windows/src/renderer/src/assets/main.css @@ -0,0 +1,171 @@ +@import './base.css'; + +body { + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background-image: url('./wavy-lines.svg'); + background-size: cover; + user-select: none; +} + +code { + font-weight: 600; + padding: 3px 5px; + border-radius: 2px; + background-color: var(--color-background-mute); + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; + font-size: 85%; +} + +#root { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + margin-bottom: 80px; +} + +.logo { + margin-bottom: 20px; + -webkit-user-drag: none; + height: 128px; + width: 128px; + will-change: filter; + transition: filter 300ms; +} + +.logo:hover { + filter: drop-shadow(0 0 1.2em #6988e6aa); +} + +.creator { + font-size: 14px; + line-height: 16px; + color: var(--ev-c-text-2); + font-weight: 600; + margin-bottom: 10px; +} + +.text { + font-size: 28px; + color: var(--ev-c-text-1); + font-weight: 700; + line-height: 32px; + text-align: center; + margin: 0 10px; + padding: 16px 0; +} + +.tip { + font-size: 16px; + line-height: 24px; + color: var(--ev-c-text-2); + font-weight: 600; +} + +.react { + background: -webkit-linear-gradient(315deg, #087ea4 55%, #7c93ee); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 700; +} + +.ts { + background: -webkit-linear-gradient(315deg, #3178c6 45%, #f0dc4e); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 700; +} + +.actions { + display: flex; + padding-top: 32px; + margin: -6px; + flex-wrap: wrap; + justify-content: flex-start; +} + +.action { + flex-shrink: 0; + padding: 6px; +} + +.action a { + cursor: pointer; + text-decoration: none; + display: inline-block; + border: 1px solid transparent; + text-align: center; + font-weight: 600; + white-space: nowrap; + border-radius: 20px; + padding: 0 20px; + line-height: 38px; + font-size: 14px; + border-color: var(--ev-button-alt-border); + color: var(--ev-button-alt-text); + background-color: var(--ev-button-alt-bg); +} + +.action a:hover { + border-color: var(--ev-button-alt-hover-border); + color: var(--ev-button-alt-hover-text); + background-color: var(--ev-button-alt-hover-bg); +} + +.versions { + position: absolute; + bottom: 30px; + margin: 0 auto; + padding: 15px 0; + font-family: 'Menlo', 'Lucida Console', monospace; + display: inline-flex; + overflow: hidden; + align-items: center; + border-radius: 22px; + background-color: #202127; + backdrop-filter: blur(24px); +} + +.versions li { + display: block; + float: left; + border-right: 1px solid var(--ev-c-gray-1); + padding: 0 20px; + font-size: 14px; + line-height: 14px; + opacity: 0.8; + &:last-child { + border: none; + } +} + +@media (max-width: 720px) { + .text { + font-size: 20px; + } +} + +@media (max-width: 620px) { + .versions { + display: none; + } +} + +@media (max-width: 350px) { + .tip, + .actions { + display: none; + } +} diff --git a/windows/src/renderer/src/assets/omi-logo.png b/windows/src/renderer/src/assets/omi-logo.png new file mode 100644 index 0000000000..5bbe9c2200 Binary files /dev/null and b/windows/src/renderer/src/assets/omi-logo.png differ diff --git a/windows/src/renderer/src/assets/omilogo.png b/windows/src/renderer/src/assets/omilogo.png new file mode 100644 index 0000000000..c46a5d6a5d Binary files /dev/null and b/windows/src/renderer/src/assets/omilogo.png differ diff --git a/windows/src/renderer/src/assets/wavy-lines.svg b/windows/src/renderer/src/assets/wavy-lines.svg new file mode 100644 index 0000000000..d08c611992 --- /dev/null +++ b/windows/src/renderer/src/assets/wavy-lines.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/src/renderer/src/components/GlobalRecordButton.tsx b/windows/src/renderer/src/components/GlobalRecordButton.tsx new file mode 100644 index 0000000000..2692973c56 --- /dev/null +++ b/windows/src/renderer/src/components/GlobalRecordButton.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { Mic, ChevronDown, Monitor } from 'lucide-react' +import { useAppState, type CaptureChoice } from '../state/AppStateProvider' +import { Shortcut } from './ui/Shortcut' + +const OPTIONS: { choice: CaptureChoice; label: string; Icon: typeof Mic; keys?: string[] }[] = [ + { choice: 'mic', label: 'Mic only', Icon: Mic, keys: ['Ctrl', 'Space'] }, + { choice: 'screen', label: 'Screen record', Icon: Monitor } +] + +/** + * Global Record control pinned to the top-right on every tab — and on Home once + * a chat has started — but hidden on the idle Home screen (which has its own + * record buttons) and while a recording is already running. + */ +export function GlobalRecordButton(): React.JSX.Element | null { + const { recorder, chat, startRecording } = useAppState() + const { pathname } = useLocation() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + + // Close the menu on any click outside the control or on Escape. A document + // listener (rather than an overlay) is z-index-proof — the old overlay sat at + // z-40 and never caught clicks on the z-50 sidebar. + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + const onIdleHome = pathname === '/home' && chat.history.length === 0 + if (recorder.recording || recorder.saving || onIdleHome) return null + + const choose = (choice: CaptureChoice): void => { + setOpen(false) + if (choice === 'mic') { + navigate('/conversations/live') + return + } + startRecording(choice) + } + + return ( +
+ + {open && ( +
+ {OPTIONS.map(({ choice, label, Icon, keys }) => ( + + ))} +
+ )} +
+ ) +} diff --git a/windows/src/renderer/src/components/Markdown.tsx b/windows/src/renderer/src/components/Markdown.tsx new file mode 100644 index 0000000000..ec8ef74048 --- /dev/null +++ b/windows/src/renderer/src/components/Markdown.tsx @@ -0,0 +1,128 @@ +// Minimal, dependency-free markdown for chat bubbles. Supports the subset the +// Omi chat actually emits — headings, bullet/numbered lists, fenced + inline +// code, bold, italic, and links. NOT a full CommonMark parser; anything it does +// not recognize falls through as plain text (so a half-streamed `**` just shows +// literally until the closing marker arrives). Renders React elements, never raw +// HTML, so there is no injection surface. + +// One regex with the token captured, so String.split keeps the delimiters: the +// result alternates plain-text / token / plain-text. Bold is listed before +// italic so `**x**` matches bold, not two italics. +const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\))/g + +function renderInline(text: string): React.ReactNode[] { + return text.split(INLINE).map((part, i) => { + if (!part) return null + if (part.startsWith('**') && part.endsWith('**')) + return {part.slice(2, -2)} + if (part.startsWith('`') && part.endsWith('`')) + return ( + + {part.slice(1, -1)} + + ) + if ( + (part.startsWith('*') && part.endsWith('*')) || + (part.startsWith('_') && part.endsWith('_')) + ) + return {part.slice(1, -1)} + const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part) + if (link) + return ( + + {link[1]} + + ) + return {part} + }) +} + +const UL = /^\s*[-*+]\s+/ +const OL = /^\s*\d+\.\s+/ +const FENCE = /^```/ +const HEADING = /^(#{1,6})\s+(.*)$/ + +export function Markdown({ text }: { text: string }): React.JSX.Element { + const lines = text.replace(/\r\n/g, '\n').split('\n') + const blocks: React.ReactNode[] = [] + let i = 0 + let key = 0 + + while (i < lines.length) { + const line = lines[i] + + if (FENCE.test(line.trim())) { + const buf: string[] = [] + i++ + while (i < lines.length && !FENCE.test(lines[i].trim())) buf.push(lines[i++]) + i++ // consume closing fence + blocks.push( +
+          {buf.join('\n')}
+        
+ ) + continue + } + + const h = HEADING.exec(line) + if (h) { + blocks.push( +

+ {renderInline(h[2])} +

+ ) + i++ + continue + } + + if (UL.test(line)) { + const items: string[] = [] + while (i < lines.length && UL.test(lines[i])) items.push(lines[i++].replace(UL, '')) + blocks.push( +
    + {items.map((it, j) => ( +
  • {renderInline(it)}
  • + ))} +
+ ) + continue + } + + if (OL.test(line)) { + const items: string[] = [] + while (i < lines.length && OL.test(lines[i])) items.push(lines[i++].replace(OL, '')) + blocks.push( +
    + {items.map((it, j) => ( +
  1. {renderInline(it)}
  2. + ))} +
+ ) + continue + } + + if (line.trim() === '') { + i++ + continue + } + + // Paragraph: gather consecutive lines until a blank line or a block starter. + const para: string[] = [] + while ( + i < lines.length && + lines[i].trim() !== '' && + !FENCE.test(lines[i].trim()) && + !HEADING.test(lines[i]) && + !UL.test(lines[i]) && + !OL.test(lines[i]) + ) + para.push(lines[i++]) + blocks.push( +

+ {renderInline(para.join('\n'))} +

+ ) + } + + return
{blocks}
+} diff --git a/windows/src/renderer/src/components/SandboxBadge.tsx b/windows/src/renderer/src/components/SandboxBadge.tsx new file mode 100644 index 0000000000..404fb3b12b --- /dev/null +++ b/windows/src/renderer/src/components/SandboxBadge.tsx @@ -0,0 +1,34 @@ +// Dev-only visual marker so multiple sandbox app windows are instantly +// distinguishable. Renders nothing unless VITE_SANDBOX_NAME is set, so it is +// inert in real builds. Fixed to the bottom-left corner, pointer-events-none so +// it never intercepts clicks. + +// Pick black/white text for legible contrast against the badge color (YIQ). +function contrastText(hex: string): string { + const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()) + if (!m) return '#000' + let h = m[1] + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + const r = parseInt(h.slice(0, 2), 16) + const g = parseInt(h.slice(2, 4), 16) + const b = parseInt(h.slice(4, 6), 16) + const yiq = (r * 299 + g * 587 + b * 114) / 1000 + return yiq >= 140 ? '#000' : '#fff' +} + +export function SandboxBadge(): React.JSX.Element | null { + const name = import.meta.env.VITE_SANDBOX_NAME as string | undefined + if (!name) return null + + const color = (import.meta.env.VITE_SANDBOX_COLOR as string | undefined)?.trim() || '#444' + + return ( +
+ {name} +
+ ) +} diff --git a/windows/src/renderer/src/components/SourcePicker.tsx b/windows/src/renderer/src/components/SourcePicker.tsx new file mode 100644 index 0000000000..55cd2ba454 --- /dev/null +++ b/windows/src/renderer/src/components/SourcePicker.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react' +import { X } from 'lucide-react' +import type { CaptureSource } from '../../../shared/types' + +export function SourcePicker(props: { + open: boolean + onClose: () => void + onPick: (s: CaptureSource) => void +}): React.JSX.Element | null { + const [sources, setSources] = useState([]) + + useEffect(() => { + if (!props.open) return + window.omi.getCaptureSources().then(setSources).catch(() => setSources([])) + }, [props.open]) + + if (!props.open) return null + + return ( +
+
e.stopPropagation()} + > +
+

+ Choose a window or screen +

+ +
+ {sources.length === 0 && ( +
Loading sources…
+ )} +
+ {sources.map((s) => ( + + ))} +
+
+
+ ) +} diff --git a/windows/src/renderer/src/components/TranscriptPopup.tsx b/windows/src/renderer/src/components/TranscriptPopup.tsx new file mode 100644 index 0000000000..13dca1bd8b --- /dev/null +++ b/windows/src/renderer/src/components/TranscriptPopup.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { Square, Monitor } from 'lucide-react' +import type { TranscriptLine } from '../../../shared/types' + +function fmtElapsed(totalSec: number): string { + const m = Math.floor(totalSec / 60) + const r = totalSec % 60 + return `${m}:${r.toString().padStart(2, '0')}` +} + +/** + * Minimal transcription card docked at the bottom-right while recording. It's + * non-blocking — the rest of the app (chat, tabs, adding a screen) stays usable + * underneath it. Shows the live microphone transcript, an elapsed timer, Stop, + * and (when no screen is captured yet) an "Add screen" button. System audio is + * transcribed too but only surfaces in the saved conversation, not here. + */ +export function TranscriptPopup(props: { + micLines: TranscriptLine[] + micInterim: string + saving: boolean + hasScreen: boolean + onStop: () => void + onAddScreen: () => void +}): React.JSX.Element { + const { micLines, micInterim, saving, hasScreen, onStop, onAddScreen } = props + const [elapsed, setElapsed] = useState(0) + + useEffect(() => { + const id = setInterval(() => setElapsed((s) => s + 1), 1000) + return () => clearInterval(id) + }, []) + + return ( +
+
+
+ + + + + + Recording + + {fmtElapsed(elapsed)} +
+ +
+ {micLines.map((l, i) => ( +
+ {l.speaker && {l.speaker}: } + {l.text} +
+ ))} + {micInterim && ( +
{micInterim}
+ )} + {micLines.length === 0 && !micInterim && ( + Listening… + )} +
+ +
+ {hasScreen ? ( + + Capturing screen + + ) : ( + + )} + +
+
+
+ ) +} diff --git a/windows/src/renderer/src/components/Versions.tsx b/windows/src/renderer/src/components/Versions.tsx new file mode 100644 index 0000000000..37a9ff0825 --- /dev/null +++ b/windows/src/renderer/src/components/Versions.tsx @@ -0,0 +1,15 @@ +import { useState } from 'react' + +function Versions(): React.JSX.Element { + const [versions] = useState(window.electron.process.versions) + + return ( +
    +
  • Electron v{versions.electron}
  • +
  • Chromium v{versions.chrome}
  • +
  • Node v{versions.node}
  • +
+ ) +} + +export default Versions diff --git a/windows/src/renderer/src/components/chat/ChatMessages.tsx b/windows/src/renderer/src/components/chat/ChatMessages.tsx new file mode 100644 index 0000000000..57f3f56e7d --- /dev/null +++ b/windows/src/renderer/src/components/chat/ChatMessages.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from 'react' +import type { ChatMsg } from '../../hooks/useChat' +import { Markdown } from '../Markdown' + +// Smooth text reveal, decoupled from SSE chunk sizes so a reply streams in evenly +// instead of landing in bulky jumps. Rendered as markdown either way. +const REVEAL_MS = 16 +const REVEAL_MIN_CHARS = 2 + +function RevealMarkdown({ + text, + startRevealed +}: { + text: string + startRevealed: boolean +}): React.JSX.Element { + const [shown, setShown] = useState(startRevealed ? text.length : 0) + const targetRef = useRef(text) + targetRef.current = text + useEffect(() => { + const id = setInterval(() => { + setShown((prev) => { + const t = targetRef.current.length + if (prev >= t) return prev + const step = Math.max(REVEAL_MIN_CHARS, Math.ceil((t - prev) / 24)) + return Math.min(t, prev + step) + }) + }, REVEAL_MS) + return () => clearInterval(id) + }, []) + return +} + +const BUBBLE: Record<'main' | 'overlay', { user: string; assistant: string }> = { + main: { + user: 'glass ml-auto max-w-[85%] rounded-2xl rounded-br-md px-4 py-3 text-sm leading-relaxed text-white', + assistant: + 'glass-subtle mr-auto max-w-[85%] rounded-2xl rounded-bl-md px-4 py-3 text-sm leading-relaxed text-white/75' + }, + // Same bubble design as the main window (Home) — shape, padding, asymmetric + // corner, and the bubble-in entrance animation — but keeping the overlay's + // neutral colors (the floating bar's dark acrylic, not Home's accent/white). + overlay: { + user: 'bubble-in ml-auto w-fit max-w-[80%] rounded-2xl rounded-br-md bg-neutral-700/70 px-3.5 py-2 text-sm leading-snug text-neutral-100', + assistant: + 'bubble-in mr-auto w-fit max-w-[80%] rounded-2xl rounded-bl-md bg-neutral-800/60 px-3.5 py-2 text-sm leading-snug text-neutral-100' + } +} + +/** + * Shared chat message list used by both the main window (Home) and the overlay. + * Owns bubble styling (per `variant`), markdown rendering, and the smooth reveal + * of the live assistant message. Callers provide their own scroll container. + */ +export function ChatMessages({ + messages, + sending, + variant +}: { + messages: ChatMsg[] + sending: boolean + variant: 'main' | 'overlay' +}): React.JSX.Element { + const cls = BUBBLE[variant] + return ( + <> + {messages.map((m, i) => { + const isLast = i === messages.length - 1 + return ( +
+ {m.role === 'assistant' ? ( + m.content ? ( + + ) : sending ? ( + '…' + ) : ( + '' + ) + ) : ( +
{m.content}
+ )} +
+ ) + })} + + ) +} diff --git a/windows/src/renderer/src/components/graph/BrainGraph.tsx b/windows/src/renderer/src/components/graph/BrainGraph.tsx new file mode 100644 index 0000000000..b027c66044 --- /dev/null +++ b/windows/src/renderer/src/components/graph/BrainGraph.tsx @@ -0,0 +1,381 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Canvas, useFrame, useThree } from '@react-three/fiber' +import { OrbitControls, Billboard, Text, Line } from '@react-three/drei' +import * as THREE from 'three' +import type { KnowledgeGraph } from '../../../../shared/types' +import { + useGraphSimulation, + fullGraphRadius, + labelFontSize, + type GraphSimulation, + type NodePosition +} from '../../lib/useGraphSimulation' +import { nodeColor } from './nodeColor' + +export type BrainGraphProps = { + graph: KnowledgeGraph + centerNodeId?: string + interactive?: boolean + // Changing this re-rolls the module positions with an animation (used to + // rearrange the graph on every onboarding screen change). + shuffleKey?: number | string + // When true, the whole WebGL canvas is UNMOUNTED while the host is off-screen + // (e.g. on a hidden MainViews tab) so it costs zero GPU, then remounts fresh + // when shown. Use for the Memories tab. Leave false (default) for onboarding, + // where the map is deliberately kept mounted across steps and must not blank. + pauseWhenHidden?: boolean +} + +// Must match GraphSimulation.nodeRadius so the spheres and the collision force +// agree on size. The center ("you") node is fixed-large; others much smaller, +// then scaled by the node's random ±50% sizeScale. +function radiusFor(node: NodePosition, isCenter: boolean): number { + return isCenter ? 24 : (10 + Math.sqrt(node.degree) * 2) * node.sizeScale +} + +// A stable 0..2π phase derived from the node id, so each module pulses on its +// own offset and the ring twinkles rather than breathing in unison. +function hashPhase(id: string): number { + let h = 0 + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 997 + return (h / 997) * Math.PI * 2 +} + +function GraphNodeMesh({ + sim, + node, + centerNodeId, + reduced, + posMap +}: { + sim: GraphSimulation + node: NodePosition + centerNodeId?: string + reduced: boolean + // Shared map (owned by GraphScene, recreated on mount) where each node writes + // its eased on-screen position so the edges can connect to it. + posMap: Map +}): React.JSX.Element { + const groupRef = useRef(null) + const coreMat = useRef(null) + const glowMat = useRef(null) + const glowMesh = useRef(null) + const target = useRef(new THREE.Vector3(node.x, node.y, node.z)) + const isFixed = node.id === centerNodeId + const color = nodeColor(node.nodeType, isFixed) + const radius = radiusFor(node, isFixed) + // The center ("you") label gets a bit bigger than the proportional size. + const labelSize = labelFontSize(node.sizeScale) * (isFixed ? 1.35 : 1) + const phase = useMemo(() => hashPhase(node.id), [node.id]) + + // Read the live simulation position each frame (no React state in the loop) + // and ease toward it so motion stays smooth. New nodes fly out from the + // center and grow 0 → full size, then settle into a gentle continuous shine. + useFrame((state) => { + const g = groupRef.current + if (!g) return + const live = sim.liveNode(node.id) + if (live) target.current.set(live.x ?? 0, live.y ?? 0, live.z ?? 0) + + if (reduced) { + g.position.copy(target.current) + g.scale.setScalar(1) + } else { + // Low lerp factor = slow, smooth glide toward the target (used both for + // the initial reveal and for the gentle reshuffle drift between screens). + g.position.lerp(target.current, 0.045) + if (g.scale.x < 1) g.scale.setScalar(Math.min(1, g.scale.x + 0.05)) + } + // Record the eased on-screen position so the connecting lines follow the + // sphere exactly (instead of snapping to the raw sim position). The map is + // plain React-owned state, so this can never throw / blank the canvas. + let v = posMap.get(node.id) + if (!v) { + v = new THREE.Vector3() + posMap.set(node.id, v) + } + v.copy(g.position) + + // Shine: pulse the emissive core + halo so the modules glow and feel alive. + // While a node is still growing in it flares brighter, giving the reveal a + // satisfying "pop" before it settles to its idle twinkle. + const entering = !reduced && g.scale.x < 1 + const t = state.clock.elapsedTime + const pulse = reduced ? 0.6 : 0.5 + 0.5 * Math.sin(t * 2 + phase) + const flare = entering ? 1.8 : 1 + if (coreMat.current) coreMat.current.emissiveIntensity = (0.85 + 0.45 * pulse) * flare + if (glowMat.current) glowMat.current.opacity = (0.12 + 0.14 * pulse) * flare + if (glowMesh.current) glowMesh.current.scale.setScalar(1 + 0.18 * pulse) + }) + + return ( + + + + + + {/* pulsing glow halo (scales with the shine) */} + + + + + {/* faint outer bloom for extra shine */} + + + + + + + {node.label} + + + + ) +} + +// A single connecting line, drawn as a fat (real pixel-width) line so it is +// actually visible — plain THREE lines render at 1px and disappear against the +// glowing nodes. Its two endpoints are rewritten every frame from the eased +// on-screen positions, so the line stays glued to both spheres as they move. +// Colored by its target (module) node, so each line matches its node. +function GraphEdge({ + sim, + edge, + color, + posMap +}: { + sim: GraphSimulation + edge: KnowledgeGraph['edges'][number] + color: string + posMap: Map +}): React.JSX.Element { + const ref = useRef<{ geometry: { setPositions(p: number[]): void } } | null>(null) + useFrame(() => { + const a = posMap.get(edge.sourceId) ?? sim.liveNode(edge.sourceId) + const b = posMap.get(edge.targetId) ?? sim.liveNode(edge.targetId) + if (!a || !b || !ref.current) return + ref.current.geometry.setPositions([ + a.x ?? 0, + a.y ?? 0, + a.z ?? 0, + b.x ?? 0, + b.y ?? 0, + b.z ?? 0 + ]) + }) + return ( + + ) +} + +function GraphEdges({ + sim, + edges, + posMap +}: { + sim: GraphSimulation + edges: KnowledgeGraph['edges'] + posMap: Map +}): React.JSX.Element { + // Only draw edges whose endpoints both exist in the sim yet. + const drawn = edges.filter((e) => sim.liveNode(e.sourceId) && sim.liveNode(e.targetId)) + return ( + <> + {drawn.map((e) => ( + + ))} + + ) +} + +// Empty-space margin around the graph. >1 pulls the camera back so there is a +// gap between the outermost node/label and the container border (even when a +// reshuffle briefly pushes a module outward). Larger = bigger gap / more zoomed +// out. Tunable. +const FRAME_MARGIN = 1.2 + +// Frames the graph for the non-interactive reveal at a SINGLE CONSTANT distance. +// It uses only the measured full-graph radius (the final "apps loaded" framing) +// — never the live bounds — so the camera distance is the same on every step and +// does NOT change when modules appear (e.g. the disk screen). Because the layout +// also pins every module to a constant radius (RING_RADIUS), both the zoom and +// the module spacing are invariant across the whole onboarding. We only recompute +// for the pane's aspect ratio (narrow half-width column), so a window resize +// reframes correctly. The interactive KG page drives its own camera instead. +function CameraRig(): null { + const { camera, size } = useThree() + useFrame(() => { + // While the pane is hidden (display:none) the canvas has no size; skip so we + // don't compute a NaN/Infinity camera that would flash when it reappears. + if (size.width === 0 || size.height === 0) return + const cam = camera as THREE.PerspectiveCamera + const r = fullGraphRadius() + const vfov = (cam.fov * Math.PI) / 180 + const aspect = size.width / Math.max(1, size.height) + const fitForHeight = r / Math.tan(vfov / 2) + const fitForWidth = r / (Math.tan(vfov / 2) * aspect) + cam.position.z = Math.max(fitForHeight, fitForWidth) * FRAME_MARGIN + cam.lookAt(0, 0, 0) + }) + return null +} + +function GraphScene({ + graph, + centerNodeId, + interactive, + shuffleKey +}: BrainGraphProps): React.JSX.Element { + const { sim, nodes, reduced } = useGraphSimulation(graph, centerNodeId) + + // Eased on-screen position of each node, written by the meshes and read by the + // edges so the lines stay glued to the spheres. Owned here (not on the sim) and + // recreated on mount, so it can never go stale across hot-reloads. + const posMapRef = useRef>(undefined) + if (!posMapRef.current) posMapRef.current = new Map() + const posMap = posMapRef.current + + // Rearrange the modules on every screen change. Skip the first run so the + // initial reveal isn't immediately reshuffled; thereafter each new shuffleKey + // re-rolls distances/angles and reheats, and the meshes ease to their new + // spots. Reduced motion keeps the graph still. + const firstShuffle = useRef(true) + useEffect(() => { + if (firstShuffle.current) { + firstShuffle.current = false + return + } + if (!reduced) sim.reshuffle?.() + }, [shuffleKey, sim, reduced]) + + // Advance the physics in the render loop (only while warm). Nothing here + // touches React state, so the scene never re-renders frame to frame. + useFrame(() => { + if (!reduced) sim.settleFrame() + }) + + return ( + <> + + + + {nodes.map((n) => ( + + ))} + {interactive ? ( + + ) : ( + + )} + + ) +} + +// Shared 3D knowledge-graph renderer. Used by onboarding (live local graph) and +// the Knowledge Graph page (server graph). Background is transparent so the +// host pane's dark/glass styling shows through. +// +// KG-page adoption (feat/knowledge-graph): render +// n.nodeType === 'person')?.id} /> +// inside a positioned (relative) container. The server graph uses the same +// KGNode/KGEdge shape, so no mapping is needed. +export function BrainGraph({ + graph, + centerNodeId, + interactive = true, + shuffleKey, + pauseWhenHidden = false +}: BrainGraphProps): React.JSX.Element { + const hostRef = useRef(null) + const [visible, setVisible] = useState(true) + + // Off-screen GPU saving for the Memories tab only (pauseWhenHidden): UNMOUNT + // the canvas while the host is collapsed to 0×0 (its MainViews tab is + // display:none'd) so it costs nothing, then remount fresh when shown. We + // unmount rather than toggle `frameloop` because toggling to 'never' across a + // resize leaves the GL canvas cleared-but-not-repainted (and can lose the + // context) → a permanent blank. Onboarding leaves pauseWhenHidden false: its + // map is kept mounted across steps and must always render. + useEffect(() => { + if (!pauseWhenHidden) return + const el = hostRef.current + if (!el) return + const update = (): void => setVisible(el.clientWidth > 0 && el.clientHeight > 0) + update() + const ro = new ResizeObserver(update) + ro.observe(el) + return () => ro.disconnect() + }, [pauseWhenHidden]) + + const showCanvas = !pauseWhenHidden || visible + + return ( +
+ {showCanvas && ( + + + + )} +
+ ) +} diff --git a/windows/src/renderer/src/components/graph/LazyBrainGraph.tsx b/windows/src/renderer/src/components/graph/LazyBrainGraph.tsx new file mode 100644 index 0000000000..94d6947196 --- /dev/null +++ b/windows/src/renderer/src/components/graph/LazyBrainGraph.tsx @@ -0,0 +1,26 @@ +import { lazy, Suspense } from 'react' +import type { BrainGraphProps } from './BrainGraph' +import { ErrorBoundary } from '../ui/ErrorBoundary' + +// Lazy wrapper so the heavy 3D stack (three + @react-three/fiber + drei + +// d3-force-3d, ~1MB) is code-split out of the initial renderer bundle and only +// downloaded/evaluated when a brain map actually mounts (Memories / Onboarding). +// This shrinks window:created→renderer:eval, the dominant startup phase. +const BrainGraphImpl = lazy(() => + import('./BrainGraph').then((m) => ({ default: m.BrainGraph })) +) + +// The 3D brain map is the only WebGL surface in the app, so it's the first thing +// to fail if the GPU process is unhealthy (e.g. a contended/locked Chromium GPU +// cache when two instances share a profile) or if its code-split chunk fails to +// load. Contain that here so a failure degrades to a blank pane instead of +// crashing the whole screen (onboarding has no other error boundary above it). +export function BrainGraph(props: BrainGraphProps): React.JSX.Element { + return ( + }> + }> + + + + ) +} diff --git a/windows/src/renderer/src/components/graph/nodeColor.test.ts b/windows/src/renderer/src/components/graph/nodeColor.test.ts new file mode 100644 index 0000000000..edc6229bc4 --- /dev/null +++ b/windows/src/renderer/src/components/graph/nodeColor.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { nodeColor } from './nodeColor' + +describe('nodeColor', () => { + it('maps node types to the macOS palette', () => { + expect(nodeColor('concept', false)).toBe('#0a84ff') // blue + expect(nodeColor('thing', false)).toBe('#a855f7') // purple + expect(nodeColor('person', false)).toBe('#22d3d3') // cyan + expect(nodeColor('place', false)).toBe('#00ff9e') // mint + expect(nodeColor('organization', false)).toBe('#ff9f0a') // orange + }) + + it('returns white for the fixed (user) node regardless of type', () => { + expect(nodeColor('person', true)).toBe('#ffffff') + expect(nodeColor('thing', true)).toBe('#ffffff') + }) + + it('defaults unknown types to blue', () => { + expect(nodeColor('mystery', false)).toBe('#0a84ff') + }) +}) diff --git a/windows/src/renderer/src/components/graph/nodeColor.ts b/windows/src/renderer/src/components/graph/nodeColor.ts new file mode 100644 index 0000000000..de494a4f22 --- /dev/null +++ b/windows/src/renderer/src/components/graph/nodeColor.ts @@ -0,0 +1,19 @@ +// Maps a knowledge-graph node type to a hex color, matching the Omi macOS +// desktop app (KnowledgeGraphNodeType.nsColor). The fixed user/center node is +// always white, like the macOS `isFixed` glow. +export function nodeColor(nodeType: string, isFixed: boolean): string { + if (isFixed) return '#ffffff' + switch (nodeType) { + case 'person': + return '#22d3d3' // cyan + case 'thing': + return '#a855f7' // purple + case 'place': + return '#00ff9e' // mint + case 'organization': + return '#ff9f0a' // orange + case 'concept': + default: + return '#0a84ff' // blue (systemBlue) + } +} diff --git a/windows/src/renderer/src/components/home/QuickGoalsWidget.tsx b/windows/src/renderer/src/components/home/QuickGoalsWidget.tsx new file mode 100644 index 0000000000..64005273c1 --- /dev/null +++ b/windows/src/renderer/src/components/home/QuickGoalsWidget.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { Target, ChevronRight } from 'lucide-react' +import { omiApi } from '../../lib/apiClient' +import { auth, onAuthStateChanged } from '../../lib/firebase' +import { toast } from '../../lib/toast' +import { GenerateGoalsButton } from '../ui/GenerateGoalsButton' + +// Compact dashboard surface for the idle Home screen: the active goals with +// their progress, mirroring the macOS dashboard Goals widget. Reads the same +// /v1/goals/all feed the Goals page uses; tapping opens the Goals page. +type Goal = { + id: string + title: string + target_value?: number | null + current_value?: number | null + // Done when is_active === false (matches the live backend Goal model). + is_active?: boolean +} + +// Complete when server-archived (is_active === false) or progress reached the +// target. Mirrors pages/Goals.tsx (the backend has no is_active write path). +function isCompleted(g: Goal): boolean { + if (g.is_active === false) return true + const target = g.target_value ?? 0 + return target > 0 && (g.current_value ?? 0) >= target +} + +function progressPct(g: Goal): number { + if (isCompleted(g)) return 100 + const target = g.target_value ?? 0 + const current = g.current_value ?? 0 + if (target > 0) return Math.max(0, Math.min(100, Math.round((current / target) * 100))) + return 0 +} + +const MAX_SHOWN = 2 + +export function QuickGoalsWidget({ onReady }: { onReady?: () => void }): React.JSX.Element | null { + const [goals, setGoals] = useState(null) + const { pathname } = useLocation() + // Tell the parent once our data has loaded, so it can reveal both widgets + // together rather than letting them pop in / reshuffle. + const readyFired = useRef(false) + useEffect(() => { + if (goals !== null && !readyFired.current) { + readyFired.current = true + onReady?.() + } + }, [goals, onReady]) + // Track the signed-in user so the fetch waits for (and re-runs on) auth being + // ready. On a cold start the Home panel mounts already at /home and fires its + // fetch immediately — before Firebase has restored the user — so without this + // the request goes out unauthenticated, fails, and (since pathname never + // changes) never retries, leaving the widget permanently hidden. + const [userId, setUserId] = useState(auth.currentUser?.uid ?? null) + const [generating, setGenerating] = useState(false) + useEffect(() => onAuthStateChanged(auth, (u) => setUserId(u?.uid ?? null)), []) + + const fetchGoals = useCallback((): (() => void) => { + let cancelled = false + omiApi + .get('/v1/goals/all') + .then((res) => { + const data = res.data as Goal[] | { goals?: Goal[] } + const list = Array.isArray(data) ? data : (data.goals ?? []) + if (!cancelled) setGoals(list.filter((g) => !isCompleted(g))) + }) + .catch(() => { + // Keep any previously-loaded goals on a transient failure rather than + // hiding the widget; only show empty if we have never loaded. + if (!cancelled) setGoals((prev) => prev ?? []) + }) + return () => { + cancelled = true + } + }, []) + + // Primary fetch: as soon as auth is ready (and again if the user changes). + // The Home panel is always mounted, so this preloads the widget's data + // independent of the current route — the cause of the "disappeared widget" + // bug was gating the only fetch on a pathname *change* to /home, which never + // re-fires while you sit on Home after a failed/early initial load. + useEffect(() => { + if (!userId) return + return fetchGoals() + }, [userId, fetchGoals]) + + // Refetch when returning to Home (pick up goals added/completed elsewhere). + useEffect(() => { + if (pathname !== '/home') return + return fetchGoals() + }, [pathname, fetchGoals]) + + // Refetch when the window regains focus, so a goal added or completed in + // another window/sandbox shows up without navigating away. + useEffect(() => { + const onFocus = (): void => { + if (auth.currentUser) fetchGoals() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [fetchGoals]) + + // One-tap AI goal generation: ask the backend to suggest a goal from the + // user's memories, create it, then refetch so the widget shows it. + const generate = useCallback(async (): Promise => { + setGenerating(true) + try { + const res = await omiApi.get('/v1/goals/suggest') + const s = res.data as { suggested_title?: string; suggested_target?: number | null } + if (!s?.suggested_title) { + toast('No suggestion right now', { tone: 'info', body: 'Omi needs a few memories first.' }) + return + } + const target = + typeof s.suggested_target === 'number' && s.suggested_target > 0 ? s.suggested_target : 1 + await omiApi.post('/v1/goals', { title: s.suggested_title, target_value: target }) + fetchGoals() + } catch { + toast('Could not generate a goal', { tone: 'error' }) + } finally { + setGenerating(false) + } + }, [fetchGoals]) + + // Still loading — render nothing. + if (!goals) return null + + // No goals yet: keep the card frame, with a one-tap AI generation button + // (same /v1/goals/suggest feature as the Goals tab) centered inside it. + if (goals.length === 0) { + return ( +
+ +
+ ) + } + + const shown = goals.slice(0, MAX_SHOWN) + + return ( + +
+
+ +
+
+ Goals + {goals.length} +
+ +
+
+ {shown.map((g) => { + const pct = progressPct(g) + return ( +
+
+ {g.title} + {pct}% +
+
+
+
+
+ ) + })} + {goals.length > MAX_SHOWN && ( +

+{goals.length - MAX_SHOWN} more

+ )} +
+ + ) +} diff --git a/windows/src/renderer/src/components/home/QuickTaskWidget.tsx b/windows/src/renderer/src/components/home/QuickTaskWidget.tsx new file mode 100644 index 0000000000..a5d530897c --- /dev/null +++ b/windows/src/renderer/src/components/home/QuickTaskWidget.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { ListChecks, ChevronRight } from 'lucide-react' +import { omiApi } from '../../lib/apiClient' +import { auth, onAuthStateChanged } from '../../lib/firebase' + +// Compact dashboard surface for the idle Home screen: a preview of the next +// couple of open tasks (soonest due first), mirroring the Goals widget. Reads +// the same /v1/action-items feed the Tasks page uses; tapping opens that page. +type ActionItem = { + id: string + description: string + completed: boolean + due_at?: string | null +} + +function startOfDay(ms: number): number { + const d = new Date(ms) + d.setHours(0, 0, 0, 0) + return d.getTime() +} + +// Right-side due chip, mirroring the Goals widget's progress label. Returns null +// for tasks with no due date (no chip shown). Overdue gets a rose tint. +function dueChip(t: ActionItem): { label: string; overdue: boolean } | null { + if (!t.due_at) return null + const due = startOfDay(new Date(t.due_at).getTime()) + const today = startOfDay(Date.now()) + const days = Math.round((due - today) / 86_400_000) + if (days < 0) return { label: 'Overdue', overdue: true } + if (days === 0) return { label: 'Today', overdue: false } + if (days === 1) return { label: 'Tomorrow', overdue: false } + return { + label: new Date(t.due_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + overdue: false + } +} + +// Sort by soonest due date; tasks with no due date sink to the bottom. +function byDueDate(a: ActionItem, b: ActionItem): number { + const av = a.due_at ? new Date(a.due_at).getTime() : Infinity + const bv = b.due_at ? new Date(b.due_at).getTime() : Infinity + return av - bv +} + +const MAX_SHOWN = 2 + +export function QuickTaskWidget({ onReady }: { onReady?: () => void }): React.JSX.Element | null { + const [items, setItems] = useState(null) + const { pathname } = useLocation() + // Tell the parent once our data has loaded (whether or not we have tasks), so + // it can reveal both widgets together instead of letting them pop in / reshuffle. + const readyFired = useRef(false) + useEffect(() => { + if (items !== null && !readyFired.current) { + readyFired.current = true + onReady?.() + } + }, [items, onReady]) + // Track auth so the fetch waits for (and re-runs on) a restored user. On a + // cold start the Home panel mounts already at /home and fetches before + // Firebase restores the user; without this the request goes out + // unauthenticated, fails, and never retries (pathname doesn't change), so the + // widget stays hidden even though there are tasks. + const [userId, setUserId] = useState(auth.currentUser?.uid ?? null) + useEffect(() => onAuthStateChanged(auth, (u) => setUserId(u?.uid ?? null)), []) + + const fetchItems = useCallback((): (() => void) => { + let cancelled = false + omiApi + .get('/v1/action-items', { params: { limit: 300, offset: 0 } }) + .then((res) => { + const data = res.data as ActionItem[] | { action_items?: ActionItem[] } + const list = Array.isArray(data) ? data : (data.action_items ?? []) + if (!cancelled) setItems(list.filter((t) => !t.completed)) + }) + .catch(() => { + // Keep previously-loaded items on a transient failure rather than + // hiding the widget; only show empty if we have never loaded. + if (!cancelled) setItems((prev) => prev ?? []) + }) + return () => { + cancelled = true + } + }, []) + + // Primary fetch: as soon as auth is ready (and again if the user changes). + // The Home panel is always mounted, so this preloads independent of the + // current route — gating the only fetch on a pathname *change* to /home was + // what left the widget hidden after a failed/early initial load. + useEffect(() => { + if (!userId) return + return fetchItems() + }, [userId, fetchItems]) + + // Refetch when returning to Home (pick up tasks changed on the Tasks page). + useEffect(() => { + if (pathname !== '/home') return + return fetchItems() + }, [pathname, fetchItems]) + + // Refetch on window focus so tasks changed elsewhere show up. + useEffect(() => { + const onFocus = (): void => { + if (auth.currentUser) fetchItems() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [fetchItems]) + + // Hide entirely until loaded or when there's nothing actionable — the idle + // screen stays clean for new users. + if (!items || items.length === 0) return null + + const shown = [...items].sort(byDueDate).slice(0, MAX_SHOWN) + + return ( + +
+
+ +
+
+ Tasks + {items.length} +
+ +
+
+ {shown.map((t) => { + const chip = dueChip(t) + return ( +
+ {t.description} + {chip && ( + + {chip.label} + + )} +
+ ) + })} + {items.length > MAX_SHOWN && ( +

+{items.length - MAX_SHOWN} more

+ )} +
+ + ) +} diff --git a/windows/src/renderer/src/components/insight/InsightToast.tsx b/windows/src/renderer/src/components/insight/InsightToast.tsx new file mode 100644 index 0000000000..d22ed5ab8d --- /dev/null +++ b/windows/src/renderer/src/components/insight/InsightToast.tsx @@ -0,0 +1,37 @@ +// src/renderer/src/components/insight/InsightToast.tsx +import { useEffect, useState } from 'react' +import type { InsightPayload } from '../../../../shared/types' +import './insight-toast.css' + +export function InsightToast(): React.JSX.Element { + const [insight, setInsight] = useState(null) + + useEffect(() => { + document.body.classList.add('insight-toast-body') + const off = window.omi.onInsightShow((p) => setInsight(p)) + return () => { + document.body.classList.remove('insight-toast-body') + off() + } + }, []) + + if (!insight) return
+ + return ( +
window.omi.insightHoverStart()} + onMouseLeave={() => window.omi.insightHoverEnd()} + > +
+ {insight.category} + +
+
{insight.headline}
+
{insight.advice}
+
{insight.sourceApp}
+
+ ) +} diff --git a/windows/src/renderer/src/components/insight/insight-toast.css b/windows/src/renderer/src/components/insight/insight-toast.css new file mode 100644 index 0000000000..da91c3851c --- /dev/null +++ b/windows/src/renderer/src/components/insight/insight-toast.css @@ -0,0 +1,23 @@ +/* src/renderer/src/components/insight/insight-toast.css */ +body.insight-toast-body { background: transparent !important; overflow: hidden; } + +.insight-card { + height: 100vh; + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + color: #f5f5f5; + background: rgba(0, 0, 0, 0.45); /* thin wash over the DWM acrylic backdrop */ + animation: insight-in 180ms ease-out; +} +@keyframes insight-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +.insight-head { display: flex; align-items: center; gap: 8px; } +.insight-cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: #b9b9b9; } +.insight-x { margin-left: auto; cursor: pointer; color: #b9b9b9; background: none; border: 0; font-size: 14px; -webkit-app-region: no-drag; } +.insight-headline { font-weight: 600; font-size: 14px; } +.insight-advice { font-size: 13px; color: #e2e2e2; overflow: hidden; } +.insight-foot { margin-top: auto; font-size: 11px; color: #9a9a9a; } diff --git a/windows/src/renderer/src/components/layout/MainViews.tsx b/windows/src/renderer/src/components/layout/MainViews.tsx new file mode 100644 index 0000000000..ccc3fab128 --- /dev/null +++ b/windows/src/renderer/src/components/layout/MainViews.tsx @@ -0,0 +1,93 @@ +import { memo, useEffect, useState } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { Home } from '../../pages/Home' +import { Conversations } from '../../pages/Conversations' +import { Memories } from '../../pages/Memories' +import { Settings } from '../../pages/Settings' +import { ConversationDetail } from '../../pages/ConversationDetail' +import { Tasks } from '../../pages/Tasks' +import { Goals } from '../../pages/Goals' +import { Apps } from '../../pages/Apps' +import { Rewind } from '../../pages/Rewind' +import { LiveConversation } from '../../pages/LiveConversation' + +// Every page stays mounted (inactive ones are just hidden) so switching tabs is +// instant. But the pages take no props, so without memo they ALL re-render on +// every navigation (MainViews re-renders when the pathname changes) — and that +// re-render reconciles heavy subtrees like the Memories brain map (an R3F scene) +// or large memory/conversation lists, which is what made tab switches lag. +// memo() makes a page re-render only from its OWN hooks/state, never from a +// parent navigation, so changing tabs just toggles the wrapper's visibility. +const HomePanel = memo(Home) +const ConversationsPanel = memo(Conversations) +const MemoriesPanel = memo(Memories) +const SettingsPanel = memo(Settings) +const TasksPanel = memo(Tasks) +const GoalsPanel = memo(Goals) +const AppsPanel = memo(Apps) +const RewindPanel = memo(Rewind) + +function panelClass(active: boolean): string { + return active ? 'flex h-full min-h-0 flex-col' : 'hidden' +} + +export function MainViews(): React.JSX.Element { + const { pathname } = useLocation() + + // Mounting every panel up front (incl. the heavy Memories R3F brain map) on + // first render blocks the main thread during the startup entrance animations + // — a ~133ms frame stall (npm run bench:anim). Defer the inactive panels until + // AFTER the animations have played. NOTE: requestIdleCallback is wrong here — + // CSS animations run on the compositor, so the main thread looks idle *during* + // them and the callback fires mid-animation, causing the very stall we're + // avoiding. A fixed timeout that lands after the animations is what we want. + // The active panel always mounts; any panel mounts on demand if navigated to + // before hydration, so tab-switching stays instant once warmed. + const [hydrateAll, setHydrateAll] = useState(false) + useEffect(() => { + const timer = setTimeout(() => setHydrateAll(true), 1800) + return () => clearTimeout(timer) + }, []) + + // Home merges the old Chat and Record screens. + if (pathname === '/' || pathname === '/live' || pathname === '/chat') { + return + } + + if (pathname === '/conversations/live') { + return + } + + const detailMatch = pathname.match(/^\/conversations\/([^/]+)$/) + if (detailMatch) { + return + } + + const isHome = pathname === '/home' + const isConversations = pathname === '/conversations' + const isMemories = pathname === '/memories' + const isSettings = pathname === '/settings' + const isTasks = pathname === '/tasks' + const isGoals = pathname === '/goals' + const isApps = pathname === '/apps' + const isRewind = pathname === '/rewind' + + return ( +
+
{(isHome || hydrateAll) && }
+
+ {(isConversations || hydrateAll) && } +
+
+ {(isMemories || hydrateAll) && } +
+
+ {(isSettings || hydrateAll) && } +
+
{(isTasks || hydrateAll) && }
+
{(isGoals || hydrateAll) && }
+
{(isApps || hydrateAll) && }
+
{(isRewind || hydrateAll) && }
+
+ ) +} diff --git a/windows/src/renderer/src/components/layout/PageHeader.tsx b/windows/src/renderer/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000000..1ef6bdba84 --- /dev/null +++ b/windows/src/renderer/src/components/layout/PageHeader.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from 'react' +import { ArrowLeft, Pencil } from 'lucide-react' + +export function PageHeader(props: { + title: string + subtitle?: string + actions?: React.ReactNode + /** When set, a back arrow is shown at the top-left and calls this on click. */ + onBack?: () => void + /** + * When set, the title becomes click-to-edit: clicking it reveals an input, + * and committing (Enter / blur) calls this with the new name. Escape cancels. + */ + onRename?: (title: string) => void + /** + * When set, replaces the

title with custom content (e.g. a segmented + * tab switcher). `title` is still required for accessibility/fallback but is + * not rendered. Ignored together with onRename. + */ + titleSlot?: React.ReactNode +}): React.JSX.Element { + const { title, subtitle, actions, onBack, onRename, titleSlot } = props + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(title) + const inputRef = useRef(null) + + useEffect(() => { + if (editing) { + inputRef.current?.focus() + inputRef.current?.select() + } + }, [editing]) + + const startEdit = (): void => { + setDraft(title) + setEditing(true) + } + + const commit = (): void => { + setEditing(false) + const next = draft.trim() + if (next && next !== title) onRename?.(next) + } + + return ( +
+
+
+ {onBack && ( + + )} +
+ {titleSlot ? ( + titleSlot + ) : editing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') commit() + else if (e.key === 'Escape') setEditing(false) + }} + className="w-full border-0 border-b border-white/25 bg-transparent pb-1 font-display text-2xl font-bold tracking-tight text-white focus:border-white/60 focus:outline-none focus:ring-0" + /> + ) : onRename ? ( + + ) : ( +

+ {title} +

+ )} + {subtitle &&

{subtitle}

} +
+
+ {actions &&
{actions}
} +
+
+ ) +} diff --git a/windows/src/renderer/src/components/layout/Sidebar.tsx b/windows/src/renderer/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000000..d96993afe2 --- /dev/null +++ b/windows/src/renderer/src/components/layout/Sidebar.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState } from 'react' +import { NavLink, useLocation } from 'react-router-dom' +import { + House, + GanttChartSquare, + ListChecks, + LayoutGrid, + History, + Monitor, + Mic, + PanelLeftClose, + PanelLeftOpen +} from 'lucide-react' +import { auth, onAuthStateChanged } from '../../lib/firebase' +import { getPreferences, onPreferencesChange, setPreferences } from '../../lib/preferences' +import { cn } from '../../lib/utils' +import type { User } from 'firebase/auth' +import type { RewindSettings } from '../../../../shared/types' + +const navItems = [ + { label: 'Home', to: '/home', Icon: House }, + { label: 'Conversations', to: '/conversations', Icon: GanttChartSquare }, + { label: 'Tasks', to: '/tasks', Icon: ListChecks }, + { label: 'Rewind', to: '/rewind', Icon: History }, + { label: 'Apps', to: '/apps', Icon: LayoutGrid } +] + +const COLLAPSE_KEY = 'omi.sidebar.collapsed' + +// Shared hover/selection background — matches .nav-active so an active tab and a +// hovered tab read as the same neutral grey. +const HOVER = 'hover:bg-[var(--nav-sel)]' + +export function Sidebar(): React.JSX.Element { + const [user, setUser] = useState(null) + const [prefName, setPrefName] = useState(getPreferences().displayName) + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem(COLLAPSE_KEY) === '1' + ) + const [rewind, setRewind] = useState(null) + const { pathname } = useLocation() + + useEffect(() => onAuthStateChanged(auth, (u) => setUser(u)), []) + + // Keep the displayed name in sync with the editable Settings/onboarding name. + useEffect(() => onPreferencesChange((p) => setPrefName(p.displayName)), []) + + useEffect(() => { + localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0') + }, [collapsed]) + + useEffect(() => { + void window.omi.rewindGetSettings().then(setRewind) + }, []) + + const email = user?.email + // Prefer the Google account's full name (stable "First Last"), then the + // onboarding-entered name, then the email. + const displayName = user?.displayName?.trim() || prefName?.trim() || email || 'Account' + const photoURL = user?.photoURL + const initial = + (user?.displayName?.trim() || prefName?.trim() || email)?.[0]?.toUpperCase() ?? '?' + + // Screen-recording = the persistent Rewind capture setting (the toggle that + // used to be a checkbox in Settings). Optimistic flip, reconcile from main. + const screenOn = !!rewind?.captureEnabled + const toggleScreen = (): void => { + if (!rewind) return + const next = { ...rewind, captureEnabled: !rewind.captureEnabled } + setRewind(next) + void window.omi.rewindSetSettings(next).then(setRewind) + } + + // Microphone = always-on listening. The toggle reflects the `continuousRecording` + // preference; flipping it starts/stops the background ContinuousRecordingHost + // (which streams the mic to /v4/listen). Viewing the live transcript is a SEPARATE + // affordance (the "New" button in Conversations / opening a conversation row) — + // this switch only turns listening on and off. + const [micOn, setMicOn] = useState(() => !!getPreferences().continuousRecording) + useEffect(() => onPreferencesChange((p) => setMicOn(!!p.continuousRecording)), []) + const toggleMic = (): void => { + setPreferences({ continuousRecording: !getPreferences().continuousRecording }) + } + + // The label/name text fades with opacity (and is width-clipped by flexbox) so + // collapsing animates smoothly instead of popping. Row padding/alignment stay + // constant in both states — only nav width and text opacity animate. + const label = (text: string): React.JSX.Element => ( + + {text} + + ) + + const linkClass = (active: boolean): string => + cn( + 'flex items-center gap-3 rounded-xl px-2.5 py-2 text-sm font-medium transition-[color] duration-150', + active ? 'nav-active' : cn('text-white/50 hover:text-white/80', HOVER) + ) + + const toggleRow = ( + text: string, + Icon: typeof Mic, + on: boolean, + onClick: () => void + ): React.JSX.Element => ( + + ) + + return ( + + ) +} diff --git a/windows/src/renderer/src/components/layout/TasksGoalsToggle.tsx b/windows/src/renderer/src/components/layout/TasksGoalsToggle.tsx new file mode 100644 index 0000000000..f78be88e4c --- /dev/null +++ b/windows/src/renderer/src/components/layout/TasksGoalsToggle.tsx @@ -0,0 +1,32 @@ +import { NavLink } from 'react-router-dom' +import { cn } from '../../lib/utils' + +// Segmented switcher that lives in the header of both the Tasks and Goals +// pages. Goals no longer has its own sidebar item — it's reached from the Tasks +// tab via this toggle. Both pages stay mounted in MainViews, so switching here +// is just a route change (instant, state preserved). +const tabs = [ + { label: 'Tasks', to: '/tasks' }, + { label: 'Goals', to: '/goals' } +] as const + +export function TasksGoalsToggle(): React.JSX.Element { + return ( +
+ {tabs.map(({ label, to }) => ( + + cn( + 'rounded-xl px-4 py-1.5 font-display text-base font-bold tracking-tight transition-all duration-200', + isActive ? 'bg-white/15 text-white' : 'text-white/45 hover:bg-white/5 hover:text-white/80' + ) + } + > + {label} + + ))} +
+ ) +} diff --git a/windows/src/renderer/src/components/onboarding/AskDemoStep.tsx b/windows/src/renderer/src/components/onboarding/AskDemoStep.tsx new file mode 100644 index 0000000000..4957f1f9d9 --- /dev/null +++ b/windows/src/renderer/src/components/onboarding/AskDemoStep.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import { StepScaffold } from './StepScaffold' +import macsImg from '../../assets/macs.png' + +type AskDemoStepProps = { + stepIndex: number + totalSteps: number + onContinue: () => void + onSkip?: () => void +} + +/** + * Onboarding demo step: the user is invited to type "Which computer should I + * buy?" in the floating bar; Omi's "answer" — a Mac comparison image — is shown + * as the payoff. The image renders unconditionally (with a mount fade-in) so it + * can never be held hostage to the floating-bar event firing; Continue is always + * available so the step can't dead-end. + */ +export function AskDemoStep({ + stepIndex, + totalSteps, + onContinue, + onSkip +}: AskDemoStepProps): React.JSX.Element { + // Drives the enter transition: mount with the "from" classes, then flip to + // "to" on the next frame so the fade+slide animates. + const [revealed, setRevealed] = useState(false) + + useEffect(() => { + // The bar should already be enabled/warm from earlier steps; ensure it. + window.omiOverlay?.setEnabled(true) + const id = requestAnimationFrame(() => setRevealed(true)) + return () => cancelAnimationFrame(id) + }, []) + + return ( + +
+ Omi's answer: a comparison of Mac models console.error('[AskDemoStep] macs.png failed to load', e)} + className={ + 'w-full rounded-2xl shadow-2xl ring-1 ring-white/10 transition-all duration-500 ease-out ' + + (revealed ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0') + } + /> +
+
+ ) +} diff --git a/windows/src/renderer/src/components/onboarding/AutoCreatedTasksStep.tsx b/windows/src/renderer/src/components/onboarding/AutoCreatedTasksStep.tsx new file mode 100644 index 0000000000..a9540d8326 --- /dev/null +++ b/windows/src/renderer/src/components/onboarding/AutoCreatedTasksStep.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react' +import { Check, ListChecks } from 'lucide-react' + +type AutoCreatedTasksStepProps = { + /** Complete onboarding and jump straight to the Tasks tab. */ + onFinish: () => void +} + +// Illustrative sample rows shown on the onboarding completion screen — they +// demonstrate the auto-task feature (real tasks come from conversations the user +// hasn't had yet). "Getting started" starts completed; the rows are clickable so +// the user can check the others off too. +const SAMPLE_TASKS = [ + { title: 'Task 1', subtitle: 'From today’s meeting' }, + { title: 'Task 2', subtitle: 'Mentioned in Slack' }, + { title: 'Task 3', subtitle: 'Getting started' } +] + +function TaskRow({ + title, + subtitle, + done, + onToggle +}: { + title: string + subtitle: string + done: boolean + onToggle: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function AutoCreatedTasksStep({ + onFinish +}: AutoCreatedTasksStepProps): React.JSX.Element { + // Track which sample rows are checked off. Task 3 ("Getting started") starts + // done; clicking any row toggles it. + const [done, setDone] = useState>(new Set([2])) + const toggle = (i: number): void => + setDone((prev) => { + const next = new Set(prev) + if (next.has(i)) next.delete(i) + else next.add(i) + return next + }) + + return ( +
+
+ {/* Soft radial glow behind the icon. */} +
+
+ +
+
+ +

Auto-created Tasks

+

+ omi listens to your conversations and automatically creates tasks, action items, and + follow-ups for you. +

+ +
+ {SAMPLE_TASKS.map((t, i) => ( + toggle(i)} + /> + ))} +
+ + +
+ ) +} diff --git a/windows/src/renderer/src/components/onboarding/AutomationPermissionStep.tsx b/windows/src/renderer/src/components/onboarding/AutomationPermissionStep.tsx new file mode 100644 index 0000000000..e704d99960 --- /dev/null +++ b/windows/src/renderer/src/components/onboarding/AutomationPermissionStep.tsx @@ -0,0 +1,53 @@ +import { Zap } from 'lucide-react' +import { setPreferences } from '../../lib/preferences' +import { PermissionStep } from './PermissionStep' + +type AutomationPermissionStepProps = { + stepIndex: number + totalSteps: number + aside?: React.ReactNode + onContinue: () => void + onSkip?: () => void +} + +export function AutomationPermissionStep({ + stepIndex, + totalSteps, + aside, + onContinue, + onSkip +}: AutomationPermissionStepProps): React.JSX.Element { + // Automation has no OS permission prompt — granting it is a local opt-in that + // records consent. useChat's action-planner pre-step gates on this preference + // (alongside the OMI_AUTOMATION env kill-switch), so flipping it on here is what + // actually lets Omi take real UI actions in your apps. + const enableAutomation = async (): Promise => { + setPreferences({ automationConsentedAt: Date.now() }) + } + + return ( + } + cardLabel="Automation" + statusText={{ + idle: 'Not enabled yet', + waiting: 'Enabling', + granted: 'Enabled' + }} + buttonLabel={{ + idle: 'Automation', + waiting: 'Enabling', + granted: 'Enabled' + }} + onActivate={enableAutomation} + onContinue={onContinue} + onSkip={onSkip} + /> + ) +} diff --git a/windows/src/renderer/src/components/onboarding/BrainMap.tsx b/windows/src/renderer/src/components/onboarding/BrainMap.tsx new file mode 100644 index 0000000000..2bf7c83234 --- /dev/null +++ b/windows/src/renderer/src/components/onboarding/BrainMap.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef } from 'react' +import type { Memory } from '../../hooks/useMemories' +import { getOrBuildNodes, computeEdges } from './brainMapModel' + +type BrainMapProps = { memories?: Memory[] } + +// Animated memory graph for the onboarding wizard's right pane. Seeds from real +// memories (decorative fallback when empty) and keeps node positions continuous +// across step transitions via the model's module-level cache. +export function BrainMap({ memories }: BrainMapProps): React.JSX.Element { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const nodes = getOrBuildNodes(memories) + const dpr = Math.min(window.devicePixelRatio || 1, 2) + let W = 0 + let H = 0 + + const resize = (): void => { + const rect = canvas.getBoundingClientRect() + W = rect.width + H = rect.height + canvas.width = Math.max(1, Math.round(W * dpr)) + canvas.height = Math.max(1, Math.round(H * dpr)) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + resize() + const ro = new ResizeObserver(resize) + ro.observe(canvas) + + const draw = (t: number): void => { + ctx.clearRect(0, 0, W, H) + + // Soft central glow. + const g = ctx.createRadialGradient(W * 0.5, H * 0.46, 0, W * 0.5, H * 0.46, W * 0.55) + g.addColorStop(0, 'rgba(40,130,255,0.10)') + g.addColorStop(1, 'rgba(0,0,0,0)') + ctx.fillStyle = g + ctx.fillRect(0, 0, W, H) + + // Drift. + for (const n of nodes) { + n.x += n.vx + n.y += n.vy + if (n.x < 0.06 || n.x > 0.94) n.vx *= -1 + if (n.y < 0.08 || n.y > 0.92) n.vy *= -1 + } + + // Edges. + ctx.lineWidth = 0.8 + for (const e of computeEdges(nodes, W, H)) { + const a = nodes[e.a] + const b = nodes[e.b] + ctx.strokeStyle = `rgba(120,200,255,${e.o.toFixed(3)})` + ctx.beginPath() + ctx.moveTo(a.x * W, a.y * H) + ctx.lineTo(b.x * W, b.y * H) + ctx.stroke() + } + + // Nodes with glow + pulse. + ctx.font = '10px system-ui, -apple-system, "Segoe UI", sans-serif' + ctx.textBaseline = 'middle' + for (const n of nodes) { + const px = n.x * W + const py = n.y * H + const pulse = 0.6 + 0.4 * Math.sin(t * 0.0016 + n.pulse) + const rr = n.r * (0.85 + 0.3 * pulse) + const glow = ctx.createRadialGradient(px, py, 0, px, py, rr * 5) + glow.addColorStop(0, `hsla(${n.hue},95%,70%,${(0.55 * pulse).toFixed(3)})`) + glow.addColorStop(1, `hsla(${n.hue},95%,70%,0)`) + ctx.fillStyle = glow + ctx.beginPath() + ctx.arc(px, py, rr * 5, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = `hsla(${n.hue},90%,62%,0.98)` + ctx.beginPath() + ctx.arc(px, py, rr, 0, Math.PI * 2) + ctx.fill() + + // Memory label beside the node; flips to the left near the right edge so + // it doesn't run off-canvas. Decorative nodes have no label. + if (n.label) { + ctx.fillStyle = 'rgba(255,255,255,0.6)' + if (px > W * 0.7) { + ctx.textAlign = 'right' + ctx.fillText(n.label, px - rr - 5, py) + } else { + ctx.textAlign = 'left' + ctx.fillText(n.label, px + rr + 5, py) + } + } + } + } + + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches + let raf = 0 + if (reduced) { + draw(0) + } else { + const loop = (t: number): void => { + draw(t) + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + } + + return () => { + if (raf) cancelAnimationFrame(raf) + ro.disconnect() + } + }, [memories]) + + return +} diff --git a/windows/src/renderer/src/components/onboarding/BuildProfileStep.tsx b/windows/src/renderer/src/components/onboarding/BuildProfileStep.tsx new file mode 100644 index 0000000000..bf1514c347 --- /dev/null +++ b/windows/src/renderer/src/components/onboarding/BuildProfileStep.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from 'react' +import { StepScaffold } from './StepScaffold' +import { OrbitScanner } from './OrbitScanner' +import { runAppIndexing } from '../../lib/appMemories' +import { rankApps } from '../../lib/appSelection' +import { addAppNodes } from '../../lib/onboardingGraph' + +type BuildProfileStepProps = { + stepIndex: number + totalSteps: number + onContinue: () => void + onSkip?: () => void +} + +type Phase = 'scanning' | 'done' + +/** + * "Discovery" onboarding step. Unlike the old disk-access step there's no + * button — the file index kicks off automatically on mount. The orbit animation + * runs while scanning; when the scan resolves we swap the label, reveal the real + * indexed-file count, and surface the Continue button. The count line always + * reserves its height (non-breaking space placeholder) so nothing shifts when + * the number arrives. + */ +export function BuildProfileStep({ + stepIndex, + totalSteps, + onContinue, + onSkip +}: BuildProfileStepProps): React.JSX.Element { + const [phase, setPhase] = useState('scanning') + const [fileCount, setFileCount] = useState(null) + // Guard against React StrictMode's double-invoke so we only kick one scan. + const startedRef = useRef(false) + + useEffect(() => { + if (startedRef.current) return + startedRef.current = true + void runScan().then((count) => { + setFileCount(count) + setPhase('done') + }) + }, []) + + return ( + +
+ +
+

+ {phase === 'scanning' ? 'Scanning your projects and apps' : 'Your workspace is mapped'} +

+ {/* Placeholder keeps the line's height before the count is known. */} +

+ {fileCount == null ? ' ' : `${fileCount.toLocaleString()} files indexed`} +

+
+
+
+ ) +} + +// Kick off the local file index and report how many files were indexed. Mirrors +// the old disk-access step's side effects: reveal the user's app nodes on the +// brain map and fire the "Uses " memory + KG rebuild. The backend may ship +// on a separate branch; if absent we simulate a delay so onboarding still flows. +async function runScan(): Promise { + const api = window.omi as { indexFilesScan?: typeof window.omi.indexFilesScan } + if (api.indexFilesScan) { + try { + const status = await window.omi.indexFilesScan() + try { + const apps = await window.omi.indexFilesApps(200) + await addAppNodes(rankApps(apps).map((a) => ({ name: a.name }))) + } catch { + /* ignore — graph just won't gain app nodes */ + } + void runAppIndexing().catch(() => {}) + return status.filesIndexed + } catch { + /* fall through to the simulated delay below */ + } + } + await new Promise((resolve) => setTimeout(resolve, 1500)) + return 0 +} diff --git a/windows/src/renderer/src/components/onboarding/DiskAccessStep.tsx b/windows/src/renderer/src/components/onboarding/DiskAccessStep.tsx new file mode 100644 index 0000000000..c3fec05183 --- /dev/null +++ b/windows/src/renderer/src/components/onboarding/DiskAccessStep.tsx @@ -0,0 +1,72 @@ +import { HardDrive } from 'lucide-react' +import { PermissionStep } from './PermissionStep' +import { runAppIndexing } from '../../lib/appMemories' +import { rankApps } from '../../lib/appSelection' +import { addAppNodes } from '../../lib/onboardingGraph' + +type DiskAccessStepProps = { + stepIndex: number + totalSteps: number + aside?: React.ReactNode + onContinue: () => void +} + +export function DiskAccessStep({ + stepIndex, + totalSteps, + aside, + onContinue +}: DiskAccessStepProps): React.JSX.Element { + // Kick off the local file index. The backend (indexFilesScan) ships on a + // separate branch; call it when present, otherwise simulate so onboarding still + // flows. Narrow inline cast avoids depending on the shared preload type here. + const runScan = async (): Promise => { + const api = window.omi as { indexFilesScan?: () => Promise } + if (api.indexFilesScan) { + try { + await api.indexFilesScan() + // Reveal purple "thing" nodes for the apps the user has, with clean + // names — the macOS onboarding moment. Best-effort; never blocks. + try { + const apps = await window.omi.indexFilesApps(200) + await addAppNodes(rankApps(apps).map((a) => ({ name: a.name }))) + } catch { + /* ignore — graph just won't gain app nodes */ + } + // Fire-and-forget: turn the freshly indexed apps into "Uses " + // memories + trigger the KG rebuild. runAppIndexing swallows its own + // errors; never block onboarding on it. + void runAppIndexing().catch(() => {}) + return + } catch { + /* fall through to the simulated delay below */ + } + } + await new Promise((resolve) => setTimeout(resolve, 1500)) + } + + return ( + } + cardLabel="File Access" + statusText={{ + idle: 'Not scanned yet', + waiting: 'Scanning your files', + granted: 'Indexed' + }} + buttonLabel={{ + idle: 'Disk Access', + waiting: 'Scanning…', + granted: 'Indexed' + }} + onActivate={runScan} + onContinue={onContinue} + /> + ) +} diff --git a/windows/src/renderer/src/components/onboarding/GoalStep.tsx b/windows/src/renderer/src/components/onboarding/GoalStep.tsx new file mode 100644 index 0000000000..77dbbe70c0 --- /dev/null +++ b/windows/src/renderer/src/components/onboarding/GoalStep.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react' +import { Sparkles } from 'lucide-react' +import { StepScaffold } from './StepScaffold' +import { generateGoal } from '../../lib/goals' + +type GoalStepProps = { + stepIndex: number + totalSteps: number + aside?: React.ReactNode + /** App names already in the onboarding brain map — used to personalize the + * AI-generated goal. */ + apps: string[] + /** Commit the chosen goal text and advance. */ + onContinue: (goal: string) => void + onSkip?: () => void +} + +// The two starter goals offered on the desktop app. They have no number, so the +// backend sync falls back to a target_value of 1 (see parseTargetValue). +const SUGGESTED = [ + 'Be more productive and focused every day', + 'Make meaningful progress on my projects' +] + +// A goal card: rounded panel matching the permission-step cards. `selected` +// flips it to the solid-white highlight just before advancing. +function GoalCard({ + label, + selected, + onClick, + className = '' +}: { + label: string + selected: boolean + onClick: () => void + className?: string +}): React.JSX.Element { + return ( + + ) +} + +export function GoalStep({ + stepIndex, + totalSteps, + aside, + apps, + onContinue, + onSkip +}: GoalStepProps): React.JSX.Element { + // 'choose' — the four buttons. + // 'typing' — the editable textarea (Type my own / review an AI draft). + // The AI button toggles `generating` while it waits on the LLM. + const [mode, setMode] = useState<'choose' | 'typing'>('choose') + const [draft, setDraft] = useState('') + const [generating, setGenerating] = useState(false) + // Which suggested card is briefly highlighted before we advance. + const [picked, setPicked] = useState(null) + + const commit = (goal: string): void => { + const text = goal.trim() + if (!text) return + onContinue(text) + } + + const pickSuggested = (goal: string): void => { + if (picked) return + setPicked(goal) + // Brief highlight before advancing, mirroring the "How did you hear" step. + setTimeout(() => commit(goal), 250) + } + + const runGenerate = async (): Promise => { + if (generating) return + setGenerating(true) + try { + const goal = await generateGoal(apps) + // Drop the suggestion into the editable textarea so the user can review + // and tweak it before committing, rather than auto-advancing on it. + setDraft(goal) + setMode('typing') + } catch { + // If generation fails, just open an empty box so the user can type. + setMode('typing') + } finally { + setGenerating(false) + } + } + + return ( + + {mode === 'choose' ? ( +
+
+ {SUGGESTED.map((goal) => ( + pickSuggested(goal)} + /> + ))} +
+ { + setDraft('') + setMode('typing') + }} + className="text-center" + /> + +
+ ) : ( +
+