Repository: ruzin/stenoai Branch: main Commit: 1c831cba0c08 Files: 147 Total size: 1.1 MB Directory structure: gitextract_90ku06c9/ ├── .clabot ├── .github/ │ ├── FUNDING.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── build-release.yml │ └── deploy-website.yml ├── .gitignore ├── CLA.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── announcements.json ├── app/ │ ├── build/ │ │ ├── entitlements.mac.plist │ │ ├── icon-dragonfly.icns │ │ └── icon.icns │ ├── electron-builder.ci.yml │ ├── main.js │ ├── package-lock.json │ ├── package.json │ ├── preload.js │ ├── renderer/ │ │ ├── .eslintrc.cjs │ │ ├── .prettierrc.json │ │ ├── components.json │ │ ├── index.html │ │ ├── postcss.config.cjs │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── components/ │ │ │ │ ├── AppShell.tsx │ │ │ │ ├── AskBar.tsx │ │ │ │ ├── AudioWave.tsx │ │ │ │ ├── BottomDockSlot.tsx │ │ │ │ ├── ChatHistoryRow.tsx │ │ │ │ ├── FolderScopePicker.tsx │ │ │ │ ├── IconPicker.tsx │ │ │ │ ├── LiveDock.tsx │ │ │ │ ├── MainToolbar.tsx │ │ │ │ ├── MeetingsShell.tsx │ │ │ │ ├── QuitDialog.tsx │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── TranscriptPanel.tsx │ │ │ │ ├── home/ │ │ │ │ │ ├── PreviousRow.tsx │ │ │ │ │ └── UpcomingCard.tsx │ │ │ │ └── ui/ │ │ │ │ ├── app-icon.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chip.tsx │ │ │ │ ├── confirm-dialog.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── kbd.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── row.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ └── typography.tsx │ │ │ ├── globals.css │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── liveDraftStore.ts │ │ │ │ ├── meetingKeys.ts │ │ │ │ ├── useAi.ts │ │ │ │ ├── useAiPrompts.ts │ │ │ │ ├── useAudioLevel.ts │ │ │ │ ├── useCalendarEvents.ts │ │ │ │ ├── useChatSessions.ts │ │ │ │ ├── useFolders.ts │ │ │ │ ├── useLiveMeeting.ts │ │ │ │ ├── useMeetings.ts │ │ │ │ ├── useModels.ts │ │ │ │ ├── useRecording.ts │ │ │ │ ├── useSettings.ts │ │ │ │ ├── useSetup.ts │ │ │ │ ├── useStreamingQuery.ts │ │ │ │ └── useTheme.ts │ │ │ ├── lib/ │ │ │ │ ├── askBarContext.tsx │ │ │ │ ├── chat.ts │ │ │ │ ├── chatPresets.tsx │ │ │ │ ├── debugLogs.ts │ │ │ │ ├── ipc.ts │ │ │ │ ├── markdown.tsx │ │ │ │ ├── meetingDetailState.ts │ │ │ │ ├── meetingsListContext.tsx │ │ │ │ ├── queryClient.ts │ │ │ │ ├── result.ts │ │ │ │ ├── router.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ └── routes/ │ │ │ ├── Chat.tsx │ │ │ ├── ChatConversation.tsx │ │ │ ├── FolderDetail.tsx │ │ │ ├── Home.tsx │ │ │ ├── MeetingDetail.tsx │ │ │ ├── Processing.tsx │ │ │ ├── Recording.tsx │ │ │ ├── Sandbox.tsx │ │ │ ├── Settings.tsx │ │ │ └── Setup.tsx │ │ ├── tailwind.config.cjs │ │ └── tsconfig.json │ └── vite.config.ts ├── prompt_tests/ │ ├── PROMPT_TESTING.md │ └── test_prompts.py ├── requirements.txt ├── scripts/ │ ├── build-backend.sh │ ├── download-ollama.sh │ ├── test_dmg_fresh_install.sh │ └── test_first_time_setup.sh ├── setup.py ├── simple_recorder.py ├── src/ │ ├── __init__.py │ ├── audio_recorder.py │ ├── config.py │ ├── folders.py │ ├── models.py │ ├── ollama_manager.py │ ├── summarizer.py │ └── transcriber.py ├── tests/ │ ├── __init__.py │ ├── test_config.py │ └── test_transcriber.py └── website/ ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── postcss.config.js ├── public/ │ ├── CNAME │ ├── privacy.html │ └── terms.html ├── src/ │ ├── App.jsx │ ├── analytics.js │ ├── components/ │ │ ├── Brand.jsx │ │ └── ThemeToggle.jsx │ ├── index.css │ ├── main.jsx │ └── sections/ │ ├── CTAFooter.jsx │ ├── FAQ.jsx │ ├── Features.jsx │ ├── Footer.jsx │ ├── Hero.jsx │ ├── HowItWorks.jsx │ ├── Industries.jsx │ ├── Models.jsx │ ├── Nav.jsx │ └── TrustStrip.jsx ├── tailwind.config.js └── vite.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .clabot ================================================ { "contributors": [], "message": "Thank you for your pull request! Before we can merge it, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/ruzin/stenoai/blob/main/CLA.md).\n\n**To sign the CLA, please comment on this PR with:**\n\n> I have read the CLA Document and I hereby sign the CLA\n\nThis is a one-time process. Once you've signed, all your future contributions to StenoAI will be covered.", "label": "cla-signed", "recheckComment": "I have read the CLA Document and I hereby sign the CLA" } ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: ruzin ================================================ FILE: .github/pull_request_template.md ================================================ ## Description Brief description of what this PR does and why it's needed. ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Testing - [ ] Tested locally with `npm start` - [ ] Verified CLI functionality works - [ ] Tested on macOS - [ ] No breaking changes to existing functionality ## Additional Notes Any additional context about this change. ================================================ FILE: .github/workflows/build-release.yml ================================================ name: Build and Release DMG on: push: tags: - 'v*.*.*' workflow_dispatch: inputs: version: description: 'Version number (e.g., 1.0.0)' required: true default: '1.0.0' permissions: contents: write jobs: build-macos: strategy: matrix: include: - arch: x64 build_cmd: build:intel runner: macos-15-intel - arch: arm64 build_cmd: build:arm64 runner: macos-14 runs-on: ${{ matrix.runner }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' cache-dependency-path: app/package-lock.json - name: Get version from package.json id: package_version working-directory: app run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - name: Install Python dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pyinstaller - name: Download bundled binaries (Ollama, ffmpeg) run: | chmod +x scripts/download-ollama.sh ./scripts/download-ollama.sh - name: Strip ad-hoc code signatures from dylibs (fixes install_name_tool on Intel) if: matrix.arch == 'x64' run: | # pywhispercpp ships pre-signed dylibs that install_name_tool can't modify # Strip signatures so PyInstaller can rewrite library paths find "$(python -c 'import site; print(site.getsitepackages()[0])')/pywhispercpp" \ -name "*.dylib" -exec codesign --remove-signature {} \; 2>/dev/null || true echo "Stripped ad-hoc signatures from pywhispercpp dylibs" - name: Build Python backend with PyInstaller run: | pyinstaller stenoai.spec --noconfirm # Verify build and architecture ls -la dist/stenoai/ file dist/stenoai/stenoai echo "Expected arch: ${{ matrix.arch }}" echo "Backend built successfully" - name: Install Electron dependencies working-directory: app run: npm ci - name: Import code signing certificate uses: apple-actions/import-codesign-certs@v2 with: p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - name: Build and notarize DMG for ${{ matrix.arch }} working-directory: app run: npm run ${{ matrix.build_cmd }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: build-${{ matrix.arch }} path: | app/dist/*.dmg app/dist/*.zip app/dist/*.yml app/dist/*.yaml retention-days: 1 release: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') needs: build-macos runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 fetch-tags: true - name: Get version from package.json id: package_version working-directory: app run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - name: Extract annotated tag message id: tag_body if: startsWith(github.ref, 'refs/tags/v') run: | # Pull the body of the annotated tag (strips the PGP signature if any). # Falls back to the commit subject if the tag is lightweight. MSG=$(git for-each-ref --format='%(contents:body)' "refs/tags/${{ github.ref_name }}") if [ -z "$MSG" ]; then MSG=$(git for-each-ref --format='%(contents:subject)' "refs/tags/${{ github.ref_name }}") fi { echo 'text<> "$GITHUB_OUTPUT" - name: Download all build artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Prepare release files run: | mkdir -p release find artifacts -name "*.dmg" -exec cp {} release/ \; find artifacts -name "*.zip" -exec cp {} release/ \; find artifacts -name "*.yml" -o -name "*.yaml" | xargs -I {} cp {} release/ 2>/dev/null || true ls -la release/ # Create website-friendly aliases for DMGs version="${{ github.event.inputs.version || steps.package_version.outputs.version }}" cd release for dmg in *.dmg; do if [[ "$dmg" == *"-x64.dmg" ]]; then cp "$dmg" "stenoAI-macos-x64.dmg" elif [[ "$dmg" == *"-arm64.dmg" ]]; then cp "$dmg" "stenoAI-macos-arm64.dmg" fi done ls -la - name: Create Release with Assets uses: softprops/action-gh-release@v1 with: name: StenoAI ${{ github.event.inputs.version || steps.package_version.outputs.version }} body: | ## StenoAI v${{ github.event.inputs.version || steps.package_version.outputs.version }} ${{ steps.tag_body.outputs.text }} ### Downloads - **Apple Silicon (M1-M5)**: `stenoAI-macos-arm64.dmg` - **Intel Macs**: `stenoAI-macos-x64.dmg` ### First Time Setup 1. Download the appropriate DMG for your Mac 2. Install by dragging StenoAI to Applications 3. Launch app - setup wizard runs automatically 4. Grant microphone permissions when prompted 5. Start recording meetings! ### Requirements - macOS 10.14 or later - Internet connection for initial setup - Microphone access permissions files: | release/*.dmg release/*.zip release/*.yml release/*.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/deploy-website.yml ================================================ name: Deploy Website to GitHub Pages on: push: branches: [ main ] paths: [ 'website/**' ] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: false jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: website/package-lock.json - name: Install dependencies working-directory: website run: npm ci - name: Build website working-directory: website run: npm run build - name: Setup Pages uses: actions/configure-pages@v4 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: website/dist - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ !app/build/ app/build/* !app/build/entitlements.mac.plist !app/build/icon.icns !app/build/icon-dragonfly.icns develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ !app/renderer/src/lib/ # Superpowers skill scratch — agent working notes, not project content. docs/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv Pipfile.lock # PEP 582 __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # StenoAI specific recordings/*.wav transcripts/*.txt output/*.json recorder_state*.json audio_buffer.npy # Node.js node_modules/ app/node_modules/ e2e/node_modules # Playwright e2e/test-results/ e2e/playwright-report/ # IDE .vscode/ .idea/ .cursor/ .codex/ .opencode/ # AI tool configs (user/session-specific) .agents/ .claude/ .github/skills/ # OS .DS_Store Thumbs.db # Config with credentials config/ *.env *_config.json config.json !app/package*.json # Local documentation and review notes CODE_REVIEW.md SESSION_LOG.md FEATURES.md # Prompt testing outputs prompt_tests/outputs/ # Bundled Ollama binary (downloaded during build) bin/ ================================================ FILE: CLA.md ================================================ # Contributor License Agreement Thank you for your interest in contributing to StenoAI (the "Project"). This Contributor License Agreement ("Agreement") documents the rights granted by contributors to the Project maintainer. ## 1. Definitions "You" (or "Your") means the copyright owner or legal entity authorized by the copyright owner that is making this Agreement. "Contribution" means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You for inclusion in the Project. "Submit" means any form of communication sent to the Project (including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems). ## 2. Grant of Copyright License You hereby grant to the Project maintainer and recipients of software distributed by the Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to: - Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works - Sublicense the above rights to third parties - **Relicense Your Contributions under different terms**, including but not limited to commercial licenses This grant includes the right to distribute Your Contributions under licenses different from the Project's current license (MIT), including proprietary commercial licenses. ## 3. Grant of Patent License You hereby grant to the Project maintainer and recipients of software distributed by the Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions. ## 4. You Retain Ownership You retain all right, title, and interest in and to Your Contributions. This Agreement does not transfer ownership; it only grants licenses as described above. ## 5. Your Representations You represent that: - You are legally entitled to grant the above licenses - Each of Your Contributions is Your original creation (or You have rights to submit it) - Your Contribution submissions include complete details of any third-party licenses or restrictions - You will notify the Project if any of the above representations becomes inaccurate ## 6. No Obligation The Project maintainer is under no obligation to: - Accept or include Your Contribution - Distribute Your Contribution in any particular version - Provide support or maintenance for Your Contribution - Release the Project or Your Contribution under any particular license ## 7. Support and Disclaimers You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including without limitation any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. --- **By submitting a Contribution, You accept and agree to the terms and conditions of this Agreement for Your present and future Contributions.** This Agreement is effective upon Your first Contribution to the Project. ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Do not use excessive emojis anywhere. ## Architecture The app is a thin Electron shell over a PyInstaller-bundled Python CLI. There is no long-running Python service — every operation is a subprocess invocation. - **Electron main (`app/main.js`, ~4.3k lines)** owns the UI window, tray, deep-link protocol, and orchestrates everything via `ipcMain.handle(...)`. Handlers shell out to the bundled backend through `getBackendPath()` → `process.resourcesPath/stenoai/stenoai` (or `dist/stenoai/stenoai` in dev) using `child_process.spawn`. - **Renderer (`app/renderer/`)** is a Vite-built React + TypeScript SPA. Runs with `contextIsolation: true` and talks to the main process exclusively through the typed bridge in `app/preload.js` → `ipc()` (`app/renderer/src/lib/ipc.ts`). Built output lives at `app/renderer/dist/index.html` and is what Electron loads at runtime. - **Python CLI (`simple_recorder.py`, ~2.5k lines, ~46 click commands)** is the single entry point bundled by `stenoai.spec`. Sub-modules in `src/`: `audio_recorder` (sounddevice), `transcriber` (pywhispercpp), `summarizer` (Ollama HTTP client), `ollama_manager` (lifecycle of the bundled `ollama serve`), `config` (JSON-backed user settings + model registry), `folders`, `models`. - **State across CLI invocations** is persisted to `recorder_state.json` and similar small JSON files — there is no daemon. Long-running recordings are a `record` subprocess kept alive by the Electron main process. - **User data lives in `~/Library/Application Support/stenoai/`** (`recordings/`, `transcripts/`, `output/`), resolved via `src.config.get_data_dirs()`. Repo-root `recordings/`/`transcripts/`/`output/` dirs are dev-only scratch. - **Bundled binaries (`bin/`)**: Ollama + ffmpeg, downloaded by `scripts/download-ollama.sh`. PyInstaller copies them into `dist/stenoai/ollama/` and `dist/stenoai/ffmpeg`. Electron then re-bundles `dist/stenoai/` as an `extraResource`. - **Deep links**: app registers the `stenoai://` URL scheme. Handler logic is in `app/main.js` near `SHORTCUT_PROTOCOL`. Used by macOS Shortcuts: `stenoai://record/start?name=...` and `stenoai://record/stop`. ## Development Commands ### Backend (Python) - Build the bundled backend: `source venv/bin/activate && pyinstaller stenoai.spec --noconfirm` - Inspect CLI surface: `dist/stenoai/stenoai --help` - Most relevant CLI commands for debugging: `status`, `setup-check`, `list_failed`, `reprocess path/to/summary.json`, `query transcript.txt`, `pipeline filename.wav` - Lint: `ruff check .` - Run all tests: `python -m unittest discover tests` - Run a single test: `python -m unittest tests.test_config.ConfigStoragePathTests.test_set_storage_path_handles_permission_errors` ### Desktop App (Electron) - Start app (dev): `cd app && npm start` - Build DMG (local, for testing): `cd app && npm run build` For setup from a clean checkout, see `CONTRIBUTING.md` and `README.md`. ## Production Readiness This app ships as a signed DMG to real users. Before considering any change complete: - **Packaged app test**: Dev mode (`npm start`) is not sufficient. Always rebuild the DMG (`npm run build`) and test the installed app from `/Applications`. - **Cold start test**: Kill all background processes (`pkill -f ollama`) and launch the app fresh. The full pipeline (record, transcribe, summarize) must work with no pre-existing services running. - **No shelling out to bundled binaries for operations that have an HTTP/library API**. macOS SIP + Electron hardened runtime strips `DYLD_LIBRARY_PATH` from child processes. Use the `ollama` Python package (HTTP API) for model operations, not `subprocess.run([ollama_path, ...])`. The only acceptable use of the Ollama binary is `ollama serve` (starting the server), which is covered by the `com.apple.security.cs.allow-dyld-environment-variables` entitlement. - **No bare `exit()` in Python code**. PyInstaller bundles don't have `exit` as a builtin. Always use `sys.exit()`. ## Brand Colors StenoAI logo gradient (used in website logo SVG and app header): - Indigo: `#6366f1` - Sky blue: `#0ea5e9` - Cyan: `#06b6d4` - CSS: `linear-gradient(135deg, #6366f1, #0ea5e9, #06b6d4)` App UI accent: `--accent-primary: #818cf8` (lighter indigo, used for focus states, active tabs, toggles) ## Git Workflow - Always create a branch for changes unless explicitly told otherwise - Never commit directly to `main` - Before creating a PR, run a self-review of the full branch diff (`git diff main...HEAD`): - Review backend code for security issues, error handling gaps, edge cases, and best practices - Review frontend code for layout bugs, CSS consistency, accessibility, and polish - Use the frontend-design skill for UI-related changes - Categorize findings by severity (critical/medium/low) and fix critical issues before merging ## Git Commit Guidelines - Do NOT include "Generated with Claude Code" attribution in commit messages - Do NOT include "Co-Authored-By: Claude " in commit messages - Keep commit messages concise and focused on what changed - Use conventional commit format when appropriate (feat:, fix:, docs:, etc.) ## Release Process Releases are automated via `.github/workflows/build-release.yml`. Never create releases manually. 1. Bump version in `app/package.json` (on the branch, before merging) 2. After PR is merged to `main`, create an **annotated tag** on main with the release notes in the tag message: ``` git tag -a v0.2.5 -m "Release notes here..." git push origin v0.2.5 ``` 3. The tag push triggers the workflow which: - Builds signed + notarized DMGs for both arm64 and x64 - Creates a GitHub Release with the tag message as the "What's New" section - Uploads both DMGs as release assets 4. The tag message becomes the release notes body — write it as markdown with a summary of changes 5. Do NOT build DMGs locally for releases, do NOT use `gh release create` manually ## README "What's New" Section The README has a "What's New" table that should be updated every ~2 weeks. When asked to update it (or when shipping a notable feature): 1. Check recently merged PRs: `gh pr list --state merged --limit 10` 2. For each notable PR, add a row to the table with the merge date and a one-sentence summary 3. Keep "Coming soon" items for features that are planned but not yet shipped 4. Remove entries older than ~2 months to keep the section fresh 5. Most recent entries go at the top of the table ## Session Logging When the user says "log session" or similar (e.g., "update session log", "document this session"): 1. Update SESSION_LOG.md in the root directory with the current session details 2. Include: date/time, summary of work, key decisions, files modified, issues resolved, next steps 3. REPLACE or CONDENSE previous session entries to keep the file concise (max 2-3 most recent sessions) 4. Keep only relevant context for the next Claude session - remove outdated or completed work details 5. Format with clear headers and organized sections ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to StenoAI Thank you for your interest in contributing to StenoAI! This guide will help you get started. ## Getting Started ### Prerequisites - macOS (required for development and testing) - Python 3.8+ - Node.js 18+ - Git ### Local Development Setup 1. **Fork and clone the repository** ```bash git clone https://github.com/your-username/stenoai.git cd stenoai ``` 2. **Set up Python environment** ```bash python3 -m venv venv source venv/bin/activate pip install -r requirements.txt pip install -e . ``` 3. **Install system dependencies** ```bash # Install Ollama brew install ollama ollama serve & ollama pull llama3.2:3b # Install ffmpeg brew install ffmpeg ``` 4. **Set up Electron app** ```bash cd app npm install npm start ``` 5. **Test the setup** ```bash # Test CLI python simple_recorder.py --help # Test app launch cd app && npm start ``` ## Development Workflow ### Making Changes 1. **Create a feature branch** ```bash git checkout -b feature/your-feature-name ``` 2. **Make your changes** - Follow existing code style and patterns - Test your changes locally - Update documentation if needed 3. **Test your changes** ```bash # Test Python code python simple_recorder.py --help python -c "import src.audio_recorder, src.transcriber, src.summarizer" # Test Electron app cd app && npm start ``` 4. **Commit and push** ```bash git add . git commit -m "Add your descriptive commit message" git push origin feature/your-feature-name ``` 5. **Create a Pull Request** - Use the PR template to describe your changes - Focus on clear description and testing details - Be responsive to review feedback ### Code Style **Python:** - Follow PEP 8 guidelines - Use type hints where appropriate - Write docstrings for functions and classes - Use `ruff` for linting: `ruff check .` **JavaScript:** - Use semicolons - Use const/let instead of var - Follow existing patterns in the codebase ### Testing Before submitting a PR, please ensure: - [ ] CLI functionality works: `python simple_recorder.py --help` - [ ] Electron app starts: `cd app && npm start` - [ ] No breaking changes to existing functionality ## Versioning This project uses manual semantic versioning: - Maintainers handle version bumps and releases - Contributors focus on code quality, not versioning - Releases are created manually using `npm version` commands ## Types of Contributions ### Bug Reports When filing a bug report, please include: - macOS version - Steps to reproduce - Expected vs actual behavior - Error messages or logs - Screenshots if applicable ### Feature Requests For feature requests, please: - Describe the problem you're trying to solve - Explain your proposed solution - Consider if this fits the project's scope and vision ### Code Contributions We welcome contributions for: - Bug fixes - Performance improvements - New features (please discuss in an issue first) - Documentation improvements - Test coverage improvements ### Documentation Help improve our documentation: - Fix typos or unclear instructions - Add examples or clarifications - Update outdated information ## Project Structure ``` stenoai/ ├── app/ # Electron desktop app │ ├── main.js # Main process │ ├── preload.js # Context-isolated IPC bridge │ ├── renderer/ # React + Vite renderer (TypeScript) │ └── package.json # App dependencies ├── src/ # Python backend │ ├── audio_recorder.py # Audio recording │ ├── transcriber.py # Whisper integration │ ├── summarizer.py # Ollama/LLM processing │ └── models.py # Data models ├── simple_recorder.py # CLI interface ├── requirements.txt # Python dependencies └── CLAUDE.md # Development instructions ``` ## Getting Help - Check existing [issues](https://github.com/ruzin/stenoai/issues) - Create a new issue for bugs or feature requests - Join discussions in the repository ## Contributor License Agreement By contributing to StenoAI, you agree to our [Contributor License Agreement (CLA)](CLA.md). **What this means:** - You retain ownership of your contributions - You grant us broad, irrevocable rights to use, modify, and relicense your contributions - This allows us to offer commercial licenses while keeping the project free for personal use **How it works:** - When you submit your first pull request, CLA Assistant will prompt you to sign - Simply comment "I have read the CLA Document and I hereby sign the CLA" on the PR - This is a one-time process - future contributions are automatically covered The project is licensed under the MIT License. See [LICENSE](LICENSE) for details. ## Recognition Contributors will be recognized in our releases and README. Thank you for helping make StenoAI better! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Skrape Limited Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
StenoAI Logo # StenoAI *Your private stenographer*

Build Release Discord License macOS Sponsors

AI-powered meeting intelligence that runs entirely on your device, your private data never leaves anywhere. Record, transcribe, summarize, and query your meetings using local AI models. Perfect for healthcare, legal and finance professionals with confidential data needs.

Trusted by users at AWS, Deliveroo, Tesco & HashiCorp.

StenoAI Interface
[![Twitter Follow](https://img.shields.io/twitter/follow/ruzin?style=social)](https://x.com/ruzin_saleem)

Disclaimer: This is an independent open-source project for meeting-notes productivity and is not affiliated with, endorsed by, or associated with any similarly named company.

## Sponsors ### Recall.ai - API for desktop recording If you're looking for a hosted desktop recording API, consider checking out [Recall.ai](https://www.recall.ai/product/desktop-recording-sdk?utm_source=github&utm_medium=sponsorship&utm_campaign=ruzin-stenoai), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more. ## 📢 What's New - **2026-04-19** 🔄 In-app auto-updates — Updates download in the background and install on next quit; no more manual DMG downloads - **2026-04-19** 💬 Inline ask bar — Query your meetings from a floating bar at the bottom of every note - **2026-04-19** 📂 Ask against saved markdown — The ask bar now reads your saved `.md` notes directly (summary, topics, and full transcript) - **2026-04-19** 📝 Diarised markdown export — Saved transcripts include `[You]` / `[Others]` speaker labels - **2026-03-25** ✍️ In-app note-taking — Jot notes during a recording and they're folded into the AI summary - **2026-03-23** 🗣️ Speaker diarisation — [You] vs [Others] labels for system audio recordings - **2026-03-23** 🌍 Auto-detect language — 99 languages supported out of the box - **2026-03-04** 🏷️ Auto-generated meeting titles — AI creates short titles from your transcripts ## Features - **Privacy-first** — 100% on-device; your recordings, transcripts, and summaries never leave your Mac - **In-app note-taking** — Jot notes while you record; they're folded straight into the AI summary - **Ask your meetings** — Natural-language Q&A across any saved note, including summary, key topics, and full transcript - **System audio capture** — Record both sides of virtual meetings, headphones on, no extra setup - **Speaker diarisation** — `[You]` vs `[Others]` labels on system-audio recordings - **Multi-language** — Auto-detect and transcribe in 99 languages - **Markdown notes** — Summaries and transcripts saved as clean Markdown you can edit, search, or sync - **Remote Ollama server** — Offload summarisation to a beefier Mac or workstation on your network - **Bring your own cloud model** — Optional OpenAI, Anthropic, or custom API endpoint for users who prefer a hosted LLM - **Under the hood** — Local transcription via whisper.cpp, summarisation via bundled Ollama (5 models to choose from) ## macOS Shortcuts (Optional)
Expand setup and calendar automation guide StenoAI supports Apple Shortcuts via deep links using the `stenoai://` URL scheme. - Start recording: `stenoai://record/start?name=Daily%20Standup` - Stop recording: `stenoai://record/stop` ### How to set it up 1. Open the **Shortcuts** app on macOS. 2. Create a new shortcut (for example: "Start StenoAI Recording"). 3. Add the **Open URLs** action. 4. Use one of the URLs above. 5. (Optional) Add a keyboard shortcut from the shortcut settings. ### Calendar event naming (optional) If you want calendar-based names, resolve the event title in your Shortcut workflow and pass it as the `name` query value in the start URL. Example: `stenoai://record/start?name=Weekly%20Product%20Sync` ### Calendar event start automation (via Rules bridge) macOS Shortcuts **cannot natively trigger** exactly at Calendar event start. To run this automatically on event timing, a third-party automation app is required. This addon uses: - **Apple Shortcuts**: builds the `stenoai://record/start?...` action. - **Rules – Calendar Automation**: watches Calendar events and triggers the shortcut. #### Architecture overview 1. Rules App monitors upcoming Calendar events. 2. Rules checks the event note/body for a marker keyword (for example `stenoai`). 3. If matched, Rules runs a Shortcut. 4. The Shortcut gets the next event title and opens: - `stenoai://record/start?name={calendar_event_title}` 5. StenoAI receives the URL and starts recording with that name. #### Step-by-step setup 1. Install **Rules – Calendar Automation** on macOS. 2. Create a Shortcut in Apple Shortcuts (example name: `StenoAI Start From Calendar Event`). 3. In that Shortcut, add actions in this order: - `Find Calendar Events` (limit to `1`, sorted by start date ascending, upcoming only) - Extract the event title from the found event - `URL Encode` the title - `Open URLs` with: - `stenoai://record/start?name=` 4. Open Rules and create a calendar-trigger rule: - Source: your target calendar(s) - Trigger window: event start (or preferred offset) - Condition: event note contains `stenoai` - Action: run Shortcut `StenoAI Start From Calendar` 5. In your Calendar event notes, add the word `stenoai` for meetings that should auto-start recording. 6. Test with a near-future event: - create event with `stenoai` in notes, - wait for trigger, - confirm StenoAI starts and uses the event title as session name. #### Notes - Without Rules (or another automation bridge), this cannot be fully event-driven from Calendar start time. - Keep using regular manual shortcuts (`Open URLs`) for non-automated scenarios. Have questions or suggestions? [Join our Discord](https://discord.gg/DZ6vcQnxxu) to chat with the community.
## Models & Performance **Transcription Models** (Whisper): - `small`: Default model - good accuracy and speed on Apple Silicon **(default)** - `base`: Faster but lower accuracy for basic meetings - `medium`: High accuracy for important meetings (slower) **Summarization Models** (Ollama): - `llama3.2:3b` (2GB): Fast and lightweight for quick meetings **(default)** - `gemma3:4b` (2.5GB): Lightweight and efficient - `qwen3.5:9b` (6.6GB): Excellent at structured output and action items - `deepseek-r1:14b` (9.0GB): Strong reasoning and analysis capabilities - `gpt-oss:20b` (14GB): OpenAI open-weight model with reasoning capabilities ## Future Roadmap ### Enhanced Features - Live transcription during recording - NVIDIA Parakeet as a transcription engine option - Editing notes after processing - Windows version ## Installation Download the latest release for your Mac (**requires macOS 14 Sonoma or later**): - [Apple Silicon (M1-M5)](https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-arm64.dmg) - [Intel Macs](https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-x64.dmg) Performance on Intel Macs is limited due to lack of dedicated AI inference capabilities on these older chips. ### Installing on macOS 1. **Download and open the DMG file** 2. **Drag the app to Applications** 3. **When you first launch the app**, macOS may show a security warning 4. **To fix this warning:** - Go to **System Settings > Privacy & Security** and click **"Open Anyway"** **Alternatively:** - Right-click StenoAI in Applications and select **"Open"** - Or run in Terminal: `xattr -cr /Applications/StenoAI.app` 5. **The app will work normally on subsequent launches** You can run it locally as well (see below) if you don't want to install a DMG. ## Local Development/Use Locally ### Prerequisites - Python 3.9+ - Node.js 18+ ### Setup ```bash git clone https://github.com/ruzin/stenoai.git cd stenoai # Backend setup python3 -m venv venv source venv/bin/activate pip install -r requirements.txt # Download bundled binaries (Ollama, ffmpeg) ./scripts/download-ollama.sh # Build the Python backend pip install pyinstaller pyinstaller stenoai.spec --noconfirm # Frontend cd app npm install npm start ``` Note: Ollama and ffmpeg are bundled - no system installation needed. The setup wizard in the app will download the required AI models automatically. ### Build ```bash cd app npm run build ``` ## Project Structure ``` stenoai/ ├── app/ # Electron desktop app ├── src/ # Python backend ├── website/ # Marketing site ├── recordings/ # Audio files ├── transcripts/ # Text output └── output/ # Summaries ``` ## Troubleshooting ### Debug Logs **Setup wizard debug console:** during first-time setup, expand the debug console panel to see real-time logs of model downloads and service startup. **Terminal logging (recommended for runtime issues):** launch the app from a terminal to stream all logs (Python subprocess output, Whisper transcription, Ollama API traffic, error stack traces): ```bash /Applications/StenoAI.app/Contents/MacOS/StenoAI ``` **System Console:** ```bash # View recent StenoAI-related logs log show --last 10m --predicate 'process CONTAINS "StenoAI" OR eventMessage CONTAINS "ollama"' --info # Monitor live logs log stream --predicate 'eventMessage CONTAINS "ollama" OR process CONTAINS "StenoAI"' --level info ``` ### Common Issues - **Update didn't install**: Auto-updates are applied on next quit. Quit via the **StenoAI → Quit** menu (not just closing the window), then reopen. - **No system audio / no `[Others]` speaker labels**: macOS needs **Screen Recording** permission. Go to **System Settings → Privacy & Security → Screen & System Audio Recording**, enable StenoAI, and relaunch the app. - **`stenoai://` deep link doesn't start recording**: Make sure StenoAI has launched at least once after install so the URL scheme is registered. If it still fails, check the terminal log for `Protocol handler registration` output. - **Recording stops early**: Check microphone permissions, Screen Recording permission (if using system audio), and available disk space. - **"Processing failed"**: Usually an Ollama service or model issue — check the terminal logs. - **Empty transcripts**: Whisper couldn't detect speech — verify audio input levels. - **Slow processing**: Normal for longer recordings; Ollama is CPU-intensive, especially on older Intel Macs. ### Logs Location - **User Data**: `~/Library/Application Support/stenoai/` - **Recordings**: `~/Library/Application Support/stenoai/recordings/` - **Transcripts**: `~/Library/Application Support/stenoai/transcripts/` - **Summaries**: `~/Library/Application Support/stenoai/output/` ## License This project is licensed under the [MIT License](LICENSE). ================================================ FILE: announcements.json ================================================ { "announcements": [] } ================================================ FILE: app/build/entitlements.mac.plist ================================================ com.apple.security.device.audio-input com.apple.security.network.client com.apple.security.files.user-selected.read-write com.apple.security.files.bookmarks.app-scope com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation com.apple.security.cs.allow-dyld-environment-variables ================================================ FILE: app/electron-builder.ci.yml ================================================ # electron-builder config for CI dry-runs (pack:unsigned). # # Inherits the base "build" config from package.json, but strips: # - extraResources (../dist/stenoai is not built in CI — no PyInstaller) # - afterSign hook (no notarization in unsigned packs) # - DMG signing (no cert in CI) # # The resulting .app is NOT runnable: it has no Python backend, is unsigned, # and is not notarized. This config exists solely to prove the Vite output + # preload.js + main.js package correctly on every PR, before release time. # Release builds continue to use the package.json config unchanged. extraResources: - from: assets to: assets filter: - trayIcon*.png dmg: sign: false mac: identity: null notarize: false ================================================ FILE: app/main.js ================================================ const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, safeStorage, Tray, Menu, nativeImage, Notification } = require('electron'); // Prevent EPIPE crashes when stdout/stderr pipe is broken (e.g. launching terminal closed) process.stdout?.on('error', () => {}); process.stderr?.on('error', () => {}); const path = require('path'); const { spawn, exec } = require('child_process'); const fs = require('fs'); const https = require('https'); const http = require('http'); const os = require('os'); const { URL, URLSearchParams } = require('url'); const crypto = require('crypto'); const { PostHog } = require('posthog-node'); const { initMain } = require('electron-audio-loopback'); const { autoUpdater } = require('electron-updater'); // E2E test-harness hooks. Set via env vars; production sees none of these. // STENOAI_USER_DATA_DIR — per-test temp userData dir (must be set before app.whenReady) // STENOAI_E2E=1 — skip tray, auto-updater, PostHog telemetry // STENOAI_E2E_MOCK_IPC=1 — install deterministic mock IPC handlers if (process.env.STENOAI_USER_DATA_DIR) { app.setPath('userData', process.env.STENOAI_USER_DATA_DIR); } const IS_E2E = process.env.STENOAI_E2E === '1'; const IS_E2E_MOCK_IPC = process.env.STENOAI_E2E_MOCK_IPC === '1'; if (IS_E2E_MOCK_IPC) { require('./e2e-mock-ipc').install({ ipcMain, BrowserWindow }); } // Initialize electron-audio-loopback before app is ready initMain(); let mainWindow; let pythonProcess; let tray = null; let isQuitting = false; // true once the window has been shown for the first time (React mounted). // Prevents activate/focus handlers from showing the window before it's ready. let windowReadyToShow = false; let shortcutQueue = []; let pendingShortcutUrls = []; let rendererShortcutReady = false; let launchedByShortcut = false; const SHORTCUT_PROTOCOL = 'stenoai'; const SHORTCUT_HOST = 'record'; const SHORTCUT_SESSION_NAME_MAX_LENGTH = 120; const gotSingleInstanceLock = app.requestSingleInstanceLock(); function extractShortcutUrlFromArgv(argv = []) { return argv.find(arg => typeof arg === 'string' && arg.startsWith(`${SHORTCUT_PROTOCOL}://`)); } function sanitizeShortcutUrlForLogs(incomingUrl) { try { const parsed = new URL(incomingUrl); return `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`; } catch (error) { return '[invalid-shortcut-url]'; } } function sanitizeShortcutSessionName(rawValue) { if (typeof rawValue !== 'string') { return null; } // Keep user-visible names readable while stripping unsupported characters. // Preserve Unicode letters (including diacritics) and common punctuation. const sanitized = rawValue .replace(/[^\p{L}\p{M}\p{N}_\s.,()@&'!+#-]/gu, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, SHORTCUT_SESSION_NAME_MAX_LENGTH); return sanitized || null; } function registerShortcutProtocolClient() { if (process.platform !== 'darwin') { return false; } // In development (electron .), macOS protocol registration needs executable + app args. if (!app.isPackaged) { return app.setAsDefaultProtocolClient( SHORTCUT_PROTOCOL, process.execPath, [path.resolve(process.argv[1])] ); } return app.setAsDefaultProtocolClient(SHORTCUT_PROTOCOL); } // Backend executable path - always use bundled stenoai function getBackendPath() { if (app.isPackaged) { // Production: bundled in app resources return path.join(process.resourcesPath, 'stenoai', 'stenoai'); } else { // Development: use local build return path.join(__dirname, '..', 'dist', 'stenoai', 'stenoai'); } } function getBackendCwd() { if (app.isPackaged) { return path.join(process.resourcesPath, 'stenoai'); } else { return path.join(__dirname, '..', 'dist', 'stenoai'); } } function parseShortcutUrl(incomingUrl) { try { const parsed = new URL(incomingUrl); if (parsed.protocol !== `${SHORTCUT_PROTOCOL}:`) { return { type: 'invalid', reason: 'invalid-protocol' }; } if (parsed.hostname !== SHORTCUT_HOST) { return { type: 'invalid', reason: 'invalid-host' }; } const cleanPath = (parsed.pathname || '').replace(/\/+$/, ''); if (cleanPath === '/start') { const sessionName = sanitizeShortcutSessionName(parsed.searchParams.get('name') || ''); return { type: 'start', sessionName }; } if (cleanPath === '/stop') { return { type: 'stop' }; } return { type: 'invalid', reason: 'invalid-path' }; } catch (error) { return { type: 'invalid', reason: 'parse-error' }; } } function ensureMainWindow() { if (!app.isReady()) { sendDebugLog('Shortcut action received before app ready; deferring window creation'); return false; } if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); } return true; } function dispatchShortcutAction(action) { if (!mainWindow || mainWindow.isDestroyed()) { return false; } if (action.type === 'start') { mainWindow.webContents.send('shortcut-start-recording', { sessionName: action.sessionName || null }); launchedByShortcut = false; return true; } if (action.type === 'stop') { mainWindow.webContents.send('shortcut-stop-recording'); launchedByShortcut = false; return true; } return false; } function flushShortcutQueue() { if (!rendererShortcutReady || !mainWindow || mainWindow.isDestroyed()) { return; } while (shortcutQueue.length > 0) { const nextAction = shortcutQueue.shift(); const dispatched = dispatchShortcutAction(nextAction); if (!dispatched) { shortcutQueue.unshift(nextAction); break; } } } function enqueueShortcutAction(action) { if (shortcutQueue.length >= 5) { sendDebugLog('Shortcut queue overflow, dropping oldest action'); shortcutQueue.shift(); } shortcutQueue.push(action); flushShortcutQueue(); } async function shouldShowShortcutNotifications() { try { const settings = await handleGetNotifications(); if (!settings.success) { return true; } return settings.notifications_enabled !== false; } catch (error) { return true; } } async function showShortcutNotification(body) { if (process.platform !== 'darwin') { return; } try { const enabled = await shouldShowShortcutNotifications(); if (!enabled || !Notification.isSupported()) { return; } new Notification({ title: 'StenoAI Shortcuts', body }).show(); } catch (error) { console.error('Failed to show shortcut notification:', error.message); } } const BACKEND_STATUS_RETRY_ATTEMPTS = 3; const BACKEND_STATUS_RETRY_DELAY_MS = 250; function wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function isBackendRecording() { for (let attempt = 1; attempt <= BACKEND_STATUS_RETRY_ATTEMPTS; attempt += 1) { try { const status = await handleGetStatus(); if (status.success) { return status.status.includes('STATUS: RECORDING'); } } catch (error) { if (attempt === BACKEND_STATUS_RETRY_ATTEMPTS) { console.error('Error checking recording status for shortcut action:', error.message); } } if (attempt < BACKEND_STATUS_RETRY_ATTEMPTS) { await wait(BACKEND_STATUS_RETRY_DELAY_MS); } } console.warn('Backend status unavailable after retries; assuming not recording for shortcut action'); return false; } async function handleShortcutUrl(incomingUrl) { const parsedAction = parseShortcutUrl(incomingUrl); const safeShortcutUrl = sanitizeShortcutUrlForLogs(incomingUrl); if (parsedAction.type === 'invalid') { sendDebugLog(`Ignored invalid shortcut URL (${parsedAction.reason}): ${safeShortcutUrl}`); await showShortcutNotification('Invalid shortcut URL'); launchedByShortcut = false; return; } const backendRecording = await isBackendRecording(); const recording = backendRecording || systemAudioRecordingActive; if (parsedAction.type === 'start') { if (recording) { await showShortcutNotification('Recording already in progress'); launchedByShortcut = false; return; } if (!ensureMainWindow()) { launchedByShortcut = true; pendingShortcutUrls.push(incomingUrl); return; } enqueueShortcutAction(parsedAction); await showShortcutNotification('Start recording requested'); return; } if (!recording) { await showShortcutNotification('Recording already stopped'); launchedByShortcut = false; return; } if (!ensureMainWindow()) { launchedByShortcut = true; pendingShortcutUrls.push(incomingUrl); return; } enqueueShortcutAction(parsedAction); await showShortcutNotification('Stop recording requested'); } // Telemetry state let posthogClient = null; let telemetryEnabled = false; let anonymousId = null; const POSTHOG_API_KEY = 'phc_U2cnTyIyKGNSVaK18FyBMltd8nmN7uHxhhm21fAHwqb'; const POSTHOG_HOST = 'https://us.i.posthog.com'; // Google Calendar OAuth2 configuration const GOOGLE_CLIENT_ID = '281073275073-20da4u5t9luk2366vd5ai0a2r55d5pf5.apps.googleusercontent.com'; const GOOGLE_CLIENT_SECRET = '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'; const GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar.readonly'; const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; // Outlook Calendar OAuth2 configuration (PKCE public client — no client secret) const OUTLOOK_CLIENT_ID = '53a8ba1f-3a2e-4fc9-afb1-b9b8ff13de19'; const OUTLOOK_SCOPES = 'Calendars.Read offline_access'; const OUTLOOK_AUTH_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'; const OUTLOOK_TOKEN_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; /** * Return a privacy-safe duration bucket string. */ function durationBucket(seconds) { if (seconds < 60) return '<1m'; if (seconds < 300) return '1-5m'; if (seconds < 900) return '5-15m'; if (seconds < 1800) return '15-30m'; if (seconds < 3600) return '30-60m'; return '60m+'; } /** * Initialize PostHog telemetry by reading config from Python backend. */ async function initTelemetry() { if (IS_E2E) { telemetryEnabled = false; return; } try { const result = await new Promise((resolve, reject) => { const proc = spawn(getBackendPath(), ['get-telemetry'], { cwd: getBackendCwd() }); let stdout = ''; proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.on('close', (code) => { if (code === 0) resolve(stdout); else reject(new Error(`get-telemetry exited with code ${code}`)); }); proc.on('error', reject); }); const config = JSON.parse(result.trim()); telemetryEnabled = config.telemetry_enabled; anonymousId = config.anonymous_id; if (telemetryEnabled) { posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST }); // Identify user for DAU tracking posthogClient.identify({ distinctId: anonymousId, properties: { platform: process.platform, arch: process.arch } }); console.log('Telemetry initialized (anonymous analytics enabled)'); } else { console.log('Telemetry disabled by user preference'); } } catch (error) { console.error('Failed to initialize telemetry:', error.message); telemetryEnabled = false; } } /** * Track an analytics event. Silent fail -- never throws. */ function trackEvent(eventName, properties = {}) { try { if (!telemetryEnabled || !posthogClient || !anonymousId) return; const packagePath = path.join(__dirname, 'package.json'); const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8')); posthogClient.capture({ distinctId: anonymousId, event: eventName, properties: { app_version: packageContent.version, platform: process.platform, arch: process.arch, ...properties } }); } catch (error) { // Silent fail -- telemetry must never break the app } } /** * Flush and shut down the PostHog client. */ async function shutdownTelemetry() { try { if (posthogClient) { await posthogClient.shutdown(); posthogClient = null; console.log('Telemetry shut down'); } } catch (error) { // Silent fail } } /** * Get the list of allowed base directories, including any custom storage path. */ let _cachedCustomStoragePath = null; function getAllowedBaseDirs() { const projectRoot = path.join(__dirname, '..'); const dirs = [ projectRoot, path.join(os.homedir(), 'Library', 'Application Support', 'stenoai') ]; if (_cachedCustomStoragePath) { dirs.push(_cachedCustomStoragePath); } return dirs; } /** * Validate that a file path is within allowed directories (security) * Prevents path traversal attacks by ensuring files are only accessed * within the app's designated data directories */ function validateSafeFilePath(filepath, allowedBaseDirs) { if (!filepath) return false; try { // Resolve to absolute path and normalize const resolvedPath = path.resolve(filepath); // Ensure it's within one of the allowed base directories for (const baseDir of allowedBaseDirs) { const resolvedBase = path.resolve(baseDir); if (resolvedPath.startsWith(resolvedBase + path.sep) || resolvedPath === resolvedBase) { return true; } } return false; } catch (error) { console.error('Error validating file path:', error); return false; } } function createWindow(options = {}) { rendererShortcutReady = false; const windowOpts = { width: 1200, height: 800, minWidth: 1000, minHeight: 600, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: false, preload: path.join(__dirname, 'preload.js'), scrollBounce: true, }, titleBarStyle: 'hiddenInset', show: false, backgroundColor: '#FAF9F5', // React UI renders the macOS traffic lights inside the sidebar's top // band rather than floating above a fixed titlebar. trafficLightPosition: { x: 18, y: 18 }, }; if (options.bounds && typeof options.bounds.x === 'number') { Object.assign(windowOpts, options.bounds); } mainWindow = new BrowserWindow(windowOpts); const rendererDist = path.join(__dirname, 'renderer', 'dist', 'index.html'); const hash = process.env.STENOAI_RENDERER_HASH; if (hash) { mainWindow.loadFile(rendererDist, { hash }); } else { mainWindow.loadFile(rendererDist); } windowReadyToShow = false; const showWhenReady = () => { windowReadyToShow = true; if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { mainWindow.show(); } }; mainWindow.once('ready-to-show', () => { if (launchedByShortcut) { return; } // Wait until React signals it has mounted. Fall back to showing after // 4s in case the signal never arrives. const fallback = setTimeout(showWhenReady, 4000); ipcMain.once('renderer-ready-to-show', () => { clearTimeout(fallback); showWhenReady(); }); }); // On macOS, hide to tray instead of destroying (like Slack, Spotify) mainWindow.on('close', (event) => { if (process.platform === 'darwin' && !isQuitting) { event.preventDefault(); mainWindow.hide(); } }); mainWindow.on('closed', () => { mainWindow = null; rendererShortcutReady = false; if (pythonProcess) { pythonProcess.kill(); } }); } function getTrayIconPath(recording) { const iconName = recording ? 'trayIconRecordingTemplate' : 'trayIconTemplate'; if (app.isPackaged) { return path.join(process.resourcesPath, 'assets', `${iconName}.png`); } return path.join(__dirname, 'assets', `${iconName}.png`); } function createTray() { const icon = nativeImage.createFromPath(getTrayIconPath(false)); icon.setTemplateImage(true); tray = new Tray(icon); tray.setToolTip('StenoAI'); updateTrayMenu(); } function updateTrayIcon(recording) { if (!tray) return; const icon = nativeImage.createFromPath(getTrayIconPath(recording)); icon.setTemplateImage(true); tray.setImage(icon); tray.setToolTip(recording ? 'StenoAI - Recording' : 'StenoAI'); updateTrayMenu(); } function showAndFocusWindow() { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } function updateTrayMenu() { if (!tray) return; const isRecording = currentRecordingProcess !== null || systemAudioRecordingActive; const appVersion = require('./package.json').version; const contextMenu = Menu.buildFromTemplate([ { label: 'Open StenoAI', click: showAndFocusWindow }, { label: isRecording ? 'Stop Recording' : 'Start Recording', click: () => { if (mainWindow) { mainWindow.webContents.send(isRecording ? 'tray-stop-recording' : 'tray-start-recording'); } } }, { label: 'Settings', click: () => { showAndFocusWindow(); if (mainWindow) { mainWindow.webContents.send('tray-open-settings'); } } }, { label: 'Hide StenoAI', click: () => { if (mainWindow) mainWindow.hide(); } }, { type: 'separator' }, { label: `StenoAI v${appVersion}`, enabled: false }, { label: 'Report a Bug', click: () => { shell.openExternal('https://discord.gg/DZ6vcQnxxu'); } }, { type: 'separator' }, { label: 'Quit StenoAI', click: () => { app.quit(); } } ]); tray.setContextMenu(contextMenu); } if (!gotSingleInstanceLock) { app.quit(); } else { app.on('second-instance', (event, argv) => { const shortcutUrl = extractShortcutUrlFromArgv(argv); if (shortcutUrl) { if (app.isReady()) { handleShortcutUrl(shortcutUrl).catch(err => { sendDebugLog(`Error handling shortcut URL: ${err.message}`); }); } else { launchedByShortcut = true; pendingShortcutUrls.push(shortcutUrl); } } if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } }); // Sends the custom in-app quit dialog to the renderer and waits for a response. // Falls back to true (allow quit) if the window is unavailable. A 5s timeout // guards against a wedged React tree — on timeout we resolve false to // preserve any active recording rather than killing it silently. async function showCustomQuitDialog(type, jobCount) { if (!mainWindow || mainWindow.isDestroyed()) return true; mainWindow.show(); mainWindow.focus(); mainWindow.webContents.send('show-quit-dialog', { type, jobCount }); return new Promise((resolve) => { const handler = (_event, data) => { clearTimeout(timer); resolve(data && data.confirmed === true); }; const timer = setTimeout(() => { ipcMain.removeListener('quit-dialog-response', handler); resolve(false); }, 5000); ipcMain.once('quit-dialog-response', handler); }); } app.on('before-quit', async (event) => { if (isQuitting) return; // Use synchronous flag -- systemAudioRecordingActive is updated via IPC on each state change if (currentRecordingProcess || systemAudioRecordingActive) { event.preventDefault(); const confirmed = await showCustomQuitDialog('recording'); if (confirmed) { if (currentRecordingProcess) { currentRecordingProcess.kill('SIGTERM'); currentRecordingProcess = null; currentRecordingSessionName = null; } if (systemAudioRecordingActive && mainWindow && !mainWindow.isDestroyed()) { try { await mainWindow.webContents.executeJavaScript('stopSystemAudioRecording("quit")'); } catch (e) { // Best effort -- file is saved even if processing doesn't start } } systemAudioRecordingActive = false; updateTrayIcon(false); isQuitting = true; app.quit(); } } else if (isProcessing || processingQueue.length > 0) { event.preventDefault(); const jobCount = processingQueue.length + (isProcessing ? 1 : 0); const confirmed = await showCustomQuitDialog('processing', jobCount); if (confirmed) { isQuitting = true; app.quit(); } } else { isQuitting = true; } }); app.on('open-url', (event, incomingUrl) => { if (process.platform !== 'darwin') { return; } event.preventDefault(); sendDebugLog(`Received shortcut URL via open-url: ${sanitizeShortcutUrlForLogs(incomingUrl)}`); if (!app.isReady()) { launchedByShortcut = true; pendingShortcutUrls.push(incomingUrl); return; } handleShortcutUrl(incomingUrl).catch(err => { sendDebugLog(`Error handling shortcut URL: ${err.message}`); }); }); app.whenReady().then(async () => { // Set application menu with Help > Learn More const appMenu = Menu.buildFromTemplate([ { role: 'appMenu' }, { role: 'fileMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' }, { role: 'help', submenu: [ { label: 'Learn More', click: () => { shell.openExternal('https://github.com/ruzin/stenoai'); } }, { label: 'Report a Bug', click: () => { shell.openExternal('https://discord.gg/DZ6vcQnxxu'); } } ] } ]); Menu.setApplicationMenu(appMenu); createWindow(); if (!IS_E2E) createTray(); setupAutoUpdater(); const protocolRegistered = registerShortcutProtocolClient(); sendDebugLog(`Protocol handler registration (${SHORTCUT_PROTOCOL}): ${protocolRegistered}`); // Load hide-dock-icon preference and apply if (process.platform === 'darwin' && app.dock) { try { const dockResult = await new Promise((resolve, reject) => { const proc = spawn(getBackendPath(), ['get-dock-icon'], { cwd: getBackendCwd() }); let stdout = ''; proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.on('close', (code) => { if (code === 0) resolve(stdout); else reject(new Error(`get-dock-icon exited with code ${code}`)); }); proc.on('error', reject); }); const dockConfig = JSON.parse(dockResult.trim()); if (dockConfig.hide_dock_icon) { app.dock.hide(); console.log('Dock icon hidden (menu bar only mode)'); } } catch (e) { console.error('Failed to load dock icon preference:', e.message); } } // Initialize telemetry and track app open await initTelemetry(); trackEvent('app_opened'); // Load custom storage path for file validation try { const spResult = await runPythonScript('simple_recorder.py', ['get-storage-path'], true); const spData = JSON.parse(spResult.trim()); if (spData.storage_path) { _cachedCustomStoragePath = spData.storage_path; console.log('Custom storage path loaded:', _cachedCustomStoragePath); } } catch (e) { // Non-fatal - custom path just won't be cached } // Register global hotkey for toggle recording (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows/Linux) const hotkeyModifier = process.platform === 'darwin' ? 'Command+Shift+R' : 'Ctrl+Shift+R'; const registered = globalShortcut.register(hotkeyModifier, () => { console.log('Global hotkey triggered: toggle recording'); if (mainWindow) { mainWindow.webContents.send('toggle-recording-hotkey'); } }); if (registered) { console.log(`Global hotkey registered: ${hotkeyModifier}`); } else { console.error(`Failed to register global hotkey: ${hotkeyModifier}`); } if (pendingShortcutUrls.length > 0) { const urlsToProcess = [...pendingShortcutUrls]; pendingShortcutUrls = []; for (const shortcutUrl of urlsToProcess) { await handleShortcutUrl(shortcutUrl); } } }); // Fallback for launch contexts where deep-link may arrive via argv instead of open-url. if (process.platform === 'darwin') { const argvShortcutUrl = extractShortcutUrlFromArgv(process.argv); if (argvShortcutUrl) { pendingShortcutUrls.push(argvShortcutUrl); launchedByShortcut = true; } } app.on('will-quit', async () => { globalShortcut.unregisterAll(); if (tray) { tray.destroy(); tray = null; } // Kill Ollama on quit. The process may have been started by Electron or // the Python backend — both write the PID to ollama.pid in _internal/. const pidFile = path.join(getBackendCwd(), '_internal', 'ollama.pid'); try { const pid = parseInt(require('fs').readFileSync(pidFile, 'utf8').trim(), 10); if (pid) { process.kill(pid, 'SIGTERM'); // Give it a moment to shut down, then force-kill if still alive setTimeout(() => { try { process.kill(pid, 'SIGKILL'); } catch (_) {} }, 1000); } require('fs').unlinkSync(pidFile); } catch (_) {} // Also kill if Electron spawned it directly if (ollamaPid) { try { process.kill(ollamaPid, 'SIGTERM'); } catch (_) {} ollamaPid = null; } await shutdownTelemetry(); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); // Only show if the window has finished its initial load. // On first launch, windowReadyToShow is false until React mounts. if (windowReadyToShow) { mainWindow.show(); mainWindow.focus(); } launchedByShortcut = false; } else { launchedByShortcut = false; createWindow(); } }); } // Focus window handler (used by notification click to bring app to foreground) ipcMain.on('focus-window', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } }); ipcMain.on('shortcut-renderer-ready', () => { rendererShortcutReady = true; flushShortcutQueue(); }); // Microphone permission handlers ipcMain.handle('check-microphone-permission', async () => { try { const status = systemPreferences.getMediaAccessStatus('microphone'); console.log('Microphone permission status:', status); return { success: true, status }; } catch (error) { console.error('Error checking microphone permission:', error); return { success: false, error: error.message }; } }); ipcMain.handle('request-microphone-permission', async () => { try { console.log('Requesting microphone permission...'); const granted = await systemPreferences.askForMediaAccess('microphone'); console.log('Microphone permission granted:', granted); return { success: true, granted }; } catch (error) { console.error('Error requesting microphone permission:', error); return { success: false, error: error.message }; } }); // Debug functionality handled by side panel now // Backend communication - always uses bundled stenoai executable function runPythonScript(script, args = [], silent = false, extraEnv = {}) { return new Promise((resolve, reject) => { const backendPath = getBackendPath(); // Log the command being executed (unless silent) console.log('Running:', `${backendPath} ${args.join(' ')}`); if (!silent) { sendDebugLog(`$ stenoai ${args.join(' ')}`); } const process = spawn(backendPath, args, { cwd: getBackendCwd(), env: Object.keys(extraEnv).length > 0 ? { ...require('process').env, ...extraEnv } : undefined }); let stdout = ''; let stderr = ''; process.stdout.on('data', (data) => { const output = data.toString(); stdout += output; console.log('Python stdout:', output); // Stream stdout to debug panel in real-time (unless silent) if (!silent) { output.split('\n').forEach(line => { if (line.trim()) sendDebugLog(line.trim()); }); } }); process.stderr.on('data', (data) => { const output = data.toString(); stderr += output; console.log('Python stderr:', output); // Stream stderr to debug panel in real-time (unless silent) if (!silent) { output.split('\n').forEach(line => { if (line.trim()) sendDebugLog('STDERR: ' + line.trim()); }); } }); process.on('close', (code) => { if (!silent) { sendDebugLog(`Command completed with exit code: ${code}`); } if (code === 0) { resolve(stdout); } else { reject(new Error(`Python script failed with code ${code}: ${stderr}`)); } }); process.on('error', (error) => { sendDebugLog(`Command error: ${error.message}`); reject(error); }); }); } async function getBackendStatusInternal(silent = true) { const result = await runPythonScript('simple_recorder.py', ['status'], silent); return { success: true, status: result }; } async function handleGetStatus() { try { return await getBackendStatusInternal(true); // Silent mode } catch (error) { return { success: false, error: error.message }; } } async function handleGetNotifications() { try { const result = await runPythonScript('simple_recorder.py', ['get-notifications']); const jsonData = JSON.parse(result); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error getting notification settings: ${error.message}`); return { success: false, error: error.message }; } } // IPC Handlers - Separate start/stop with better error handling ipcMain.handle('start-recording', async (event, sessionName) => { try { sendDebugLog(`Starting recording session: ${sessionName || 'Meeting'}`); sendDebugLog('$ python simple_recorder.py start'); // Start recording (removed clear-state to prevent race conditions) const result = await runPythonScript('simple_recorder.py', ['start', sessionName || 'Meeting']); if (result.includes('SUCCESS')) { sendDebugLog('Recording started successfully'); trackEvent('recording_started'); return { success: true, message: result }; } else { sendDebugLog(`Recording failed: ${result}`); return { success: false, error: result }; } } catch (error) { console.error('Start recording error:', error.message); sendDebugLog(`Recording error: ${error.message}`); trackEvent('error_occurred', { error_type: 'start_recording' }); return { success: false, error: error.message }; } }); ipcMain.handle('stop-recording', async () => { try { const result = await runPythonScript('simple_recorder.py', ['stop']); if (result.includes('SUCCESS') || result.includes('Recording saved')) { trackEvent('recording_stopped'); return { success: true, message: result }; } else { return { success: false, error: result }; } } catch (error) { console.error('Stop recording error:', error.message); trackEvent('error_occurred', { error_type: 'stop_recording' }); return { success: false, error: error.message }; } }); ipcMain.handle('get-status', handleGetStatus); ipcMain.handle('process-recording', async (event, audioFile, sessionName) => { try { const cloudKey = loadCloudApiKey(); const env = cloudKey ? { STENOAI_CLOUD_API_KEY: cloudKey } : {}; const result = await runPythonScript('simple_recorder.py', ['process', audioFile, '--name', sessionName], false, env); trackEvent('transcription_completed', { success: true }); trackEvent('summarization_completed', { success: true }); return { success: true, result: result }; } catch (error) { trackEvent('error_occurred', { error_type: 'process_recording' }); return { success: false, error: error.message }; } }); ipcMain.handle('test-system', async () => { try { const result = await runPythonScript('simple_recorder.py', ['test']); return { success: true, result: result }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('select-audio-file', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: [ { name: 'Audio Files', extensions: ['wav', 'mp3', 'm4a', 'aac', 'webm'] } ] }); if (!result.canceled && result.filePaths.length > 0) { return { success: true, filePath: result.filePaths[0] }; } return { success: false, error: 'No file selected' }; }); ipcMain.handle('list-meetings', async () => { try { const result = await runPythonScript('simple_recorder.py', ['list-meetings'], true); // Silent mode return { success: true, meetings: JSON.parse(result) }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('clear-state', async () => { try { const result = await runPythonScript('simple_recorder.py', ['clear-state']); return { success: true, message: result }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('reprocess-meeting', async (event, summaryFile, regenerateTitle, sessionName) => { try { const args = ['reprocess', summaryFile]; if (regenerateTitle) args.push('--regenerate-title'); sendDebugLog(`🔄 Reprocessing meeting: ${summaryFile}`); sendDebugLog(`$ stenoai ${args.join(' ')}`); const cloudKey = loadCloudApiKey(); const reprocessEnv = cloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: cloudKey } : undefined; await new Promise((resolve, reject) => { const proc = spawn(getBackendPath(), args, { cwd: getBackendCwd(), env: reprocessEnv }); let stderrBuf = ''; const procTimeout = setTimeout(() => { console.error('reprocess timed out after 30 minutes, killing'); proc.kill(); }, 30 * 60 * 1000); proc.on('error', (err) => { clearTimeout(procTimeout); reject(new Error(`reprocess spawn error: ${err.message}`)); }); proc.stdout.on('data', (data) => { const text = data.toString(); text.split('\n').forEach(line => { if (line.startsWith('CHUNK:')) { try { const encoded = line.slice(6); const chunk = Buffer.from(encoded, 'base64').toString('utf-8'); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-chunk', { chunk, sessionName }); } } catch (e) { console.log('CHUNK decode error:', e.message); } } else if (line.startsWith('TITLE:')) { const title = line.slice(6); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-title', { title, sessionName }); } } else if (line === 'STREAM_COMPLETE') { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-complete', { success: true, sessionName }); } } else if (line.trim()) { sendDebugLog(line.trim()); } }); }); proc.stderr.on('data', (data) => { const msg = data.toString().trim(); if (msg) { stderrBuf += msg + '\n'; sendDebugLog(`STDERR: ${msg}`); } }); proc.on('close', (code) => { clearTimeout(procTimeout); if (code === 0) { console.log(`✅ Completed reprocessing: ${sessionName}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('processing-complete', { success: true, sessionName, message: 'Reprocessing completed successfully' }); } resolve(); } else { reject(new Error(`reprocess exited with code ${code}: ${stderrBuf.slice(-500)}`)); } }); }); sendDebugLog('✅ Meeting reprocessed successfully'); return { success: true }; } catch (error) { sendDebugLog(`❌ Reprocessing failed: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('regen-meeting-title', async (event, summaryFile, sessionName) => { try { const cloudKey = loadCloudApiKey(); const regenEnv = cloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: cloudKey } : undefined; await new Promise((resolve, reject) => { const proc = spawn(getBackendPath(), ['regen-title', summaryFile], { cwd: getBackendCwd(), env: regenEnv, }); let stderrBuf = ''; const procTimeout = setTimeout(() => { proc.kill(); }, 2 * 60 * 1000); proc.on('error', (err) => { clearTimeout(procTimeout); reject(new Error(err.message)); }); proc.stdout.on('data', (data) => { data.toString().split('\n').forEach((line) => { if (line.startsWith('TITLE:')) { const title = line.slice(6); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-title', { title, sessionName }); } } }); }); proc.stderr.on('data', (data) => { stderrBuf += data.toString(); }); proc.on('close', (code) => { clearTimeout(procTimeout); if (code === 0) resolve(); else reject(new Error(`regen-title exited with code ${code}: ${stderrBuf.slice(-300)}`)); }); }); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('query-transcript', async (event, summaryFile, question) => { try { sendDebugLog(`🤖 Querying transcript: ${question.substring(0, 50)}...`); // Run the query command (pass cloud key for cloud provider) const cloudKey = loadCloudApiKey(); const env = cloudKey ? { STENOAI_CLOUD_API_KEY: cloudKey } : {}; const result = await runPythonScript('simple_recorder.py', ['query', summaryFile, '-q', question], false, env); // Parse the JSON response try { const jsonResponse = JSON.parse(result.trim()); if (jsonResponse.success) { sendDebugLog('✅ Query answered successfully'); trackEvent('ai_query_used', { success: true }); return { success: true, answer: jsonResponse.answer }; } else { sendDebugLog(`❌ Query failed: ${jsonResponse.error}`); trackEvent('ai_query_used', { success: false }); return { success: false, error: jsonResponse.error }; } } catch (parseError) { // If parsing fails, check if the result contains any JSON const jsonMatch = result.match(/\{[\s\S]*\}/); if (jsonMatch) { const jsonResponse = JSON.parse(jsonMatch[0]); if (jsonResponse.success) { trackEvent('ai_query_used', { success: true }); return { success: true, answer: jsonResponse.answer }; } else { trackEvent('ai_query_used', { success: false }); return { success: false, error: jsonResponse.error }; } } sendDebugLog(`❌ Failed to parse query response: ${parseError.message}`); trackEvent('ai_query_used', { success: false }); return { success: false, error: 'Failed to parse AI response' }; } } catch (error) { sendDebugLog(`❌ Query failed: ${error.message}`); trackEvent('error_occurred', { error_type: 'query_transcript' }); return { success: false, error: error.message }; } }); const activeQueryProcs = new Map(); ipcMain.on('query-cancel', (_event, queryId) => { const proc = activeQueryProcs.get(queryId); if (proc) { console.log(`[QUERY] Cancelling queryId=${queryId}`); proc.kill(); activeQueryProcs.delete(queryId); } }); ipcMain.on('query-transcript-stream', (event, queryId, summaryFile, question) => { console.log(`[QUERY] IPC received: question="${question.substring(0, 50)}" file="${summaryFile}"`); sendDebugLog(`🤖 Streaming query: ${question.substring(0, 50)}...`); const cloudKey = loadCloudApiKey(); const env = cloudKey ? { ...process.env, STENOAI_CLOUD_API_KEY: cloudKey } : process.env; let proc; try { const backendPath = getBackendPath(); proc = require('child_process').spawn(backendPath, ['query-streaming', summaryFile, '-q', question], { env, cwd: getBackendCwd(), }); } catch (err) { event.sender.send('query-done', { queryId, success: false, error: err.message }); return; } activeQueryProcs.set(queryId, proc); // Kill the spawned proc if the renderer sender goes away before the query // finishes. Keep a reference so we can remove the listener on normal close // (otherwise repeated queries on a long-lived sender leak one-time listeners). const onSenderDestroyed = () => { if (activeQueryProcs.has(queryId)) { proc.kill(); activeQueryProcs.delete(queryId); } }; event.sender.once('destroyed', onSenderDestroyed); let buf = ''; let chunkCount = 0; proc.stdout.on('data', (data) => { buf += data.toString(); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (line.startsWith('CHAT_CHUNK:') || line.startsWith('CHUNK:')) { const prefixLen = line.startsWith('CHAT_CHUNK:') ? 11 : 6; try { const chunk = Buffer.from(line.slice(prefixLen), 'base64').toString('utf-8'); chunkCount++; if (chunkCount === 1) console.log(`[QUERY] First chunk received (queryId=${queryId})`); if (!event.sender.isDestroyed()) event.sender.send('query-chunk', { queryId, chunk }); else { console.log(`[QUERY] Sender destroyed, killing process queryId=${queryId}`); proc.kill(); activeQueryProcs.delete(queryId); } } catch (e) { console.log(`[QUERY] Chunk decode error: ${e.message}`); } } else if (line === 'CHAT_STREAM_COMPLETE' || line === 'STREAM_COMPLETE') { console.log(`[QUERY] STREAM_COMPLETE received, ${chunkCount} chunks sent`); if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true }); else console.log(`[QUERY] Sender destroyed at STREAM_COMPLETE`); } else if (line.startsWith('CHAT_STREAM_ERROR:') || line.startsWith('STREAM_ERROR:')) { const errMsg = line.startsWith('CHAT_STREAM_ERROR:') ? line.slice(18) : line.slice(13); console.log(`[QUERY] STREAM_ERROR: ${errMsg}`); if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: false, error: errMsg }); } } }); proc.stderr.on('data', (data) => { const msg = data.toString().trim(); if (msg) console.log(`[QUERY stderr] ${msg.substring(0, 200)}`); }); proc.on('close', (code) => { activeQueryProcs.delete(queryId); if (!event.sender.isDestroyed()) { event.sender.removeListener('destroyed', onSenderDestroyed); } console.log(`[QUERY] Process closed, code=${code}, chunks=${chunkCount}, bufRemainder=${buf.length > 0 ? JSON.stringify(buf.substring(0, 100)) : 'empty'}`); if (buf.trim() === 'CHAT_STREAM_COMPLETE' || buf.trim() === 'STREAM_COMPLETE') { console.log(`[QUERY] STREAM_COMPLETE was in buf remainder — sending done now`); if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true }); } else if (code !== 0 && code !== null && !event.sender.isDestroyed()) { // code === null means killed (cancelled) — renderer already handles that case event.sender.send('query-done', { queryId, success: false, error: `Process exited with code ${code}` }); } }); proc.on('error', (err) => { activeQueryProcs.delete(queryId); if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: false, error: err.message }); }); }); // Cross-note chat (Chat tab). Same wire protocol as query-transcript-stream // (CHAT_CHUNK / CHAT_STREAM_COMPLETE / CHAT_STREAM_ERROR -> query-chunk / // query-done) so the renderer can reuse useStreamingQuery. Cloud-only — // the Python CLI rejects local providers because we don't have retrieval // yet and a full-corpus prompt blows local context windows. ipcMain.on('chat-global-stream', (event, queryId, question, folderId) => { sendDebugLog(`💬 Global chat query: ${String(question || '').slice(0, 80)}... (folder: ${folderId || 'all'})`); const cloudKey = loadCloudApiKey(); const env = cloudKey ? { ...process.env, STENOAI_CLOUD_API_KEY: cloudKey } : process.env; const args = ['chat-global-streaming', '-q', question]; if (folderId && typeof folderId === 'string' && folderId !== 'all') { args.push('-f', folderId); } let proc; try { proc = require('child_process').spawn( getBackendPath(), args, { env, cwd: getBackendCwd() }, ); } catch (err) { event.sender.send('query-done', { queryId, success: false, error: err.message }); return; } activeQueryProcs.set(queryId, proc); const onSenderDestroyed = () => { if (activeQueryProcs.has(queryId)) { proc.kill(); activeQueryProcs.delete(queryId); } }; event.sender.once('destroyed', onSenderDestroyed); let buf = ''; let chunkCount = 0; proc.stdout.on('data', (data) => { buf += data.toString(); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (line.startsWith('CHAT_CHUNK:')) { try { const chunk = Buffer.from(line.slice(11), 'base64').toString('utf-8'); chunkCount++; if (!event.sender.isDestroyed()) { event.sender.send('query-chunk', { queryId, chunk }); } else { proc.kill(); activeQueryProcs.delete(queryId); } } catch (e) { /* ignore decode errors */ } } else if (line === 'CHAT_STREAM_COMPLETE') { if (!event.sender.isDestroyed()) { event.sender.send('query-done', { queryId, success: true }); } } else if (line.startsWith('CHAT_STREAM_ERROR:')) { const errMsg = line.slice(18); if (!event.sender.isDestroyed()) { event.sender.send('query-done', { queryId, success: false, error: errMsg }); } } } }); proc.stderr.on('data', (data) => { const msg = data.toString().trim(); if (msg) sendDebugLog(`[chat-global stderr] ${msg.slice(0, 200)}`); }); proc.on('close', (code) => { activeQueryProcs.delete(queryId); if (!event.sender.isDestroyed()) { event.sender.removeListener('destroyed', onSenderDestroyed); } if (buf.trim() === 'CHAT_STREAM_COMPLETE') { if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true }); } else if (code !== 0 && code !== null && !event.sender.isDestroyed()) { event.sender.send('query-done', { queryId, success: false, error: `Process exited with code ${code}` }); } }); proc.on('error', (err) => { activeQueryProcs.delete(queryId); if (!event.sender.isDestroyed()) { event.sender.send('query-done', { queryId, success: false, error: err.message }); } }); }); // Chat sessions persistence. // // The legacy renderer reads/writes `chat_sessions.json` as a flat array. // The new renderer uses an enriched `{ sessions: [...] }` shape. To avoid // silently breaking the legacy UI when a user toggles between renderers, we // store the new shape in a separate file (`chat_sessions_v2.json`) and never // modify the legacy file. On first load, if v2 is absent we read the legacy // file once for migration; subsequent saves only touch v2. // // Writes use tmp+rename to keep the file atomic across crashes / power loss // (a truncated chat_sessions file is hard to recover and would lose all // chat history on next launch). const CHAT_SESSIONS_V2_FILENAME = 'chat_sessions_v2.json'; const CHAT_SESSIONS_LEGACY_FILENAME = 'chat_sessions.json'; function chatSessionsV2Path() { return path.join(app.getPath('userData'), CHAT_SESSIONS_V2_FILENAME); } function chatSessionsLegacyPath() { return path.join(app.getPath('userData'), CHAT_SESSIONS_LEGACY_FILENAME); } ipcMain.handle('save-chat-sessions', async (event, data) => { const filePath = chatSessionsV2Path(); const tmpPath = `${filePath}.tmp`; try { fs.writeFileSync(tmpPath, JSON.stringify(data), 'utf-8'); fs.renameSync(tmpPath, filePath); return { success: true }; } catch (err) { try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch (_) {} return { success: false, error: err.message }; } }); ipcMain.handle('load-chat-sessions', async () => { const v2Path = chatSessionsV2Path(); // Prefer v2 file when present if (fs.existsSync(v2Path)) { try { const raw = fs.readFileSync(v2Path, 'utf-8'); return { success: true, data: JSON.parse(raw) }; } catch (err) { // Corrupt v2 file — quarantine it so we don't keep failing on every load, // then fall through to legacy migration / empty state. const corruptPath = `${v2Path}.corrupt-${Date.now()}`; try { fs.renameSync(v2Path, corruptPath); } catch (_) {} console.error(`[chat-sessions] v2 file unreadable, quarantined to ${corruptPath}:`, err.message); } } // First run on the new renderer: try to migrate from the legacy file. // Legacy file is read but never modified, so legacy renderer remains intact. const legacyPath = chatSessionsLegacyPath(); if (fs.existsSync(legacyPath)) { try { const raw = fs.readFileSync(legacyPath, 'utf-8'); return { success: true, data: JSON.parse(raw), migratedFromLegacy: true }; } catch (err) { console.error('[chat-sessions] legacy file unreadable:', err.message); } } return { success: true, data: null }; }); ipcMain.handle('save-meeting-notes', async (event, sessionName, notes) => { try { const outputDir = path.join(getBackendCwd(), '_internal', 'output'); if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); const safeName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_'); const notesFile = path.join(outputDir, `${safeName}_notes.txt`); fs.writeFileSync(notesFile, notes, 'utf-8'); return { success: true, path: notesFile }; } catch (error) { console.error('Failed to save meeting notes:', error); return { success: false, error: error.message }; } }); ipcMain.handle('update-meeting', async (event, summaryFilePath, updates) => { try { const projectRoot = path.join(__dirname, '..'); // Define allowed base directories for file operations (includes custom storage) const allowedBaseDirs = getAllowedBaseDirs(); // Convert to absolute path if needed const absolutePath = path.isAbsolute(summaryFilePath) ? summaryFilePath : path.join(projectRoot, summaryFilePath); // Security: Validate file path is within allowed directories if (!validateSafeFilePath(absolutePath, allowedBaseDirs)) { console.error(`Security: Blocked attempt to update file outside allowed directories: ${absolutePath}`); return { success: false, error: 'Invalid file path' }; } // Read existing data if (!fs.existsSync(absolutePath)) { return { success: false, error: 'Meeting file not found' }; } const isMarkdown = absolutePath.endsWith('.md'); let data; if (isMarkdown) { const raw = fs.readFileSync(absolutePath, 'utf8'); // Escape a string for a YAML double-quoted scalar. Backslash MUST be // escaped before the quote, and embedded newlines must become literal // \n so they don't end the scalar mid-line. const yamlQuote = (s) => '"' + String(s) .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '') + '"'; // Strip the outer quotes only — the simple frontmatter we read here is // for the response shape (data.session_info.name) and doesn't need to // reverse YAML escapes for its sole consumer (the renderer). const readTitle = (rawValue) => rawValue.trim().replace(/^"|"$/g, ''); // Line-based rewrite: only mutate the keys we're updating, leave every // other line (including non-string values like arrays/booleans) byte- // identical so we don't corrupt structured fields like `folders: [...]`. let title = ''; let updatedAt = new Date().toISOString(); let body = raw; let updatedRaw = raw; if (raw.startsWith('---')) { const parts = raw.split('---', 3); if (parts.length >= 3) { const fmText = parts[1]; body = parts[2]; const lines = fmText.split('\n'); let titleSeen = false; let updatedAtSeen = false; const newLines = lines.map((line) => { const colon = line.indexOf(':'); if (colon === -1) return line; const key = line.slice(0, colon).trim(); if (key === 'title') { titleSeen = true; const original = line.slice(colon + 1); if (updates.name !== undefined) { return `title: ${yamlQuote(updates.name)}`; } title = readTitle(original); return line; } if (key === 'updated_at') { updatedAtSeen = true; return `updated_at: ${yamlQuote(updatedAt)}`; } return line; }); if (!titleSeen && updates.name !== undefined) { // Insert before the trailing blank line (if any) for readability. const insertIdx = newLines[newLines.length - 1] === '' ? newLines.length - 1 : newLines.length; newLines.splice(insertIdx, 0, `title: ${yamlQuote(updates.name)}`); title = updates.name; } else if (updates.name !== undefined) { title = updates.name; } if (!updatedAtSeen) { const insertIdx = newLines[newLines.length - 1] === '' ? newLines.length - 1 : newLines.length; newLines.splice(insertIdx, 0, `updated_at: ${yamlQuote(updatedAt)}`); } updatedRaw = `---${newLines.join('\n')}---${body}`; } } fs.writeFileSync(absolutePath, updatedRaw, 'utf8'); data = { session_info: { name: updates.name !== undefined ? updates.name : title, summary_file: absolutePath, updated_at: updatedAt, }, }; } else { data = JSON.parse(fs.readFileSync(absolutePath, 'utf8')); if (updates.name !== undefined) { data.session_info.name = updates.name; } if (updates.summary !== undefined) { data.summary = updates.summary; } if (updates.participants !== undefined) { data.participants = updates.participants; } if (updates.key_points !== undefined) { data.key_points = updates.key_points; } if (updates.action_items !== undefined) { data.action_items = updates.action_items; } data.session_info.updated_at = new Date().toISOString(); fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf8'); } console.log(`Updated meeting: ${absolutePath}`); return { success: true, message: 'Meeting updated successfully' }; } catch (error) { console.error('Update meeting error:', error); return { success: false, error: error.message }; } }); ipcMain.handle('reveal-meeting-folder', async (event, filePath) => { try { const projectRoot = path.join(__dirname, '..'); const allowedBaseDirs = getAllowedBaseDirs(); const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath); if (!validateSafeFilePath(absolutePath, allowedBaseDirs)) { return { success: false, error: 'Invalid file path: outside allowed directories' }; } shell.showItemInFolder(absolutePath); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('delete-meeting', async (event, meetingData) => { try { const fs = require('fs'); const path = require('path'); // meetingData is the actual meeting object, not a file path const meeting = meetingData; // Build correct file paths from the meeting data - convert to absolute paths const projectRoot = path.join(__dirname, '..'); // Define allowed base directories for file operations (includes custom storage) const allowedBaseDirs = getAllowedBaseDirs(); const summaryFile = meeting.session_info?.summary_file; const transcriptFile = meeting.session_info?.transcript_file; const audioFile = meeting.session_info?.audio_file; const sessionName = meeting.session_info?.name; // Convert relative paths to absolute paths const absolutePaths = []; if (summaryFile) { absolutePaths.push(path.isAbsolute(summaryFile) ? summaryFile : path.join(projectRoot, summaryFile)); } if (transcriptFile) { absolutePaths.push(path.isAbsolute(transcriptFile) ? transcriptFile : path.join(projectRoot, transcriptFile)); } if (audioFile) { absolutePaths.push(path.isAbsolute(audioFile) ? audioFile : path.join(projectRoot, audioFile)); } if (summaryFile && sessionName) { const outputDir = path.dirname(path.isAbsolute(summaryFile) ? summaryFile : path.join(projectRoot, summaryFile)); const safeName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_'); absolutePaths.push(path.join(outputDir, `${safeName}_notes.txt`)); } console.log('Attempting to delete files:', absolutePaths); let deletedCount = 0; let validationErrors = 0; // Delete all related files with path validation for (const file of absolutePaths) { try { // Security: Validate file path is within allowed directories if (!validateSafeFilePath(file, allowedBaseDirs)) { console.error(`Security: Blocked attempt to delete file outside allowed directories: ${file}`); validationErrors++; continue; } if (fs.existsSync(file)) { fs.unlinkSync(file); deletedCount++; console.log(`Deleted: ${file}`); } else { console.log(`File not found (already deleted?): ${file}`); } } catch (err) { console.warn(`Could not delete ${file}:`, err.message); } } if (validationErrors > 0) { return { success: false, error: `Blocked ${validationErrors} file deletion(s) due to security validation` }; } return { success: true, message: `Deleted meeting and ${deletedCount} associated files` }; } catch (error) { console.error('Delete meeting error:', error); return { success: false, error: error.message }; } }); // Queue status handler ipcMain.handle('get-queue-status', async () => { return { success: true, isProcessing, queueSize: processingQueue.length, currentJob: currentProcessingJob?.sessionName || null, hasRecording: currentRecordingProcess !== null || systemAudioRecordingActive, isPaused: currentRecordingProcess !== null && recordingRuntimeState.isPaused, elapsedSeconds: currentRecordingProcess !== null ? getRecordingElapsedSeconds() : 0, sessionName: currentRecordingSessionName }; }); // Global recording state management let systemAudioRecordingActive = false; // Track system audio recording for tray/quit let currentRecordingProcess = null; let currentRecordingSessionName = null; // Surfaced in get-queue-status so renderer knows which meeting is live let processingQueue = []; let isProcessing = false; let currentProcessingJob = null; let recordingRuntimeState = { startedAtMs: null, pausedAtMs: null, pausedTotalMs: 0, isPaused: false }; let ollamaProcess = null; // Track spawned Ollama process for cleanup on quit let ollamaPid = null; // Store PID separately since unref() disconnects the process let ollamaStartedByUs = false; function resetRecordingRuntimeState() { recordingRuntimeState = { startedAtMs: null, pausedAtMs: null, pausedTotalMs: 0, isPaused: false }; } function startRecordingRuntimeState() { recordingRuntimeState = { startedAtMs: Date.now(), pausedAtMs: null, pausedTotalMs: 0, isPaused: false }; } function markRecordingPaused() { if (!recordingRuntimeState.startedAtMs || recordingRuntimeState.isPaused) { return; } recordingRuntimeState.isPaused = true; recordingRuntimeState.pausedAtMs = Date.now(); } function markRecordingResumed() { if (!recordingRuntimeState.isPaused) { return; } if (recordingRuntimeState.pausedAtMs) { recordingRuntimeState.pausedTotalMs += Date.now() - recordingRuntimeState.pausedAtMs; } recordingRuntimeState.isPaused = false; recordingRuntimeState.pausedAtMs = null; } function getRecordingElapsedSeconds() { if (!recordingRuntimeState.startedAtMs) { return 0; } let pausedMs = recordingRuntimeState.pausedTotalMs; if (recordingRuntimeState.isPaused && recordingRuntimeState.pausedAtMs) { pausedMs += Date.now() - recordingRuntimeState.pausedAtMs; } return Math.max( 0, Math.floor((Date.now() - recordingRuntimeState.startedAtMs - pausedMs) / 1000) ); } // Processing queue management async function processNextInQueue() { if (isProcessing || processingQueue.length === 0) { return; } isProcessing = true; currentProcessingJob = processingQueue.shift(); console.log(`🔄 Processing queued job: ${currentProcessingJob.sessionName}`); try { const queueCloudKey = loadCloudApiKey(); const queueEnv = queueCloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: queueCloudKey } : undefined; const processArgs = ['process-streaming', currentProcessingJob.audioFile, '--name', currentProcessingJob.sessionName]; if (currentProcessingJob.notesFile && fs.existsSync(currentProcessingJob.notesFile)) { processArgs.push('--notes', currentProcessingJob.notesFile); } await new Promise((resolve, reject) => { const proc = spawn(getBackendPath(), processArgs, { cwd: getBackendCwd(), env: queueEnv }); let stderrBuf = ''; // Timeout: kill process if it runs longer than 30 minutes const procTimeout = setTimeout(() => { console.error('process-streaming timed out after 30 minutes, killing'); proc.kill(); }, 30 * 60 * 1000); proc.on('error', (err) => { clearTimeout(procTimeout); reject(new Error(`process-streaming spawn error: ${err.message}`)); }); proc.stdout.on('data', (data) => { const text = data.toString(); // Parse protocol lines text.split('\n').forEach(line => { if (line.startsWith('CHUNK:')) { try { const encoded = line.slice(6); const chunk = Buffer.from(encoded, 'base64').toString('utf-8'); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-chunk', { chunk, sessionName: currentProcessingJob.sessionName }); } } catch (e) { console.log('CHUNK decode error:', e.message); } } else if (line.startsWith('TRANSCRIPTION_COMPLETE:')) { sendDebugLog(`Transcription complete (${line.split(':')[1]} chars)`); trackEvent('transcription_completed', { success: true }); } else if (line.startsWith('TITLE:')) { const title = line.slice(6); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-title', { title, sessionName: currentProcessingJob.sessionName }); } } else if (line === 'STREAM_COMPLETE') { trackEvent('summarization_completed', { success: true }); } else if (line.startsWith('SAVED:')) { sendDebugLog(`Summary saved: ${line.slice(6)}`); } else if (line.trim()) { sendDebugLog(line.trim()); } }); }); proc.stderr.on('data', (data) => { const msg = data.toString().trim(); if (msg) { stderrBuf += msg + '\n'; sendDebugLog(`STDERR: ${msg}`); } }); proc.on('close', (code) => { clearTimeout(procTimeout); if (code === 0) { console.log(`✅ Completed streaming processing: ${currentProcessingJob.sessionName}`); // Notify frontend that streaming is done and meeting is saved if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-complete', { success: true, sessionName: currentProcessingJob.sessionName }); // Also send processing-complete for backward compat (reloads meeting list) mainWindow.webContents.send('processing-complete', { success: true, sessionName: currentProcessingJob.sessionName, message: 'Processing completed successfully' }); } resolve(); } else { reject(new Error(`process-streaming exited with code ${code}: ${stderrBuf.slice(-500)}`)); } }); }); } catch (error) { console.error(`❌ Processing failed for ${currentProcessingJob.sessionName}:`, error); trackEvent('error_occurred', { error_type: 'processing_queue' }); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('processing-complete', { success: false, sessionName: currentProcessingJob.sessionName, error: error.message }); } } finally { isProcessing = false; currentProcessingJob = null; // Process next job in queue setTimeout(processNextInQueue, 1000); } } function addToProcessingQueue(audioFile, sessionName, notesFile) { processingQueue.push({ audioFile, sessionName, notesFile }); console.log(`📋 Added to processing queue: ${sessionName} (Queue size: ${processingQueue.length})`); processNextInQueue(); } ipcMain.handle('start-recording-ui', async (_, sessionName) => { try { if (currentRecordingProcess) { return { success: false, error: 'Recording already in progress' }; } // Start recording (removed clear-state to prevent race conditions) console.log('Starting long recording process...'); sendDebugLog(`Starting recording process: ${sessionName || 'Meeting'}`); sendDebugLog('$ stenoai record 7200'); const actualSessionName = sessionName || 'Meeting'; // Start background recording with 2-hour limit // Pass cloud API key via env var for cloud summarization const recordEnv = {}; const cloudKey = loadCloudApiKey(); if (cloudKey) recordEnv.STENOAI_CLOUD_API_KEY = cloudKey; currentRecordingProcess = spawn(getBackendPath(), ['record', '7200', actualSessionName], { cwd: getBackendCwd(), env: Object.keys(recordEnv).length > 0 ? { ...require('process').env, ...recordEnv } : undefined }); currentRecordingSessionName = actualSessionName; startRecordingRuntimeState(); let hasStarted = false; let processingSucceeded = false; let recordedAudioFile = null; // Authoritative pointer to the final summary file once Python finishes // auto-renaming + writing it (emitted as `SAVED:`). Use this in // preference to the name/audio fallbacks since it can't drift. let savedSummaryFile = null; currentRecordingProcess.stdout.on('data', (data) => { const output = data.toString(); // Capture the audio file path when the recording is saved const audioMatch = output.match(/Recording saved:\s*(.+\.wav)/); if (audioMatch) { recordedAudioFile = audioMatch[1].trim(); } console.log('Recording stdout:', output); // Parse streaming protocol + send to debug panel output.split('\n').forEach(line => { if (line.startsWith('CHUNK:')) { const encoded = line.slice(6); try { const chunk = Buffer.from(encoded, 'base64').toString('utf-8'); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-chunk', { chunk, sessionName: actualSessionName }); } } catch (e) { /* ignore decode errors */ } } else if (line.startsWith('TITLE:')) { const title = line.slice(6); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-title', { title, sessionName: actualSessionName }); } } else if (line === 'STREAM_COMPLETE') { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('summary-complete', { success: true, sessionName: actualSessionName }); } } else if (line.startsWith('SAVED:')) { savedSummaryFile = line.slice(6).trim(); } else if (line.trim()) { sendDebugLog(line.trim()); } }); // Background recording process handles complete pipeline - just notify when done if (output.includes('✅ Complete processing finished!')) { processingSucceeded = true; console.log(`🎉 Recording and processing completed for: ${actualSessionName}`); // Notify frontend that everything is done if (mainWindow) { // Get the processed meeting data to send to frontend runPythonScript('simple_recorder.py', ['list-meetings'], true) .then(meetingsResult => { const allMeetings = JSON.parse(meetingsResult); // Prefer the SAVED: pointer Python emits — that's the // exact summary file written this session and survives the // auto-rename. Fall back to name match (only if user kept the // placeholder), then to audio-file basename. let processedMeeting = null; if (savedSummaryFile) { processedMeeting = allMeetings.find( m => m.session_info?.summary_file === savedSummaryFile, ); } if (!processedMeeting) { processedMeeting = allMeetings.find(m => m.session_info?.name === actualSessionName); } if (!processedMeeting && recordedAudioFile) { const audioBasename = path.basename(recordedAudioFile); processedMeeting = allMeetings.find(m => m.session_info?.audio_file && path.basename(m.session_info.audio_file) === audioBasename ); } mainWindow.webContents.send('processing-complete', { success: true, sessionName: actualSessionName, message: 'Recording and processing completed successfully', meetingData: processedMeeting }); }) .catch(error => { console.error('Error getting processed meeting data:', error); // Fallback - send without meetingData, frontend will refresh mainWindow.webContents.send('processing-complete', { success: true, sessionName: actualSessionName, message: 'Recording and processing completed successfully' }); }); } } // Detect explicit processing failure from backend if (output.includes('❌ Processing pipeline failed')) { processingSucceeded = true; // Prevent duplicate notification from close handler console.error(`Processing failed for: ${actualSessionName}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('processing-complete', { success: false, sessionName: actualSessionName, message: 'Processing failed: summarization error (check that Ollama and a model are available)' }); } } // Don't queue background recordings for additional processing - they handle it themselves! if (output.includes('Recording to:') && !hasStarted) { hasStarted = true; } }); currentRecordingProcess.stderr.on('data', (data) => { const output = data.toString(); console.log('Recording stderr:', output); // Send real-time stderr to debug panel (same as runPythonScript) output.split('\n').forEach(line => { if (line.trim()) sendDebugLog('STDERR: ' + line.trim()); }); }); currentRecordingProcess.on('close', (code) => { console.log(`Recording process closed with code ${code}`); sendDebugLog(`Recording process completed with exit code: ${code}`); currentRecordingProcess = null; currentRecordingSessionName = null; resetRecordingRuntimeState(); updateTrayIcon(false); // If process exited without a success or failure message, notify the user if (!processingSucceeded && hasStarted && mainWindow && !mainWindow.isDestroyed()) { console.error(`Recording process exited (code ${code}) without completing processing`); mainWindow.webContents.send('processing-complete', { success: false, sessionName: actualSessionName, message: `Processing failed unexpectedly (exit code ${code})` }); } }); // Give it time to start await new Promise(resolve => setTimeout(resolve, 2000)); if (currentRecordingProcess) { trackEvent('recording_started'); updateTrayIcon(true); return { success: true, message: 'Recording started successfully' }; } else { return { success: false, error: 'Failed to start recording process' }; } } catch (error) { console.error('Start recording UI error:', error.message); currentRecordingProcess = null; currentRecordingSessionName = null; resetRecordingRuntimeState(); updateTrayIcon(false); trackEvent('error_occurred', { error_type: 'start_recording_ui' }); return { success: false, error: error.message }; } }); ipcMain.handle('pause-recording-ui', async () => { try { if (!currentRecordingProcess) { sendDebugLog('Pause failed: No recording process found'); return { success: false, error: 'No recording in progress' }; } console.log('Pausing recording process...'); sendDebugLog('Sending SIGUSR1 to pause recording...'); // Send SIGUSR1 to pause recording (Unix only) if (process.platform !== 'win32') { currentRecordingProcess.kill('SIGUSR1'); markRecordingPaused(); sendDebugLog('SIGUSR1 sent successfully'); return { success: true, message: 'Recording paused' }; } else { return { success: false, error: 'Pause not supported on Windows' }; } } catch (error) { console.error('Pause recording UI error:', error.message); sendDebugLog(`Pause error: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('resume-recording-ui', async () => { try { if (!currentRecordingProcess) { sendDebugLog('Resume failed: No recording process found'); return { success: false, error: 'No recording in progress' }; } console.log('Resuming recording process...'); sendDebugLog('Sending SIGUSR2 to resume recording...'); // Send SIGUSR2 to resume recording (Unix only) if (process.platform !== 'win32') { currentRecordingProcess.kill('SIGUSR2'); markRecordingResumed(); sendDebugLog('SIGUSR2 sent successfully'); return { success: true, message: 'Recording resumed' }; } else { return { success: false, error: 'Resume not supported on Windows' }; } } catch (error) { console.error('Resume recording UI error:', error.message); sendDebugLog(`Resume error: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('stop-recording-ui', async () => { try { if (!currentRecordingProcess) { return { success: false, error: 'No recording in progress' }; } console.log('Stopping recording process...'); // Send SIGTERM to trigger graceful stop and processing currentRecordingProcess.kill('SIGTERM'); // Don't wait - let the process complete independently // The process will handle: stop recording → transcribe → summarize → exit currentRecordingProcess = null; currentRecordingSessionName = null; resetRecordingRuntimeState(); updateTrayIcon(false); trackEvent('recording_stopped'); return { success: true, message: 'Recording stopped - processing will complete in background' }; } catch (error) { console.error('Stop recording UI error:', error.message); currentRecordingProcess = null; currentRecordingSessionName = null; resetRecordingRuntimeState(); updateTrayIcon(false); trackEvent('error_occurred', { error_type: 'stop_recording_ui' }); return { success: false, error: error.message }; } }); // Setup IPC handlers ipcMain.handle('startup-setup-check', async () => { try { console.log('Running startup setup check...'); // Use Python backend to check setup const result = await runPythonScript('simple_recorder.py', ['setup-check']); console.log('Setup check result:', result); // Parse the output to determine if setup is complete const allGood = result.includes('🎉 System check passed!'); // Extract check results for UI display const lines = result.split('\n'); const checks = []; lines.forEach(line => { if (line.includes('✅') || line.includes('❌') || line.includes('⚠️')) { const parts = line.split(/\s{2,}/); // Split on multiple spaces if (parts.length >= 2) { checks.push([parts[0].trim(), parts[1].trim()]); } } }); console.log('Parsed checks:', checks); console.log('All good:', allGood); return { success: true, allGood, checks }; } catch (error) { console.error('Setup check error:', error); return { success: false, error: error.message }; } }); ipcMain.handle('setup-system-check', async () => { try { // Check Python installation const pythonResult = await new Promise((resolve) => { exec('python3 --version', (error, stdout, stderr) => { if (error) { resolve(false); } else { resolve(true); } }); }); if (!pythonResult) { return { success: false, error: 'Python 3 not found. Please install Python 3.8+' }; } // Create required directories - match Python logic for DMG vs development const os = require('os'); const currentPath = __dirname; let baseDir; // Detect if running from app bundle (DMG install) or development if (currentPath.includes('StenoAI.app') || currentPath.includes('Applications')) { // DMG/Production: Use Application Support folder baseDir = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai'); } else { // Development: Use project relative paths baseDir = path.join(__dirname, '..'); } const dirs = ['recordings', 'transcripts', 'output']; for (const dir of dirs) { const dirPath = path.join(baseDir, dir); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } // Create venv directory if it doesn't exist const projectRoot = path.join(__dirname, '..'); const venvPath = path.join(projectRoot, 'venv'); if (!fs.existsSync(venvPath)) { await new Promise((resolve, reject) => { const process = spawn('python3', ['-m', 'venv', 'venv'], { cwd: projectRoot }); process.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error('Failed to create virtual environment')); } }); process.on('error', reject); }); } trackEvent('setup_completed', { step: 'system_check' }); return { success: true, message: 'System setup complete - Python and directories ready' }; } catch (error) { trackEvent('setup_failed', { step: 'system_check' }); return { success: false, error: error.message }; } }); ipcMain.handle('setup-ffmpeg', async () => { try { sendDebugLog('$ Checking for existing ffmpeg installation...'); // Check bundled ffmpeg first (shipped with the app), then system paths const bundledFfmpeg = app.isPackaged ? path.join(process.resourcesPath, 'stenoai', 'ffmpeg') : path.join(__dirname, '..', 'dist', 'stenoai', 'ffmpeg'); const ffmpegPaths = [bundledFfmpeg, 'ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']; sendDebugLog(`$ Checking: ${ffmpegPaths.join(', ')}`); let ffmpegPath = null; for (const testPath of ffmpegPaths) { try { const found = await new Promise((resolve) => { const proc = spawn(testPath, ['-version'], { timeout: 5000 }); proc.on('error', () => resolve(false)); proc.on('close', (code) => resolve(code === 0)); }); if (found) { ffmpegPath = testPath; sendDebugLog(`Found ffmpeg at: ${testPath}`); break; } } catch (error) { // Try next path continue; } } if (!ffmpegPath) { sendDebugLog('ffmpeg not found in any common locations'); } // Install ffmpeg if not present if (!ffmpegPath) { sendDebugLog('ffmpeg not found, checking for Homebrew...'); sendDebugLog('$ Checking: brew, /opt/homebrew/bin/brew, /usr/local/bin/brew'); // First check if Homebrew is installed and get its path const brewPaths = ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew']; let brewPath = null; for (const testPath of brewPaths) { try { const found = await new Promise((resolve) => { const proc = spawn(testPath, ['--version'], { timeout: 5000 }); proc.on('error', () => resolve(false)); proc.on('close', (code) => resolve(code === 0)); }); if (found) { brewPath = testPath; sendDebugLog(`Found Homebrew at: ${testPath}`); break; } } catch (error) { // Try next path continue; } } if (!brewPath) { sendDebugLog('Homebrew not found in any common locations'); } // Install Homebrew if missing if (!brewPath) { sendDebugLog('Homebrew not found, installing...'); sendDebugLog('$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'); // Note: This uses the official Homebrew installation script // Using exec here is intentional as this is the documented installation method // The URL is hardcoded and not user-controlled await new Promise((resolve, reject) => { const process = exec('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', { timeout: 600000 }); process.stdout.on('data', (data) => { sendDebugLog(data.toString().trim()); }); process.stderr.on('data', (data) => { sendDebugLog('STDERR: ' + data.toString().trim()); }); process.on('close', (code) => { if (code === 0) { sendDebugLog('Homebrew installation completed successfully'); resolve(); } else { sendDebugLog(`Homebrew installation failed with exit code: ${code}`); reject(new Error('Failed to install Homebrew automatically')); } }); }); // After installing, set brewPath to the default location brewPath = '/opt/homebrew/bin/brew'; } else { sendDebugLog('Homebrew found, proceeding with ffmpeg installation...'); } // Now install ffmpeg via Homebrew using spawn for security sendDebugLog(`$ ${brewPath} install ffmpeg`); await new Promise((resolve, reject) => { const process = spawn(brewPath, ['install', 'ffmpeg'], { timeout: 300000 }); process.stdout.on('data', (data) => { sendDebugLog(data.toString().trim()); }); process.stderr.on('data', (data) => { sendDebugLog('STDERR: ' + data.toString().trim()); }); process.on('close', (code) => { if (code === 0) { sendDebugLog('ffmpeg installation completed successfully'); resolve(); } else { sendDebugLog(`ffmpeg installation failed with exit code: ${code}`); reject(new Error('Failed to install ffmpeg via Homebrew')); } }); process.on('error', (error) => { sendDebugLog(`ffmpeg installation error: ${error.message}`); reject(error); }); }); } else { sendDebugLog('ffmpeg already installed, skipping installation'); } return { success: true, message: 'ffmpeg ready' }; } catch (error) { sendDebugLog(`ffmpeg setup failed: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('setup-python', async () => { try { // Python backend is bundled via PyInstaller - no setup needed sendDebugLog('Python backend is bundled, skipping setup'); return { success: true, message: 'Python backend bundled' }; // Legacy code below - kept for reference but never runs const projectRoot = path.join(__dirname, '..'); const venvPath = path.join(projectRoot, 'venv'); sendDebugLog(`Working directory: ${projectRoot}`); // Create virtual environment if it doesn't exist if (!fs.existsSync(venvPath)) { sendDebugLog('Python virtual environment not found, creating...'); sendDebugLog('$ python3 -m venv venv'); await new Promise((resolve, reject) => { const process = spawn('python3', ['-m', 'venv', 'venv'], { cwd: projectRoot, stdio: 'pipe' }); process.stdout.on('data', (data) => { sendDebugLog(data.toString().trim()); }); process.stderr.on('data', (data) => { sendDebugLog('STDERR: ' + data.toString().trim()); }); process.on('close', (code) => { if (code === 0) { sendDebugLog('Virtual environment created successfully'); resolve(); } else { sendDebugLog(`Virtual environment creation failed with exit code: ${code}`); reject(new Error('Failed to create virtual environment')); } }); process.on('error', (error) => { sendDebugLog(`Process error: ${error.message}`); reject(error); }); }); } else { sendDebugLog('Python virtual environment already exists'); } // Install requirements including Whisper sendDebugLog('Installing Python dependencies...'); sendDebugLog('$ pip install -r requirements.txt openai-whisper'); return new Promise((resolve) => { const pythonPath = path.join(venvPath, 'bin', 'python'); const process = spawn(pythonPath, ['-m', 'pip', 'install', '-r', 'requirements.txt', 'openai-whisper'], { cwd: projectRoot, stdio: 'pipe' }); let output = ''; process.stdout.on('data', (data) => { const text = data.toString().trim(); if (text) { sendDebugLog(text); output += text; } }); process.stderr.on('data', (data) => { const text = data.toString().trim(); if (text) { sendDebugLog('STDERR: ' + text); output += text; } }); process.on('close', (code) => { if (code === 0) { sendDebugLog('Python dependencies installation completed successfully'); trackEvent('setup_completed', { step: 'python_dependencies' }); resolve({ success: true, message: 'Python dependencies and Whisper installed' }); } else { sendDebugLog(`Python dependencies installation failed with exit code: ${code}`); trackEvent('setup_failed', { step: 'python_dependencies' }); resolve({ success: false, error: `Installation failed: ${output}` }); } }); process.on('error', (error) => { resolve({ success: false, error: `Process error: ${error.message}` }); }); }); } catch (error) { return { success: false, error: error.message }; } }); // ── Auto-updater ── function setupAutoUpdater() { if (IS_E2E) { sendDebugLog('Auto-updater: skipped (E2E mode)'); return; } // Don't check for updates in dev mode if (!app.isPackaged) { sendDebugLog('Auto-updater: skipped (dev mode)'); return; } autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on('checking-for-update', () => { sendDebugLog('Auto-updater: checking for updates...'); }); autoUpdater.on('update-available', (info) => { sendDebugLog(`Auto-updater: update available (v${info.version})`); if (mainWindow) { mainWindow.webContents.send('update-available', { version: info.version }); } }); autoUpdater.on('update-not-available', () => { sendDebugLog('Auto-updater: up to date'); }); autoUpdater.on('download-progress', (progress) => { sendDebugLog(`Auto-updater: downloading ${Math.round(progress.percent)}%`); if (mainWindow) { mainWindow.webContents.send('update-download-progress', { percent: Math.round(progress.percent) }); } }); autoUpdater.on('update-downloaded', (info) => { sendDebugLog(`Auto-updater: v${info.version} ready to install`); if (mainWindow) { mainWindow.webContents.send('update-downloaded', { version: info.version }); } }); autoUpdater.on('error', (err) => { sendDebugLog(`Auto-updater error: ${err.message}`); }); // Check on launch (after a short delay to not block startup) setTimeout(() => { autoUpdater.checkForUpdates().catch(() => {}); }, 10000); // Re-check every 30 minutes setInterval(() => { autoUpdater.checkForUpdates().catch(() => {}); }, 30 * 60 * 1000); } ipcMain.on('install-update', () => { // Bypass the mainWindow 'close' handler's preventDefault+hide so that // quitAndInstall's window-close step actually quits the app. Without this // the app just minimises and Squirrel never gets to apply the update. isQuitting = true; autoUpdater.quitAndInstall(false, true); }); // Add IPC handler for sending debug logs to frontend function sendDebugLog(message) { // Send to main window (both setup console and debug panel) if (mainWindow) { mainWindow.webContents.send('debug-log', message); } } ipcMain.handle('setup-ollama-and-model', async () => { try { // Check AI provider -- skip local Ollama setup for remote/cloud try { const providerResult = await runPythonScript('simple_recorder.py', ['get-ai-provider'], true); const providerConfig = JSON.parse(providerResult.trim()); if (providerConfig.ai_provider === 'remote' || providerConfig.ai_provider === 'cloud') { sendDebugLog(`AI provider is "${providerConfig.ai_provider}" -- skipping local Ollama setup`); return { success: true, skipped: true }; } } catch (e) { sendDebugLog(`Could not read AI provider, proceeding with local setup: ${e.message}`); } // Check macOS version — bundled Ollama requires macOS 14 (Sonoma) or later const macosRelease = os.release(); // e.g. "23.1.0" for macOS 14.1 const darwinMajor = parseInt(macosRelease.split('.')[0], 10); // Darwin 23 = macOS 14 (Sonoma), Darwin 22 = macOS 13 (Ventura), etc. if (darwinMajor < 23) { const macosVersion = darwinMajor >= 22 ? '13 (Ventura)' : darwinMajor >= 21 ? '12 (Monterey)' : `(Darwin ${darwinMajor})`; sendDebugLog(`macOS ${macosVersion} detected — Ollama requires macOS 14 (Sonoma) or later`); return { success: false, error: 'StenoAI requires macOS 14 (Sonoma) or later for local AI summarization. Please update your macOS or use a remote Ollama server in Settings.' }; } sendDebugLog('Locating bundled Ollama...'); const finalOllamaPath = await findOllamaExecutable(); if (!finalOllamaPath) { sendDebugLog('Error: Bundled Ollama not found'); return { success: false, error: 'Bundled Ollama not found. Please reinstall StenoAI.' }; } sendDebugLog(`Found bundled Ollama at: ${finalOllamaPath}`); // Reuse already-running Ollama if its API is reachable on 11434. // Avoids "address already in use" when the user (or a previous launch) // already has Ollama up. const httpProbe = require('http'); const ollamaAlreadyRunning = await new Promise((resolve) => { const req = httpProbe.get('http://127.0.0.1:11434/api/tags', { timeout: 1500 }, (res) => { resolve(res.statusCode === 200); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); if (ollamaAlreadyRunning) { sendDebugLog('Ollama already running on 127.0.0.1:11434 — reusing existing instance'); } let ollamaExited = false; let ollamaExitCode = null; let ollamaDyldError = false; if (!ollamaAlreadyRunning) { sendDebugLog('Starting Ollama service...'); sendDebugLog(`$ ${finalOllamaPath} serve`); ollamaProcess = spawn(finalOllamaPath, ['serve'], { detached: true, stdio: ['ignore', 'ignore', 'pipe'], env: getOllamaEnv() }); ollamaPid = ollamaProcess.pid; // Write PID file so quit handler can find the process try { require('fs').writeFileSync(path.join(getBackendCwd(), '_internal', 'ollama.pid'), String(ollamaPid)); } catch (_) {} ollamaProcess.stderr.on('data', (data) => { const msg = data.toString().trim(); if (msg) sendDebugLog(`Ollama: ${msg}`); if (msg.includes('Symbol not found') || msg.includes('dyld')) ollamaDyldError = true; }); ollamaProcess.on('exit', (code) => { ollamaExited = true; ollamaExitCode = code; ollamaPid = null; if (code !== 0 && code !== null) { sendDebugLog(`Ollama process exited with code ${code}`); } }); ollamaProcess.unref(); ollamaStartedByUs = true; } // Wait for Ollama to be ready (poll with early exit detection). // When we reused an existing instance, skip the wait — it's already up. sendDebugLog('Waiting for Ollama service to be ready...'); const maxAttempts = ollamaAlreadyRunning ? 1 : 30; let ready = ollamaAlreadyRunning; for (let i = 0; i < maxAttempts && !ready; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); if (ollamaExited) { sendDebugLog(`Ollama process died during startup (exit code: ${ollamaExitCode})`); break; } try { const http = require('http'); ready = await new Promise((resolve) => { const req = http.get('http://127.0.0.1:11434/api/tags', { timeout: 2000 }, (res) => { resolve(res.statusCode === 200); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); if (ready) { sendDebugLog(`Ollama ready after ${i + 1} seconds`); break; } } catch (e) { // Continue polling } } if (!ready) { if (ollamaExited) { if (ollamaDyldError) { return { success: false, error: 'Ollama crashed due to incompatible macOS version. StenoAI requires macOS 14 (Sonoma) or later for local AI. Please update macOS or use a remote Ollama server in Settings.' }; } return { success: false, error: `Ollama failed to start (exit code: ${ollamaExitCode}). Check debug logs for details.` }; } sendDebugLog('Warning: Ollama may not be fully ready, attempting pull anyway...'); } sendDebugLog('Downloading AI model (this may take several minutes)...'); sendDebugLog('POST http://127.0.0.1:11434/api/pull {name: "llama3.2:3b"}'); const http = require('http'); return new Promise((resolve) => { const postData = JSON.stringify({ name: 'llama3.2:3b' }); const req = http.request({ hostname: '127.0.0.1', port: 11434, path: '/api/pull', method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 600000 }, (res) => { let lastStatus = ''; res.on('data', (chunk) => { // Ollama streams newline-delimited JSON const lines = chunk.toString().split('\n').filter(Boolean); for (const line of lines) { try { const json = JSON.parse(line); if (json.error) { sendDebugLog(`Pull error: ${json.error}`); return; } // Log progress without spamming duplicate status const status = json.status || ''; if (json.total && json.completed) { const pct = Math.round((json.completed / json.total) * 100); const msg = `${status} ${pct}%`; if (msg !== lastStatus) { sendDebugLog(msg); lastStatus = msg; } } else if (status !== lastStatus) { sendDebugLog(status); lastStatus = status; } } catch (e) { // Non-JSON line, log as-is sendDebugLog(chunk.toString().trim()); } } }); res.on('end', async () => { if (res.statusCode === 200) { sendDebugLog('AI model download completed successfully'); try { await runPythonScript('simple_recorder.py', ['set-model', 'llama3.2:3b'], true); } catch (e) { // Non-fatal -- config reset is best-effort } trackEvent('setup_completed', { step: 'ollama_and_model' }); resolve({ success: true, message: 'Ollama and AI model ready' }); } else { sendDebugLog(`AI model download failed with status: ${res.statusCode}`); trackEvent('setup_failed', { step: 'ollama_and_model' }); resolve({ success: false, error: 'Failed to download AI model', details: `HTTP ${res.statusCode}` }); } }); }); req.on('error', (error) => { sendDebugLog(`Pull request error: ${error.message}`); resolve({ success: false, error: 'Failed to download AI model', details: error.message }); }); req.on('timeout', () => { req.destroy(); sendDebugLog('Model pull timed out after 10 minutes'); resolve({ success: false, error: 'Model download timed out' }); }); req.write(postData); req.end(); }); } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('setup-whisper', async () => { try { // Download whisper model using the bundled backend const backendPath = getBackendPath(); sendDebugLog('Downloading Whisper transcription model (~500MB)...'); sendDebugLog(`$ ${backendPath} download-whisper-model`); return new Promise((resolve) => { const process = spawn(backendPath, ['download-whisper-model'], { stdio: 'pipe' }); process.stdout.on('data', (data) => { const text = data.toString().trim(); if (text) sendDebugLog(text); }); process.stderr.on('data', (data) => { const text = data.toString().trim(); if (text) sendDebugLog('STDERR: ' + text); }); process.on('close', (code) => { if (code === 0) { sendDebugLog('Whisper model downloaded successfully'); resolve({ success: true, message: 'Whisper model ready' }); } else { sendDebugLog(`Whisper model download failed with exit code: ${code}`); resolve({ success: false, error: 'Failed to download Whisper model' }); } }); process.on('error', (error) => { sendDebugLog(`Process error: ${error.message}`); resolve({ success: false, error: error.message }); }); }); // Legacy code below - kept for reference but never runs const projectRoot = path.join(__dirname, '..'); const pythonPath = path.join(projectRoot, 'venv', 'bin', 'python'); sendDebugLog('Installing Whisper speech recognition...'); sendDebugLog(`$ ${pythonPath} -m pip install openai-whisper`); return new Promise((resolve) => { const process = spawn(pythonPath, ['-m', 'pip', 'install', 'openai-whisper'], { cwd: projectRoot, stdio: 'pipe' }); let output = ''; process.stdout.on('data', (data) => { const text = data.toString().trim(); if (text) { sendDebugLog(text); output += text; } }); process.stderr.on('data', (data) => { const text = data.toString().trim(); if (text) { sendDebugLog('STDERR: ' + text); output += text; } }); process.on('close', (code) => { if (code === 0) { sendDebugLog('Whisper installation completed successfully'); resolve({ success: true, message: 'Whisper installed successfully' }); } else { sendDebugLog(`Whisper installation failed with exit code: ${code}`); resolve({ success: false, error: `Whisper installation failed: ${output}` }); } }); process.on('error', (error) => { resolve({ success: false, error: `Process error: ${error.message}` }); }); }); } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('setup-test', async () => { try { sendDebugLog('Running system test...'); sendDebugLog('$ python simple_recorder.py test'); // Test the complete system const result = await runPythonScript('simple_recorder.py', ['test']); // Log the full result to debug console result.split('\n').forEach(line => { if (line.trim()) sendDebugLog(line.trim()); }); if (result.includes('System check passed') || result.includes('SUCCESS')) { sendDebugLog('System test completed successfully'); trackEvent('setup_completed', { step: 'system_test' }); return { success: true, message: 'System test passed' }; } else { // Extract specific error details from the output const errorLines = result.split('\n').filter(line => line.includes('ERROR:')); const specificError = errorLines.length > 0 ? errorLines[errorLines.length - 1].replace('ERROR: ', '') : 'Unknown error'; sendDebugLog(`System test failed: ${specificError}`); trackEvent('setup_failed', { step: 'system_test' }); return { success: false, error: `System test failed: ${specificError}`, details: result }; } } catch (error) { sendDebugLog(`System test error: ${error.message}`); return { success: false, error: error.message }; } }); // Settings window IPC handlers ipcMain.handle('trigger-setup-wizard', async () => { try { console.log('🔧 Starting setup wizard from settings...'); // Trigger the main window's setup flow if (mainWindow) { mainWindow.webContents.send('trigger-setup-flow'); } return { success: true, message: 'Setup wizard triggered in main window' }; } catch (error) { console.error('Setup wizard failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('get-app-version', async () => { try { const packagePath = path.join(__dirname, 'package.json'); const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8')); return { success: true, version: packageContent.version, name: packageContent.productName || packageContent.name }; } catch (error) { return { success: false, error: error.message }; } }); // Storage path handlers ipcMain.handle('get-storage-path', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-storage-path'], true); const jsonData = JSON.parse(result.trim()); // Python only returns the user's custom path (empty string when not set). // Augment with the platform default so the renderer can show "where your // data actually lives" without hardcoding the path. custom_path mirrors // storage_path but is null when empty for cleaner conditionals. const customPath = jsonData.storage_path && jsonData.storage_path.trim() ? jsonData.storage_path : null; const defaultPath = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai'); return { success: true, storage_path: customPath || defaultPath, custom_path: customPath, default_path: defaultPath, }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('set-storage-path', async (event, storagePath) => { try { const args = ['set-storage-path']; if (storagePath) { args.push(storagePath); } const result = await runPythonScript('simple_recorder.py', args); // Update cached custom path for file validation _cachedCustomStoragePath = storagePath || null; const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) { return JSON.parse(jsonMatch[0]); } return { success: true, storage_path: storagePath }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('select-storage-folder', async () => { try { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], title: 'Choose storage location for StenoAI data', buttonLabel: 'Select Folder' }); if (!result.canceled && result.filePaths.length > 0) { return { success: true, folderPath: result.filePaths[0] }; } return { success: false, error: 'No folder selected' }; } catch (error) { return { success: false, error: error.message }; } }); // Folder management handlers ipcMain.handle('list-folders', async () => { try { const result = await runPythonScript('simple_recorder.py', ['list-folders'], true); return { success: true, ...JSON.parse(result.trim()) }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('create-folder', async (event, name, color) => { try { const args = ['create-folder', name]; if (color) args.push('--color', color); const result = await runPythonScript('simple_recorder.py', args); const jsonMatch = result.match(/\{.*\}/s); return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('rename-folder', async (event, folderId, name) => { try { const result = await runPythonScript('simple_recorder.py', ['rename-folder', folderId, name]); const jsonMatch = result.match(/\{.*\}/s); return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('update-folder-icon', async (event, folderId, icon) => { try { const result = await runPythonScript('simple_recorder.py', ['update-folder-icon', folderId, icon]); const jsonMatch = result.match(/\{.*\}/s); return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('delete-folder', async (event, folderId) => { try { const result = await runPythonScript('simple_recorder.py', ['delete-folder', folderId]); const jsonMatch = result.match(/\{.*\}/s); return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('reorder-folders', async (event, folderIds) => { try { const args = ['reorder-folders', ...folderIds]; const result = await runPythonScript('simple_recorder.py', args); const jsonMatch = result.match(/\{.*\}/s); return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('add-meeting-to-folder', async (event, summaryFile, folderId) => { try { const result = await runPythonScript('simple_recorder.py', ['add-meeting-to-folder', summaryFile, folderId]); const jsonMatch = result.match(/\{.*\}/s); return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('remove-meeting-from-folder', async (event, summaryFile, folderId) => { try { const result = await runPythonScript('simple_recorder.py', ['remove-meeting-from-folder', summaryFile, folderId]); const jsonMatch = result.match(/\{.*\}/s); return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('get-ai-prompts', async () => { try { // Read the summarization prompt from the Python backend const summarizerPath = path.join(__dirname, '..', 'src', 'summarizer.py'); if (fs.existsSync(summarizerPath)) { const content = fs.readFileSync(summarizerPath, 'utf8'); // Extract the full prompt from the _create_permissive_prompt method const promptMatch = content.match(/def _create_permissive_prompt[\s\S]*?return f"""([\s\S]*?)"""/); if (promptMatch) { return { success: true, summarization: promptMatch[1].trim() }; } } return { success: true, summarization: 'Prompt not found in summarizer.py' }; } catch (error) { return { success: false, error: error.message }; } }); // Helper function to ensure Ollama service is running async function ensureOllamaRunning() { try { // Check if Ollama service is responding const http = require('http'); const response = await new Promise((resolve) => { const req = http.get('http://127.0.0.1:11434/api/version', { timeout: 3000 }, (res) => { resolve(res.statusCode === 200); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); if (response) { return true; // Service is running } // Service not running, try to start it // Check macOS version — bundled Ollama requires macOS 14 (Sonoma) or later const macRelease = os.release(); if (parseInt(macRelease.split('.')[0], 10) < 23) { sendDebugLog('macOS version too old for bundled Ollama — requires macOS 14 (Sonoma) or later'); return false; } const ollamaPath = await findOllamaExecutable(); if (!ollamaPath) { return false; } // Start Ollama service in background with proper env vars for dylibs ollamaProcess = spawn(ollamaPath, ['serve'], { detached: true, stdio: 'ignore', env: getOllamaEnv() }); ollamaPid = ollamaProcess.pid; try { require('fs').writeFileSync(path.join(getBackendCwd(), '_internal', 'ollama.pid'), String(ollamaPid)); } catch (_) {} ollamaProcess.on('exit', () => { ollamaPid = null; }); ollamaProcess.unref(); ollamaStartedByUs = true; // Wait for service to start await new Promise(resolve => setTimeout(resolve, 2000)); return true; } catch (error) { console.error('Error ensuring Ollama is running:', error); return false; } } // Check if Ollama is installed (for setup wizard) ipcMain.handle('check-ollama-installed', async () => { try { const ollamaPath = await findOllamaExecutable(); if (!ollamaPath) { return { success: true, installed: false }; } return { success: true, installed: true, path: ollamaPath }; } catch (error) { return { success: false, installed: false, error: error.message }; } }); // Model management handlers ipcMain.handle('check-model-installed', async (event, modelName) => { try { const result = await runPythonScript('simple_recorder.py', ['check-model', modelName]); // Parse the last JSON line from output (skip any log lines) const lines = result.trim().split('\n'); for (let i = lines.length - 1; i >= 0; i--) { try { const data = JSON.parse(lines[i]); return { success: true, installed: data.installed }; } catch (e) { continue; } } return { success: false, installed: false, error: 'Could not parse backend response' }; } catch (error) { return { success: false, installed: false, error: error.message }; } }); ipcMain.handle('list-models', async () => { try { const result = await runPythonScript('simple_recorder.py', ['list-models']); const jsonData = JSON.parse(result); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error listing models: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('get-current-model', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-model']); const jsonData = JSON.parse(result); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error getting current model: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('set-model', async (event, modelName) => { try { sendDebugLog(`Setting model to: ${modelName}`); const result = await runPythonScript('simple_recorder.py', ['set-model', modelName]); // Extract JSON from output (might have other text before it) const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) { const jsonData = JSON.parse(jsonMatch[0]); trackEvent('model_changed', { model: modelName }); return jsonData; } trackEvent('model_changed', { model: modelName }); return { success: true, model: modelName }; } catch (error) { sendDebugLog(`Error setting model: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('get-whisper-model', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-whisper-model'], true); return JSON.parse(result.trim()); } catch (e) { return { success: false, error: e.message }; } }); ipcMain.handle('set-whisper-model', async (event, modelSize) => { try { const result = await runPythonScript('simple_recorder.py', ['set-whisper-model', modelSize]); return JSON.parse(result.trim()); } catch (e) { return { success: false, error: e.message }; } }); ipcMain.handle('get-keep-recordings', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-keep-recordings'], true); return JSON.parse(result.trim()); } catch (e) { return { success: false, error: e.message }; } }); ipcMain.handle('set-keep-recordings', async (event, enabled) => { try { const result = await runPythonScript('simple_recorder.py', ['set-keep-recordings', enabled.toString()]); return JSON.parse(result.trim()); } catch (e) { return { success: false, error: e.message }; } }); ipcMain.handle('get-notifications', handleGetNotifications); ipcMain.handle('set-notifications', async (event, enabled) => { try { sendDebugLog(`Setting notifications to: ${enabled}`); const result = await runPythonScript('simple_recorder.py', ['set-notifications', enabled ? 'True' : 'False']); // Extract JSON from output const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) { const jsonData = JSON.parse(jsonMatch[0]); return jsonData; } return { success: true, notifications_enabled: enabled }; } catch (error) { sendDebugLog(`Error setting notifications: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('get-telemetry', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-telemetry']); const jsonData = JSON.parse(result); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error getting telemetry settings: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('set-telemetry', async (event, enabled) => { try { sendDebugLog(`Setting telemetry to: ${enabled}`); const result = await runPythonScript('simple_recorder.py', ['set-telemetry', enabled ? 'True' : 'False']); // Update in-memory state telemetryEnabled = enabled; if (enabled && !posthogClient) { posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST }); console.log('Telemetry re-enabled'); } else if (!enabled && posthogClient) { await shutdownTelemetry(); console.log('Telemetry disabled'); } // Extract JSON from output const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) { const jsonData = JSON.parse(jsonMatch[0]); return jsonData; } return { success: true, telemetry_enabled: enabled }; } catch (error) { sendDebugLog(`Error setting telemetry: ${error.message}`); return { success: false, error: error.message }; } }); // Hide dock icon IPC handlers ipcMain.handle('get-dock-icon', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-dock-icon']); const jsonData = JSON.parse(result); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error getting dock icon settings: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('set-dock-icon', async (event, hidden) => { try { sendDebugLog(`Setting hide dock icon to: ${hidden}`); const result = await runPythonScript('simple_recorder.py', ['set-dock-icon', hidden ? 'True' : 'False']); // Apply immediately if (process.platform === 'darwin' && app.dock) { if (hidden) { app.dock.hide(); } else { app.dock.show(); } } // Extract JSON from output const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) { const jsonData = JSON.parse(jsonMatch[0]); return jsonData; } return { success: true, hide_dock_icon: hidden }; } catch (error) { sendDebugLog(`Error setting dock icon: ${error.message}`); return { success: false, error: error.message }; } }); // System audio capture IPC handlers ipcMain.handle('get-system-audio', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-system-audio'], true); const jsonData = JSON.parse(result); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error getting system audio setting: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('set-system-audio', async (event, enabled) => { try { sendDebugLog(`Setting system audio to: ${enabled}`); const result = await runPythonScript('simple_recorder.py', ['set-system-audio', enabled ? 'True' : 'False']); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) { return JSON.parse(jsonMatch[0]); } return { success: true, system_audio_enabled: enabled }; } catch (error) { sendDebugLog(`Error setting system audio: ${error.message}`); return { success: false, error: error.message }; } }); // Language IPC handlers ipcMain.handle('get-language', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-language'], true); const jsonData = JSON.parse(result); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error getting language setting: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('set-language', async (event, languageCode) => { try { sendDebugLog(`Setting language to: ${languageCode}`); const result = await runPythonScript('simple_recorder.py', ['set-language', languageCode]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) { return JSON.parse(jsonMatch[0]); } return { success: true, language: languageCode }; } catch (error) { sendDebugLog(`Error setting language: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('get-user-name', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-user-name'], true); const jsonData = JSON.parse(result.trim()); return { success: true, ...jsonData }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('set-user-name', async (event, name) => { try { const result = await runPythonScript('simple_recorder.py', ['set-user-name', String(name ?? '')]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: true, user_name: String(name ?? '').trim() }; } catch (error) { return { success: false, error: error.message }; } }); // AI Provider IPC handlers function getCloudKeyPath() { return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.cloud-api-key'); } function saveCloudApiKey(key) { try { const keyDir = path.dirname(getCloudKeyPath()); if (!fs.existsSync(keyDir)) { fs.mkdirSync(keyDir, { recursive: true }); } const encrypted = safeStorage.encryptString(key); fs.writeFileSync(getCloudKeyPath(), encrypted); return true; } catch (error) { console.error('Failed to save cloud API key:', error.message); return false; } } function loadCloudApiKey() { try { const keyPath = getCloudKeyPath(); if (!fs.existsSync(keyPath)) return null; const encrypted = fs.readFileSync(keyPath); return safeStorage.decryptString(encrypted); } catch (error) { console.error('Failed to load cloud API key:', error.message); return null; } } function hasCloudApiKey() { return fs.existsSync(getCloudKeyPath()); } ipcMain.handle('get-ai-provider', async () => { try { const result = await runPythonScript('simple_recorder.py', ['get-ai-provider'], true); const jsonData = JSON.parse(result.trim()); // Override cloud_api_key_set with safeStorage check jsonData.cloud_api_key_set = hasCloudApiKey(); return { success: true, ...jsonData }; } catch (error) { sendDebugLog(`Error getting AI provider: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('set-ai-provider', async (event, provider) => { try { sendDebugLog(`Setting AI provider to: ${provider}`); const result = await runPythonScript('simple_recorder.py', ['set-ai-provider', provider]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: true, ai_provider: provider }; } catch (error) { sendDebugLog(`Error setting AI provider: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('set-remote-ollama-url', async (event, url) => { try { const result = await runPythonScript('simple_recorder.py', ['set-remote-ollama-url', url]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('set-cloud-api-url', async (event, url) => { try { const result = await runPythonScript('simple_recorder.py', ['set-cloud-api-url', url]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('set-cloud-api-key', async (event, key) => { try { const saved = saveCloudApiKey(key); return { success: saved, cloud_api_key_set: saved }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('set-cloud-provider', async (event, provider) => { try { const result = await runPythonScript('simple_recorder.py', ['set-cloud-provider', provider]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('set-cloud-model', async (event, model) => { try { const result = await runPythonScript('simple_recorder.py', ['set-cloud-model', model]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('test-remote-ollama', async (event, url) => { try { sendDebugLog(`Testing remote Ollama at: ${url}`); const result = await runPythonScript('simple_recorder.py', ['test-remote-ollama', url]); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: false, error: 'No response' }; } catch (error) { sendDebugLog(`Remote Ollama test failed: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('test-cloud-api', async () => { try { sendDebugLog('Testing cloud API connection...'); const apiKey = loadCloudApiKey(); const env = apiKey ? { STENOAI_CLOUD_API_KEY: apiKey } : {}; const result = await runPythonScript('simple_recorder.py', ['test-cloud-api'], false, env); const jsonMatch = result.match(/\{.*\}/s); if (jsonMatch) return JSON.parse(jsonMatch[0]); return { success: false, error: 'No response' }; } catch (error) { sendDebugLog(`Cloud API test failed: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('get-recordings-dir', async () => { try { // Get recordings directory from Python config const result = await runPythonScript('simple_recorder.py', ['get-storage-path'], true); const jsonData = JSON.parse(result.trim()); let recordingsDir; if (jsonData.storage_path) { recordingsDir = path.join(jsonData.storage_path, 'recordings'); } else if (app.isPackaged) { recordingsDir = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', 'recordings'); } else { recordingsDir = path.join(__dirname, '..', 'recordings'); } // Ensure directory exists if (!fs.existsSync(recordingsDir)) { fs.mkdirSync(recordingsDir, { recursive: true }); } return { success: true, path: recordingsDir }; } catch (error) { sendDebugLog(`Error getting recordings dir: ${error.message}`); return { success: false, error: error.message }; } }); ipcMain.handle('process-system-audio-recording', async (event, audioFilePath, sessionName) => { try { sendDebugLog(`Queuing system audio recording for processing: ${audioFilePath}`); // Validate file path const allowedBaseDirs = getAllowedBaseDirs(); if (!validateSafeFilePath(audioFilePath, allowedBaseDirs)) { return { success: false, error: 'Invalid file path' }; } if (!fs.existsSync(audioFilePath)) { return { success: false, error: 'Audio file not found' }; } const actualSessionName = sessionName || 'Meeting'; // Check for user notes file const safeName = actualSessionName.replace(/[^a-zA-Z0-9_-]/g, '_'); const notesFile = path.join(getBackendCwd(), '_internal', 'output', `${safeName}_notes.txt`); const notesPath = fs.existsSync(notesFile) ? notesFile : undefined; // Use the existing processing queue to avoid concurrent Ollama/Whisper runs addToProcessingQueue(audioFilePath, actualSessionName, notesPath); trackEvent('recording_stopped', { recording_mode: 'system_audio' }); return { success: true, message: 'Added to processing queue' }; } catch (error) { sendDebugLog(`Error queuing system audio: ${error.message}`); trackEvent('error_occurred', { error_type: 'process_system_audio' }); return { success: false, error: error.message }; } }); // Track system audio recording state for tray icon ipcMain.on('system-audio-recording-state', (event, isRecording) => { systemAudioRecordingActive = isRecording; updateTrayIcon(isRecording); updateTrayMenu(); }); ipcMain.handle('pull-model', async (event, modelName) => { try { sendDebugLog(`Pulling model: ${modelName}`); sendDebugLog('This may take several minutes...'); return new Promise((resolve) => { const proc = spawn(getBackendPath(), ['pull-model', modelName], { cwd: getBackendCwd() }); let lastStdoutLine = ''; proc.stdout.on('data', (data) => { const output = data.toString().trim(); sendDebugLog(output); if (output) lastStdoutLine = output; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('model-pull-progress', { model: modelName, progress: output }); } }); proc.stderr.on('data', (data) => { const output = data.toString().trim(); sendDebugLog(output); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('model-pull-progress', { model: modelName, progress: output }); } }); proc.on('close', (code) => { // The backend prints a JSON result as the last stdout line. // Check it even on exit code 0, since the Python CLI may // catch errors and still exit cleanly. let pullResult = null; try { pullResult = JSON.parse(lastStdoutLine); } catch (_) {} const succeeded = code === 0 && (!pullResult || pullResult.success !== false); if (succeeded) { sendDebugLog(`Successfully pulled model: ${modelName}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('model-pull-complete', { model: modelName, success: true }); } resolve({ success: true, model: modelName }); } else { const errorMsg = (pullResult && pullResult.error) || `Process exited with code ${code}`; sendDebugLog(`Failed to pull model: ${modelName} - ${errorMsg}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('model-pull-complete', { model: modelName, success: false, error: errorMsg }); } resolve({ success: false, error: errorMsg }); } }); proc.on('error', (error) => { sendDebugLog(`Error pulling model: ${error.message}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('model-pull-complete', { model: modelName, success: false, error: error.message }); } resolve({ success: false, error: error.message }); }); }); } catch (error) { sendDebugLog(`Error in pull-model handler: ${error.message}`); return { success: false, error: error.message }; } }); // Helper to build env vars for running the bundled Ollama binary directly function getOllamaEnv() { let ollamaDir; if (app.isPackaged) { ollamaDir = path.join(process.resourcesPath, 'stenoai', '_internal', 'ollama'); } else { ollamaDir = path.join(__dirname, '..', 'bin'); } const env = { ...process.env }; const existing = env.DYLD_LIBRARY_PATH || ''; env.DYLD_LIBRARY_PATH = existing ? `${ollamaDir}:${existing}` : ollamaDir; env.MLX_METAL_PATH = path.join(ollamaDir, 'mlx.metallib'); return env; } // Helper function to find Ollama executable (bundled only) async function findOllamaExecutable() { let bundledOllamaPath; if (app.isPackaged) { // Production: bundled inside PyInstaller _internal directory bundledOllamaPath = path.join(process.resourcesPath, 'stenoai', '_internal', 'ollama', 'ollama'); } else { // Development: in project bin/ directory bundledOllamaPath = path.join(__dirname, '..', 'bin', 'ollama'); } if (fs.existsSync(bundledOllamaPath)) { console.log(`Using bundled Ollama: ${bundledOllamaPath}`); return bundledOllamaPath; } console.error(`Bundled Ollama not found at: ${bundledOllamaPath}`); return null; } // Update checking functionality async function checkForUpdates() { return new Promise((resolve) => { const options = { hostname: 'api.github.com', path: '/repos/ruzin/stenoai/releases/latest', method: 'GET', headers: { 'User-Agent': 'StenoAI-Updater' } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const release = JSON.parse(data); const latestVersion = release.tag_name.replace(/^v/, ''); // Remove 'v' prefix if present // Get current version from package.json const packagePath = path.join(__dirname, 'package.json'); const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const currentVersion = packageContent.version; console.log(`Current version: ${currentVersion}, Latest version: ${latestVersion}`); // Simple version comparison (works for semantic versioning) const isUpdateAvailable = compareVersions(currentVersion, latestVersion) < 0; resolve({ success: true, updateAvailable: isUpdateAvailable, currentVersion: currentVersion, latestVersion: latestVersion, releaseUrl: release.html_url, releaseName: release.name || `Version ${latestVersion}`, downloadUrl: getDownloadUrl(release.assets) }); } catch (error) { console.error('Error parsing GitHub API response:', error); resolve({ success: false, error: 'Failed to parse update data' }); } }); }); req.on('error', (error) => { console.error('Error checking for updates:', error); resolve({ success: false, error: error.message }); }); req.setTimeout(10000, () => { req.destroy(); resolve({ success: false, error: 'Update check timeout' }); }); req.end(); }); } function compareVersions(current, latest) { const currentParts = current.split('.').map(Number); const latestParts = latest.split('.').map(Number); for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { const currentPart = currentParts[i] || 0; const latestPart = latestParts[i] || 0; if (currentPart < latestPart) return -1; if (currentPart > latestPart) return 1; } return 0; } function getDownloadUrl(assets) { // Find the appropriate download URL based on platform/architecture const platform = process.platform; const arch = process.arch; if (platform === 'darwin') { // Look for macOS DMG files const armAsset = assets.find(asset => asset.name.includes('arm64') && asset.name.includes('dmg') ); const intelAsset = assets.find(asset => asset.name.includes('x64') && asset.name.includes('dmg') ); // Prefer ARM64 for Apple Silicon, fallback to Intel if (arch === 'arm64' && armAsset) return armAsset.browser_download_url; if (intelAsset) return intelAsset.browser_download_url; if (armAsset) return armAsset.browser_download_url; } // Fallback to first asset or releases page return assets.length > 0 ? assets[0].browser_download_url : null; } ipcMain.handle('check-for-updates', async () => { return await checkForUpdates(); }); ipcMain.handle('check-announcements', async () => { const packagePath = path.join(__dirname, 'package.json'); const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const currentVersion = packageContent.version; // Try local file first (for development/testing) const localPath = path.join(__dirname, '..', 'announcements.json'); if (fs.existsSync(localPath)) { try { const localData = JSON.parse(fs.readFileSync(localPath, 'utf8')); console.log('Loaded announcements from local file'); return { success: true, announcements: localData.announcements || [], currentVersion }; } catch (error) { console.error('Error reading local announcements.json:', error); } } // Fall back to remote return new Promise((resolve) => { const options = { hostname: 'raw.githubusercontent.com', path: '/ruzin/stenoai/main/announcements.json', method: 'GET', headers: { 'User-Agent': 'StenoAI-App' } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = JSON.parse(data); resolve({ success: true, announcements: parsed.announcements || [], currentVersion }); } catch (error) { console.error('Error parsing announcements:', error); resolve({ success: false, error: 'Failed to parse announcements' }); } }); }); req.on('error', (error) => { console.error('Error fetching announcements:', error); resolve({ success: false, error: error.message }); }); req.setTimeout(10000, () => { req.destroy(); resolve({ success: false, error: 'Announcements fetch timeout' }); }); req.end(); }); }); ipcMain.handle('open-release-page', async (event, url) => { try { if (typeof url !== 'string' || !url) { return { success: false, error: 'invalid url' }; } let parsed; try { parsed = new URL(url); } catch { return { success: false, error: 'invalid url' }; } // Release pages live on github.com -- restrict to that origin so a // compromised renderer cannot launch arbitrary external URLs through // this channel. if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com') { return { success: false, error: 'unsupported url' }; } await shell.openExternal(url); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); // Generic external-URL opener for renderer-triggered links (e.g. meeting // join URLs on Home). Http/https only — rejects custom schemes so a // compromised renderer cannot launch arbitrary protocol handlers. ipcMain.handle('open-external', async (event, url) => { try { if (typeof url !== 'string' || !url) { return { success: false, error: 'invalid url' }; } let parsed; try { parsed = new URL(url); } catch { return { success: false, error: 'invalid url' }; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return { success: false, error: 'unsupported scheme' }; } await shell.openExternal(url); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); // ── Google Calendar: Token Storage ────────────────────────────────────── function getTokenFilePath() { return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.google-tokens'); } function saveGoogleTokens(tokens) { try { const tokenDir = path.dirname(getTokenFilePath()); if (!fs.existsSync(tokenDir)) { fs.mkdirSync(tokenDir, { recursive: true }); } const encrypted = safeStorage.encryptString(JSON.stringify(tokens)); fs.writeFileSync(getTokenFilePath(), encrypted); console.log('Google tokens saved'); } catch (error) { console.error('Failed to save Google tokens:', error.message); } } function loadGoogleTokens() { try { const tokenPath = getTokenFilePath(); if (!fs.existsSync(tokenPath)) return null; const encrypted = fs.readFileSync(tokenPath); const decrypted = safeStorage.decryptString(encrypted); return JSON.parse(decrypted); } catch (error) { console.error('Failed to load Google tokens:', error.message); return null; } } function deleteGoogleTokens() { try { const tokenPath = getTokenFilePath(); if (fs.existsSync(tokenPath)) { fs.unlinkSync(tokenPath); console.log('Google tokens deleted'); } } catch (error) { console.error('Failed to delete Google tokens:', error.message); } } // ── Outlook Calendar: Token Storage ───────────────────────────────────── function getOutlookTokenFilePath() { return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.outlook-tokens'); } function saveOutlookTokens(tokens) { try { const tokenDir = path.dirname(getOutlookTokenFilePath()); if (!fs.existsSync(tokenDir)) { fs.mkdirSync(tokenDir, { recursive: true }); } const encrypted = safeStorage.encryptString(JSON.stringify(tokens)); fs.writeFileSync(getOutlookTokenFilePath(), encrypted); console.log('Outlook tokens saved'); } catch (error) { console.error('Failed to save Outlook tokens:', error.message); } } function loadOutlookTokens() { try { const tokenPath = getOutlookTokenFilePath(); if (!fs.existsSync(tokenPath)) return null; const encrypted = fs.readFileSync(tokenPath); const decrypted = safeStorage.decryptString(encrypted); return JSON.parse(decrypted); } catch (error) { console.error('Failed to load Outlook tokens:', error.message); return null; } } function deleteOutlookTokens() { try { const tokenPath = getOutlookTokenFilePath(); if (fs.existsSync(tokenPath)) { fs.unlinkSync(tokenPath); console.log('Outlook tokens deleted'); } } catch (error) { console.error('Failed to delete Outlook tokens:', error.message); } } // ── Google Calendar: OAuth2 Flow with PKCE ────────────────────────────── function startGoogleAuth() { return new Promise((resolve, reject) => { // Generate PKCE code verifier and challenge const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); const state = crypto.randomBytes(16).toString('hex'); let timeoutId = null; // Start temporary HTTP server on loopback for OAuth redirect const server = http.createServer(async (req, res) => { try { const reqUrl = new URL(req.url, `http://127.0.0.1`); if (!reqUrl.pathname.startsWith('/callback')) { res.writeHead(404); res.end(); return; } const code = reqUrl.searchParams.get('code'); const error = reqUrl.searchParams.get('error'); const returnedState = reqUrl.searchParams.get('state'); if (returnedState !== state) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('

Invalid state parameter

Possible CSRF attack. Please try again.

'); return; } if (error) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Authorization denied

You can close this tab.

'); server.close(); if (timeoutId) clearTimeout(timeoutId); reject(new Error(`Auth denied: ${error}`)); return; } if (!code) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('

Missing authorization code

'); return; } // Exchange code for tokens const port = server.address().port; const tokens = await exchangeCodeForTokens(code, codeVerifier, port); saveGoogleTokens(tokens); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Connected to Google Calendar

You can close this tab and return to StenoAI.

'); server.close(); if (timeoutId) clearTimeout(timeoutId); // Notify renderer and bring app to foreground if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('google-auth-changed'); mainWindow.show(); mainWindow.focus(); } resolve({ success: true }); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end('

Authentication failed

Please try again.

'); server.close(); if (timeoutId) clearTimeout(timeoutId); reject(err); } }); // Listen on loopback only (security: not 0.0.0.0) server.listen(0, '127.0.0.1', () => { const port = server.address().port; const redirectUri = `http://127.0.0.1:${port}/callback`; const authParams = new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, redirect_uri: redirectUri, response_type: 'code', scope: GOOGLE_SCOPES, access_type: 'offline', prompt: 'consent', state: state, code_challenge: codeChallenge, code_challenge_method: 'S256' }); const authUrl = `${GOOGLE_AUTH_URL}?${authParams.toString()}`; shell.openExternal(authUrl); }); timeoutId = setTimeout(() => { if (server.listening) { server.close(); reject(new Error('OAuth timeout: no response within 5 minutes')); } }, 5 * 60 * 1000); }); } function exchangeCodeForTokens(code, codeVerifier, port) { return new Promise((resolve, reject) => { const redirectUri = `http://127.0.0.1:${port}/callback`; const postData = new URLSearchParams({ code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, redirect_uri: redirectUri, grant_type: 'authorization_code', code_verifier: codeVerifier }).toString(); const options = { hostname: 'oauth2.googleapis.com', path: '/token', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = JSON.parse(data); if (parsed.error) { reject(new Error(`Token exchange failed: ${parsed.error_description || parsed.error}`)); return; } // Store expiry as absolute timestamp parsed.expires_at = Date.now() + (parsed.expires_in * 1000); resolve(parsed); } catch (err) { reject(new Error('Failed to parse token response')); } }); }); req.on('error', reject); req.write(postData); req.end(); }); } // ── Google Calendar: Token Refresh ────────────────────────────────────── async function getValidAccessToken() { const tokens = loadGoogleTokens(); if (!tokens) return null; // Check if token is expired or about to expire (5-min buffer) const bufferMs = 5 * 60 * 1000; if (tokens.expires_at && Date.now() < tokens.expires_at - bufferMs) { return tokens.access_token; } // Token expired, try to refresh if (!tokens.refresh_token) { deleteGoogleTokens(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('google-auth-changed'); } return null; } try { const newTokens = await refreshAccessToken(tokens.refresh_token); // Preserve the refresh token (Google may not return it again) newTokens.refresh_token = newTokens.refresh_token || tokens.refresh_token; newTokens.expires_at = Date.now() + (newTokens.expires_in * 1000); saveGoogleTokens(newTokens); return newTokens.access_token; } catch (error) { console.error('Token refresh failed:', error.message); if (error.message && (error.message.includes('invalid_grant') || error.message.includes('Token has been expired or revoked'))) { deleteGoogleTokens(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('google-auth-changed'); } } return null; } } function refreshAccessToken(refreshToken) { return new Promise((resolve, reject) => { const postData = new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, refresh_token: refreshToken, grant_type: 'refresh_token' }).toString(); const options = { hostname: 'oauth2.googleapis.com', path: '/token', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = JSON.parse(data); if (parsed.error) { reject(new Error(`Refresh failed: ${parsed.error_description || parsed.error}`)); return; } resolve(parsed); } catch (err) { reject(new Error('Failed to parse refresh response')); } }); }); req.on('error', reject); req.write(postData); req.end(); }); } // ── Google Calendar: Fetch Events ─────────────────────────────────────── function fetchCalendarEvents(accessToken, maxResults = 7) { return new Promise((resolve, reject) => { const now = new Date(); const weekAhead = new Date(now); weekAhead.setDate(weekAhead.getDate() + 7); const params = new URLSearchParams({ timeMin: now.toISOString(), timeMax: weekAhead.toISOString(), maxResults: String(maxResults), singleEvents: 'true', orderBy: 'startTime', fields: 'items(id,summary,description,start,end,attendees,htmlLink,conferenceData)' }); const options = { hostname: 'www.googleapis.com', path: `/calendar/v3/calendars/primary/events?${params.toString()}`, method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}` } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = JSON.parse(data); if (parsed.error) { reject(new Error(`Calendar API error: ${parsed.error.message || parsed.error}`)); return; } resolve(parsed.items || []); } catch (err) { reject(new Error('Failed to parse calendar response')); } }); }); req.on('error', reject); req.end(); }); } // ── Outlook Calendar: OAuth2 Flow with PKCE ───────────────────────────── function startOutlookAuth() { return new Promise((resolve, reject) => { const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); const state = crypto.randomBytes(16).toString('hex'); let timeoutId = null; const server = http.createServer(async (req, res) => { try { const reqUrl = new URL(req.url, `http://localhost`); // Ignore favicon and other noise — only handle the root path if (reqUrl.pathname !== '/') { res.writeHead(404); res.end(); return; } const code = reqUrl.searchParams.get('code'); const error = reqUrl.searchParams.get('error'); const returnedState = reqUrl.searchParams.get('state'); if (returnedState !== state) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('

Invalid state parameter

Possible CSRF attack. Please try again.

'); return; } if (error) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Authorization denied

You can close this tab.

'); server.close(); if (timeoutId) clearTimeout(timeoutId); reject(new Error(`Auth denied: ${error}`)); return; } if (!code) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('

Missing authorization code

'); return; } const port = server.address().port; const tokens = await exchangeOutlookCodeForTokens(code, codeVerifier, port); saveOutlookTokens(tokens); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Connected to Outlook Calendar

You can close this tab and return to StenoAI.

'); server.close(); if (timeoutId) clearTimeout(timeoutId); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('outlook-auth-changed'); mainWindow.show(); mainWindow.focus(); } resolve({ success: true }); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end('

Authentication failed

Please try again.

'); server.close(); if (timeoutId) clearTimeout(timeoutId); reject(err); } }); server.listen(0, '127.0.0.1', () => { const port = server.address().port; const redirectUri = `http://localhost:${port}`; const authParams = new URLSearchParams({ client_id: OUTLOOK_CLIENT_ID, redirect_uri: redirectUri, response_type: 'code', scope: OUTLOOK_SCOPES, response_mode: 'query', state: state, code_challenge: codeChallenge, code_challenge_method: 'S256' }); const authUrl = `${OUTLOOK_AUTH_URL}?${authParams.toString()}`; shell.openExternal(authUrl); }); timeoutId = setTimeout(() => { if (server.listening) { server.close(); reject(new Error('OAuth timeout: no response within 5 minutes')); } }, 5 * 60 * 1000); }); } function exchangeOutlookCodeForTokens(code, codeVerifier, port) { return new Promise((resolve, reject) => { const redirectUri = `http://localhost:${port}`; const postData = new URLSearchParams({ code, client_id: OUTLOOK_CLIENT_ID, redirect_uri: redirectUri, grant_type: 'authorization_code', code_verifier: codeVerifier }).toString(); const tokenUrl = new URL(OUTLOOK_TOKEN_URL); const options = { hostname: tokenUrl.hostname, path: tokenUrl.pathname, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = JSON.parse(data); if (parsed.error) { reject(new Error(`Token exchange failed: ${parsed.error_description || parsed.error}`)); return; } parsed.expires_at = Date.now() + (parsed.expires_in * 1000); resolve(parsed); } catch (err) { reject(new Error('Failed to parse token response')); } }); }); req.on('error', reject); req.write(postData); req.end(); }); } // ── Outlook Calendar: Token Refresh ───────────────────────────────────── async function getValidOutlookAccessToken() { const tokens = loadOutlookTokens(); if (!tokens) return null; const bufferMs = 5 * 60 * 1000; if (tokens.expires_at && Date.now() < tokens.expires_at - bufferMs) { return tokens.access_token; } if (!tokens.refresh_token) { deleteOutlookTokens(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('outlook-auth-changed'); } return null; } try { const newTokens = await refreshOutlookAccessToken(tokens.refresh_token); newTokens.refresh_token = newTokens.refresh_token || tokens.refresh_token; newTokens.expires_at = Date.now() + (newTokens.expires_in * 1000); saveOutlookTokens(newTokens); return newTokens.access_token; } catch (error) { console.error('Outlook token refresh failed:', error.message); // Only delete tokens for irrecoverable errors (revoked/expired grant) // Transient network errors should not force re-authentication if (error.message && (error.message.includes('invalid_grant') || error.message.includes('interaction_required'))) { deleteOutlookTokens(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('outlook-auth-changed'); } } return null; } } function refreshOutlookAccessToken(refreshToken) { return new Promise((resolve, reject) => { const postData = new URLSearchParams({ client_id: OUTLOOK_CLIENT_ID, refresh_token: refreshToken, grant_type: 'refresh_token', scope: OUTLOOK_SCOPES }).toString(); const tokenUrl = new URL(OUTLOOK_TOKEN_URL); const options = { hostname: tokenUrl.hostname, path: tokenUrl.pathname, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = JSON.parse(data); if (parsed.error) { reject(new Error(`Refresh failed: ${parsed.error_description || parsed.error}`)); return; } resolve(parsed); } catch (err) { reject(new Error('Failed to parse refresh response')); } }); }); req.on('error', reject); req.write(postData); req.end(); }); } // ── Outlook Calendar: Fetch Events ────────────────────────────────────── function fetchOutlookCalendarEvents(accessToken, maxResults = 7) { return new Promise((resolve, reject) => { const now = new Date(); const weekAhead = new Date(now); weekAhead.setDate(weekAhead.getDate() + 7); const params = new URLSearchParams({ startDateTime: now.toISOString(), endDateTime: weekAhead.toISOString(), $top: String(maxResults), $orderby: 'start/dateTime', $select: 'id,subject,body,start,end,attendees,webLink,onlineMeeting,isOnlineMeeting' }); const options = { hostname: 'graph.microsoft.com', path: `/v1.0/me/calendarView?${params.toString()}`, method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}`, 'Prefer': 'outlook.timezone="UTC"' } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = JSON.parse(data); if (parsed.error) { reject(new Error(`Outlook Calendar API error: ${parsed.error.message || parsed.error}`)); return; } const events = (parsed.value || []).map(normalizeOutlookEvent); resolve(events); } catch (err) { reject(new Error('Failed to parse Outlook calendar response')); } }); }); req.on('error', reject); req.end(); }); } function normalizeOutlookEvent(event) { // Map Microsoft Graph event shape to Google Calendar shape for renderer compatibility const stripHtml = (html) => { if (!html) return ''; return html .replace(//gi, '\n') .replace(/<\/(?:div|p|tr|li|h[1-6])>/gi, '\n') .replace(/<[^>]*>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/\r\n/g, '\n') .replace(/[ \t]+/g, ' ') .replace(/(\s*\n){3,}/g, '\n\n') .trim(); }; const ensureUtcSuffix = (dt) => { if (!dt) return undefined; return dt.endsWith('Z') ? dt : dt + 'Z'; }; return { id: event.id, summary: event.subject || 'No title', description: stripHtml(event.body?.content), start: { dateTime: ensureUtcSuffix(event.start?.dateTime), timeZone: 'UTC' }, end: { dateTime: ensureUtcSuffix(event.end?.dateTime), timeZone: 'UTC' }, attendees: (event.attendees || []).map(a => ({ email: a.emailAddress?.address || '', displayName: a.emailAddress?.name || '', responseStatus: a.status?.response || '' })), htmlLink: event.webLink, conferenceData: event.isOnlineMeeting && event.onlineMeeting ? { entryPoints: [{ uri: event.onlineMeeting.joinUrl, entryPointType: 'video' }] } : undefined }; } // ── Google Calendar: IPC Handlers ─────────────────────────────────────── ipcMain.handle('google-auth-start', async () => { try { await startGoogleAuth(); // Only disconnect Outlook after Google auth succeeds deleteOutlookTokens(); return { success: true }; } catch (error) { console.error('Google auth failed:', error.message); return { success: false, error: error.message }; } }); ipcMain.handle('google-auth-status', async () => { try { const tokens = loadGoogleTokens(); return { success: true, connected: !!tokens }; } catch (error) { return { success: false, connected: false }; } }); ipcMain.handle('google-auth-disconnect', async () => { try { // Best-effort token revocation const tokens = loadGoogleTokens(); if (tokens && tokens.access_token) { try { await new Promise((resolve) => { const revokeParams = new URLSearchParams({ token: tokens.access_token }); const req = https.request({ hostname: 'oauth2.googleapis.com', path: `/revoke?${revokeParams.toString()}`, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, () => resolve()); req.on('error', () => resolve()); // Best-effort req.end(); }); } catch (e) { // Best-effort revocation -- ignore errors } } deleteGoogleTokens(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('google-auth-changed'); } return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); function normalizeCalendarEvent(event) { const start = event.start?.dateTime || event.start?.date || (typeof event.start === 'string' ? event.start : ''); const end = event.end?.dateTime || event.end?.date || (typeof event.end === 'string' ? event.end : ''); return { id: event.id, title: event.summary || event.title || 'No title', start, end, meeting_url: event.hangoutLink || event.onlineMeeting?.joinUrl || event.meeting_url || undefined, }; } ipcMain.handle('get-calendar-events', async () => { try { // Check which provider is connected (only one at a time) const googleToken = await getValidAccessToken(); if (googleToken) { const raw = await fetchCalendarEvents(googleToken); return { success: true, events: raw.map(normalizeCalendarEvent) }; } const outlookToken = await getValidOutlookAccessToken(); if (outlookToken) { const raw = await fetchOutlookCalendarEvents(outlookToken); return { success: true, events: raw.map(normalizeCalendarEvent) }; } return { success: false, needsAuth: true }; } catch (error) { console.error('Failed to fetch calendar events:', error.message); return { success: false, error: error.message }; } }); // ── Outlook Calendar: IPC Handlers ────────────────────────────────────── ipcMain.handle('outlook-auth-start', async () => { try { await startOutlookAuth(); // Only disconnect Google after Outlook auth succeeds deleteGoogleTokens(); return { success: true }; } catch (error) { console.error('Outlook auth failed:', error.message); return { success: false, error: error.message }; } }); ipcMain.handle('outlook-auth-status', async () => { try { const tokens = loadOutlookTokens(); return { success: true, connected: !!tokens }; } catch (error) { return { success: false, connected: false }; } }); ipcMain.handle('outlook-auth-disconnect', async () => { try { deleteOutlookTokens(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('outlook-auth-changed'); } return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ================================================ FILE: app/package-lock.json ================================================ { "name": "stenoai", "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stenoai", "version": "0.2.13", "license": "MIT", "dependencies": { "axios": "^1.6.0", "electron-audio-loopback": "^1.0.0", "electron-updater": "^6.8.3", "posthog-node": "^4.18.0" }, "devDependencies": { "@electron/notarize": "^3.1.1", "@playwright/test": "^1.48.0", "@types/node": "^20.12.0", "electron": "^31.0.1", "electron-builder": "^24.0.0", "playwright": "^1.48.0", "puppeteer-core": "^24.37.3", "typescript": "^5.5.0" } }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" }, "engines": { "node": ">= 8.9.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" } }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", "dev": true, "license": "MIT", "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" }, "engines": { "node": ">=10.12.0" } }, "node_modules/@electron/asar/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/@electron/asar/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, "engines": { "node": "*" } }, "node_modules/@electron/get": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", "license": "MIT", "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "engines": { "node": ">=12" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "node_modules/@electron/notarize": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", "integrity": "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", "promise-retry": "^2.0.1" }, "engines": { "node": ">= 22.12.0" } }, "node_modules/@electron/osx-sign": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" }, "engines": { "node": ">=12.0.0" } }, "node_modules/@electron/osx-sign/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, "license": "MIT", "engines": { "node": ">= 8.0.0" }, "funding": { "url": "https://github.com/sponsors/gjtorikian/" } }, "node_modules/@electron/osx-sign/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/@electron/osx-sign/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/@electron/universal": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", "dev": true, "license": "MIT", "dependencies": { "@electron/asar": "^3.2.1", "@malept/cross-spawn-promise": "^1.1.0", "debug": "^4.3.1", "dir-compare": "^3.0.0", "fs-extra": "^9.0.1", "minimatch": "^3.0.4", "plist": "^3.0.4" }, "engines": { "node": ">=8.6" } }, "node_modules/@electron/universal/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/@electron/universal/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=10" } }, "node_modules/@electron/universal/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/@electron/universal/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, "engines": { "node": "*" } }, "node_modules/@electron/universal/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { "node": ">=12" } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", "dev": true, "funding": [ { "type": "individual", "url": "https://github.com/sponsors/malept" }, { "type": "tidelift", "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" } ], "license": "Apache-2.0", "dependencies": { "cross-spawn": "^7.0.1" }, "engines": { "node": ">= 10" } }, "node_modules/@malept/flatpak-bundler": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=10" } }, "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/@malept/flatpak-bundler/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", "optional": true, "engines": { "node": ">=14" } }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=18" } }, "node_modules/@puppeteer/browsers": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" }, "engines": { "node": ">=18" } }, "node_modules/@puppeteer/browsers/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sindresorhus/is?sponsor=1" } }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.0" }, "engines": { "node": ">=10" } }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" } }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "dev": true, "license": "MIT" }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" } }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "license": "MIT" }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", "dev": true, "license": "MIT", "optional": true }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", "license": "MIT", "optional": true, "dependencies": { "@types/node": "*" } }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", "dev": true, "license": "MIT" }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "4" }, "engines": { "node": ">= 6.0.0" } }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "dev": true, "license": "MIT", "peerDependencies": { "ajv": "^6.9.1" } }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/app-builder-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", "dev": true, "license": "MIT" }, "node_modules/app-builder-lib": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", "dev": true, "license": "MIT", "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/notarize": "2.2.1", "@electron/osx-sign": "1.0.5", "@electron/universal": "1.5.1", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "bluebird-lst": "^1.0.9", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "chromium-pickle-js": "^0.2.0", "debug": "^4.3.4", "ejs": "^3.1.8", "electron-publish": "24.13.1", "form-data": "^4.0.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "minimatch": "^5.1.1", "read-config-file": "6.3.2", "sanitize-filename": "^1.6.3", "semver": "^7.3.8", "tar": "^6.1.12", "temp-file": "^3.4.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "dmg-builder": "24.13.3", "electron-builder-squirrel-windows": "24.13.3" } }, "node_modules/app-builder-lib/node_modules/@electron/notarize": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=10" } }, "node_modules/app-builder-lib/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/app-builder-lib/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/app-builder-lib/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, "node_modules/app-builder-lib/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/archiver": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" }, "engines": { "node": ">= 10" } }, "node_modules/archiver-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" }, "engines": { "node": ">= 6" } }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "node_modules/archiver-utils/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "license": "MIT", "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "dev": true, "license": "MIT", "optional": true, "engines": { "node": ">=0.8" } }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.1" }, "engines": { "node": ">=4" } }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", "optional": true, "engines": { "node": ">=8" } }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true, "license": "MIT" }, "node_modules/async-exit-hook": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" } }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true, "license": "ISC", "engines": { "node": ">= 4.0.0" } }, "node_modules/axios": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "node_modules/b4a": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.5.tgz", "integrity": "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA==", "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" }, "peerDependenciesMeta": { "react-native-b4a": { "optional": true } } }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" }, "peerDependenciesMeta": { "bare-abort-controller": { "optional": true } } }, "node_modules/bare-fs": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "engines": { "bare": ">=1.16.0" }, "peerDependencies": { "bare-buffer": "*" }, "peerDependenciesMeta": { "bare-buffer": { "optional": true } } }, "node_modules/bare-os": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "dev": true, "license": "Apache-2.0", "optional": true, "engines": { "bare": ">=1.14.0" } }, "node_modules/bare-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "streamx": "^2.21.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { "bare-buffer": { "optional": true }, "bare-events": { "optional": true } } }, "node_modules/bare-url": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "bare-path": "^3.0.0" } }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT" }, "node_modules/basic-ftp": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true, "license": "MIT" }, "node_modules/bluebird-lst": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", "dev": true, "license": "MIT", "dependencies": { "bluebird": "^3.5.5" } }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", "optional": true }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "license": "MIT", "engines": { "node": "*" } }, "node_modules/buffer-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", "dev": true, "license": "MIT", "engines": { "node": ">=0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, "license": "MIT" }, "node_modules/builder-util": { "version": "24.13.1", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", "dev": true, "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", "7zip-bin": "~5.2.0", "app-builder-bin": "4.0.0", "bluebird-lst": "^1.0.9", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0" } }, "node_modules/builder-util-runtime": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" }, "engines": { "node": ">=12.0.0" } }, "node_modules/builder-util/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/builder-util/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/builder-util/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", "license": "MIT", "engines": { "node": ">=10.6.0" } }, "node_modules/cacheable-request": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", "license": "MIT", "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" }, "engines": { "node": ">=8" } }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true, "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/chromium-bidi": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "node_modules/chromium-pickle-js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", "dev": true, "license": "MIT" }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/sibiraj-s" } ], "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" }, "engines": { "node": ">=12" } }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, "engines": { "node": ">=7.0.0" } }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "dev": true, "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/compare-version": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" }, "engines": { "node": ">= 10" } }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/config-file-ts": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", "dev": true, "license": "MIT", "dependencies": { "glob": "^10.3.10", "typescript": "^5.3.3" } }, "node_modules/config-file-ts/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "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", "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/config-file-ts/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/config-file-ts/node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true, "license": "MIT" }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "buffer": "^5.1.0" } }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", "peer": true, "bin": { "crc32": "bin/crc32.njs" }, "engines": { "node": ">=0.8" } }, "node_modules/crc32-stream": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" }, "engines": { "node": ">= 10" } }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" }, "engines": { "node": ">= 8" } }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", "dev": true, "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" }, "engines": { "node": ">=6.0" }, "peerDependenciesMeta": { "supports-color": { "optional": true } } }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", "dev": true, "license": "MIT", "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" }, "engines": { "node": ">= 14" } }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT", "optional": true }, "node_modules/devtools-protocol": { "version": "0.0.1566079", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/dir-compare": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", "dev": true, "license": "MIT", "dependencies": { "buffer-equal": "^1.0.0", "minimatch": "^3.0.4" } }, "node_modules/dir-compare/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/dir-compare/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, "engines": { "node": "*" } }, "node_modules/dmg-builder": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "node_modules/dmg-builder/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/dmg-builder/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/dmg-builder/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/dmg-license": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "bin": { "dmg-license": "bin/dmg-license.js" }, "engines": { "node": ">=8" } }, "node_modules/dotenv": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=10" } }, "node_modules/dotenv-expand": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, "license": "MIT" }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" }, "engines": { "node": ">=0.10.0" } }, "node_modules/electron": { "version": "31.7.7", "resolved": "https://registry.npmjs.org/electron/-/electron-31.7.7.tgz", "integrity": "sha512-HZtZg8EHsDGnswFt0QeV8If8B+et63uD6RJ7I4/xhcXqmTIbI08GoubX/wm+HdY0DwcuPe1/xsgqpmYvjdjRoA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" }, "engines": { "node": ">= 12.20.55" } }, "node_modules/electron-audio-loopback": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/electron-audio-loopback/-/electron-audio-loopback-1.0.6.tgz", "integrity": "sha512-QW0ogDqMpWHDAQHmQyssJ+Yh4qR3kWCP3Q4H9WuIXKwVlgkqOYGyt0v/JzbK3tBNTwfqbuHZy86kwCCajxqAdg==", "license": "MIT", "peerDependencies": { "electron": ">=31.0.1" } }, "node_modules/electron-builder": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", "dev": true, "license": "MIT", "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "dmg-builder": "24.13.3", "fs-extra": "^10.1.0", "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "read-config-file": "6.3.2", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" }, "engines": { "node": ">=14.0.0" } }, "node_modules/electron-builder-squirrel-windows": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", "builder-util": "24.13.1", "fs-extra": "^10.1.0" } }, "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "peer": true, "engines": { "node": ">= 10.0.0" } }, "node_modules/electron-builder/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/electron-builder/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/electron-builder/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/electron-publish": { "version": "24.13.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "node_modules/electron-publish/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/electron-publish/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/electron-publish/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/electron-updater": { "version": "6.8.3", "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", "license": "MIT", "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "node_modules/electron-updater/node_modules/builder-util-runtime": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", "license": "MIT", "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" }, "engines": { "node": ">=12.0.0" } }, "node_modules/electron-updater/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/electron-updater/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/electron-updater/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, "node_modules/electron-updater/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "dev": true, "license": "MIT" }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-set-tostringtag": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", "license": "MIT", "optional": true }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", "optional": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" }, "engines": { "node": ">=6.0" }, "optionalDependencies": { "source-map": "~0.6.1" } }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" }, "engines": { "node": ">=4" } }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" } }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "bin": { "extract-zip": "cli.js" }, "engines": { "node": ">= 10.17.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" } }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", "dev": true, "engines": [ "node >=0.6.0" ], "license": "MIT", "optional": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "license": "MIT", "dependencies": { "pend": "~1.2.0" } }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", "dev": true, "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" } }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], "license": "MIT", "engines": { "node": ">=4.0" }, "peerDependenciesMeta": { "debug": { "optional": true } } }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" } }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, "license": "MIT", "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" }, "engines": { "node": ">=6 <7 || >=8" } }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, "engines": { "node": ">= 8" } }, "node_modules/fs-minipass/node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "license": "MIT", "dependencies": { "pump": "^3.0.0" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "dev": true, "license": "MIT", "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" }, "engines": { "node": ">= 14" } }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "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", "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, "engines": { "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/glob/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, "engines": { "node": "*" } }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", "license": "BSD-3-Clause", "optional": true, "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" }, "engines": { "node": ">=10.0" } }, "node_modules/global-agent/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "optional": true, "bin": { "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "license": "MIT", "optional": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/got": { "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" }, "engines": { "node": ">=10.19.0" }, "funding": { "url": "https://github.com/sindresorhus/got?sponsor=1" } }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-tostringtag": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, "engines": { "node": ">=10" } }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "dev": true, "license": "MIT", "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" }, "engines": { "node": ">= 6" } }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" }, "engines": { "node": ">=10.19.0" } }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", "debug": "4" }, "engines": { "node": ">= 6" } }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "engines": { "node": "^8.11.2 || >=10" } }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "BSD-3-Clause" }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "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.", "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, "license": "ISC" }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "dev": true, "license": "MIT", "engines": { "node": ">= 12" } }, "node_modules/is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", "dev": true, "license": "MIT", "dependencies": { "ci-info": "^3.2.0" }, "bin": { "is-ci": "bin.js" } }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, "license": "MIT", "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 18.0.0" }, "funding": { "url": "https://github.com/sponsors/gjtorikian/" } }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/isaacs" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "dev": true, "license": "Apache-2.0", "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" }, "engines": { "node": ">=10" } }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "license": "ISC", "optional": true }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" }, "engines": { "node": ">=6" } }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, "engines": { "node": ">= 0.6.3" } }, "node_modules/lazystream/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "license": "MIT", "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, "license": "MIT", "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, "license": "MIT", "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, "license": "MIT", "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, "license": "MIT", "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, "license": "MIT", "peer": true }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, "engines": { "node": ">=10" } }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", "license": "MIT", "optional": true, "dependencies": { "escape-string-regexp": "^4.0.0" }, "engines": { "node": ">=10" } }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, "license": "MIT", "bin": { "mime": "cli.js" }, "engines": { "node": ">=4.0.0" } }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { "node": ">=10" } }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "dev": true, "license": "ISC", "engines": { "node": ">=8" } }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "dev": true, "license": "MIT", "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" }, "engines": { "node": ">= 8" } }, "node_modules/minizlib/node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "dev": true, "license": "MIT" }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, "engines": { "node": ">=10" } }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.4.0" } }, "node_modules/node-addon-api": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", "dev": true, "license": "MIT", "optional": true }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", "optional": true, "engines": { "node": ">= 0.4" } }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { "wrappy": "1" } }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", "dev": true, "license": "MIT", "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" } }, "node_modules/pac-proxy-agent/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/pac-proxy-agent/node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" }, "engines": { "node": ">= 14" } }, "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "4" }, "engines": { "node": ">= 14" } }, "node_modules/pac-resolver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", "dev": true, "license": "MIT", "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" }, "engines": { "node": ">= 14" } }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=18" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { "node": ">=18" } }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", "dev": true, "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" }, "engines": { "node": ">=10.4.0" } }, "node_modules/posthog-node": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz", "integrity": "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==", "license": "MIT", "dependencies": { "axios": "^1.8.2" }, "engines": { "node": ">=15.0.0" } }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, "license": "MIT", "peer": true }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "dev": true, "license": "MIT", "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" }, "engines": { "node": ">=10" } }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" } }, "node_modules/proxy-agent/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/proxy-agent/node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" }, "engines": { "node": ">= 14" } }, "node_modules/proxy-agent/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "4" }, "engines": { "node": ">= 14" } }, "node_modules/proxy-agent/node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "dev": true, "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/puppeteer-core": { "version": "24.37.4", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.4.tgz", "integrity": "sha512-sQYtYgaNaLYO82k2FHmr7bR1tCmo2fBupEI7Kd0WpBlMropNcfxSTLOJXVRkhiHig0dUiMI7g0yq+HJI1IDCzg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" }, "engines": { "node": ">=18" } }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/read-config-file": { "version": "6.3.2", "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", "dev": true, "license": "MIT", "dependencies": { "config-file-ts": "^0.2.4", "dotenv": "^9.0.2", "dotenv-expand": "^5.1.0", "js-yaml": "^4.1.0", "json5": "^2.2.0", "lazy-val": "^1.0.4" }, "engines": { "node": ">=12.0.0" } }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" }, "engines": { "node": ">= 6" } }, "node_modules/readdir-glob": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { "minimatch": "^5.1.0" } }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", "license": "MIT" }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", "license": "BSD-3-Clause", "optional": true, "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" }, "engines": { "node": ">=8.0" } }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT", "peer": true }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", "dev": true, "license": "WTFPL OR ISC", "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "node_modules/sax": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" } }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/semver-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", "license": "MIT", "optional": true }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "license": "MIT", "optional": true, "dependencies": { "type-fest": "^0.13.1" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, "engines": { "node": ">=8" } }, "node_modules/shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" }, "engines": { "node": ">=10" } }, "node_modules/simple-update-notifier/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" }, "engines": { "node": ">=8" } }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "dev": true, "license": "MIT", "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, "node_modules/socks-proxy-agent": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, "engines": { "node": ">= 14" } }, "node_modules/socks-proxy-agent/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause", "optional": true }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", "dev": true, "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" } }, "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" } }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" }, "engines": { "node": ">= 8.0" } }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "deprecated": "Old versions of tar 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", "dev": true, "license": "ISC", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" }, "engines": { "node": ">=10" } }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "node_modules/tar-fs/node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" }, "engines": { "node": ">=6" } }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "streamx": "^2.12.5" } }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", "dev": true, "license": "MIT", "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "node_modules/temp-file/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { "node": ">=12" } }, "node_modules/temp-file/node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "node_modules/temp-file/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" } }, "node_modules/tiny-typed-emitter": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", "engines": { "node": ">=14.14" } }, "node_modules/tmp-promise": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", "dev": true, "license": "MIT", "dependencies": { "tmp": "^0.2.0" } }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", "dev": true, "license": "WTFPL", "dependencies": { "utf8-byte-length": "^1.0.1" } }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, "license": "0BSD" }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "license": "(MIT OR CC0-1.0)", "optional": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/typed-query-selector": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "dev": true, "license": "MIT" }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { "node": ">=14.17" } }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "license": "MIT", "engines": { "node": ">= 4.0.0" } }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", "dev": true, "license": "(WTFPL OR MIT)" }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, "license": "MIT", "peer": true }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" }, "engines": { "node": ">=0.6.0" } }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "dev": true, "license": "Apache-2.0" }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" }, "engines": { "node": ">= 8" } }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "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 } } }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", "dev": true, "license": "MIT", "engines": { "node": ">=8.0" } }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" }, "engines": { "node": ">=12" } }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" }, "engines": { "node": ">= 10" } }, "node_modules/zip-stream/node_modules/archiver-utils": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" }, "engines": { "node": ">= 10" } }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } } } } ================================================ FILE: app/package.json ================================================ { "name": "stenoai", "version": "0.2.13", "description": "AI-powered meeting transcription and analysis for Mac", "main": "main.js", "homepage": "https://github.com/ruzin/stenoai", "repository": { "type": "git", "url": "https://github.com/ruzin/stenoai.git" }, "scripts": { "start": "npm run build:renderer && electron .", "start:nobuild": "electron .", "dev:renderer": "vite", "build:renderer": "vite build", "typecheck:renderer": "tsc -p renderer/tsconfig.json --noEmit", "lint:renderer": "eslint renderer/src --ext .ts,.tsx", "format:renderer": "prettier --write renderer/src", "build": "electron-builder", "pack:unsigned": "electron-builder --dir --config electron-builder.ci.yml", "build-mac": "electron-builder --mac", "build:intel": "electron-builder --mac --x64", "build:arm64": "electron-builder --mac --arm64", "dist": "electron-builder --publish=never", "pack": "electron-builder --dir", "release": "electron-builder --publish=always", "version:patch": "npm version patch && git push && git push --tags", "version:minor": "npm version minor && git push && git push --tags", "version:major": "npm version major && git push && git push --tags", "release:patch": "npm run version:patch && npm run build", "release:minor": "npm run version:minor && npm run build", "release:major": "npm run version:major && npm run build", "test:e2e": "playwright test --config ../e2e/playwright.config.ts", "test:e2e:update": "playwright test --config ../e2e/playwright.config.ts --update-snapshots" }, "keywords": [ "audio", "transcription", "recording", "whisper", "ollama", "ai", "meetings" ], "author": "StenoAI Team", "license": "MIT", "devDependencies": { "@electron/notarize": "^3.1.1", "@playwright/test": "^1.48.0", "@types/node": "^20.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/parser": "^8.59.0", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.5.0", "electron": "^31.0.1", "electron-builder": "^24.0.0", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "playwright": "^1.48.0", "postcss": "^8.5.10", "prettier": "^3.8.3", "puppeteer-core": "^24.37.3", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "typescript": "^5.5.0", "vite": "^5.4.21" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.99.2", "@tanstack/react-virtual": "^3.13.24", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "electron-audio-loopback": "^1.0.0", "electron-updater": "^6.8.3", "lucide-react": "^1.8.0", "posthog-node": "^4.18.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.14.2", "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" }, "build": { "appId": "com.stenoai.recorder", "productName": "StenoAI", "protocols": [ { "name": "StenoAI Shortcut Links", "schemes": [ "stenoai" ] } ], "directories": { "output": "dist", "buildResources": "build" }, "files": [ "**/*", "!dist/**/*", "!build/**/*", "!assets/**/*", "!.git/**/*", "renderer/dist/**", "preload.js" ], "extraResources": [ { "from": "../dist/stenoai", "to": "stenoai", "filter": [ "**/*" ] }, { "from": "assets", "to": "assets", "filter": [ "trayIcon*.png" ] } ], "mac": { "icon": "build/icon-dragonfly.icns", "category": "public.app-category.productivity", "target": [ "dmg", "zip" ], "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "hardenedRuntime": true, "gatekeeperAssess": false, "identity": "Skrape Limited (HSDX294RG4)", "notarize": { "teamId": "HSDX294RG4" }, "extendInfo": { "NSMicrophoneUsageDescription": "StenoAI needs microphone access to record and transcribe meetings.", "NSScreenCaptureUsageDescription": "StenoAI needs screen capture access to record system audio from virtual meetings." } }, "dmg": { "title": "StenoAI Installer", "contents": [ { "x": 130, "y": 220, "type": "file" }, { "x": 410, "y": 220, "type": "link", "path": "/Applications" } ], "window": { "width": 540, "height": 380 }, "artifactName": "stenoAI-macos-${version}-${arch}.${ext}", "sign": true }, "publish": { "provider": "github", "owner": "ruzin", "repo": "stenoai" } } } ================================================ FILE: app/preload.js ================================================ /** * Preload — contextBridge boundary for the React renderer. * * This is the only surface the renderer gets. Every function here is a thin * wrapper over ipcRenderer.invoke / .send / .on, whitelisted to the channels * listed in app/docs/ipc-contract.md. Any drift between this file and that * doc is a contract break — update both in the same commit. */ const { contextBridge, ipcRenderer } = require('electron'); const VERSION = 1; const invoke = (channel, ...args) => ipcRenderer.invoke(channel, ...args); const send = (channel, ...args) => ipcRenderer.send(channel, ...args); // Every M→R event uses the same pattern: subscribe and return an unsubscribe // fn. The wrapper strips the IpcRendererEvent so the renderer only sees the // payload — that's an intentional part of the contract (renderer code must // stay unaware of Electron internals). const subscribe = (channel, cb) => { const handler = (_event, payload) => cb(payload); ipcRenderer.on(channel, handler); return () => ipcRenderer.removeListener(channel, handler); }; // Streaming helper. query-chunk + query-done are both multiplexed across // every in-flight query; the helper filters by queryId so the caller only // gets events for the stream they asked for. Returns an unsubscribe fn // that also sends query-cancel to the main process. const subscribeQueryStream = (queryId, { onChunk, onDone, onError } = {}) => { const chunkHandler = (_event, payload) => { if (payload && payload.queryId === queryId && onChunk) onChunk(payload.chunk); }; const doneHandler = (_event, payload) => { if (!payload || payload.queryId !== queryId) return; cleanup(); if (payload.success) { if (onDone) onDone(); } else { if (onError) onError(new Error(payload.error || 'query failed')); } }; const cleanup = () => { ipcRenderer.removeListener('query-chunk', chunkHandler); ipcRenderer.removeListener('query-done', doneHandler); }; ipcRenderer.on('query-chunk', chunkHandler); ipcRenderer.on('query-done', doneHandler); return () => { cleanup(); ipcRenderer.send('query-cancel', queryId); }; }; const stenoai = { version: VERSION, app: { getVersion: () => invoke('get-app-version'), }, window: { focus: () => send('focus-window'), readyToShow: () => send('renderer-ready-to-show'), }, shell: { openExternal: (url) => invoke('open-external', url), }, system: { getStatus: () => invoke('get-status'), test: () => invoke('test-system'), clearState: () => invoke('clear-state'), }, setup: { check: () => invoke('startup-setup-check'), systemCheck: () => invoke('setup-system-check'), ffmpeg: () => invoke('setup-ffmpeg'), python: () => invoke('setup-python'), ollamaAndModel: () => invoke('setup-ollama-and-model'), whisper: () => invoke('setup-whisper'), test: () => invoke('setup-test'), triggerWizard: () => invoke('trigger-setup-wizard'), }, perm: { checkMicrophone: () => invoke('check-microphone-permission'), requestMicrophone: () => invoke('request-microphone-permission'), }, recording: { start: (name) => invoke('start-recording-ui', name), stop: () => invoke('stop-recording-ui'), pause: () => invoke('pause-recording-ui'), resume: () => invoke('resume-recording-ui'), reportSystemAudioState: (active) => send('system-audio-recording-state', active), processSystemAudio: (filePath, name) => invoke('process-system-audio-recording', filePath, name), processFile: (filePath, name) => invoke('process-recording', filePath, name), pickAudioFile: () => invoke('select-audio-file'), getQueue: () => invoke('get-queue-status'), getDir: () => invoke('get-recordings-dir'), }, meetings: { list: () => invoke('list-meetings'), update: (summaryFile, patch) => invoke('update-meeting', summaryFile, patch), revealFolder: (filePath) => invoke('reveal-meeting-folder', filePath), delete: (meeting) => invoke('delete-meeting', meeting), reprocess: (summaryFile, regenTitle, name) => invoke('reprocess-meeting', summaryFile, regenTitle, name), regenTitle: (summaryFile, name) => invoke('regen-meeting-title', summaryFile, name), saveNotes: (name, notes) => invoke('save-meeting-notes', name, notes), }, query: { ask: (file, q) => invoke('query-transcript', file, q), askStream: (id, file, q) => send('query-transcript-stream', id, file, q), chatGlobalStream: (id, q, folderId) => send('chat-global-stream', id, q, folderId ?? null), cancel: (id) => send('query-cancel', id), }, chat: { save: (data) => invoke('save-chat-sessions', data), load: () => invoke('load-chat-sessions'), }, folders: { list: () => invoke('list-folders'), create: (name, color) => invoke('create-folder', name, color), rename: (id, name) => invoke('rename-folder', id, name), updateIcon: (id, icon) => invoke('update-folder-icon', id, icon), delete: (id) => invoke('delete-folder', id), reorder: (ids) => invoke('reorder-folders', ids), addMeeting: (summaryFile, folderId) => invoke('add-meeting-to-folder', summaryFile, folderId), removeMeeting: (summaryFile, folderId) => invoke('remove-meeting-from-folder', summaryFile, folderId), }, models: { checkOllama: () => invoke('check-ollama-installed'), list: () => invoke('list-models'), getCurrent: () => invoke('get-current-model'), set: (name) => invoke('set-model', name), checkInstalled: (name) => invoke('check-model-installed', name), pull: (name) => invoke('pull-model', name), }, settings: { getNotifications: () => invoke('get-notifications'), setNotifications: (v) => invoke('set-notifications', v), getTelemetry: () => invoke('get-telemetry'), setTelemetry: (v) => invoke('set-telemetry', v), getDockIcon: () => invoke('get-dock-icon'), setDockIcon: (v) => invoke('set-dock-icon', v), getSystemAudio: () => invoke('get-system-audio'), setSystemAudio: (v) => invoke('set-system-audio', v), getLanguage: () => invoke('get-language'), setLanguage: (code) => invoke('set-language', code), getUserName: () => invoke('get-user-name'), setUserName: (name) => invoke('set-user-name', name), getStoragePath: () => invoke('get-storage-path'), setStoragePath: (p) => invoke('set-storage-path', p), pickStorageFolder: () => invoke('select-storage-folder'), getAiPrompts: () => invoke('get-ai-prompts'), }, ai: { getProvider: () => invoke('get-ai-provider'), setProvider: (p) => invoke('set-ai-provider', p), setRemoteOllamaUrl: (url) => invoke('set-remote-ollama-url', url), testRemoteOllama: (url) => invoke('test-remote-ollama', url), setCloudApiUrl: (url) => invoke('set-cloud-api-url', url), setCloudApiKey: (key) => invoke('set-cloud-api-key', key), setCloudProvider: (p) => invoke('set-cloud-provider', p), setCloudModel: (m) => invoke('set-cloud-model', m), testCloudApi: () => invoke('test-cloud-api'), }, calendar: { google: { connect: () => invoke('google-auth-start'), status: () => invoke('google-auth-status'), disconnect: () => invoke('google-auth-disconnect'), }, outlook: { connect: () => invoke('outlook-auth-start'), status: () => invoke('outlook-auth-status'), disconnect: () => invoke('outlook-auth-disconnect'), }, getEvents: () => invoke('get-calendar-events'), }, updates: { check: () => invoke('check-for-updates'), announcements: () => invoke('check-announcements'), openReleasePage: (url) => invoke('open-release-page', url), install: () => send('install-update'), }, shortcuts: { rendererReady: () => send('shortcut-renderer-ready'), }, dialog: { respondQuit: (confirmed) => send('quit-dialog-response', { confirmed }), }, // All main-driven events. Every subscribe returns an unsubscribe fn. on: { debugLog: (cb) => subscribe('debug-log', cb), setupFlowTriggered: (cb) => subscribe('trigger-setup-flow', cb), toggleRecordingHotkey: (cb) => subscribe('toggle-recording-hotkey', cb), summaryChunk: (cb) => subscribe('summary-chunk', cb), summaryTitle: (cb) => subscribe('summary-title', cb), summaryComplete: (cb) => subscribe('summary-complete', cb), processingComplete: (cb) => subscribe('processing-complete', cb), queryChunk: (cb) => subscribe('query-chunk', cb), queryDone: (cb) => subscribe('query-done', cb), modelPullProgress: (cb) => subscribe('model-pull-progress', cb), modelPullComplete: (cb) => subscribe('model-pull-complete', cb), updateAvailable: (cb) => subscribe('update-available', cb), updateDownloadProgress: (cb) => subscribe('update-download-progress', cb), updateDownloaded: (cb) => subscribe('update-downloaded', cb), googleAuthChanged: (cb) => subscribe('google-auth-changed', cb), outlookAuthChanged: (cb) => subscribe('outlook-auth-changed', cb), shortcutStartRecording: (cb) => subscribe('shortcut-start-recording', cb), shortcutStopRecording: (cb) => subscribe('shortcut-stop-recording', cb), trayStartRecording: (cb) => subscribe('tray-start-recording', cb), trayStopRecording: (cb) => subscribe('tray-stop-recording', cb), trayOpenSettings: (cb) => subscribe('tray-open-settings', cb), showQuitDialog: (cb) => subscribe('show-quit-dialog', cb), }, subscribeQueryStream, }; contextBridge.exposeInMainWorld('stenoai', stenoai); ================================================ FILE: app/renderer/.eslintrc.cjs ================================================ module.exports = { root: true, parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 2022, sourceType: 'module', ecmaFeatures: { jsx: true } }, env: { browser: true, es2022: true }, plugins: ['@typescript-eslint', 'react', 'react-hooks'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', 'prettier', ], settings: { react: { version: 'detect' } }, rules: { 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], }, ignorePatterns: ['dist', 'node_modules'], }; ================================================ FILE: app/renderer/.prettierrc.json ================================================ { "semi": true, "singleQuote": true, "trailingComma": "es5", "printWidth": 100, "tabWidth": 2, "arrowParens": "always" } ================================================ FILE: app/renderer/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "src/globals.css", "baseColor": "stone", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: app/renderer/index.html ================================================ StenoAI
================================================ FILE: app/renderer/postcss.config.cjs ================================================ const path = require('node:path'); module.exports = { plugins: { tailwindcss: { config: path.join(__dirname, 'tailwind.config.cjs') }, autoprefixer: {}, }, }; ================================================ FILE: app/renderer/src/App.tsx ================================================ import * as React from 'react'; import { Sandbox } from '@/routes/Sandbox'; import { Settings } from '@/routes/Settings'; import { Setup } from '@/routes/Setup'; import { Chat } from '@/routes/Chat'; import { ChatConversation } from '@/routes/ChatConversation'; import { StreamingProvider } from '@/hooks/useStreamingQuery'; import { Home } from '@/routes/Home'; import { MeetingDetail } from '@/routes/MeetingDetail'; import { FolderDetail } from '@/routes/FolderDetail'; import { Recording } from '@/routes/Recording'; import { Processing, ProcessingDock } from '@/routes/Processing'; import { AskBar, TranscriptBar } from '@/components/AskBar'; import { BottomDockSlot } from '@/components/BottomDockSlot'; import { LiveDock } from '@/components/LiveDock'; import { QuitDialog } from '@/components/QuitDialog'; import { AskBarProvider } from '@/lib/askBarContext'; import { useRecording, useRecordingEvents, useRecordingProcessingEffects, } from '@/hooks/useRecording'; import { navigate, useRoute, rememberNonSettingsRoute } from '@/lib/router'; import { ipc } from '@/lib/ipc'; import { primeDebugLogs } from '@/lib/debugLogs'; export function App() { const route = useRoute(); React.useLayoutEffect(() => { if (typeof window !== 'undefined' && window.stenoai) { ipc().window.readyToShow(); } }, []); React.useEffect(() => { if (typeof window === 'undefined' || !window.stenoai) return; const off = [ ipc().on.trayOpenSettings(() => navigate('/settings')), ipc().on.setupFlowTriggered(() => navigate('/setup')), // Capture backend debug-log lines from app start (not just when Settings // → Developer is open) so the console always has the full session. primeDebugLogs((cb) => ipc().on.debugLog(cb)), ]; return () => off.forEach((fn) => fn()); }, []); useRecordingEvents(); useRecordingProcessingEffects(); // Track the last non-settings route so the sidebar Settings toggle and the // Settings page's Back button can return the user to where they came from // (e.g. a meeting they were viewing) instead of dropping them on Home. React.useEffect(() => { rememberNonSettingsRoute(route); }, [route]); // Cold-reload mid-processing: if we restart the app while the backend is // still summarizing, drop the user on /meetings/processing so they don't // sit on Home wondering what happened. Only fires once on first render. const recording = useRecording(); const didAutoRouteRef = React.useRef(false); React.useEffect(() => { if (didAutoRouteRef.current) return; if (recording.isLoading) return; didAutoRouteRef.current = true; if ( recording.status === 'processing' && (route === '/' || route === '' || route === '/meetings') ) { navigate('/meetings/processing'); } else if ( (recording.status === 'recording' || recording.status === 'paused') && (route === '/' || route === '' || route === '/meetings') ) { navigate('/recording'); } }, [recording.isLoading, recording.status, route]); // ⌘K — focus sidebar search. Capture-phase listener so it wins over nested // handlers; fires even when focus is in a form control (the search input // itself is exempt by the data-sidebar-search check). React.useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return; const search = document.querySelector( '[data-sidebar-search]', ); if (search) { e.preventDefault(); search.focus(); search.select(); } }; document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); }, []); const isRecordingRoute = route === '/recording'; const isProcessingRoute = route === '/meetings/processing'; // The /chat page has its own large composer, so the floating AskBar dock // would just stack a second redundant input below the same page. The // sub-route /chat/ (conversation view) also owns its own composer. const isChatRoute = route === '/chat' || route.startsWith('/chat/'); const showAskBar = !isRecordingRoute && !isProcessingRoute && !isChatRoute; return ( {/* Bottom dock — shared anchor across recording → processing → meeting. */} {isRecordingRoute && } {isProcessingRoute && } {showAskBar && } {/* Transcript — floats above the chat bar (only on real meeting routes). */} {showAskBar && ( )} ); } function RouteView({ route }: { route: string }) { if (route === '/dev' || route.startsWith('/dev/')) return ; if (route === '/settings') return ; if (route === '/setup') return ; if (route === '/recording') return ; if (route === '/chat') return ; if (route.startsWith('/chat/')) { const sessionId = safeDecode(route.slice('/chat/'.length)); return ; } if (route === '/meetings/processing') return ; if (route.startsWith('/meetings/')) { const summaryFile = safeDecode(route.slice('/meetings/'.length)); return ; } if (route.startsWith('/folders/')) { const folderId = safeDecode(route.slice('/folders/'.length)); return ; } if (route === '/meetings') return ; return ; } // Tolerate malformed % escapes — a bad route shouldn't crash the renderer. function safeDecode(s: string): string { try { return decodeURIComponent(s); } catch { return s; } } ================================================ FILE: app/renderer/src/components/AppShell.tsx ================================================ import * as React from 'react'; import { MainToolbar } from '@/components/MainToolbar'; import { cn } from '@/lib/utils'; import type { RecordingStatus } from '@/hooks/useRecording'; interface AppShellProps { recordingStatus: RecordingStatus; recordingElapsed?: number; onToggleRecording: () => void; onToggleSidebar: () => void; sidebar: React.ReactNode; sidebarWidth: number; sidebarCollapsed: boolean; askBarSlot?: React.ReactNode; contentAlign?: 'top' | 'center'; /** * When true, omits the MainToolbar (record button). Implies `bleed`. * Used by /recording where the LiveDock owns recording controls. */ hideToolbar?: boolean; /** * When true, renders children directly inside the main pane without the * centered max-w-[820px] content wrapper. Use for routes that own their * own header/scroll layout (e.g. Settings). Implied by hideToolbar. */ bleed?: boolean; children: React.ReactNode; } export function AppShell({ recordingStatus, recordingElapsed, onToggleRecording, onToggleSidebar, sidebar, sidebarWidth, sidebarCollapsed, askBarSlot, contentAlign = 'top', hideToolbar = false, bleed = false, children, }: AppShellProps) { const effectiveWidth = sidebarCollapsed ? 0 : sidebarWidth; const useBleed = hideToolbar || bleed; return (
{sidebar}
{!hideToolbar && ( )} {useBleed ? ( children ) : (
{children}
)} {askBarSlot && (
{askBarSlot}
)}
); } ================================================ FILE: app/renderer/src/components/AskBar.tsx ================================================ import * as React from 'react'; import { ArrowUp, Check, ChevronDown, ChevronUp, Copy, Square, X, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { renderMarkdown } from '@/lib/markdown'; import { useAskBar } from '@/lib/askBarContext'; import { useChatSessions, type ChatMessage, type ChatSession, } from '@/hooks/useChatSessions'; import { useGlobalStreaming } from '@/hooks/useStreamingQuery'; import { TranscriptPanelContent } from '@/components/TranscriptPanel'; import { useMeeting } from '@/hooks/useMeetings'; // --------------------------------------------------------------------------- // Transcript bar — rendered separately above the chat bar // --------------------------------------------------------------------------- export function TranscriptBar() { const { activeSummaryFile, transcriptOpen, setTranscriptOpen } = useAskBar(); const meeting = useMeeting(activeSummaryFile ?? undefined); const [copied, setCopied] = React.useState(false); const copyTranscript = async () => { if (!meeting.data) return; const text = (meeting.data.transcript ?? '').trim(); if (!text) return; await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }; if (!transcriptOpen || !activeSummaryFile) return null; return (
e.stopPropagation()} >
); } export function AskBar() { const { activeSummaryFile, activeMeetingName, transcriptOpen, setTranscriptOpen } = useAskBar(); const chat = useChatSessions(activeSummaryFile, activeMeetingName); const streaming = useGlobalStreaming(); const [expanded, setExpanded] = React.useState(false); const [sessionMenuOpen, setSessionMenuOpen] = React.useState(false); const [input, setInput] = React.useState(''); const [activeStreamId, setActiveStreamId] = React.useState(null); const pendingPersistRef = React.useRef(null); const scrollRef = React.useRef(null); const containerRef = React.useRef(null); const inputRef = React.useRef(null); const activeStream = activeStreamId ? streaming.streams[activeStreamId] : null; const isStreaming = activeStream?.status === 'streaming'; const session = chat.activeSession; const hasMessages = (session?.messages.length ?? 0) > 0; const hidden = !activeSummaryFile; const canSend = input.trim().length > 0 && !isStreaming; const cancelStreamRef = React.useRef(streaming.cancelStream); cancelStreamRef.current = streaming.cancelStream; React.useEffect(() => { setExpanded(false); setSessionMenuOpen(false); setTranscriptOpen(false); setActiveStreamId((prev) => { if (prev) { cancelStreamRef.current(prev); pendingPersistRef.current = null; } return null; }); }, [activeSummaryFile, setTranscriptOpen]); React.useEffect(() => { if (!expanded && !transcriptOpen) return; const handler = (e: MouseEvent) => { const target = e.target as Element | null; // Treat the AskBar container AND the floating TranscriptBar as in-bounds. // Without the transcript check, clicks inside the transcript's search // input or copy button would close the panel before the click resolves. const inside = (containerRef.current && containerRef.current.contains(target as Node)) || (target && target.closest?.('[data-transcript-bar]')); if (!inside) { setExpanded(false); setSessionMenuOpen(false); setTranscriptOpen(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [expanded, transcriptOpen, setTranscriptOpen]); React.useEffect(() => { if (!scrollRef.current) return; scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [session?.messages.length, activeStream?.text, expanded]); React.useEffect(() => { if (!activeStreamId) return; const stream = streaming.streams[activeStreamId]; if (!stream) return; const sessionId = pendingPersistRef.current; if (!sessionId) return; if (stream.status === 'streaming') return; const content = stream.text.trim() || (stream.status === 'error' ? `Error: ${stream.error ?? 'query failed'}` : '(empty response)'); const message: ChatMessage = { role: 'assistant', content, ts: Date.now() }; void chat.appendMessage(sessionId, message); pendingPersistRef.current = null; streaming.clearStream(activeStreamId); setActiveStreamId(null); }, [activeStreamId, streaming, chat]); // Re-entrancy guard. submitPrompt awaits createSession/appendMessage; rapid // suggestion-chip clicks (or Enter) before those resolve would otherwise // create duplicate sessions and clobber the persistence ref. const submittingRef = React.useRef(false); const submitPrompt = async (raw: string) => { const q = raw.trim(); if (!q || !activeSummaryFile || isStreaming) return; if (submittingRef.current) return; submittingRef.current = true; try { let sessionId = session?.id ?? null; if (!sessionId) { sessionId = await chat.createSession(deriveSessionName(q)); } const userMsg: ChatMessage = { role: 'user', content: q, ts: Date.now() }; await chat.appendMessage(sessionId, userMsg); setInput(''); const streamId = streaming.startStream(activeSummaryFile, q); pendingPersistRef.current = sessionId; setActiveStreamId(streamId); setExpanded(true); setTranscriptOpen(false); } finally { submittingRef.current = false; } }; const submit = () => submitPrompt(input); const stop = () => { if (!activeStreamId) return; streaming.cancelStream(activeStreamId); }; const onPickSession = (id: string) => { chat.setActiveId(id); setSessionMenuOpen(false); setExpanded(true); }; const onNewSession = async () => { setSessionMenuOpen(false); if (session && session.messages.length === 0) { setExpanded(true); return; } await chat.createSession(); setExpanded(true); }; const handleTranscriptToggle = () => { if (transcriptOpen) { setTranscriptOpen(false); } else { setTranscriptOpen(true); setExpanded(false); setSessionMenuOpen(false); } }; const handleInputFocus = () => { setExpanded(true); if (transcriptOpen) setTranscriptOpen(false); }; const handleCollapse = () => { setExpanded(false); setSessionMenuOpen(false); }; if (hidden) return null; const showChatPanel = expanded && (hasMessages || isStreaming); return (
{/* Chat message panel */} {showChatPanel && (
setSessionMenuOpen((v) => !v)} onPickSession={onPickSession} onDeleteSession={(id) => void chat.deleteSession(id)} onNewSession={() => void onNewSession()} onCollapse={handleCollapse} />
)} {/* Suggestion chips — appear when ask bar is focused with empty conversation */} {expanded && !hasMessages && !isStreaming && (
{SUGGESTION_CHIPS.map((chip) => ( ))}
)} {/* Chat composer */}
{ e.preventDefault(); void submit(); }} > {/* Transcript toggle */} {/* Text input */} setInput(e.target.value)} onFocus={handleInputFocus} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (isStreaming) stop(); else void submit(); } if (e.key === 'Escape') { handleCollapse(); (e.target as HTMLElement).blur(); } }} placeholder={hasMessages ? 'Continue chat…' : 'Ask anything about this meeting…'} aria-label="Ask about this meeting" /> {/* Send / stop */} {isStreaming ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // Chat header with floating session dropdown // --------------------------------------------------------------------------- interface ChatHeaderProps { session: ChatSession | null; meetingName: string | null; sessions: ChatSession[]; activeId: string | null; sessionMenuOpen: boolean; onOpenSessions: () => void; onPickSession: (id: string) => void; onDeleteSession: (id: string) => void; onNewSession: () => void; onCollapse: () => void; } function ChatHeader({ session, meetingName, sessions, activeId, sessionMenuOpen, onOpenSessions, onPickSession, onDeleteSession, onNewSession, onCollapse, }: ChatHeaderProps) { return (
{sessionMenuOpen && ( )}
); } // --------------------------------------------------------------------------- // Session dropdown // --------------------------------------------------------------------------- interface SessionDropdownProps { sessions: ChatSession[]; activeId: string | null; onPick: (id: string) => void; onDelete: (id: string) => void; } function SessionDropdown({ sessions, activeId, onPick, onDelete }: SessionDropdownProps) { return (
{sessions.length === 0 ? (

No saved chats yet.

) : ( sessions.map((s) => { const isActive = s.id === activeId; return (
); }) )}
); } // --------------------------------------------------------------------------- // Message list + bubbles // --------------------------------------------------------------------------- interface MessageListProps { messages: ChatMessage[]; liveText: string; streaming: boolean; } function MessageList({ messages, liveText, streaming }: MessageListProps) { return (
{messages.map((m, i) => ( ))} {streaming && (
{liveText ? (
{renderMarkdown(liveText)}
) : (
Thinking
)}
)}
); } function MessageBubble({ message }: { message: ChatMessage }) { const isUser = message.role === 'user'; return (
{isUser ? (
{message.content}
) : (
{renderMarkdown(message.content)}
)}
); } // Markdown rendering moved to lib/markdown.tsx so the Chat tab can share it. const SUGGESTION_CHIPS: { label: string; prompt: string }[] = [ { label: 'Summarize key decisions', prompt: 'Summarize the key decisions made' }, { label: 'Action items', prompt: 'What action items were discussed?' }, { label: 'Main topics', prompt: 'What were the main topics covered?' }, ]; function deriveSessionName(prompt: string): string { const trimmed = prompt.trim().replace(/\s+/g, ' '); if (trimmed.length <= 40) return trimmed; return `${trimmed.slice(0, 40).trimEnd()}…`; } ================================================ FILE: app/renderer/src/components/AudioWave.tsx ================================================ import { useAudioLevel } from '@/hooks/useAudioLevel'; interface AudioWaveProps { /** True while a recording is active and unpaused. */ active: boolean; /** Paused recordings keep the bars but stop reading the mic. */ paused?: boolean; /** Number of bars to render. */ bars?: number; /** Total height in px. */ height?: number; /** Bar width in px. */ barWidth?: number; /** Gap between bars in px. */ gap?: number; /** CSS color for the bars. */ color?: string; } /** * Speech-reactive bar-graph driven by useAudioLevel. Falls back to a flat * shimmer if mic permission is denied. Used inside the recording pill on * /recording and the MainToolbar record button. */ export function AudioWave({ active, paused = false, bars = 7, height = 16, barWidth = 2, gap = 2, color = 'currentColor', }: AudioWaveProps) { const levels = useAudioLevel({ enabled: active && !paused, bars }); return ( {levels.map((lvl, i) => ( ))} ); } ================================================ FILE: app/renderer/src/components/BottomDockSlot.tsx ================================================ import * as React from 'react'; import { useSidebarCollapsed, useSidebarWidth } from '@/components/Sidebar'; interface BottomDockSlotProps { children: React.ReactNode; /** Distance from bottom in px. 0 for the primary dock, 72 for the floater above it. */ bottomOffset?: number; } /** * Canonical fixed-bottom anchor used by AskBar, LiveDock, and ProcessingDock. * Ensures all three states sit in the exact same screen slot so transitions * between recording → processing → meeting feel like a content swap, not a * layout reshuffle. Width tracks the sidebar like AskBar does today. */ export function BottomDockSlot({ children, bottomOffset = 0 }: BottomDockSlotProps) { const { sidebarCollapsed } = useSidebarCollapsed(); const { width: sidebarWidth } = useSidebarWidth(); // Match AppShell's main-pane left edge: 0 when sidebar is collapsed (main // has marginLeft:0), sidebarWidth when expanded. Anything else shifts the // dock's centerline off the notes column's centerline. const left = sidebarCollapsed ? 0 : sidebarWidth; // Only the primary dock (bottomOffset === 0, sitting at the screen bottom) // gets the fade backdrop — the floater above it would double-stack. const showFade = bottomOffset === 0; return (
{showFade && ( // Narrower, lighter fade — just enough to soften scrolling content as // it approaches the pill. The solid band below keeps content from // peeking out around the pill. The pill sits on its own opaque raised // surface and renders above both layers. <>
)}
{children}
); } ================================================ FILE: app/renderer/src/components/ChatHistoryRow.tsx ================================================ import * as React from 'react'; import { MessageSquare, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { relativeTime } from '@/lib/chat'; import { navigate } from '@/lib/router'; export interface ChatHistoryRowSession { id: string; name: string; updatedAt: number; } interface ChatHistoryRowProps { session: ChatHistoryRowSession; /** Pass the route's current sessionId to highlight the active row. */ activeId?: string | null; /** Show a relative-time chip on the right. Used on the /chat entry page; * the dropdown variant hides it because group headers carry the time. */ showTime?: boolean; /** Fires after a successful navigate so the parent (e.g. a History * popover) can close itself. No-op for non-dismissible parents. */ onSelect?: () => void; onRename: (name: string) => void; onDelete: () => void | Promise; } /** * Single row used by both the Chat entry page's Recents list and the * conversation page's History dropdown. Shared so the rename/delete * affordance behaves identically in both places. * * Hover surfaces a "..." button that opens a secondary menu with Rename * (pencil) and Delete (trash, --danger). Rename swaps the title for an * inline input — Enter saves, Escape cancels, blur auto-commits. */ export function ChatHistoryRow({ session, activeId, showTime = false, onSelect, onRename, onDelete, }: ChatHistoryRowProps) { const [menuOpen, setMenuOpen] = React.useState(false); const [renaming, setRenaming] = React.useState(false); const [draft, setDraft] = React.useState(session.name); const inputRef = React.useRef(null); // Tracks whether the user pressed Escape so the imminent blur cancels // instead of committing the (possibly edited) draft. const cancelRef = React.useRef(false); React.useEffect(() => { if (!renaming) return; inputRef.current?.focus(); inputRef.current?.select(); }, [renaming]); const startRename = () => { setDraft(session.name); setRenaming(true); setMenuOpen(false); }; const commitRename = () => { const next = draft.trim(); if (next && next !== session.name) onRename(next); setRenaming(false); }; const isActive = activeId === session.id; const navigateToChat = () => { navigate(`/chat/${encodeURIComponent(session.id)}`); onSelect?.(); }; return (
{renaming ? ( setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitRename(); } else if (e.key === 'Escape') { e.preventDefault(); cancelRef.current = true; setRenaming(false); } }} onBlur={() => { if (cancelRef.current) { cancelRef.current = false; return; } commitRename(); }} className="flex-1 min-w-0 rounded border-0 bg-transparent px-1 py-0 text-[13px] outline-none focus:shadow-[inset_0_0_0_1px_hsl(var(--border))]" style={{ color: 'var(--fg-1)' }} /> ) : ( )} {showTime && !renaming && ( {relativeTime(session.updatedAt)} )} e.stopPropagation()} >
); } ================================================ FILE: app/renderer/src/components/FolderScopePicker.tsx ================================================ import * as React from 'react'; import { ChevronDown, Folder as FolderIcon, Inbox } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { useFolders } from '@/hooks/useFolders'; import type { Folder } from '@/lib/ipc'; interface FolderScopePickerProps { /** Selected folder ID. null = all notes. */ value: string | null; onChange: (folderId: string | null) => void; } /** * Compact "scope" chip used inside chat composers. Lets the user limit a * cross-note query to a single folder instead of asking across everything. * Backend filter happens server-side; this just persists the choice and * passes it to startGlobalStream. */ export function FolderScopePicker({ value, onChange }: FolderScopePickerProps) { const folders = useFolders(); const [open, setOpen] = React.useState(false); const folder = React.useMemo(() => { if (!value) return null; return folders.data?.find((f) => f.id === value) ?? null; }, [folders.data, value]); // If the scoped folder was deleted out from under us, drop the scope so we // don't keep filtering against a dead id (and so the chip stops lying about // what's selected). React.useEffect(() => { if (value && folders.data && !folder) { onChange(null); } }, [value, folders.data, folder, onChange]); const label = folder ? folder.name : 'All notes'; return (
Ask across…
{(folders.data ?? []).length > 0 && (
)} {(folders.data ?? []).map((f) => ( ))} ); } ================================================ FILE: app/renderer/src/components/IconPicker.tsx ================================================ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import * as LucideIcons from 'lucide-react'; import { Search, X } from 'lucide-react'; // --------------------------------------------------------------------------- // Icon catalogue — name (kebab-case lucide icon) + searchable tags // --------------------------------------------------------------------------- const ICON_LIST = [ // Work & org { name: 'briefcase', tags: ['work','job','office','bag'] }, { name: 'building-2', tags: ['office','company','building','org'] }, { name: 'users', tags: ['team','people','group'] }, { name: 'user', tags: ['person','profile','account'] }, { name: 'user-check', tags: ['person','verified','profile'] }, { name: 'presentation', tags: ['slides','talk','pitch'] }, { name: 'chart-bar', tags: ['stats','data','analytics','graph'] }, { name: 'chart-line', tags: ['trend','graph','analytics'] }, { name: 'chart-pie', tags: ['breakdown','analytics','chart'] }, { name: 'target', tags: ['goal','aim','objective'] }, { name: 'trophy', tags: ['win','award','achievement'] }, { name: 'handshake', tags: ['deal','partner','agreement'] }, { name: 'calendar', tags: ['date','schedule','event'] }, { name: 'calendar-days', tags: ['date','schedule','meeting'] }, { name: 'clock', tags: ['time','schedule','timer'] }, { name: 'mail', tags: ['email','message','inbox'] }, { name: 'inbox', tags: ['email','messages','all'] }, { name: 'send', tags: ['email','message','send'] }, { name: 'phone', tags: ['call','contact','mobile'] }, { name: 'video', tags: ['meeting','call','zoom'] }, { name: 'laptop', tags: ['computer','work','device'] }, { name: 'monitor', tags: ['desktop','screen','computer'] }, { name: 'pen-line', tags: ['write','edit','notes'] }, { name: 'pencil', tags: ['write','edit','notes'] }, { name: 'clipboard', tags: ['notes','list','task'] }, { name: 'clipboard-list', tags: ['tasks','checklist','todo'] }, { name: 'list-checks', tags: ['done','checklist','todo'] }, { name: 'check-square', tags: ['done','complete','task'] }, { name: 'file-text', tags: ['document','notes','file'] }, { name: 'file', tags: ['document','file','generic'] }, { name: 'files', tags: ['documents','multiple','files'] }, { name: 'folder', tags: ['folder','directory','files'] }, { name: 'folder-open', tags: ['folder','open','files'] }, { name: 'archive', tags: ['store','archive','box'] }, { name: 'box', tags: ['storage','package','box'] }, { name: 'package', tags: ['delivery','product','box'] }, { name: 'bookmark', tags: ['save','mark','favourite'] }, { name: 'tag', tags: ['label','category','tag'] }, { name: 'tags', tags: ['labels','categories','tags'] }, { name: 'layers', tags: ['stack','categories','layers'] }, { name: 'flag', tags: ['priority','flag','mark'] }, { name: 'link', tags: ['url','link','connect'] }, { name: 'search', tags: ['find','search','magnify'] }, { name: 'filter', tags: ['sort','filter','refine'] }, // Health & medical { name: 'stethoscope', tags: ['health','medical','doctor','clinic'] }, { name: 'heart-pulse', tags: ['health','medical','heart','vital'] }, { name: 'heart', tags: ['health','favourite','love','care'] }, { name: 'activity', tags: ['health','pulse','monitor','vital'] }, { name: 'pill', tags: ['medicine','pharmacy','drug'] }, { name: 'hospital', tags: ['health','building','medical'] }, { name: 'thermometer', tags: ['health','temperature','medical'] }, { name: 'eye', tags: ['vision','view','health'] }, { name: 'brain', tags: ['mind','neuro','health','thinking'] }, { name: 'microscope', tags: ['science','lab','research','health'] }, // Legal & finance { name: 'scale', tags: ['legal','law','justice','balance'] }, { name: 'gavel', tags: ['legal','court','ruling','law'] }, { name: 'scroll', tags: ['legal','document','contract'] }, { name: 'shield', tags: ['protection','security','privacy','legal'] }, { name: 'shield-check', tags: ['safe','verified','security','legal'] }, { name: 'lock', tags: ['private','secure','lock'] }, { name: 'key', tags: ['access','key','unlock','security'] }, { name: 'landmark', tags: ['bank','government','institution'] }, { name: 'banknote', tags: ['money','finance','payment','cash'] }, { name: 'coins', tags: ['money','finance','currency'] }, { name: 'wallet', tags: ['payment','money','finance'] }, { name: 'credit-card', tags: ['payment','finance','card'] }, { name: 'trending-up', tags: ['growth','finance','increase'] }, { name: 'trending-down', tags: ['decline','finance','decrease'] }, // Property & home { name: 'home', tags: ['house','property','home','real estate'] }, { name: 'map-pin', tags: ['location','place','address','property'] }, { name: 'map', tags: ['location','navigation','area'] }, { name: 'compass', tags: ['navigate','direction','explore'] }, { name: 'door-open', tags: ['entry','access','property','door'] }, { name: 'sofa', tags: ['interior','home','living','furniture'] }, { name: 'bed', tags: ['bedroom','property','sleep'] }, // Education & research { name: 'graduation-cap', tags: ['education','study','university','degree'] }, { name: 'book', tags: ['read','study','learn','book'] }, { name: 'book-open', tags: ['read','study','content','open'] }, { name: 'library', tags: ['books','research','archive','library'] }, { name: 'lightbulb', tags: ['idea','inspiration','creative'] }, { name: 'flask-conical', tags: ['science','research','lab','experiment'] }, { name: 'test-tube', tags: ['science','lab','chemistry','test'] }, { name: 'telescope', tags: ['research','discovery','explore'] }, // Tech & dev { name: 'code-2', tags: ['code','developer','programming','tech'] }, { name: 'terminal', tags: ['code','shell','dev','console'] }, { name: 'git-branch', tags: ['code','version control','dev','git'] }, { name: 'database', tags: ['data','storage','tech','db'] }, { name: 'server', tags: ['infrastructure','tech','cloud','server'] }, { name: 'cloud', tags: ['cloud','storage','tech','backup'] }, { name: 'cpu', tags: ['hardware','tech','compute','processor'] }, { name: 'wifi', tags: ['network','internet','connection'] }, { name: 'settings', tags: ['config','preferences','settings'] }, { name: 'wrench', tags: ['tool','fix','maintenance','dev'] }, { name: 'bug', tags: ['error','debug','issue','dev'] }, { name: 'zap', tags: ['fast','power','lightning','automation'] }, { name: 'rocket', tags: ['launch','startup','fast','deploy'] }, // Creative & media { name: 'mic', tags: ['audio','record','voice','meeting'] }, { name: 'headphones', tags: ['audio','listen','music','media'] }, { name: 'music', tags: ['audio','song','playlist','media'] }, { name: 'camera', tags: ['photo','image','capture','media'] }, { name: 'image', tags: ['photo','picture','gallery','media'] }, { name: 'film', tags: ['video','movie','media','film'] }, { name: 'play-circle', tags: ['video','play','media','watch'] }, { name: 'pen-tool', tags: ['design','draw','creative','pen'] }, { name: 'palette', tags: ['design','colour','creative','art'] }, { name: 'layout-grid', tags: ['design','grid','layout','ui'] }, { name: 'sparkles', tags: ['ai','magic','new','creative','feature'] }, // Nature & misc { name: 'sun', tags: ['morning','bright','day','energy'] }, { name: 'moon', tags: ['night','dark','evening','rest'] }, { name: 'star', tags: ['favourite','important','rate','star'] }, { name: 'globe', tags: ['world','international','web','global'] }, { name: 'leaf', tags: ['nature','green','environment','eco'] }, { name: 'tree-pine', tags: ['nature','forest','environment'] }, { name: 'mountain', tags: ['landscape','outdoors','nature'] }, { name: 'flame', tags: ['hot','urgent','fire','energy'] }, { name: 'coffee', tags: ['break','morning','casual','relax'] }, { name: 'smile', tags: ['happy','personal','casual','mood'] }, ]; // Deduplicate by name const ICONS = Array.from(new Map(ICON_LIST.map((i) => [i.name, i])).values()); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Convert kebab-case to PascalCase for lucide-react named exports. */ function toPascalCase(name: string): string { return name .split('-') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); } /** Render a lucide icon by its kebab-case name. Falls back to Folder icon. */ export function LucideIcon({ name, size = 16, className, style, }: { name: string; size?: number; className?: string; style?: React.CSSProperties; }) { const pascal = toPascalCase(name); // eslint-disable-next-line @typescript-eslint/no-explicit-any const icons = LucideIcons as Record; const Comp = icons[pascal] ?? icons['Folder']; return ; } // --------------------------------------------------------------------------- // IconPicker // --------------------------------------------------------------------------- const PANEL_W = 276; const PANEL_MAX_H = 320; const SEARCH_H = 52; const GAP = 6; interface IconPickerProps { anchorRect: DOMRect; onSelect: (iconName: string) => void; onClose: () => void; } export function IconPicker({ anchorRect, onSelect, onClose }: IconPickerProps) { const [query, setQuery] = React.useState(''); const inputRef = React.useRef(null); const panelRef = React.useRef(null); const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return ICONS; return ICONS.filter( (i) => i.name.includes(q) || i.tags.some((t) => t.includes(q)), ); }, [query]); // Focus search on mount React.useEffect(() => { inputRef.current?.focus(); }, []); // Click-outside to close (slight delay so the originating click doesn't immediately close) React.useEffect(() => { const handler = (e: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(e.target as Node)) { onClose(); } }; const t = setTimeout(() => document.addEventListener('mousedown', handler), 50); return () => { clearTimeout(t); document.removeEventListener('mousedown', handler); }; }, [onClose]); // Escape to close React.useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [onClose]); // Position: below anchor, horizontally centred; flip above if not enough space const vw = window.innerWidth; const vh = window.innerHeight; let left = anchorRect.left + anchorRect.width / 2 - PANEL_W / 2; let top = anchorRect.bottom + GAP; if (left < 8) left = 8; if (left + PANEL_W > vw - 8) left = vw - PANEL_W - 8; if (top + PANEL_MAX_H > vh - 8) top = Math.max(8, anchorRect.top - PANEL_MAX_H - GAP); const portal = document.getElementById('dialog-host') ?? document.body; return ReactDOM.createPortal(
{/* Search bar */}
setQuery(e.target.value)} placeholder="Search icons…" style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', fontSize: 13, color: 'var(--fg-1)', fontFamily: 'var(--font-sans)', }} /> {query && ( )}
{/* Icon grid */}
{filtered.length === 0 ? (
No icons match "{query}"
) : (
{filtered.map((icon) => ( { onSelect(icon.name); onClose(); }} /> ))}
)}
, portal, ); } function IconButton({ name, onSelect }: { name: string; onSelect: () => void }) { const [hovered, setHovered] = React.useState(false); return ( ); } ================================================ FILE: app/renderer/src/components/LiveDock.tsx ================================================ import { Pause, Play, Square } from 'lucide-react'; import { AudioWave } from '@/components/AudioWave'; import { useRecording } from '@/hooks/useRecording'; /** * Recording-state dock for the /recording route. Mounted at App level inside * BottomDockSlot so it shares the same screen slot as AskBar + ProcessingDock * — when the user stops, the visual frame stays put while the contents swap. */ export function LiveDock() { const recording = useRecording(); const paused = recording.status === 'paused'; const isRecording = recording.status === 'recording'; const stopped = !paused && !isRecording; const onPauseToggle = () => { if (paused) void recording.resumeRecording(); else if (isRecording) void recording.pauseRecording(); }; const onStop = () => { void recording.stopRecording(); }; return (
); } function RecordingPill({ paused, stopped, elapsedSeconds, }: { paused: boolean; stopped: boolean; elapsedSeconds: number; }) { const label = stopped ? 'Processing' : paused ? 'Paused' : 'Recording'; const active = !stopped; return ( {label} {formatElapsed(elapsedSeconds)} ); } function formatElapsed(seconds: number): string { const s = Math.max(0, seconds | 0); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const rem = s % 60; const pad = (n: number) => n.toString().padStart(2, '0'); if (h > 0) return `${h}:${pad(m)}:${pad(rem)}`; return `${pad(m)}:${pad(rem)}`; } ================================================ FILE: app/renderer/src/components/MainToolbar.tsx ================================================ import * as React from 'react'; import { MessageSquare, Moon, MoreHorizontal, Monitor, PanelLeftClose, PanelLeftOpen, PencilLine, Sun } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { AudioWave } from '@/components/AudioWave'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { useSetSystemAudio, useSystemAudioSetting, } from '@/hooks/useSettings'; import type { RecordingStatus } from '@/hooks/useRecording'; import { useTheme } from '@/hooks/useTheme'; import { useRoute, navigate } from '@/lib/router'; import { cn } from '@/lib/utils'; interface MainToolbarProps { recordingStatus: RecordingStatus; elapsedSeconds?: number; onToggleRecording: () => void; sidebarCollapsed: boolean; onToggleSidebar: () => void; } export function MainToolbar({ recordingStatus, elapsedSeconds = 0, onToggleRecording, sidebarCollapsed, onToggleSidebar, }: MainToolbarProps) { const isRecording = recordingStatus === 'recording' || recordingStatus === 'paused'; const isPaused = recordingStatus === 'paused'; const isProcessing = recordingStatus === 'processing'; const { resolved: resolvedTheme, setTheme } = useTheme(); // Route-aware primary action. On chat routes the "+ New" affordance maps // to a new chat (navigates back to /chat entry). Everywhere else it's // the recording button. Recording always wins if a session is active — // we don't want a navigation to silently swallow a stop-recording click. const route = useRoute(); const isChatRoute = route === '/chat' || route.startsWith('/chat/'); const showChatPrimary = isChatRoute && !isRecording && !isProcessing; // Matches sb-top padding-left (82px clears macOS traffic lights) const toggleLeft = 82; return (
{/* Toggle button lives here (inside a no-drag child of a drag ancestor) so Electron correctly computes the no-drag region even when the sidebar aside has pointer-events:none. position:fixed keeps it at the same screen coords as the sb-top button position. */}
); } function RecordingOptionsPopover() { const systemAudio = useSystemAudioSetting(); const setSystemAudio = useSetSystemAudio(); const enabled = systemAudio.data ?? false; return (

Recording options

Deep links and the tray menu also start and stop recording.

setSystemAudio.mutate(v)} />

Capture both sides of calls on macOS. Grants microphone permission on first use.

); } function formatElapsed(seconds: number): string { const s = Math.max(0, seconds | 0); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const rem = s % 60; const pad = (n: number) => n.toString().padStart(2, '0'); if (h > 0) return `${h}:${pad(m)}:${pad(rem)}`; return `${pad(m)}:${pad(rem)}`; } ================================================ FILE: app/renderer/src/components/MeetingsShell.tsx ================================================ import * as React from 'react'; import { AppShell } from '@/components/AppShell'; import { Sidebar, useSidebarCollapsed, useSidebarWidth, type SidebarContextAction, type SidebarFolder, type SidebarMeeting, } from '@/components/Sidebar'; import { MeetingsListProvider } from '@/lib/meetingsListContext'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useMeetings, useDeleteMeeting, useUpdateMeeting } from '@/hooks/useMeetings'; import { useAddMeetingToFolder, useCreateFolder, useDeleteFolder, useFolders, useRemoveMeetingFromFolder, useRenameFolder, } from '@/hooks/useFolders'; import { useRecording } from '@/hooks/useRecording'; import { navigate, useRoute } from '@/lib/router'; import type { Meeting } from '@/lib/ipc'; interface MeetingsShellProps { activeSummaryFile: string | null; contentAlign?: 'top' | 'center'; askBarSlot?: React.ReactNode; /** * When true, the main toolbar (record button) is hidden AND the centered * content wrapper is omitted. Used by /recording where the LiveDock owns * recording controls. */ hideToolbar?: boolean; /** * When true, omits the centered content wrapper but keeps the toolbar * visible. Used by /settings, which has its own full-viewport layout. */ bleed?: boolean; children: React.ReactNode; } export function MeetingsShell({ activeSummaryFile, contentAlign = 'top', askBarSlot, hideToolbar = false, bleed = false, children, }: MeetingsShellProps) { const meetings = useMeetings(); const folders = useFolders(); const recording = useRecording(); const route = useRoute(); const createFolder = useCreateFolder(); const renameFolder = useRenameFolder(); const deleteFolder = useDeleteFolder(); const addToFolder = useAddMeetingToFolder(); const removeFromFolder = useRemoveMeetingFromFolder(); const updateMeeting = useUpdateMeeting(); const deleteMeeting = useDeleteMeeting(); const { sidebarCollapsed, toggleSidebar } = useSidebarCollapsed(); const { width: sidebarWidth, setWidth: setSidebarWidth } = useSidebarWidth(); const [search, setSearch] = React.useState(''); const [newFolderOpen, setNewFolderOpen] = React.useState(false); const [newFolderName, setNewFolderName] = React.useState(''); const [renameTarget, setRenameTarget] = React.useState< { type: 'folder' | 'meeting'; id: string; current: string; itemRect: DOMRectReadOnly } | null >(null); const [context, setContext] = React.useState(null); const [deleteTarget, setDeleteTarget] = React.useState< | { type: 'folder'; id: string; name: string; meetingCount: number } | { type: 'meeting'; id: string; name: string } | null >(null); // Sidebar shows only folder rows + counts. buildSidebar gives us folder // metadata (name + meeting count); the per-folder meetings array is unused. const { sidebarFolders } = React.useMemo( () => buildSidebar({ meetings: meetings.data ?? [], folders: folders.data ?? [], search: '', activeSummaryFile, }), [meetings.data, folders.data, activeSummaryFile], ); const totalMeetings = meetings.data?.length ?? 0; const isRecording = recording.status === 'recording' || recording.status === 'paused'; // Toolbar button behaviour: idle → start (which auto-navigates to /recording); // recording or paused → navigate back to /recording instead of stopping. Stop // is intentionally only available from the LiveDock on the /recording route. const onToggleRecording = () => { if (recording.status === 'idle') { void recording.startRecording(); } else if (isRecording) { navigate('/recording'); } }; const onDropMeetingOnFolder = async (summaryFile: string, folderId: string | null) => { const meeting = meetings.data?.find((m) => m.session_info.summary_file === summaryFile); if (!meeting) return; const current = meeting.folders ?? []; const currentFolderId = current[0] ?? null; if (currentFolderId === folderId) return; if (currentFolderId) { await removeFromFolder.mutateAsync({ summaryFile, folderId: currentFolderId }); } if (folderId) { await addToFolder.mutateAsync({ summaryFile, folderId }); } }; const handleCreateFolder = async () => { const name = newFolderName.trim(); if (!name) return; await createFolder.mutateAsync({ name }); setNewFolderName(''); setNewFolderOpen(false); }; const openRename = ( type: 'folder' | 'meeting', id: string, current: string, itemRect: DOMRectReadOnly, ) => { setRenameTarget({ type, id, current, itemRect }); setContext(null); }; const commitRename = async (type: 'folder' | 'meeting', id: string, value: string) => { try { if (type === 'folder') { await renameFolder.mutateAsync({ id, name: value }); } else { await updateMeeting.mutateAsync({ summaryFile: id, patch: { name: value } }); } } catch (err) { console.error('Rename failed:', err); } }; const openDeleteConfirm = () => { if (!context) return; if (context.type === 'folder') { const folder = folders.data?.find((f) => f.id === context.id); if (!folder) return setContext(null); const meetingCount = meetings.data?.filter((m) => (m.folders ?? []).includes(context.id)).length ?? 0; setDeleteTarget({ type: 'folder', id: context.id, name: folder.name, meetingCount }); } else { const target = meetings.data?.find((m) => m.session_info.summary_file === context.id); if (!target) return setContext(null); setDeleteTarget({ type: 'meeting', id: context.id, name: target.session_info.name || 'Untitled Meeting', }); } setContext(null); }; const handleConfirmDelete = async () => { if (!deleteTarget) return; if (deleteTarget.type === 'folder') { await deleteFolder.mutateAsync(deleteTarget.id); } else { const target = meetings.data?.find( (m) => m.session_info.summary_file === deleteTarget.id, ); if (target) { await deleteMeeting.mutateAsync(target); if (activeSummaryFile === deleteTarget.id) navigate('/'); } } setDeleteTarget(null); }; return ( <> setNewFolderOpen(true)} onDropMeetingOnFolder={onDropMeetingOnFolder} onContextAction={setContext} currentRoute={route} /> } > {children} {context && ( setContext(null)} onRename={(label) => openRename(context.type, context.id, label, context.itemRect)} onDelete={openDeleteConfirm} folders={folders.data ?? []} meetings={meetings.data ?? []} /> )} !o && setDeleteTarget(null)} title={ deleteTarget?.type === 'folder' ? `Delete folder "${deleteTarget.name}"?` : deleteTarget ? `Delete note "${deleteTarget.name}"?` : '' } description={ deleteTarget?.type === 'folder' ? ( deleteTarget.meetingCount > 0 ? ( <> {deleteTarget.meetingCount} meeting {deleteTarget.meetingCount === 1 ? '' : 's'} will be moved back to All Notes. No recordings or transcripts will be deleted. ) : ( <>No recordings or transcripts will be deleted. ) ) : ( <>This will delete the transcript, summary, and all associated files. ) } confirmLabel="Delete" destructive onConfirm={handleConfirmDelete} isPending={deleteFolder.isPending || deleteMeeting.isPending} /> New folder Group related meetings together. Folder names are only visible to you. setNewFolderName(e.target.value)} placeholder="e.g. Acme Corp" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateFolder(); }} /> {renameTarget && ( { void commitRename(renameTarget.type, renameTarget.id, value); setRenameTarget(null); }} onCancel={() => setRenameTarget(null)} /> )} ); } // --------------------------------------------------------------------------- // Helpers (formerly in classic MeetingsShell — inlined since we now ship one // React UI). Kept exported because FolderDetail + Home reuse formatDateLabel. // --------------------------------------------------------------------------- interface ContextMenuProps { action: SidebarContextAction; onClose: () => void; onRename: (currentLabel: string) => void; onDelete: () => void; folders: Array<{ id: string; name: string }>; meetings: Meeting[]; } function ContextMenu({ action, onClose, onRename, onDelete, folders, meetings, }: ContextMenuProps) { const ref = React.useRef(null); React.useEffect(() => { const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [onClose]); const currentLabel = action.type === 'folder' ? (folders.find((f) => f.id === action.id)?.name ?? '') : (meetings.find((m) => m.session_info.summary_file === action.id)?.session_info.name ?? ''); return (
); } function RenamePopover({ initialValue, itemRect, onSave, onCancel, }: { initialValue: string; itemRect: DOMRectReadOnly; onSave: (value: string) => void; onCancel: () => void; }) { const [value, setValue] = React.useState(initialValue); const [visible, setVisible] = React.useState(false); const inputRef = React.useRef(null); const closingRef = React.useRef(false); const valueRef = React.useRef(value); valueRef.current = value; React.useEffect(() => { const id = requestAnimationFrame(() => setVisible(true)); inputRef.current?.select(); return () => cancelAnimationFrame(id); }, []); const close = React.useCallback((save: boolean) => { if (closingRef.current) return; closingRef.current = true; setVisible(false); setTimeout(() => { if (save) { const trimmed = valueRef.current.trim(); if (trimmed && trimmed !== initialValue) { onSave(trimmed); } else { onCancel(); } } else { onCancel(); } }, 120); }, [initialValue, onSave, onCancel]); return (
setValue(e.target.value)} onBlur={() => close(true)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); close(true); } if (e.key === 'Escape') { e.preventDefault(); close(false); } }} />
); } interface BuildArgs { meetings: Meeting[]; folders: Array<{ id: string; name: string; icon?: string; color?: string; order: number }>; search: string; activeSummaryFile: string | null; } function buildSidebar({ meetings, folders, search, activeSummaryFile }: BuildArgs) { const needle = search.trim().toLowerCase(); const match = (m: Meeting) => !needle || m.session_info.name.toLowerCase().includes(needle); const foldered = new Set(); const sidebarFolders: SidebarFolder[] = [...folders] .sort((a, b) => a.order - b.order) .map((f) => { const folderMeetings = meetings.filter((m) => (m.folders ?? []).includes(f.id)); folderMeetings.forEach((m) => foldered.add(m.session_info.summary_file)); return { id: f.id, name: f.name, icon: f.icon, color: f.color, meetings: folderMeetings .filter(match) .map((m) => meetingToSidebar(m, activeSummaryFile)), }; }); const sidebarUnfiled = meetings .filter((m) => !foldered.has(m.session_info.summary_file)) .filter(match) .map((m) => meetingToSidebar(m, activeSummaryFile)); return { sidebarUnfiled, sidebarFolders }; } function meetingToSidebar(meeting: Meeting, activeSummaryFile: string | null): SidebarMeeting { return { summaryFile: meeting.session_info.summary_file, title: meeting.session_info.name, dateLabel: formatDateLabel(meeting.session_info), active: meeting.session_info.summary_file === activeSummaryFile, }; } export function formatDateLabel(info: Meeting['session_info']): string | undefined { const raw = info.processed_at ?? info.updated_at; if (!raw) return undefined; const d = new Date(raw); if (Number.isNaN(d.getTime())) return undefined; const now = new Date(); const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); if (sameDay) return 'Today'; const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); const wasYesterday = d.getFullYear() === yesterday.getFullYear() && d.getMonth() === yesterday.getMonth() && d.getDate() === yesterday.getDate(); if (wasYesterday) return 'Yesterday'; if (now.getTime() - d.getTime() < 7 * 24 * 60 * 60 * 1000) { return d.toLocaleDateString(undefined, { weekday: 'short' }); } return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } ================================================ FILE: app/renderer/src/components/QuitDialog.tsx ================================================ import * as React from 'react'; import { createPortal } from 'react-dom'; import { CircleAlert } from 'lucide-react'; import { ipc } from '@/lib/ipc'; interface DialogState { type: 'recording' | 'processing'; jobCount?: number; } export function QuitDialog() { const [mounted, setMounted] = React.useState(false); const [visible, setVisible] = React.useState(false); const [state, setState] = React.useState({ type: 'recording' }); React.useEffect(() => { if (typeof window === 'undefined' || !window.stenoai) return; return ipc().on.showQuitDialog((payload) => { setState({ type: payload.type, jobCount: payload.jobCount }); setMounted(true); requestAnimationFrame(() => { requestAnimationFrame(() => setVisible(true)); }); }); }, []); React.useEffect(() => { if (!mounted) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') handleCancel(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [mounted]); const dismiss = (confirmed: boolean) => { setVisible(false); setTimeout(() => setMounted(false), 200); ipc().dialog.respondQuit(confirmed); }; const handleCancel = () => dismiss(false); const handleConfirm = () => dismiss(true); if (!mounted) return null; const host = document.getElementById('dialog-host'); if (!host) return null; const isRecording = state.type === 'recording'; const title = isRecording ? 'Recording in progress' : 'Processing in progress'; const count = state.jobCount ?? 1; const body = isRecording ? 'Quitting will stop and save the current recording.' : `${count} recording${count !== 1 ? 's are' : ' is'} still being processed. Quitting will cancel processing.`; const confirmLabel = isRecording ? 'Stop & quit' : 'Quit anyway'; return createPortal(
{ if (e.target === e.currentTarget) handleCancel(); }} style={{ position: 'fixed', inset: 0, zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.25)', opacity: visible ? 1 : 0, transition: 'opacity 200ms cubic-bezier(0.2,0,0,1)', }} >

{title}

{body}

, host, ); } function CancelButton({ onClick }: { onClick: () => void }) { const [hovered, setHovered] = React.useState(false); return ( ); } function ConfirmButton({ onClick, label }: { onClick: () => void; label: string }) { const [hovered, setHovered] = React.useState(false); return ( ); } ================================================ FILE: app/renderer/src/components/Sidebar.tsx ================================================ import * as React from 'react'; import { ChevronDown, Home as HomeIcon, Inbox, MessageSquare, Plus, Search, Settings as SettingsIcon, } from 'lucide-react'; import { navigate, toggleSettings } from '@/lib/router'; import { cn, shortcut } from '@/lib/utils'; import { LucideIcon, IconPicker } from '@/components/IconPicker'; import { useUpdateFolderIcon } from '@/hooks/useFolders'; export interface SidebarMeeting { summaryFile: string; title: string; dateLabel?: string; active?: boolean; folderId?: string | null; } export interface SidebarFolder { id: string; name: string; icon?: string; /** User-chosen folder color. Used to tint the sidebar icon so it * matches the chip in the FolderScopePicker / FolderDetail header. */ color?: string; meetings: SidebarMeeting[]; } export interface SidebarContextAction { type: 'folder' | 'meeting'; id: string; clientX: number; clientY: number; itemRect: DOMRectReadOnly; } // sessionStorage so collapsed state resets to open on every app restart const COLLAPSED_KEY = 'steno-sidebar-collapsed'; const WIDTH_KEY = 'steno-sidebar-width'; const DEFAULT_WIDTH = 270; const MIN_WIDTH = 220; const MAX_WIDTH = 480; // Module-level singleton store. useState hooks on these values are not enough: // MeetingsShell and BottomDockSlot need to share a single source of truth, or // the dock and the main pane drift out of sync (one collapses, the other still // thinks the sidebar is open) and the chat bar stops aligning with the notes. type Listener = () => void; const collapsedStore = (() => { let value = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(COLLAPSED_KEY) === 'true'; const listeners = new Set(); return { get: () => value, set: (next: boolean) => { if (value === next) return; value = next; try { sessionStorage.setItem(COLLAPSED_KEY, String(next)); } catch (_) {} listeners.forEach((l) => l()); }, subscribe: (l: Listener) => { listeners.add(l); return () => listeners.delete(l); }, }; })(); const widthStore = (() => { let value = DEFAULT_WIDTH; if (typeof localStorage !== 'undefined') { const stored = localStorage.getItem(WIDTH_KEY); if (stored) { const parsed = parseInt(stored, 10); if (!isNaN(parsed)) { value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, parsed)); } } } const listeners = new Set(); return { get: () => value, set: (next: number) => { const clamped = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, next)); if (value === clamped) return; value = clamped; try { localStorage.setItem(WIDTH_KEY, String(clamped)); } catch (_) {} listeners.forEach((l) => l()); }, subscribe: (l: Listener) => { listeners.add(l); return () => listeners.delete(l); }, }; })(); export function useSidebarCollapsed() { const sidebarCollapsed = React.useSyncExternalStore( collapsedStore.subscribe, collapsedStore.get, collapsedStore.get, ); const toggleSidebar = React.useCallback(() => { collapsedStore.set(!collapsedStore.get()); }, []); return { sidebarCollapsed, toggleSidebar }; } export function useSidebarWidth() { const width = React.useSyncExternalStore( widthStore.subscribe, widthStore.get, widthStore.get, ); const setWidth = React.useCallback((w: number) => widthStore.set(w), []); return { width, setWidth }; } interface SidebarProps { collapsed: boolean; onToggleCollapsed: () => void; width: number; onWidthChange: (w: number) => void; search: string; onSearchChange: (value: string) => void; folders: SidebarFolder[]; totalMeetings: number; onNewFolder: () => void; onDropMeetingOnFolder?: (summaryFile: string, folderId: string | null) => void; onContextAction?: (action: SidebarContextAction) => void; currentRoute: string; } export function Sidebar({ collapsed, onToggleCollapsed: _onToggleCollapsed, width, onWidthChange, search, onSearchChange, folders, totalMeetings, onNewFolder, onDropMeetingOnFolder, onContextAction, currentRoute, }: SidebarProps) { const [foldersOpen, setFoldersOpen] = React.useState(true); const [dragOverFolder, setDragOverFolder] = React.useState(null); const [dragOverAllMeetings, setDragOverAllMeetings] = React.useState(false); const isDraggingRef = React.useRef(false); const [iconPicker, setIconPicker] = React.useState<{ id: string; anchorRect: DOMRect } | null>(null); const updateIcon = useUpdateFolderIcon(); const isHomeActive = currentRoute === '/' || currentRoute === ''; const isAllMeetingsActive = currentRoute === '/meetings'; // Match /chat as well as any /chat/ conversation route — the same Chat // tab item should stay highlighted when drilling into a session. const isChatActive = currentRoute === '/chat' || currentRoute.startsWith('/chat/'); // Malformed % escapes throw URIError. Guard so a bad route can't crash // the entire sidebar render. const activeFolderId = React.useMemo(() => { if (!currentRoute.startsWith('/folders/')) return null; const raw = currentRoute.slice('/folders/'.length); try { return decodeURIComponent(raw); } catch { return raw; } }, [currentRoute]); const handleFolderDrop = (e: React.DragEvent, folderId: string | null) => { e.preventDefault(); const file = e.dataTransfer.getData('application/x-steno-meeting'); if (file && onDropMeetingOnFolder) onDropMeetingOnFolder(file, folderId); setDragOverFolder(null); setDragOverAllMeetings(false); }; const handleFolderContext = (e: React.MouseEvent, id: string) => { if (!onContextAction) return; e.preventDefault(); const itemRect = e.currentTarget.getBoundingClientRect(); onContextAction({ type: 'folder', id, clientX: e.clientX, clientY: e.clientY, itemRect }); }; const onResizeMouseDown = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = width; isDraggingRef.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const onMove = (ev: MouseEvent) => { onWidthChange(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startWidth + ev.clientX - startX))); }; const onUp = () => { isDraggingRef.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, [width, onWidthChange], ); const filteredFolders = React.useMemo(() => { const needle = search.trim().toLowerCase(); if (!needle) return folders; return folders.filter((f) => f.name.toLowerCase().includes(needle)); }, [folders, search]); return ( ); } ================================================ FILE: app/renderer/src/components/TranscriptPanel.tsx ================================================ import * as React from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Search as SearchIcon } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { useMeeting } from '@/hooks/useMeetings'; import type { Meeting } from '@/lib/ipc'; interface Segment { speaker: 'You' | 'Others' | null; text: string; } /** Bare transcript content — no outer card or header. Used inside the dock's mv-transcript panel. */ export function TranscriptPanelContent({ summaryFile, }: { summaryFile: string; onClose?: () => void; }) { const meeting = useMeeting(summaryFile); if (meeting.isLoading) { return
Loading…
; } if (!meeting.data) { return
No transcript available.
; } return ; } function TranscriptBody({ meeting }: { meeting: Meeting }) { const segments = React.useMemo(() => parseTranscript(meeting), [meeting]); const [query, setQuery] = React.useState(''); const filtered = React.useMemo(() => { if (!query.trim()) return segments; const needle = query.trim().toLowerCase(); return segments.filter((s) => s.text.toLowerCase().includes(needle)); }, [segments, query]); const parentRef = React.useRef(null); const rowVirtualizer = useVirtualizer({ count: filtered.length, getScrollElement: () => parentRef.current, estimateSize: () => 80, overscan: 8, }); if (segments.length === 0) { return (
No transcript available.
); } return (
} placeholder="Search transcript" value={query} onChange={(e) => setQuery(e.target.value)} className="flex-1" />
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const segment = filtered[virtualRow.index]; return (
); })}
); } function TranscriptRow({ segment, highlight }: { segment: Segment; highlight: string }) { return (
{segment.speaker && ( {segment.speaker} )}

{renderHighlighted(segment.text, highlight)}

); } function renderHighlighted(text: string, highlight: string): React.ReactNode { const needle = highlight.trim(); if (!needle) return text; const lower = text.toLowerCase(); const ln = needle.toLowerCase(); const parts: React.ReactNode[] = []; let cursor = 0; let idx = lower.indexOf(ln, cursor); let key = 0; while (idx !== -1) { if (idx > cursor) parts.push(text.slice(cursor, idx)); parts.push( {text.slice(idx, idx + needle.length)} , ); cursor = idx + needle.length; idx = lower.indexOf(ln, cursor); } if (cursor < text.length) parts.push(text.slice(cursor)); return parts; } function parseTranscript(meeting: Meeting): Segment[] { if (meeting.is_diarised && meeting.diarised_text) { const blocks = meeting.diarised_text.split(/(?=\[You\]|\[Others\])/); return blocks .map((b) => b.trim()) .filter(Boolean) .map((b): Segment => { if (b.startsWith('[You]')) return { speaker: 'You', text: b.replace('[You]', '').trim() }; if (b.startsWith('[Others]')) return { speaker: 'Others', text: b.replace('[Others]', '').trim() }; return { speaker: null, text: b }; }); } const text = (meeting.transcript ?? '').trim(); if (!text) return []; const sentences = text .split(/(?<=[.!?])\s+(?=[A-Z"'(\[])/) .map((s) => s.trim()) .filter(Boolean); return (sentences.length > 1 ? sentences : [text]).map((s) => ({ speaker: null, text: s })); } ================================================ FILE: app/renderer/src/components/home/PreviousRow.tsx ================================================ import { Folder as FolderIcon, Loader2 } from 'lucide-react'; import type { Meeting } from '@/lib/ipc'; import { navigate } from '@/lib/router'; import { useMeetingsList } from '@/lib/meetingsListContext'; interface PreviousRowProps { meeting: Meeting; folderName?: string; } export function PreviousRow({ meeting, folderName }: PreviousRowProps) { const info = meeting.session_info; const when = formatTime(info.processed_at ?? info.updated_at); const duration = formatDuration(info.duration_seconds); const preview = previewText(meeting); const participants = Array.isArray(meeting.participants) ? meeting.participants.length : 0; const list = useMeetingsList(); const isLive = meeting.is_recording; const isProcessing = meeting.is_processing; const isSynthetic = isLive || isProcessing; // Synthetic rows route to the live or processing screen instead of trying // to open the sentinel summary_file (which doesn't exist on disk yet). const targetPath = isLive ? '/recording' : isProcessing ? '/meetings/processing' : `/meetings/${encodeURIComponent(info.summary_file)}`; return (
!isSynthetic && list?.startMeetingDrag(info.summary_file, e) } onContextMenu={(e) => !isSynthetic && list?.openMeetingContextMenu(info.summary_file, e) } onClick={() => navigate(targetPath)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(targetPath); } }} >
{isSynthetic ? 'Now' : (when ?? '')}
{info.name || 'Untitled note'}
{isLive && } {isProcessing && }
{preview && !isSynthetic && (
{preview}
)} {(folderName || participants > 0) && (
{folderName && ( {folderName} )} {participants > 0 && ( <> {folderName && ·} {participants} {participants === 1 ? 'person' : 'people'} )}
)}
{duration && {duration}}
); } function LiveBadge() { return ( Recording ); } function ProcessingBadge() { return ( Processing ); } function formatTime(iso?: string): string | undefined { if (!iso) return undefined; const d = new Date(iso); if (Number.isNaN(d.getTime())) return undefined; const pad = (n: number) => n.toString().padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; } function formatDuration(seconds?: number): string | undefined { if (!seconds || seconds <= 0) return undefined; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m`; return `${s}s`; } function previewText(meeting: Meeting): string | undefined { const summary = meeting.summary?.trim(); if (summary) return summary; const kp = meeting.key_points?.[0]; if (typeof kp === 'string' && kp.trim()) return kp.trim(); return undefined; } ================================================ FILE: app/renderer/src/components/home/UpcomingCard.tsx ================================================ import * as React from 'react'; import { Video } from 'lucide-react'; import type { CalendarEvent } from '@/lib/ipc'; import { ipc } from '@/lib/ipc'; import { cn } from '@/lib/utils'; import { useRecording } from '@/hooks/useRecording'; interface UpcomingCardProps { event: CalendarEvent; } export function UpcomingCard({ event }: UpcomingCardProps) { const relative = relativeLabel(event.start); const { dayLabel, clock, end } = formatStartEnd(event.start, event.end); const meetingUrl = event.meeting_url?.trim(); const recording = useRecording(); // Click the card → start a new recording titled after this event. The // event title becomes the note's session name (instead of the auto // 'Note' placeholder), so the AI rename step skips it and the user // gets the meeting they expected. Doesn't open the join URL — the // Join / Start now buttons on the right own that action. const onStart = () => { if (recording.status !== 'idle') return; void recording.startRecording(event.title); }; // Open the meeting URL externally. Used by the inner Join button only. const onJoin = (e: React.MouseEvent) => { e.stopPropagation(); if (!meetingUrl) return; void ipc().shell.openExternal(meetingUrl); }; // Start recording AND open the URL — used by the urgent "Start now" // button when the meeting is imminent. const onStartAndJoin = (e: React.MouseEvent) => { e.stopPropagation(); onStart(); if (meetingUrl) void ipc().shell.openExternal(meetingUrl); }; return (
{ // Only handle Enter/Space when the card itself has focus — inner // buttons (Join, Start now) own their own keyboard activation, and // we don't want to double-fire them. if (e.target !== e.currentTarget) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onStart(); } }} > {/* Relative time block */}
{relative.prefix && ( {relative.prefix} )} {relative.value}
{/* Meta + title */}
{dayLabel} · {end ? `${clock} – ${end}` : clock}
{event.title}
{/* CTA */}
{meetingUrl ? ( relative.urgent ? ( ) : ( ) ) : null}
); } function relativeLabel(startIso: string): { prefix: string | null; value: string; urgent: boolean } { const start = new Date(startIso); if (Number.isNaN(start.getTime())) return { prefix: null, value: '—', urgent: false }; const diffMs = start.getTime() - Date.now(); const diffMins = Math.round(diffMs / 60000); if (diffMins <= 0) return { prefix: null, value: 'Now', urgent: true }; if (diffMins < 60) return { prefix: 'In', value: `${diffMins} mins`, urgent: diffMins <= 15 }; const hrs = Math.round(diffMins / 60); if (hrs < 24) return { prefix: 'In', value: `${hrs} hrs`, urgent: false }; const days = Math.round(hrs / 24); return { prefix: 'In', value: `${days} day${days === 1 ? '' : 's'}`, urgent: false }; } function formatStartEnd(startIso: string, endIso: string) { const start = new Date(startIso); const now = new Date(); const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); const dayLabel = sameDay(start, now) ? 'Today' : sameDay(start, tomorrow) ? 'Tomorrow' : start.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' }); const pad = (n: number) => n.toString().padStart(2, '0'); const clock = Number.isNaN(start.getTime()) ? '' : `${pad(start.getHours())}:${pad(start.getMinutes())}`; const endDate = endIso ? new Date(endIso) : null; const endClock = endDate && !Number.isNaN(endDate.getTime()) ? `${pad(endDate.getHours())}:${pad(endDate.getMinutes())}` : null; return { dayLabel, clock, end: endClock }; } ================================================ FILE: app/renderer/src/components/ui/app-icon.tsx ================================================ import { cn } from '@/lib/utils'; interface AppIconProps { size?: number; className?: string; } export function AppIcon({ size = 80, className }: AppIconProps) { return ( ); } ================================================ FILE: app/renderer/src/components/ui/button.tsx ================================================ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors duration-fast ease-steno focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-4', { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-ink-700 dark:hover:bg-white', outline: 'border border-border bg-transparent hover:bg-muted', secondary: 'bg-secondary text-secondary-foreground hover:bg-paper-2 dark:hover:bg-[hsl(54,7%,18%)]', ghost: 'hover:bg-muted', destructive: 'bg-destructive text-destructive-foreground hover:opacity-90', link: 'text-foreground underline-offset-4 hover:underline decoration-border', }, size: { default: 'h-9 px-4', sm: 'h-8 px-3 text-xs', lg: 'h-11 px-6 text-base', icon: 'h-9 w-9', }, }, defaultVariants: { variant: 'default', size: 'default' }, } ); export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } export const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( ); } ); Button.displayName = 'Button'; export { buttonVariants }; ================================================ FILE: app/renderer/src/components/ui/card.tsx ================================================ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const cardVariants = cva('rounded-lg', { variants: { raised: { true: 'border border-border bg-card shadow-sm', false: 'bg-transparent', }, padded: { true: 'p-6', false: '', }, }, defaultVariants: { raised: false, padded: false }, }); export interface CardProps extends React.HTMLAttributes, VariantProps {} export const Card = React.forwardRef( ({ className, raised, padded, ...props }, ref) => (
) ); Card.displayName = 'Card'; export const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardHeader.displayName = 'CardHeader'; export const CardTitle = React.forwardRef< HTMLHeadingElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); CardTitle.displayName = 'CardTitle'; export const CardDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); CardDescription.displayName = 'CardDescription'; export const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); CardContent.displayName = 'CardContent'; export const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardFooter.displayName = 'CardFooter'; export { cardVariants }; ================================================ FILE: app/renderer/src/components/ui/chip.tsx ================================================ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const chipVariants = cva( 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors duration-fast ease-steno focus:outline-none [&_svg]:size-3 [&_svg]:shrink-0', { variants: { variant: { default: 'border-border bg-transparent text-foreground hover:bg-muted', muted: 'border-transparent bg-muted text-muted-foreground hover:bg-paper-2 dark:hover:bg-[hsl(54,7%,18%)]', destructive: 'border-transparent bg-destructive/10 text-destructive hover:bg-destructive/15', }, interactive: { true: 'cursor-pointer', false: 'cursor-default', }, }, defaultVariants: { variant: 'default', interactive: false }, } ); export interface ChipProps extends React.HTMLAttributes, VariantProps { asButton?: boolean; } export const Chip = React.forwardRef( ({ className, variant, interactive, asButton, onClick, ...props }, ref) => { const clickable = !!onClick || !!interactive || !!asButton; if (asButton) { // Explicit type="button" — defaulting to submit silently breaks chips // rendered inside
(e.g. the chat composer's suggestion chips). return ( ); } ================================================ FILE: app/renderer/src/components/ui/dialog.tsx ================================================ import * as React from 'react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { X } from 'lucide-react'; import { cn } from '@/lib/utils'; export const Dialog = DialogPrimitive.Root; export const DialogTrigger = DialogPrimitive.Trigger; export const DialogPortal = DialogPrimitive.Portal; export const DialogClose = DialogPrimitive.Close; export const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; export const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} Close )); DialogContent.displayName = DialogPrimitive.Content.displayName; export function DialogHeader({ className, ...props }: React.HTMLAttributes) { return
; } export function DialogFooter({ className, ...props }: React.HTMLAttributes) { return (
); } export const DialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogTitle.displayName = DialogPrimitive.Title.displayName; export const DialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogDescription.displayName = DialogPrimitive.Description.displayName; ================================================ FILE: app/renderer/src/components/ui/input.tsx ================================================ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const inputVariants = cva( 'flex w-full rounded-md border border-border bg-transparent text-sm transition-colors duration-fast ease-steno placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', { variants: { variant: { default: '', sunken: 'border-transparent bg-paper-1 dark:bg-[hsl(54,7%,14%)]', inherit: 'border-transparent bg-transparent p-0 font-[inherit] text-[inherit] leading-[inherit] tracking-[inherit]', }, size: { default: 'h-9 px-3', sm: 'h-8 px-3 text-xs', lg: 'h-11 px-4 text-base', }, }, defaultVariants: { variant: 'default', size: 'default' }, } ); type Size = NonNullable['size']>; type Variant = NonNullable['variant']>; export interface InputProps extends Omit, 'size'> { variant?: Variant; size?: Size; iconStart?: React.ReactNode; iconEnd?: React.ReactNode; } export const Input = React.forwardRef( ({ className, variant, size, iconStart, iconEnd, type = 'text', ...props }, ref) => { const hasStart = !!iconStart; const hasEnd = !!iconEnd; const input = ( ); if (!hasStart && !hasEnd) return input; return (
{hasStart && ( {iconStart} )} {input} {hasEnd && ( {iconEnd} )}
); } ); Input.displayName = 'Input'; export interface TextareaProps extends React.TextareaHTMLAttributes { variant?: Variant; autoResize?: boolean; } export const Textarea = React.forwardRef( ({ className, variant, autoResize, onInput, rows = 2, ...props }, ref) => { const handleInput = React.useCallback( (e: React.InputEvent) => { if (autoResize) { const el = e.currentTarget; el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; } onInput?.(e); }, [autoResize, onInput], ); return (