Repository: heyito/ito Branch: main Commit: ab9d158b61dd Files: 400 Total size: 2.2 MB Directory structure: gitextract_bmhz2bpu/ ├── .claude/ │ └── settings.local.json ├── .cursor/ │ └── rules/ │ ├── always.mdc │ ├── code-conventions.mdc │ ├── react.mdc │ └── typescript.mdc ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── app-deploy.yml │ ├── autolink-pr-to-issue.yml │ ├── build-image.yml │ ├── build.yml │ ├── ci-controller.yml │ ├── deploy-server.yml │ ├── infra-deploy.yml │ ├── native-build-check.yml │ └── test-runner.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ └── settings.json ├── AGENTS.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── app/ │ ├── app.tsx │ ├── assets/ │ │ ├── .gitignore │ │ ├── accesssibility.webm │ │ └── microphone.webm │ ├── components/ │ │ ├── analytics/ │ │ │ └── index.ts │ │ ├── auth/ │ │ │ ├── Auth0Provider.tsx │ │ │ └── useAuth.ts │ │ ├── home/ │ │ │ ├── HomeKit.tsx │ │ │ ├── ProUpgradeDialog.tsx │ │ │ └── contents/ │ │ │ ├── AboutContent.tsx │ │ │ ├── DictionaryContent.tsx │ │ │ ├── HomeContent.tsx │ │ │ ├── NotesContent.tsx │ │ │ ├── SettingsContent.tsx │ │ │ ├── activityMessages.ts │ │ │ └── settings/ │ │ │ ├── AccountSettingsContent.tsx │ │ │ ├── AdvancedSettingsContent.tsx │ │ │ ├── AudioSettingsContent.tsx │ │ │ ├── GeneralSettingsContent.tsx │ │ │ ├── KeyboardSettingsContent.tsx │ │ │ └── PricingBillingSettingsContent.tsx │ │ ├── icons/ │ │ │ ├── AppleIcon.tsx │ │ │ ├── AppleNotesIcon.tsx │ │ │ ├── AsterikIcon.tsx │ │ │ ├── AudioIcon.tsx │ │ │ ├── AvatarIcon.tsx │ │ │ ├── ChatGPTIcon.tsx │ │ │ ├── ClaudeIcon.tsx │ │ │ ├── CodeWindowIcon.tsx │ │ │ ├── ColorSchemeIcon.tsx │ │ │ ├── CursorIcon.tsx │ │ │ ├── DiscordIcon.tsx │ │ │ ├── FanIcon.tsx │ │ │ ├── GitHubIcon.tsx │ │ │ ├── GmailIcon.tsx │ │ │ ├── GoogleIcon.tsx │ │ │ ├── IMessageIcon.tsx │ │ │ ├── ItoIcon.tsx │ │ │ ├── MicrosoftIcon.tsx │ │ │ ├── NotionIcon.tsx │ │ │ ├── SlackIcon.tsx │ │ │ ├── SpeedIcon.tsx │ │ │ ├── TotalWordsIcon.tsx │ │ │ ├── VSCodeIcon.tsx │ │ │ └── XIcon.tsx │ │ ├── pill/ │ │ │ ├── Pill.tsx │ │ │ └── contents/ │ │ │ ├── AudioBars.tsx │ │ │ ├── AudioBarsBase.tsx │ │ │ ├── LoadingAnimation.tsx │ │ │ ├── PreviewAudioBars.tsx │ │ │ └── TooltipButton.tsx │ │ ├── ui/ │ │ │ ├── animated-checkmark.tsx │ │ │ ├── app-orbit-image.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── keyboard-key.tsx │ │ │ ├── keyboard-shortcut-editor.tsx │ │ │ ├── microphone-selector.tsx │ │ │ ├── multi-shortcut-editor.tsx │ │ │ ├── nav-item.tsx │ │ │ ├── note.tsx │ │ │ ├── spinner.tsx │ │ │ ├── status-indicator.tsx │ │ │ ├── switch.tsx │ │ │ ├── tip.tsx │ │ │ └── tooltip.tsx │ │ ├── welcome/ │ │ │ ├── WelcomeKit.tsx │ │ │ ├── contents/ │ │ │ │ ├── AnyAppContent.tsx │ │ │ │ ├── CheckEmailContent.tsx │ │ │ │ ├── CreateAccountContent.tsx │ │ │ │ ├── DataControlContent.tsx │ │ │ │ ├── EmailLoginContent.tsx │ │ │ │ ├── EmailSignupContent.tsx │ │ │ │ ├── GoodToGoContent.tsx │ │ │ │ ├── IntroducingIntelligentModeContent.tsx │ │ │ │ ├── KeyboardTestContext.tsx │ │ │ │ ├── MicrophoneTestContent.tsx │ │ │ │ ├── PermissionsContent.tsx │ │ │ │ ├── ReferralContent.tsx │ │ │ │ ├── SignInContent.tsx │ │ │ │ └── TryItOutContent.tsx │ │ │ └── styles.css │ │ └── window/ │ │ ├── OnboardingTitlebar.tsx │ │ ├── Titlebar.tsx │ │ ├── TitlebarContext.tsx │ │ └── WindowContext.tsx │ ├── generated/ │ │ ├── buf/ │ │ │ └── validate/ │ │ │ └── validate_pb.ts │ │ ├── ito_connect.ts │ │ └── ito_pb.ts │ ├── hooks/ │ │ ├── useBillingState.test.ts │ │ ├── useBillingState.ts │ │ ├── useDeviceChangeListener.ts │ │ └── usePlatform.ts │ ├── index.d.ts │ ├── index.html │ ├── media/ │ │ └── microphone.ts │ ├── renderer.tsx │ ├── sentry.ts │ ├── store/ │ │ ├── useAdvancedSettingsStore.ts │ │ ├── useAudioStore.ts │ │ ├── useAuthStore.ts │ │ ├── useDictionaryStore.ts │ │ ├── useMainStore.ts │ │ ├── useNotesStore.ts │ │ ├── useOnboardingStore.ts │ │ ├── usePermissionsStore.ts │ │ ├── useSettingsStore.ts │ │ ├── useShortcutEditingStore.ts │ │ └── useUserMetadataStore.ts │ ├── styles/ │ │ ├── app.css │ │ ├── globals.css │ │ └── window.css │ └── utils/ │ ├── audioUtils.ts │ ├── healthCheck.test.ts │ ├── healthCheck.ts │ ├── keyboard.test.ts │ ├── keyboard.ts │ └── utils.ts ├── build/ │ ├── entitlements.mac.inherit.plist │ └── entitlements.mac.plist ├── build-app.sh ├── build-binaries.sh ├── commitlint.config.js ├── components.json ├── dev-app-update.yml ├── electron-builder.config.js ├── electron.vite.config.ts ├── eslint.config.mjs ├── lib/ │ ├── __tests__/ │ │ ├── fixtures/ │ │ │ ├── auth.ts │ │ │ └── database.ts │ │ ├── helpers/ │ │ │ └── testUtils.ts │ │ ├── mocks/ │ │ │ ├── electron.ts │ │ │ └── sqlite.ts │ │ └── setup.ts │ ├── auth/ │ │ ├── config.test.ts │ │ ├── config.ts │ │ ├── events.test.ts │ │ └── events.ts │ ├── clients/ │ │ ├── grpcClient.test.ts │ │ ├── grpcClient.ts │ │ └── itoHttpClient.ts │ ├── constants/ │ │ ├── external-links.ts │ │ ├── generated-defaults.ts │ │ ├── keyboard-defaults.ts │ │ ├── store-keys.test.ts │ │ └── store-keys.ts │ ├── main/ │ │ ├── app.ts │ │ ├── appNap.ts │ │ ├── audio/ │ │ │ ├── AudioStreamManager.test.ts │ │ │ └── AudioStreamManager.ts │ │ ├── autoUpdaterWrapper.ts │ │ ├── context/ │ │ │ └── ContextGrabber.ts │ │ ├── env.ts │ │ ├── grammar/ │ │ │ ├── GrammarRulesService.test.ts │ │ │ └── GrammarRulesService.ts │ │ ├── index.d.ts │ │ ├── interactions/ │ │ │ ├── InteractionManager.test.ts │ │ │ └── InteractionManager.ts │ │ ├── itoSessionManager.test.ts │ │ ├── itoSessionManager.ts │ │ ├── itoStreamController.test.ts │ │ ├── itoStreamController.ts │ │ ├── logger.ts │ │ ├── main.ts │ │ ├── recordingStateNotifier.ts │ │ ├── sentry.ts │ │ ├── sqlite/ │ │ │ ├── db.test.ts │ │ │ ├── db.ts │ │ │ ├── migrations.ts │ │ │ ├── models.ts │ │ │ ├── repo.test.ts │ │ │ ├── repo.ts │ │ │ ├── schema.ts │ │ │ └── utils.ts │ │ ├── store.test.ts │ │ ├── store.ts │ │ ├── syncService.test.ts │ │ ├── syncService.ts │ │ ├── teardown.ts │ │ ├── text/ │ │ │ ├── TextInserter.test.ts │ │ │ └── TextInserter.ts │ │ ├── timing/ │ │ │ ├── TimingCollector.test.ts │ │ │ └── TimingCollector.ts │ │ ├── tray.ts │ │ ├── voiceInputService.test.ts │ │ └── voiceInputService.ts │ ├── media/ │ │ ├── IAccessibilityContextProvider.ts │ │ ├── active-application.test.ts │ │ ├── active-application.ts │ │ ├── audio.test.ts │ │ ├── audio.ts │ │ ├── keyboard.test.ts │ │ ├── keyboard.ts │ │ ├── macOSAccessibilityContextProvider.ts │ │ ├── microphoneSetUp.ts │ │ ├── native-interface.test.ts │ │ ├── native-interface.ts │ │ ├── selected-text-reader.test.ts │ │ ├── selected-text-reader.ts │ │ ├── systemAudio.ts │ │ └── text-writer.ts │ ├── preload/ │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── index.d.ts │ │ └── preload.ts │ ├── protocol/ │ │ └── index.ts │ ├── types/ │ │ ├── cursorContext.ts │ │ ├── ipc.ts │ │ └── keyboard.ts │ ├── utils/ │ │ ├── applicationDetection.ts │ │ ├── crossPlatform.ts │ │ ├── settings.test.ts │ │ └── settings.ts │ ├── utils.ts │ └── window/ │ ├── index.ts │ ├── ipcDev.ts │ ├── ipcEvents.test.ts │ └── ipcEvents.ts ├── native/ │ ├── Cargo.toml │ ├── active-application/ │ │ ├── Cargo.toml │ │ ├── active-application.manifest │ │ ├── build.rs │ │ └── src/ │ │ └── main.rs │ ├── audio-recorder/ │ │ ├── Cargo.toml │ │ ├── audio-recorder.manifest │ │ ├── build.rs │ │ └── src/ │ │ └── main.rs │ ├── clippy.toml │ ├── cursor-context/ │ │ ├── Package.resolved │ │ ├── Package.swift │ │ └── Sources/ │ │ └── cursor-context/ │ │ ├── CLI.swift │ │ └── main.swift │ ├── global-key-listener/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ ├── build.rs │ │ ├── global-key-listener.manifest │ │ └── src/ │ │ ├── key_codes.rs │ │ └── main.rs │ ├── macos-text/ │ │ ├── .gitignore │ │ ├── Package.swift │ │ └── Sources/ │ │ └── focused-text-reader/ │ │ └── main.swift │ ├── rustfmt.toml │ ├── selected-text-reader/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── selected-text-reader.manifest │ │ └── src/ │ │ ├── macos.rs │ │ ├── main.rs │ │ └── windows.rs │ └── text-writer/ │ ├── Cargo.toml │ ├── build.rs │ ├── src/ │ │ ├── macos_writer.rs │ │ ├── main.rs │ │ └── windows_writer.rs │ └── text-writer.manifest ├── package.json ├── resources/ │ └── build/ │ ├── entitlements.mac.plist │ └── icon.icns ├── scripts/ │ ├── clean-app-data.js │ └── generate-constants.js ├── server/ │ ├── .dockerignore │ ├── Dockerfile │ ├── README.md │ ├── buf.gen.yaml │ ├── buf.yaml │ ├── docker-compose.yml │ ├── infra/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── bin/ │ │ │ └── infra.ts │ │ ├── cdk.context.json │ │ ├── cdk.json │ │ ├── jest.config.js │ │ ├── lambdas/ │ │ │ ├── firehose-transform.ts │ │ │ ├── opensearch-bootstrap.ts │ │ │ ├── run-migration.ts │ │ │ └── timing-merger.ts │ │ ├── lib/ │ │ │ ├── cicd-stack.ts │ │ │ ├── constants.ts │ │ │ ├── helpers.ts │ │ │ ├── network-stack.ts │ │ │ ├── observability-stack.ts │ │ │ ├── platform-stack.ts │ │ │ ├── security-stack.ts │ │ │ ├── service/ │ │ │ │ ├── fargate-task.ts │ │ │ │ ├── firehose-config.ts │ │ │ │ ├── index.ts │ │ │ │ ├── log-groups.ts │ │ │ │ ├── migration-lambda.ts │ │ │ │ └── opensearch-bootstrap.ts │ │ │ ├── service-stack.ts │ │ │ └── timing-config.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── package.json │ ├── scripts/ │ │ ├── migrate-audio-to-s3.ts │ │ ├── migrate.sh │ │ └── setup-minio.sh │ ├── src/ │ │ ├── auth/ │ │ │ ├── auth0Helpers.ts │ │ │ └── userContext.ts │ │ ├── clients/ │ │ │ ├── asrConfig.ts │ │ │ ├── cerebrasClient.ts │ │ │ ├── errors.ts │ │ │ ├── groqClient.test.ts │ │ │ ├── groqClient.ts │ │ │ ├── intentTranscriptionConfig.ts │ │ │ ├── llmProvider.ts │ │ │ ├── providerUtils.ts │ │ │ ├── providers.ts │ │ │ └── s3storageClient.ts │ │ ├── constants/ │ │ │ ├── generated-defaults.ts │ │ │ ├── markers.ts │ │ │ └── storage.ts │ │ ├── db/ │ │ │ ├── models.ts │ │ │ └── repo.ts │ │ ├── db.ts │ │ ├── generated/ │ │ │ ├── buf/ │ │ │ │ └── validate/ │ │ │ │ └── validate_pb.ts │ │ │ ├── ito_connect.ts │ │ │ └── ito_pb.ts │ │ ├── index.ts │ │ ├── ito.proto │ │ ├── migrations/ │ │ │ ├── 1722889955000_initial_schema.js │ │ │ ├── 1752006262324_add-raw-audio-column.js │ │ │ ├── 1752099660683_add-duration-ms.js │ │ │ ├── 1753297915000_add-advanced-settings.js │ │ │ ├── 1754938499581_update-advanced-settings.js │ │ │ ├── 1756922843670_raw-audio-reference.js │ │ │ ├── 1760496947939_add-temporary-analytics-token.js │ │ │ ├── 1761765111646_add-user-trials.js │ │ │ ├── 1761778190395_add-user-subscriptions.js │ │ │ ├── 1762468699097_add-subscription-end-at.js │ │ │ ├── 1763753112000_make-llm-settings-nullable.js │ │ │ └── schema/ │ │ │ └── initial.js │ │ ├── prompts/ │ │ │ ├── transcription.test.ts │ │ │ └── transcription.ts │ │ ├── server.ts │ │ ├── services/ │ │ │ ├── __tests__/ │ │ │ │ └── helpers.ts │ │ │ ├── auth0.ts │ │ │ ├── billing.test.ts │ │ │ ├── billing.ts │ │ │ ├── cloudWatchLogger.ts │ │ │ ├── errorInterceptor.ts │ │ │ ├── ito/ │ │ │ │ ├── audioUtils.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── itoService.ts │ │ │ │ ├── timingService.ts │ │ │ │ ├── transcribeStreamHandler.ts │ │ │ │ ├── transcribeStreamV2Handler.ts │ │ │ │ └── types.ts │ │ │ ├── logging.test.ts │ │ │ ├── logging.ts │ │ │ ├── loggingInterceptor.ts │ │ │ ├── stripeWebhook.test.ts │ │ │ ├── stripeWebhook.ts │ │ │ ├── timing/ │ │ │ │ └── ServerTimingCollector.ts │ │ │ ├── trial.test.ts │ │ │ ├── trial.ts │ │ │ ├── validationInterceptor.test.ts │ │ │ └── validationInterceptor.ts │ │ ├── utils/ │ │ │ ├── abortUtils.ts │ │ │ ├── audio.ts │ │ │ ├── audioProcessing.ts │ │ │ └── renderCallback.ts │ │ └── validation/ │ │ ├── HeaderValidator.test.ts │ │ ├── HeaderValidator.ts │ │ ├── schemas.test.ts │ │ └── schemas.ts │ ├── test-client.ts │ └── tsconfig.json ├── shared-constants.js ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.web.json └── vite-env.d.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.local.json ================================================ { "permissions": { "allow": [ "WebFetch(domain:github.com)", "WebFetch(domain:docs.microsoft.com)", "Bash(bun build:rust:win:*)", "Bash(bun runTest:*)", "Bash(bun test:*)", "Bash(cargo build:*)", "Bash(git show:*)", "WebFetch(domain:stackoverflow.com)", "WebFetch(domain:www.electronjs.org)", "WebFetch(domain:www.electron.build)", "Bash(where:*)", "Bash(cargo test)", "Bash(cargo test:*)", "Bash(bun:*)", "Bash(cargo clippy:*)", "Bash(cargo clean:*)", "Bash(awk:*)", "WebFetch(domain:connectrpc.com)", "Bash(git log:*)" ], "deny": [], "ask": [] } } ================================================ FILE: .cursor/rules/always.mdc ================================================ --- description: globs: alwaysApply: true --- ## Development cycle 1. Before writing any code, come up with an extremely good plan, review the plan, and then ask the user for permission to execute the plan. 2. Always rely on the user for manually testing 3. Always use bun as your preferred package manager. 4. Always rely on the user to install new packages / update new packages 5. Prefer code that is easy to read and self-documenting. Use comments sparingly. 6. Never use empty catch statements. ================================================ FILE: .cursor/rules/code-conventions.mdc ================================================ --- description: globs: **/*.ts,**/*.tsx alwaysApply: false --- ## Technologies used - **React** - Build interactive UIs with React components - **Tailwind CSS** - Utility-first CSS framework for rapid UI development - **TypeScript** - Full type safety across all packages - **PNPM Workspaces** - Fast, disk-efficient package management - **ESLint & Prettier** - Code quality tools configured and ready to use - **Auth** - We use Auth0 - **DB** - We use postgres in the server and SQLite in the app - **Package manager** We use Bun as our preferred package manager. Please use bun whenever applicable. # Code Conventions When writing or modifying code in this project, please adhere to the following conventions: 1. **TypeScript Best Practices**: Follow standard, idiomatic TypeScript coding practices for structure, naming, and types, unless otherwise overridden. 2. **Minimal Comments**: Avoid adding comments unless they explain complex logic or non-obvious decisions. Well-written, self-explanatory code is preferred. Do not add comments that merely restate what the code does. 3. **Tests as Documentation**: Rely on comprehensive tests (which will be added later if not present) to document the behavior and usage of the code, rather than extensive comments within the code itself. 4. **File naming conventions**: Use kebab-case when naming directories, TypeScript, and other files. Only make the exact changes I request—do not modify, remove, or alter any other code, styling, or page elements unless explicitly instructed. If my request conflicts with existing code, styling, or functionality, or if you anticipate any issues, pause execution and notify me for confirmation before proceeding. Always follow this rule for every modification. If in doubt, ask before making any change. ================================================ FILE: .cursor/rules/react.mdc ================================================ --- description: globs: **/*.tsx alwaysApply: false --- React Naming Conventions: - Use kebab-case for files and directories. Components: - DO not use 'use client' or 'use server' statements - Favor named exports for components - Ensure components are modular, reusable, and maintain a clear separation of concerns. - Always split React components out so there is only ever one per file - Keep logic as low as possible. UI and Styling: - Implement responsive design with Tailwind CSS; use a mobile-first approach ================================================ FILE: .cursor/rules/typescript.mdc ================================================ --- description: globs: **/*.ts,**/*.tsx alwaysApply: false --- # TypeScript Best Practices ## Type System - Prefer interfaces over types for object definitions - Use type for unions, intersections, and mapped types - NEVER use `any` or `as any` types or coercion - Use strict TypeScript configuration - Leverage TypeScript's built-in utility types - Use generics for reusable type patterns ## Naming Conventions - Use PascalCase for type names and interfaces - Use camelCase for variables and functions - Use UPPER_CASE for constants - Use descriptive names with auxiliary verbs (e.g., isLoading, hasError) - Prefix interfaces for React props with 'Props' (e.g., ButtonProps) ## Code Organization - Keep type definitions close to where they're used - Export types and interfaces from dedicated type files when shared - Use barrel exports (index.ts) for organizing exports - Place shared types in a `types.ts` file - Co-locate component props with their components ## Functions - Use explicit return types for public functions - Use arrow functions for callbacks and methods - Implement proper error handling with custom error types - Use function overloads for complex type scenarios - Prefer async/await over Promises - Prefer function declarations over function expressions. - Prefer functional programming over classes. ## Best Practices - Enable strict mode in tsconfig.json - Use readonly for immutable properties - Leverage discriminated unions for type safety - Use type guards for runtime type checking - Implement proper null checking - Avoid type assertions unless necessary ## Error Handling - Do not proactively add error handling - Handle Promise rejections properly ================================================ FILE: .gitattributes ================================================ # Mark generated protobuf/buf code as generated # This makes GitHub collapse these files by default in PRs app/generated/** linguist-generated=true server/src/generated/** linguist-generated=true ================================================ FILE: .github/workflows/app-deploy.yml ================================================ # .github/workflows/app-deploy.yml name: Run Migrations and Deploy App on: workflow_call: inputs: environment: type: string required: true description: 'The environment to build for' workflow_dispatch: inputs: environment: type: string required: true description: 'The environment to build for' permissions: contents: read id-token: write jobs: migrate-and-deploy: runs-on: ubuntu-latest environment: ${{ inputs.environment }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v2 with: role-to-assume: arn:aws:iam::287641434880:role/ItoGitHubCiCdRole aws-region: us-west-2 - name: Run migrations run: | aws lambda invoke \ --function-name ${{ vars.AWS_STAGE }}-ItoDb-migration \ --payload '{}' \ out.json cat out.json if jq -e '.FunctionError' out.json > /dev/null; then echo "❌ Lambda execution failed:" echo "Message: $(jq -r '.errorMessage' out.json)" exit 1 fi if jq -e '.errorType' out.json > /dev/null; then echo "❌ Migration failed:" echo "Error: $(jq -r '.errorType' out.json)" echo "Message: $(jq -r '.errorMessage' out.json)" exit 1 fi - name: Force new ECS deployment run: | aws ecs update-service \ --cluster ${{ vars.AWS_STAGE }}-ito-cluster \ --service ${{ vars.AWS_STAGE }}-ito-service \ --force-new-deployment ================================================ FILE: .github/workflows/autolink-pr-to-issue.yml ================================================ name: Auto-link PR to Issue on: pull_request: types: [opened] jobs: autolink: runs-on: ubuntu-latest steps: - name: Extract issue number from branch name id: extract_issue run: | BRANCH_NAME="${{ github.head_ref }}" echo "Branch name: $BRANCH_NAME" # Extract issue number from branch name pattern ito-XXX if [[ $BRANCH_NAME =~ ^ito-([0-9]+) ]]; then ISSUE_NUMBER="${BASH_REMATCH[1]}" echo "issue_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT echo "Found issue number: $ISSUE_NUMBER" else echo "No issue number found in branch name" echo "issue_number=" >> $GITHUB_OUTPUT fi - name: Link PR to Issue if: steps.extract_issue.outputs.issue_number != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ steps.extract_issue.outputs.issue_number }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | # Check if issue exists ISSUE_EXISTS=$(gh api repos/heyito/ito/issues/$ISSUE_NUMBER --jq '.number' 2>/dev/null || echo "") if [ -n "$ISSUE_EXISTS" ]; then echo "Linking PR #$PR_NUMBER to issue #$ISSUE_NUMBER" # Add comment to PR mentioning the issue (creates automatic link) gh api repos/heyito/ito/issues/$PR_NUMBER/comments \ --method POST \ --field body="Resolves #$ISSUE_NUMBER" echo "Successfully linked PR #$PR_NUMBER to issue #$ISSUE_NUMBER" else echo "Issue #$ISSUE_NUMBER does not exist, skipping autolink" fi ================================================ FILE: .github/workflows/build-image.yml ================================================ name: Build and Push Docker Image on: workflow_call: inputs: environment: type: string required: true description: 'The environment to build for' workflow_dispatch: inputs: environment: type: string required: true description: 'The environment to build for' permissions: contents: read id-token: write jobs: build-and-push: runs-on: ubuntu-latest environment: ${{ inputs.environment }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v2 with: role-to-assume: arn:aws:iam::287641434880:role/ItoGitHubCiCdRole aws-region: us-west-2 - name: Login to ECR run: | aws ecr get-login-password \ | docker login \ --username AWS \ --password-stdin 287641434880.dkr.ecr.us-west-2.amazonaws.com - name: Build & push multi-arch image working-directory: server run: | docker buildx create --use docker buildx build \ --platform linux/arm64,linux/amd64 \ --tag 287641434880.dkr.ecr.us-west-2.amazonaws.com/${{ vars.AWS_STAGE }}-ito-server:latest \ --push . ================================================ FILE: .github/workflows/build.yml ================================================ name: Build and Release on: release: types: [published] permissions: contents: write id-token: write # required for OIDC role assumption jobs: build-mac: runs-on: macos-latest environment: ${{ github.event.release.target_commitish == 'main' && 'production' || 'develop' }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Bun uses: oven-sh/setup-bun@v1 - name: Install dependencies run: bun install - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: toolchain: stable targets: 'x86_64-apple-darwin,aarch64-apple-darwin' - name: Set up environment run: | echo "VITE_AUTH0_DOMAIN=\"${{ secrets.VITE_AUTH0_DOMAIN }}\"" >> .env echo "VITE_AUTH0_CLIENT_ID=\"${{ secrets.VITE_AUTH0_CLIENT_ID }}\"" >> .env echo "VITE_AUTH0_AUDIENCE=\"${{ secrets.VITE_AUTH0_AUDIENCE }}\"" >> .env echo "VITE_POSTHOG_API_KEY=\"${{ secrets.VITE_POSTHOG_API_KEY }}\"" >> .env echo "VITE_POSTHOG_HOST=\"${{ secrets.VITE_POSTHOG_HOST }}\"" >> .env echo "VITE_GRPC_BASE_URL=\"${{ vars.VITE_GRPC_BASE_URL }}\"" >> .env echo "VITE_UPDATER_BUCKET=\"${{ vars.VITE_UPDATER_BUCKET }}\"" >> .env echo "VITE_SENTRY_DSN=\"${{ vars.VITE_SENTRY_DSN }}\"" >> .env echo "VITE_SENTRY_ENV=\"${{ vars.VITE_SENTRY_ENV }}\"" >> .env echo "VITE_SENTRY_TRACES_SAMPLE_RATE=\"${{ vars.VITE_SENTRY_TRACES_SAMPLE_RATE }}\"" >> .env echo "VITE_SENTRY_PROFILES_SAMPLE_RATE=\"${{ vars.VITE_SENTRY_PROFILES_SAMPLE_RATE }}\"" >> .env echo "VITE_ITO_VERSION=\"${GITHUB_REF#refs/tags/v}\"" >> .env echo "ITO_ENV=\"${{ github.event.release.target_commitish == 'main' && 'prod' || 'dev' }}\"" >> .env echo "VITE_ITO_ENV=\"${{ github.event.release.target_commitish == 'main' && 'prod' || 'dev' }}\"" >> .env echo "APPLE_ID=\"${{ secrets.APPLE_ID }}\"" >> .env echo "APPLE_TEAM_ID=\"${{ secrets.APPLE_TEAM_ID }}\"" >> .env echo "APPLE_APP_SPECIFIC_PASSWORD=\"${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}\"" >> .env echo "CSC_LINK=\"release.p12\"" >> .env echo "CSC_KEY_PASSWORD=\"${{ secrets.MACOS_CERT_PASSWORD }}\"" >> .env echo "GH_TOKEN=\"${{ secrets.GITHUB_TOKEN }}\"" >> .env echo "GRPC_BASE_URL=\"${{ vars.GRPC_BASE_URL }}\"" >> .env echo "Created .env file:" cat .env - name: Decode and install certificate run: | echo "${{ secrets.MACOS_CERT_BASE64 }}" | base64 --decode > release.p12 - name: Build and package macOS application env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: ./build-app.sh mac - name: Upload Mac Installer DMG uses: actions/upload-artifact@v4 with: name: Ito-Mac-Installer path: dist/Ito-${{ github.event.release.target_commitish == 'main' && 'Installer' || 'dev-Installer' }}.dmg - name: Upload Mac Build Artifacts uses: actions/upload-artifact@v4 with: name: Mac-Build-Artifacts path: | dist/*.yml dist/*universal-mac.zip dist/*universal-mac.zip.blockmap dist/*.dmg dist/*.dmg.blockmap build-windows-rust: runs-on: windows-latest environment: ${{ github.event.release.target_commitish == 'main' && 'production' || 'develop' }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Rust with MSVC toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable-x86_64-pc-windows-msvc targets: 'x86_64-pc-windows-msvc' - name: Build Rust native modules with MSVC shell: pwsh run: | # Find Visual Studio installation (GitHub Actions uses Enterprise) $vsPath = if (Test-Path "C:\Program Files\Microsoft Visual Studio\2022\Enterprise") { "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" } elseif (Test-Path "C:\Program Files\Microsoft Visual Studio\2022\Community") { "C:\Program Files\Microsoft Visual Studio\2022\Community" } else { throw "Visual Studio 2022 not found" } Write-Host "Using Visual Studio at: $vsPath" # Launch VS Developer PowerShell and build & "$vsPath\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 # Build each native module $modules = @("global-key-listener", "audio-recorder", "text-writer", "active-application", "selected-text-reader") foreach ($module in $modules) { Write-Host "Building $module with MSVC..." Set-Location "native\$module" cargo build --release --target x86_64-pc-windows-msvc Set-Location ..\.. } - name: Prepare artifacts directory shell: pwsh run: | New-Item -ItemType Directory -Force -Path rust-binaries $modules = @("global-key-listener", "audio-recorder", "text-writer", "active-application", "selected-text-reader") foreach ($module in $modules) { $sourcePath = "native\target\x86_64-pc-windows-msvc\release\$module.exe" $destPath = "rust-binaries\$module.exe" if (Test-Path $sourcePath) { Copy-Item $sourcePath $destPath Write-Host "Copied $module.exe" } else { Write-Error "$sourcePath not found!" exit 1 } } - name: Upload Rust binaries uses: actions/upload-artifact@v4 with: name: Windows-Rust-Binaries path: rust-binaries/*.exe build-windows: runs-on: ubuntu-latest needs: build-windows-rust environment: ${{ github.event.release.target_commitish == 'main' && 'production' || 'develop' }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Download Rust binaries built with MSVC uses: actions/download-artifact@v4 with: name: Windows-Rust-Binaries path: rust-binaries-msvc - name: Place Rust binaries in expected locations run: | # Place binaries where electron-builder expects them for module in global-key-listener audio-recorder text-writer active-application selected-text-reader; do mkdir -p "native/target/x86_64-pc-windows-msvc/release" cp "rust-binaries-msvc/$module.exe" "native/target/x86_64-pc-windows-msvc/release/" echo "Placed $module.exe" done - name: Set up Bun uses: oven-sh/setup-bun@v1 - name: Install dependencies run: bun install - name: Set up environment run: | echo "VITE_AUTH0_DOMAIN=\"${{ secrets.VITE_AUTH0_DOMAIN }}\"" >> .env echo "VITE_AUTH0_CLIENT_ID=\"${{ secrets.VITE_AUTH0_CLIENT_ID }}\"" >> .env echo "VITE_AUTH0_AUDIENCE=\"${{ secrets.VITE_AUTH0_AUDIENCE }}\"" >> .env echo "VITE_POSTHOG_API_KEY=\"${{ secrets.VITE_POSTHOG_API_KEY }}\"" >> .env echo "VITE_POSTHOG_HOST=\"${{ secrets.VITE_POSTHOG_HOST }}\"" >> .env echo "VITE_GRPC_BASE_URL=\"${{ vars.VITE_GRPC_BASE_URL }}\"" >> .env echo "VITE_UPDATER_BUCKET=\"${{ vars.VITE_UPDATER_BUCKET }}\"" >> .env echo "VITE_SENTRY_DSN=\"${{ vars.VITE_SENTRY_DSN }}\"" >> .env echo "VITE_SENTRY_ENV=\"${{ vars.VITE_SENTRY_ENV }}\"" >> .env echo "VITE_SENTRY_TRACES_SAMPLE_RATE=\"${{ vars.VITE_SENTRY_TRACES_SAMPLE_RATE }}\"" >> .env echo "VITE_SENTRY_PROFILES_SAMPLE_RATE=\"${{ vars.VITE_SENTRY_PROFILES_SAMPLE_RATE }}\"" >> .env echo "VITE_ITO_VERSION=\"${GITHUB_REF#refs/tags/v}\"" >> .env echo "ITO_ENV=\"${{ github.event.release.target_commitish == 'main' && 'prod' || 'dev' }}\"" >> .env echo "VITE_ITO_ENV=\"${{ github.event.release.target_commitish == 'main' && 'prod' || 'dev' }}\"" >> .env echo "GH_TOKEN=\"${{ secrets.GITHUB_TOKEN }}\"" >> .env echo "GRPC_BASE_URL=\"${{ vars.GRPC_BASE_URL }}\"" >> .env echo "Created .env file:" cat .env - name: Build and package Windows application (unsigned) env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: ./build-app.sh windows --skip-binaries - name: Upload unsigned Windows artifacts for signing uses: actions/upload-artifact@v4 with: name: Windows-Unsigned-Artifacts path: | dist/*.exe dist/*.yml dist/*.nsis.7z dist/*.zip dist/*.blockmap sign-windows: runs-on: windows-latest needs: build-windows environment: ${{ github.event.release.target_commitish == 'main' && 'production' || 'develop' }} steps: - name: Download unsigned Windows artifacts uses: actions/download-artifact@v4 with: name: Windows-Unsigned-Artifacts path: dist - name: Sign Windows executable with Azure Trusted Signing uses: Azure/trusted-signing-action@v0 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} endpoint: https://eus.codesigning.azure.net/ trusted-signing-account-name: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME }} files-folder: dist files-folder-filter: exe file-digest: SHA256 timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 - name: Regenerate metadata after signing shell: bash run: | # Find the signed executable file in the dist directory # This searches for any file matching the pattern "Ito-*.exe" SIGNED_EXE=$(find dist -name "Ito-*.exe" | head -1) if [ -z "$SIGNED_EXE" ]; then echo "Error: No signed executable found" exit 1 fi echo "Found signed executable: $SIGNED_EXE" # Calculate the SHA512 checksum of the signed file # cut -d' ' -f1 extracts just the hash part (before the filename) NEW_CHECKSUM=$(sha512sum "$SIGNED_EXE" | cut -d' ' -f1) # Convert hex checksum to base64 format (required by electron-updater) # xxd -r -p converts hex string to raw bytes # base64 -w 0 encodes to base64 without line wrapping NEW_CHECKSUM_B64=$(echo -n "$NEW_CHECKSUM" | xxd -r -p | base64 -w 0) # Get the file size in bytes using stat FILE_SIZE=$(stat -c%s "$SIGNED_EXE") # Extract just the filename from the full path FILENAME=$(basename "$SIGNED_EXE") echo "New checksum (hex): $NEW_CHECKSUM" echo "New checksum (base64): $NEW_CHECKSUM_B64" echo "File size: $FILE_SIZE" echo "Filename: $FILENAME" # Update the auto-updater metadata file with the signed file's information # This is critical because signing changes the file's checksum if [ -f "dist/latest.yml" ]; then echo "Updating latest.yml with new checksum after signing" # Update each field in latest.yml using sed (stream editor) # The | delimiter is used instead of / to avoid conflicts with filenames sed -i "s|url: .*|url: $FILENAME|" dist/latest.yml # Update download URL sed -i "s|sha512: .*|sha512: $NEW_CHECKSUM_B64|" dist/latest.yml # Update checksum sed -i "s|size: .*|size: $FILE_SIZE|" dist/latest.yml # Update file size sed -i "s|path: .*|path: $FILENAME|" dist/latest.yml # Update path echo "Updated latest.yml contents:" cat dist/latest.yml else echo "Warning: latest.yml not found in dist directory" ls -la dist/ fi - name: Upload signed Windows artifacts uses: actions/upload-artifact@v4 with: name: Windows-Build-Artifacts path: | dist/*.yml dist/*.exe dist/*.nsis.7z dist/*.zip dist/*.blockmap upload-to-s3: runs-on: ubuntu-latest needs: [build-mac, sign-windows] environment: ${{ github.event.release.target_commitish == 'main' && 'production' || 'develop' }} steps: - name: Download Mac Build Artifacts uses: actions/download-artifact@v4 with: name: Mac-Build-Artifacts path: mac-dist - name: Download Windows Build Artifacts uses: actions/download-artifact@v4 with: name: Windows-Build-Artifacts path: windows-dist - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v2 with: role-to-assume: arn:aws:iam::287641434880:role/ItoGitHubCiCdRole aws-region: us-west-2 - name: Upload Build Output to S3 run: | echo "Deploying version ${{ github.ref_name }} to S3" BUCKET=s3://${{ vars.AWS_STAGE }}-ito-releases/releases echo "Listing existing files in root of releases/" aws s3 ls "$BUCKET/" | grep -vE '/$' | awk '{print $4}' > existing_files.txt echo "Combining Mac and Windows artifacts" mkdir -p combined-dist # Copy Mac artifacts if [ -d "mac-dist" ]; then cp -r mac-dist/* combined-dist/ fi # Copy Windows artifacts if [ -d "windows-dist" ]; then cp -r windows-dist/* combined-dist/ fi echo "Files to upload:" ls -la combined-dist/ echo "Identifying which files to delete post-upload" find combined-dist -maxdepth 1 \( \ -name '*.yml' -o \ -name '*universal-mac.zip' -o \ -name '*universal-mac.zip.blockmap' -o \ -name '*.dmg' -o \ -name '*.dmg.blockmap' -o \ -name '*.exe' -o \ -name '*.nsis.7z' -o \ -name '*.zip' -o \ -name '*.blockmap' \ \) | xargs -I{} basename {} > uploaded_root_files.txt echo "Uploading full combined dist to versioned folder: $BUCKET/${{ github.ref_name }}/" aws s3 cp combined-dist $BUCKET/${{ github.ref_name }}/ --recursive echo "Uploading selected files to root of releases/" for FILE in \ $(find combined-dist -maxdepth 1 -name '*.yml') \ $(find combined-dist -maxdepth 1 -name '*universal-mac.zip') \ $(find combined-dist -maxdepth 1 -name '*universal-mac.zip.blockmap') \ $(find combined-dist -maxdepth 1 -name '*.dmg') \ $(find combined-dist -maxdepth 1 -name '*.dmg.blockmap') \ $(find combined-dist -maxdepth 1 -name '*.exe') \ $(find combined-dist -maxdepth 1 -name '*.nsis.7z') \ $(find combined-dist -maxdepth 1 -name '*.zip') \ $(find combined-dist -maxdepth 1 -name '*.blockmap') do if [ -f "$FILE" ]; then aws s3 cp "$FILE" $BUCKET/ fi done # Compare and find stale files (exist in bucket but not in upload list) # Exclude .blockmap files from deletion to preserve differential update capability echo "Existing files in bucket root:" cat existing_files.txt echo "" echo "Files uploaded to bucket root:" cat uploaded_root_files.txt echo "" echo "Computing stale files (existing - uploaded)..." comm -23 <(sort existing_files.txt) <(sort uploaded_root_files.txt) > all_stale_files.txt echo "All stale files before filtering:" cat all_stale_files.txt || echo "(none)" echo "" echo "Filtering out .blockmap files..." grep -v '\.blockmap$' all_stale_files.txt > stale_files.txt || echo "(no non-blockmap stale files found)" echo "Stale files identified for deletion (excluding .blockmap files for differential updates):" cat stale_files.txt || echo "(none)" # Delete each stale file (but preserve blockmaps) while IFS= read -r file; do echo "Deleting stale file: $file" aws s3 rm "$BUCKET/$file" done < stale_files.txt - name: Invalidate CDN Cache for Release Files run: | echo "Invalidating cache for release files on distribution: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}. Updated files should be available within 15 minutes." aws cloudfront create-invalidation \ --distribution-id "${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}" \ --paths "/*.yml" "/*.dmg" "/*.exe" "/*.zip" "/*.blockmap" - name: Upload installers to GitHub Release uses: softprops/action-gh-release@v1 with: tag_name: ${{ github.ref_name }} files: | mac-dist/*.dmg windows-dist/*.exe ================================================ FILE: .github/workflows/ci-controller.yml ================================================ name: CI Controller on: push: branches: - 'main' - 'dev' pull_request: branches: - '**' jobs: run-tests: uses: ./.github/workflows/test-runner.yml native-build-check: uses: ./.github/workflows/native-build-check.yml # This job ONLY runs for pushes to 'dev' and 'main' deploy-server: needs: run-tests if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev') uses: ./.github/workflows/deploy-server.yml with: # Dynamically set the environment based on the branch environment: ${{ github.ref_name == 'main' && 'production' || 'develop' }} secrets: inherit ================================================ FILE: .github/workflows/deploy-server.yml ================================================ name: Deploy Server Worfklow on: workflow_call: inputs: environment: type: string required: true jobs: paths-check: runs-on: ubuntu-latest outputs: server_changed: ${{ steps.filter.outputs.server }} infra_changed: ${{ steps.filter.outputs.infra }} steps: - uses: actions/checkout@v3 with: fetch-depth: 0 # Fetch full history to allow comparing against previous commits - name: Detect changed areas id: filter uses: dorny/paths-filter@v2 with: base: ${{ github.event.before }} filters: | server: - server/src/** - server/Dockerfile - server/package.json infra: - server/infra/** list-files: 'none' # Step 1: Build and push Docker image first (if server code changed) # This ensures :latest tag is updated before CDK references it build-image: needs: paths-check if: ${{ needs.paths-check.outputs.server_changed == 'true' }} uses: ./.github/workflows/build-image.yml with: environment: ${{ inputs.environment }} secrets: inherit # Step 2: Deploy infrastructure (always runs if anything changed) # If task definition changes, ECS will automatically restart with new :latest deploy-infra: needs: - paths-check - build-image if: ${{ always() && (needs.paths-check.outputs.infra_changed == 'true' || needs.paths-check.outputs.server_changed == 'true') && (needs.build-image.result == 'success' || needs.build-image.result == 'skipped') }} uses: ./.github/workflows/infra-deploy.yml with: environment: ${{ inputs.environment }} secrets: inherit # Step 3: Run migrations and force ECS deployment (only if server changed) # Migration + force-new-deployment ensures ECS picks up new image even if task def didn't change deploy-app: needs: - paths-check - build-image - deploy-infra if: ${{ needs.paths-check.outputs.server_changed == 'true' }} uses: ./.github/workflows/app-deploy.yml with: environment: ${{ inputs.environment }} secrets: inherit ================================================ FILE: .github/workflows/infra-deploy.yml ================================================ name: Infra CDK on: workflow_call: inputs: environment: type: string required: true description: 'The environment to build for' workflow_dispatch: inputs: environment: type: string required: true description: 'The environment to build for' permissions: contents: read id-token: write # for OIDC jobs: cdk-deploy: runs-on: ubuntu-latest environment: ${{ inputs.environment }} steps: - name: Checkout infra uses: actions/checkout@v3 - name: Setup Node.js & CDK uses: actions/setup-node@v3 with: node-version: '20' - name: Install infra dependencies working-directory: server/infra run: npm install - name: Verify CDK version working-directory: server/infra run: npx cdk --version - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::287641434880:role/ItoGitHubCiCdRole aws-region: us-west-2 - name: Synthesize CDK template working-directory: server/infra run: npx cdk synth - name: Show CDK diff working-directory: server/infra run: npx cdk diff - name: Refresh AWS credentials before deploy uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::287641434880:role/ItoGitHubCiCdRole aws-region: us-west-2 - name: Deploy to AWS working-directory: server/infra env: AUTH0_DOMAIN: '${{ secrets.VITE_AUTH0_DOMAIN }}' AUTH0_AUDIENCE: '${{ secrets.VITE_AUTH0_AUDIENCE }}' AUTH0_CLIENT_ID: '${{ secrets.VITE_AUTH0_CLIENT_ID }}' AUTH0_MGMT_CLIENT_ID: '${{ secrets.AUTH0_MGMT_CLIENT_ID }}' AUTH0_MGMT_CLIENT_SECRET: '${{ secrets.AUTH0_MGMT_CLIENT_SECRET }}' STRIPE_SECRET_KEY: '${{ secrets.STRIPE_SECRET_KEY }}' STRIPE_WEBHOOK_SECRET: '${{ secrets.STRIPE_WEBHOOK_SECRET }}' STRIPE_PRICE_ID: '${{ secrets.STRIPE_PRICE_ID }}' APP_PROTOCOL: '${{ vars.APP_PROTOCOL }}' STRIPE_PUBLIC_BASE_URL: '${{ vars.STRIPE_PUBLIC_BASE_URL }}' run: npx cdk deploy "Ito-${{ vars.AWS_STAGE }}/*" --require-approval never ================================================ FILE: .github/workflows/native-build-check.yml ================================================ name: Native Build Check on: workflow_call: jobs: build-check-mac: runs-on: macos-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable targets: 'x86_64-apple-darwin,aarch64-apple-darwin' - name: Cache Rust dependencies uses: actions/cache@v4 with: path: | ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ native/target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo- - name: Verify native binaries compile (x86_64) working-directory: native run: cargo build --workspace --release --target x86_64-apple-darwin - name: Verify native binaries compile (aarch64) working-directory: native run: cargo build --workspace --release --target aarch64-apple-darwin build-check-windows: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install build dependencies run: | sudo apt-get update sudo apt-get install -y mingw-w64 - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable targets: 'x86_64-pc-windows-gnu' - name: Cache Rust dependencies uses: actions/cache@v4 with: path: | ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ native/target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo- - name: Verify native binaries compile (Windows) working-directory: native run: cargo build --workspace --release --target x86_64-pc-windows-gnu ================================================ FILE: .github/workflows/test-runner.yml ================================================ name: Test Runner on: workflow_call: jobs: test: runs-on: macos-latest env: ITO_ENV: dev steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Bun uses: oven-sh/setup-bun@v1 - name: Set ITO_ENV based on branch run: | if [[ "${GITHUB_REF_NAME}" == "main" || "${GITHUB_BASE_REF}" == "main" ]]; then echo "ITO_ENV=prod" >> "$GITHUB_ENV" else echo "ITO_ENV=dev" >> "$GITHUB_ENV" fi - name: Install dependencies run: bun install && cd server && bun install && cd .. - name: Run tests run: bun runAllTests - name: Check code formatting run: bun format:app - name: Check code linting run: bun lint:app - name: Check Rust formatting run: bun format:native - name: Check Rust linting run: bun lint:native ================================================ FILE: .gitignore ================================================ out/* release/* node_modules .env .env.local .env.development.local .env.test.local .env.production.local */dist/* dist/* .DS_Store native/**/target native/target-rust-analyzer native/**/.build out.json tsconfig.node.tsbuildinfo server/deploy-dev.sh # Sentry Config File .env.sentry-build-plugin .electron-builder-cache .npm-cache .cache ================================================ FILE: .prettierignore ================================================ node_modules/ dist/ build/ .next/ coverage/ out/ # Generated proto files **/generated/ lib/generated/ app/generated/ server/src/generated/ # CDK outputs server/infra/cdk.out/ server/infra/*.d.ts server/infra/*.js *.assets.json *.template.json # Build artifacts *.min.js *.bundle.js # Dependencies .pnpm-store/ bun.lockb pnpm-lock.yaml yarn.lock package-lock.json # Test coverage .nyc_output/ # IDE and system files .vscode/ .idea/ .DS_Store *.swp *.swo # Logs *.log logs/ # Environment files .env .env.* # Electron .vite/ # Binary files resources/binaries/ *.exe *.dll *.so *.dylib # Native build artifacts native/**/.build/** native/**/build/** native/**/*.json ================================================ FILE: .prettierrc.json ================================================ { "semi": false, "singleQuote": true, "arrowParens": "avoid", "tabWidth": 2 } ================================================ FILE: .vscode/settings.json ================================================ { "eslint.validate": [ "javascript", "javascriptreact", "typescript", "typescriptreact" ], "editor.formatOnSave": true, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "rust-analyzer.linkedProjects": [ "./native/selected-text-reader/Cargo.toml", "./native/text-writer/Cargo.toml", "./native/audio-recorder/Cargo.toml", "./native/active-application/Cargo.toml", "./native/global-key-listener/Cargo.toml" ], "rust-analyzer.cargo.targetDir": "${workspaceFolder}/native/target-rust-analyzer" } ================================================ FILE: AGENTS.md ================================================ # Repository Guidelines ## Project Structure & Module Organization - `app/` hosts the Electron renderer (React + Tailwind); `lib/` contains shared TypeScript modules, preload logic, and unit tests. - `server/` holds the Bun-based transcription API, database migrations, and infrastructure scripts. - `native/` includes Rust crates for keyboard, audio, and text bridges; rebuild via scripts as needed. - `resources/` provides packaging assets (icons, updater configs), while `build/` and `out/` house generated installers; avoid editing build outputs. - Use `scripts/` for automation and keep generated proto/constants under `lib/generated` (created by build steps). ## Build, Test, and Development Commands - `bun install` aligns dependencies after pulls. - `bun run dev` starts the Electron shell with live reload; `bun run dev:rust` rebuilds native bridges via `./build-binaries.sh` before launching. - Packaging: `bun run build:app` for cross-platform; use `bun run build:mac` / `bun run build:win` for platform installers. - Backend (`server/`): `bun install`, `bun run local-db-up` (Postgres), `bun run db:migrate`, `bun run dev`. ## Coding Style & Naming Conventions - TypeScript + React across app/lib; server targets modern ECMAScript on Bun. - Prettier (2-space indent) and ESLint enforce formatting. Run `bun run format` and `bun run lint` (or `*:app` variants) before submitting. - Components/classes use `PascalCase`, hooks/utilities `camelCase`, constants `SCREAMING_SNAKE_CASE`. Co-locate Tailwind styles with components and reuse tokens via `lib/constants`. - Always prefer console commands over log commands. E.g. use `console.log` instead of `log.info`. ## Testing Guidelines - Unit tests run with Bun. Renderer/shared specs live in `lib/__tests__`; server tests reside under `server/src/**`. Name files with `.test.ts`. - Run `bun run runLibTests`, `bun run runServerTests`, or `bun run runAllTests`; paste output in PR notes. - Seed backend tests with `bun run local-db-up` and avoid hitting external services—mock microphone, OS, and network dependencies. ## Commit & Pull Request Guidelines - Conventional commits are enforced via commitlint (example: `feat(app): add dictation overlay`). Scope by top-level folder. - PRs need a summary, linked issue, test commands, and screenshots/GIFs for UI work. Call out schema/config updates and refresh `.env.example` files. ## Environment & Security Notes - Copy `.env.example` (root) and `server/.env.example`; never commit credentials. - After modifying protobufs or constants, run `bun run generate:constants` so `lib/generated` stays in sync with the app bundle. ================================================ FILE: CLAUDE.md ================================================ # Claude Context for ITO Project ## Project Overview This is the ITO project - an AI assistant application with both client and server components. ## Project Structure - `app/` - Client application code - `server/` - Server-side code with gRPC services - `server/src/ito.proto` - Protocol buffer definitions - `server/src/clients/` - Various client implementations (Groq, LLM providers, etc.) ## Branch Main development branch: `dev` ## Development Commands - Dev: `bun dev` (starts electron-vite dev with watch) - Server: `docker compose up --build` (run from server directory) - Build: `bun build:app:mac` or `bun build:app:windows` - Test: `bun runAllTests` (runs lib, server, and native tests) - Lib tests: `bun runLibTests` - Server tests: `bun runServerTests` - Native tests: `bun runNativeTests` (or see "Native Binary Tests" section) - Lint: - TypeScript: `bun lint` (check) or `bun lint:fix` (fix) - Rust: `bun lint:native` (check) or `bun lint:fix:native` (fix) - Type check: `bun type-check` - Format: - TypeScript: `bun format` (check) or `bun format:fix` (fix) - Rust: `bun format:native` (check) or `bun format:fix:native` (fix) ## Native Binary Tests The `native/` directory contains Rust binaries that power the app's core functionality. The modules are organized as a Cargo workspace, allowing you to test and build all modules with a single command. ### Running Tests Test all native modules: ```bash cd native cargo test --workspace ``` Or use the npm script: ```bash bun runNativeTests ``` Test a single module: ```bash cd native/global-key-listener cargo test ``` ### Native Modules - `global-key-listener` - Keyboard event capture and hotkey management - `audio-recorder` - Audio recording with sample rate conversion - `text-writer` - Cross-platform text input simulation - `active-application` - Active window detection - `selected-text-reader` - Selected text extraction ### Linting and Formatting Rust code follows standard formatting and linting rules defined in `native/`: - **rustfmt.toml** - Code formatting configuration (100 char width, Unix line endings) - **clippy.toml** - Linter configuration (cognitive complexity threshold) - **Cargo.toml** - Workspace-level lint rules (pedantic + nursery warnings) Run checks locally: ```bash # Check formatting bun format:native # Auto-fix formatting bun format:fix:native # Check lints bun lint:native # Auto-fix lints (where possible) bun lint:fix:native ``` ### CI/CD Native tests and builds are integrated into the existing CI workflows: **Tests** (`.github/workflows/test-runner.yml`): - Unit tests run on macOS runner (OS-agnostic tests) - Runs automatically via `bun runAllTests` on all pushes and PRs - Executed as part of the main CI controller workflow **Compilation Checks** (`.github/workflows/native-build-check.yml`): - macOS: Verifies compilation for x86_64 and aarch64 architectures - Windows: Verifies cross-compilation for x86_64-pc-windows-gnu - Runs automatically on all pushes and PRs via the CI controller - Ensures binaries compile correctly for both platforms before merging **Release Builds** (`.github/workflows/build.yml`): - Full release compilation happens during tagged releases - Also includes compilation verification before packaging ## Code Style Preferences - Keep code as simple as possible - Don't create overly long files - Group related code into useful, well-named functions - Prefer clean, readable code over complex solutions - Follow existing patterns and conventions in the codebase - Always prefer console commands over log commands. E.g. use `console.log` instead of `log.info`. ## Tech Stack - TypeScript - bun - gRPC with Protocol Buffers - React (for UI components) - Various LLM providers (Groq, etc.) ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2024 Demox Labs This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ================================================ FILE: README.md ================================================ # [DEPRECATED] - This project is no longer maintained # Ito
Ito Logo

Smart dictation. Everywhere you want.

Ito is an intelligent voice assistant that brings seamless voice dictation to any application on your computer. Simply hold down your trigger key, speak naturally, and watch your words appear instantly in any text field.

macOS Windows Version License

--- ## ✨ Features ### 🎙️ **Universal Voice Dictation** - **Works in any app**: Emails, documents, chat applications, web browsers, code editors - **Global keyboard shortcuts**: Customizable trigger keys that work system-wide - **Real-time transcription**: High-accuracy speech-to-text powered by advanced AI models - **Instant text insertion**: Automatically types transcribed text into the focused text field ### 🧠 **Smart & Adaptive** - **Custom dictionary**: Add technical terms, names, and specialized vocabulary - **Context awareness**: Learns from your usage patterns to improve accuracy - **Multi-language support**: Transcribe in multiple languages - **Intelligent punctuation**: Automatically adds appropriate punctuation ### ⚙️ **Powerful Customization** - **Flexible shortcuts**: Configure any key combination as your trigger - **Audio preferences**: Choose your preferred microphone - **Privacy controls**: Local processing options and data control settings - **Seamless integration**: Works with any application ### 💾 **Data Management** - **Notes system**: Automatically save transcriptions for later reference - **Interaction history**: Track your dictation sessions and improve over time - **Cloud sync**: Keep your settings and data synchronized across devices - **Export capabilities**: Export your notes and interaction data --- ## 🚀 Quick Start ### Prerequisites - **macOS 10.15+** or **Windows 10+** - **Node.js 20+** and **Bun** (for development) - **Rust toolchain** (for building native components) - **Microphone access** and **Accessibility permissions** ### Installation 1. **Download the latest release** from [heyito.ai](https://www.heyito.ai/) or the [GitHub releases page](https://github.com/heyito/ito/releases) 2. **Install the application**: - **macOS**: Open the `.dmg` file and drag Ito to Applications - **Windows**: Run the `.exe` installer and follow the setup wizard 3. **Grant permissions** when prompted: - **Microphone access**: Required for voice input - **Accessibility access**: Required for global keyboard shortcuts and text insertion 4. **Set up authentication**: - Sign in with Google, Apple, Github through Auth0 or create a local account - Complete the guided onboarding process ### First Use 1. **Configure your trigger key**: Choose a comfortable keyboard shortcut (default: `Fn + Space`) 2. **Test your microphone**: Ensure clear audio input during the setup process 3. **Try it out**: Hold your trigger key and speak into any text field 4. **Customize settings**: Adjust voice sensitivity, shortcuts, and preferences --- ## 🛠️ Development ### Building from Source > **Important**: Ito requires a local transcription server for voice processing. See [server/README.md](server/README.md) for detailed server setup instructions. ```bash # Clone the repository git clone https://github.com/heyito/ito.git cd ito # Install dependencies bun install # Set up environment variables cp .env.example .env # Build native components (Rust binaries) ./build-binaries.sh # Set up and start the server (required for transcription) cd server cp .env.example .env # Edit with your API keys bun install bun run local-db-up # Start PostgreSQL database bun run db:migrate # Run database migrations bun run dev # Start development server cd .. # Start the Electron app (in a new terminal) bun run dev ``` ### Build Requirements #### All Platforms - **Rust**: Install via [rustup.rs](https://rustup.rs/) - **Windows users**: See Windows-specific instructions below for GNU toolchain setup - **macOS/Linux users**: Default installation is sufficient #### macOS - **Xcode Command Line Tools**: `xcode-select --install` #### Windows **Required Setup:** This setup uses git bash for shell operations. Download from [git](https://git-scm.com/downloads) 1. **Install Docker Desktop**: Download from [docker.com](https://www.docker.com/products/docker-desktop/) and ensure it's running 2. **Install Rust** (with GNU target) Download and run the official [Rust installer for Windows](https://rustup.rs/). This installs `rustup` and the MSVC toolchain by default. Add the GNU target (needed for our native components): rustup toolchain install stable-x86_64-pc-windows-gnu rustup target add x86_64-pc-windows-gnu --- 3. **Install 7-Zip** winget install 7zip.7zip --- 4. **Install GCC & MinGW-w64 via MSYS2** Install [MSYS2](https://www.msys2.org/). Open the **MSYS2 MinGW x64** shell (from the Start Menu). Update and install the toolchain: pacman -Syu # run twice if asked to restart pacman -S --needed mingw-w64-x86_64-toolchain Verify the tools exist: ls /mingw64/bin/gcc.exe /mingw64/bin/dlltool.exe --- 5. **Use the MinGW tools when building** (Git Bash) You normally develop and build in **Git Bash**. Before building, prepend the MinGW path: export PATH="/c/msys64/mingw64/bin:$PATH" export DLLTOOL="/c/msys64/mingw64/bin/dlltool.exe" export CC_x86_64_pc_windows_gnu="/c/msys64/mingw64/bin/x86_64-w64-mingw32-gcc.exe" export AR_x86_64_pc_windows_gnu="/c/msys64/mingw64/bin/ar.exe" export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER="/c/msys64/mingw64/bin/x86_64-w64-mingw32-gcc.exe" Check you’re picking up the right ones: which gcc # -> /c/msys64/mingw64/bin/gcc.exe which dlltool # -> /c/msys64/mingw64/bin/dlltool.exe ⚠️ **Do not add `C:\msys64\ucrt64\bin` to PATH.** That’s the wrong runtime and will break linking. 💡 To avoid running these exports every session, add the lines above to your Git Bash `~/.bashrc` file. They will be applied automatically whenever you open a new Git Bash window. --- 6. **Restart Git Bash if you update MSYS2** Whenever you update MSYS2 packages with `pacman -Syu`, restart Git Bash so the changes take effect. > **Note**: Windows builds use Docker for cross-compilation to ensure consistent builds. The Docker container handles the Windows build environment automatically. ### Project Structure ``` ito/ ├── app/ # Electron renderer (React frontend) │ ├── components/ # React components │ ├── store/ # Zustand state management │ └── styles/ # TailwindCSS styles ├── lib/ # Shared library code │ ├── main/ # Electron main process │ ├── preload/ # Preload scripts & IPC │ └── media/ # Audio/keyboard native interfaces ├── native/ # Native components (Rust/Swift) │ ├── audio-recorder/ # Audio capture (Rust) │ ├── global-key-listener/ # Keyboard events (Rust) │ ├── text-writer/ # Text insertion (Rust) │ └── active-application/ # Get the active application for context (Rust) ├── server/ # gRPC transcription server │ ├── src/ # Server implementation │ └── infra/ # AWS infrastructure (CDK) └── resources/ # Build resources & assets ``` ### Available Scripts ```bash # Development bun run dev # Start with hot reload bun run dev:rust # Build Rust components and start dev # Building Native Components bun run build:rust # Build for current platform bun run build:rust:mac # Build for macOS (with universal binary) bun run build:rust:win # Build for Windows # Building Application bun run build:mac # Build for macOS bun run build:win # Build for Windows ./build-app.sh mac # Build macOS using build script ./build-app.sh windows # Build Windows using build script (requires Docker) # Code Quality bun run lint # Run ESLint bun run format # Run Prettier bun run lint:fix # Fix linting issues ``` --- ## 🏗️ Architecture ### Client Architecture **Ito** is built as a modern Electron application with a sophisticated multi-process architecture: - **Main Process**: Handles system integration, permissions, and native component coordination - **Renderer Process**: React-based UI with real-time audio visualization - **Preload Scripts**: Secure IPC bridge between main and renderer processes - **Native Components**: High-performance Rust binaries for audio capture and keyboard handling ### Technology Stack **Frontend:** - **Electron** - Cross-platform desktop framework - **React 19** - Modern UI library with concurrent features - **TypeScript** - Type-safe development - **TailwindCSS** - Utility-first styling - **Zustand** - Lightweight state management - **Framer Motion** - Smooth animations **Backend:** - **Node.js** - Runtime environment - **gRPC** - High-performance RPC for transcription services - **SQLite** - Local data storage - **Protocol Buffers** - Efficient data serialization **Native Components:** - **Rust** - System-level audio recording and keyboard event handling - **Swift** - macOS-specific text manipulation and accessibility features - **cpal** - Cross-platform audio library - **enigo** - Cross-platform input simulation **Infrastructure:** - **AWS CDK** - Infrastructure as code - **Docker** - Containerized deployments - **Auth0** - Authentication and user management ### Communication Flow ```mermaid graph TD A[User Holds Trigger Key] --> B[Global Key Listener] B --> C[Main Process] C --> D[Audio Recorder Service] D --> E[gRPC Transcription Service] E --> F[AI Transcription Model] F --> G[Transcribed Text] G --> H[Text Writer Service] H --> I[Active Text Field] ``` --- ## 🔧 Configuration ### Keyboard Shortcuts Customize your trigger keys in **Settings > Keyboard**: - **Single key**: `Space`, `Fn`, etc. - **Key combinations**: `Cmd + Space`, `Ctrl + Shift + V`, etc. - **Complex shortcuts**: `Fn + Cmd + Space` for advanced workflows ### Audio Settings Fine-tune audio capture in **Settings > Audio**: - **Microphone selection**: Choose from available input devices - **Sensitivity adjustment**: Optimize for your voice and environment - **Noise reduction**: Filter background noise automatically - **Audio feedback**: Enable/disable sound effects ### Privacy & Data Control your data in **Settings > General**: - **Local processing**: Keep voice data on your device - **Cloud sync**: Synchronize settings across devices - **Analytics**: Share anonymous usage data (optional) - **Data export**: Download your notes and interaction history --- ## 🔒 Privacy & Security ### Data Handling - **Local-enabled**: Voice processing can be done entirely on your device or using our cloud - **Encrypted transmission**: All network communication uses TLS encryption - **Minimal data collection**: Only essential data is processed and stored - **User control**: Full control and transparency over data retention and deletion ### Permissions **Ito** requires specific system permissions to function: - **Microphone Access**: To capture your voice for transcription - **Accessibility Access**: To detect keyboard shortcuts and insert text - **Network Access**: For cloud features and updates (optional) ### Open Source This project is open source under the GNU General Public License. You can: - Audit the source code for security and privacy - Contribute improvements and bug fixes - Fork and customize for your specific needs - Report security issues through responsible disclosure --- ## 🤝 Contributing We welcome contributions! Whether you're fixing bugs, adding features, or improving documentation, your help makes **Ito** better for everyone. ### Getting Started 1. **Fork the repository** and clone your fork 2. **Create a feature branch** from `dev` 3. **Make your changes** with clear commit messages 4. **Test thoroughly** across supported platforms 5. **Submit a pull request** with a detailed description ### Development Guidelines - **Code Style**: Use Prettier and ESLint configurations - **Type Safety**: Maintain strong TypeScript typing - **Testing**: Add tests for new features - **Documentation**: Update docs for API changes - **Performance**: Consider impact on time between recording and text insertion ### Areas for Contribution - **Accuracy improvements**: Better transcription algorithms - **Language support**: Additional language models - **UI/UX enhancements**: Better user experience - **Platform support**: Windows stability testing, Linux compatibility - **Documentation**: Tutorials, guides, and examples --- ## 📄 License This project is licensed under the **GNU General Public License** - see the [LICENSE](LICENSE) file for details. --- ## 🙏 Acknowledgments **Ito** is built with and inspired by amazing open source projects: - **[Electron React App](https://github.com/guasam/electron-react-app)** by @guasam - The foundational template that provided our modern Electron + React architecture - **Electron** - Cross-platform desktop apps with web technologies - **React** - Modern UI development - **Rust** - Systems programming language for native components - **gRPC** - High-performance RPC framework - **TailwindCSS** - Utility-first CSS framework --- ## 📞 Support - **Community**: [GitHub Discussions](https://github.com/heyito/ito/discussions) - **Issues**: [GitHub Issues](https://github.com/heyito/ito/issues) - **Website**: [heyito.ai](https://www.heyito.ai) ================================================ FILE: app/app.tsx ================================================ import { HashRouter, Routes, Route } from 'react-router-dom' import appIcon from '@/resources/build/icon.png' import HomeKit from '@/app/components/home/HomeKit' import WelcomeKit from '@/app/components/welcome/WelcomeKit' import Pill from '@/app/components/pill/Pill' import { STEP_NAMES, STEP_NAMES_ARRAY, useOnboardingStore, } from '@/app/store/useOnboardingStore' import { useAuth } from '@/app/components/auth/useAuth' import { WindowContextProvider } from '@/lib/window' import { Auth0Provider } from '@/app/components/auth/Auth0Provider' import { useDeviceChangeListener } from './hooks/useDeviceChangeListener' import { verifyStoredMicrophone } from './media/microphone' import { useEffect } from 'react' const MainApp = () => { const { onboardingCompleted, onboardingStep } = useOnboardingStore() const { isAuthenticated } = useAuth() useDeviceChangeListener() useEffect(() => { verifyStoredMicrophone() }, []) const onboardingSetupCompleted = onboardingStep >= STEP_NAMES_ARRAY.indexOf(STEP_NAMES.TRY_IT_OUT) const shouldEnableShortcutGlobally = onboardingCompleted || onboardingSetupCompleted // If authenticated and onboarding completed, show main app if (isAuthenticated && onboardingCompleted) { window.api.send( 'electron-store-set', 'settings.isShortcutGloballyEnabled', shouldEnableShortcutGlobally, ) return } // If authenticated but onboarding not completed, continue onboarding window.api.send( 'electron-store-set', 'settings.isShortcutGloballyEnabled', shouldEnableShortcutGlobally, ) return } export default function App() { return ( {/* Route for the pill window */} } /> {/* Default route for the main application window */} } /> ) } ================================================ FILE: app/assets/.gitignore ================================================ ================================================ FILE: app/components/analytics/index.ts ================================================ import posthog from 'posthog-js' import log from 'electron-log' import { STORE_KEYS } from '../../../lib/constants/store-keys' import { v4 as uuidv4 } from 'uuid' import type { OnboardingCategory } from '../../store/useOnboardingStore' // Get or generate a machine-based device ID that's shared across all windows const getSharedDeviceId = async (): Promise => { try { // Just request the device ID - main process handles generation/caching const deviceId = await window.api?.invoke('analytics:get-device-id') if (deviceId) { console.log('[Analytics] Using machine-based device ID:', deviceId) return deviceId } throw new Error('No device ID returned from main process') } catch (error) { log.error('[Analytics] Could not get machine device ID:', error) // In true emergency, generate a temporary UUID as fallback return uuidv4() } } // Check if analytics should be enabled const getAnalyticsEnabled = (): boolean => { if (!import.meta.env.VITE_POSTHOG_API_KEY) { console.warn('[Analytics] No PostHog API key found, analytics disabled') return false } try { const settings = window.electron?.store?.get(STORE_KEYS.SETTINGS) return settings?.shareAnalytics ?? true } catch (error) { console.warn( '[Analytics] Could not read settings, defaulting to enabled:', error, ) return true } } const initPostHog = () => { const isPill = typeof window !== 'undefined' && typeof window.location !== 'undefined' && typeof window.location.hash === 'string' && window.location.hash.startsWith('#/pill') posthog.init(import.meta.env.VITE_POSTHOG_API_KEY, { api_host: import.meta.env.VITE_POSTHOG_HOST, disable_session_recording: true, disable_surveys: true, advanced_disable_decide: true, persistence: 'cookie', // Disable default web auto-capture and pageviews for the pill window only autocapture: !isPill, capture_pageview: !isPill, sanitize_properties: (props: Record) => { const p = { ...props } delete (p as any).$current_url delete (p as any).$pathname delete (p as any).$host delete (p as any).$referrer return p }, }) } // Initialize PostHog only if analytics is enabled let isAnalyticsInitialized = false let sharedDeviceId: string | null = null const analyticsEnabled = getAnalyticsEnabled() console.log('[Analytics] Analytics enabled:', analyticsEnabled) // Initialize PostHog asynchronously const initializeAnalytics = async () => { if (!analyticsEnabled) { console.log('[Analytics] PostHog disabled by user settings') return } try { sharedDeviceId = await getSharedDeviceId() console.log('[Analytics] Using shared device ID:', sharedDeviceId) initPostHog() if (sharedDeviceId) { posthog.register({ device_id: sharedDeviceId }) } // Attempt to resolve and alias install token to website distinct id try { const result = await window.api?.invoke('analytics:resolve-install-token') if (result && result.success && result.websiteDistinctId) { try { posthog.alias(result.websiteDistinctId) console.log( '[Analytics] Aliased to website distinct_id from install token', ) } catch (aliasErr) { log.warn('[Analytics] alias() failed:', aliasErr) } } } catch (err) { log.warn('[Analytics] resolve-install-token failed:', err) } isAnalyticsInitialized = true // Update the service instance after successful initialization analytics.updateInitializationStatus(true, sharedDeviceId) console.log( '[Analytics] PostHog initialized with shared device ID:', sharedDeviceId, ) } catch (error) { log.error('[Analytics] Failed to initialize analytics:', error) } } // Initialize analytics when the module loads initializeAnalytics() // Event types for type safety export interface BaseEventProperties { timestamp?: string session_id?: string [key: string]: any } export interface OnboardingEventProperties extends BaseEventProperties { step: number step_name: string category: OnboardingCategory total_steps: number referral_source?: string provider?: string } export interface HotkeyEventProperties extends BaseEventProperties { action: 'press' | 'release' keys: string[] duration_ms?: number session_duration_ms?: number } export interface AuthEventProperties extends BaseEventProperties { provider: string is_returning_user: boolean user_id?: string } export interface SettingsEventProperties extends BaseEventProperties { setting_name: string old_value: any new_value: any setting_category: string } export interface UserProperties { user_id: string email?: string name?: string provider?: string created_at?: string last_active?: string onboarding_completed?: boolean referral_source?: string keyboard_shortcuts?: string[] } // Event constants export const ANALYTICS_EVENTS = { // Onboarding events ONBOARDING_STARTED: 'onboarding_started', ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', ONBOARDING_STEP_VIEWED: 'onboarding_step_viewed', ONBOARDING_COMPLETED: 'onboarding_completed', ONBOARDING_ABANDONED: 'onboarding_abandoned', // Authentication events AUTH_SIGNUP_STARTED: 'auth_signup_started', AUTH_SIGNUP_COMPLETED: 'auth_signup_completed', AUTH_SIGNIN_STARTED: 'auth_signin_started', AUTH_SIGNIN_COMPLETED: 'auth_signin_completed', AUTH_SIGNIN_FAILED: 'auth_signin_failed', AUTH_LOGOUT: 'auth_logout', AUTH_LOGOUT_FAILED: 'auth_logout_failed', AUTH_STATE_GENERATION_FAILED: 'auth_state_generation_failed', AUTH_METHOD_FAILED: 'auth_method_failed', // Recording events RECORDING_STARTED: 'recording_started', RECORDING_COMPLETED: 'recording_completed', MANUAL_RECORDING_STARTED: 'manual_recording_started', MANUAL_RECORDING_COMPLETED: 'manual_recording_completed', MANUAL_RECORDING_ABANDONED: 'manual_recording_abandoned', // Settings events SETTING_CHANGED: 'setting_changed', MICROPHONE_CHANGED: 'microphone_changed', KEYBOARD_SHORTCUTS_CHANGED: 'keyboard_shortcuts_changed', } as const export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS] /** * Professional Analytics Service for Ito * Handles all analytics tracking with proper typing and error handling */ class AnalyticsService { private isInitialized: boolean = isAnalyticsInitialized private currentUserId: string | null = null private currentProvider: string | null = null private sessionStartTime: number = Date.now() private deviceId: string | null = null constructor() { // Device ID will be set after async initialization this.deviceId = sharedDeviceId console.log( `[Analytics] Service initialized (enabled: ${this.isInitialized}, deviceId: ${this.deviceId || 'pending'})`, ) } /** * Enable analytics (re-initialize if needed) */ async enableAnalytics() { if (!this.isInitialized && import.meta.env.VITE_POSTHOG_API_KEY) { try { const deviceId = await getSharedDeviceId() this.deviceId = deviceId initPostHog() if (deviceId) { posthog.register({ device_id: deviceId }) } this.isInitialized = true console.log( '[Analytics] Analytics enabled and initialized with shared device ID:', deviceId, ) } catch (error) { log.error('[Analytics] Failed to enable analytics:', error) } } } /** * Disable analytics */ disableAnalytics() { this.isInitialized = false this.currentUserId = null this.currentProvider = null try { posthog.opt_out_capturing() } catch (error) { log.warn('[Analytics] Failed to opt-out capturing:', error) } console.log('[Analytics] Analytics disabled') } /** * Check if analytics is currently enabled */ isEnabled(): boolean { return this.isInitialized } /** * Set user identification and properties */ identifyUser( userId: string, properties: Partial = {}, provider?: string, ) { console.log('identifyUser', userId, properties, provider) // Store provider information if (provider) { this.currentProvider = provider } if (!this.shouldTrack()) { console.log( '[Analytics] User identification skipped - analytics disabled or self-hosted user', ) return } try { if (this.currentUserId !== userId) { this.currentUserId = userId const props = { user_id: userId, last_active: new Date().toISOString(), ...properties, } posthog.identify(userId, props) console.log( `[Analytics] User identified: ${userId} (deviceId: ${this.deviceId || 'pending'})`, ) } else if (Object.keys(properties).length > 0) { posthog.identify(undefined, { ...properties, last_active: new Date().toISOString(), }) } } catch (error) { log.error('[Analytics] Failed to identify user:', error) } } /** * Update user properties */ updateUserProperties(properties: Partial) { if (!this.shouldTrack() || !this.currentUserId) { console.log( '[Analytics] User properties update skipped - analytics disabled, self-hosted user, or user not identified', ) return } try { posthog.identify(undefined, properties) console.log('[Analytics] User properties updated') } catch (error) { log.error('[Analytics] Failed to update user properties:', error) } } /** * Track a generic event */ track(eventName: AnalyticsEvent, properties: BaseEventProperties = {}) { if (!this.shouldTrack()) { return } try { const eventProperties = { timestamp: new Date().toISOString(), session_duration_ms: Date.now() - this.sessionStartTime, ...properties, } posthog.capture(eventName, { ...eventProperties, ...(this.currentUserId ? { user_id: this.currentUserId } : {}), }) console.log( `[Analytics] Event tracked: ${eventName} (deviceId: ${this.deviceId || 'pending'}, userId: ${this.currentUserId || 'anonymous'})`, ) } catch (error) { log.error(`[Analytics] Failed to track event ${eventName}:`, error) } } /** * Track onboarding events */ trackOnboarding( eventName: Extract< AnalyticsEvent, | 'onboarding_started' | 'onboarding_step_completed' | 'onboarding_step_viewed' | 'onboarding_completed' | 'onboarding_abandoned' >, properties: OnboardingEventProperties, ) { console.log('trackOnboarding', eventName, properties) this.track(eventName, properties) } /** * Track authentication events */ trackAuth( eventName: Extract< AnalyticsEvent, | 'auth_signup_started' | 'auth_signup_completed' | 'auth_signin_started' | 'auth_signin_completed' | 'auth_logout' >, properties: AuthEventProperties, ) { this.track(eventName, properties) } /** * Track settings changes */ trackSettings( eventName: Extract< AnalyticsEvent, | 'setting_changed' | 'microphone_changed' | 'keyboard_shortcut_changed' | 'privacy_mode_toggled' | 'keyboard_shortcuts_changed' >, properties: SettingsEventProperties, ) { this.track(eventName, properties) } /** * Track permission events */ trackPermission( eventName: Extract< AnalyticsEvent, 'permission_requested' | 'permission_granted' | 'permission_denied' >, permissionType: 'microphone' | 'accessibility', properties: BaseEventProperties = {}, ) { this.track(eventName, { permission_type: permissionType, ...properties, }) } /** * Reset analytics (for logout) */ resetUser() { if (!this.isInitialized) { console.log('[Analytics] User reset skipped - analytics disabled') return } try { posthog.reset() this.currentUserId = null this.currentProvider = null console.log('[Analytics] User session reset') } catch (error) { log.error('[Analytics] Failed to reset user session:', error) } } /** * Get current session duration */ getSessionDuration(): number { return Date.now() - this.sessionStartTime } /** * Check if user is identified */ isUserIdentified(): boolean { return this.currentUserId !== null } /** * Get the current device ID */ getDeviceId(): string | null { return this.deviceId } /** * Update initialization status (called after async initialization completes) */ updateInitializationStatus(isInitialized: boolean, deviceId: string | null) { this.isInitialized = isInitialized this.deviceId = deviceId console.log( `[Analytics] Service status updated (enabled: ${this.isInitialized}, deviceId: ${this.deviceId})`, ) } /** * Check if analytics should be tracked based on provider */ private shouldTrack(): boolean { if (!this.isInitialized) { return false } // Skip tracking for self-hosted users if (this.currentProvider === 'self-hosted') { console.log('[Analytics] Tracking skipped - self-hosted user') return false } return true } } // Export singleton instance export const analytics = new AnalyticsService() // Function to update analytics based on settings change export const updateAnalyticsFromSettings = (shareAnalytics: boolean) => { if (shareAnalytics && !analytics.isEnabled()) { analytics.enableAnalytics() console.log('[Analytics] Analytics enabled by settings change') } else if (!shareAnalytics && analytics.isEnabled()) { analytics.disableAnalytics() console.log('[Analytics] Analytics disabled by settings change') } } // Export convenience functions export const trackEvent = analytics.track.bind(analytics) export const identifyUser = analytics.identifyUser.bind(analytics) export const updateUserProperties = analytics.updateUserProperties.bind(analytics) export const resetAnalytics = analytics.resetUser.bind(analytics) ================================================ FILE: app/components/auth/Auth0Provider.tsx ================================================ import React from 'react' import { Auth0Provider as Auth0ReactProvider } from '@auth0/auth0-react' import { Auth0Config, validateAuth0Config } from '../../../lib/auth/config' interface Auth0ProviderProps { children: React.ReactNode } export const Auth0Provider: React.FC = ({ children }) => { // Validate configuration on startup React.useEffect(() => { try { validateAuth0Config() } catch (error) { console.error('Auth0 configuration error:', error) } }, []) return ( {children} ) } export default Auth0Provider ================================================ FILE: app/components/auth/useAuth.ts ================================================ import { useAuth0 } from '@auth0/auth0-react' import { useCallback, useEffect, useMemo } from 'react' import { Auth0Connections, Auth0Config } from '../../../lib/auth/config' import { useAuthStore } from '../../store/useAuthStore' import { type AuthUser, type AuthTokens } from '../../../lib/main/store' import { useMainStore } from '@/app/store/useMainStore' import { analytics, ANALYTICS_EVENTS } from '../analytics' import { STORE_KEYS } from '../../../lib/constants/store-keys' import { useOnboardingStore } from '@/app/store/useOnboardingStore' export const useAuth = () => { const { logout, user, isAuthenticated: auth0IsAuthenticated, isLoading: auth0IsLoading, error: auth0Error, getAccessTokenSilently, getIdTokenClaims, } = useAuth0() // Get auth state from our store const { isAuthenticated: storeIsAuthenticated, user: storeUser, tokens, isLoading: storeIsLoading, error: storeError, clearAuth, setSelfHostedMode, } = useAuthStore() // Combine Auth0 and store state - prioritize store state for external auth const isAuthenticated = storeIsAuthenticated || auth0IsAuthenticated const isLoading = storeIsLoading || auth0IsLoading const error = storeError || auth0Error // Convert Auth0 user to our user interface const auth0User: AuthUser | null = useMemo(() => { if (!user) return null return { id: user.sub || '', email: user.email, name: user.name, picture: user.picture, provider: user.sub?.includes('|') ? user.sub.split('|')[0] : 'unknown', lastSignInAt: new Date().toISOString(), // Only updated when user object changes } }, [user]) // Dependency array now correctly includes 'user' // Prioritize store user over Auth0 user const authUser = storeUser || auth0User // Hydrate per-user onboarding state helper async function hydrateOnboardingState(context?: string): Promise { try { const saved = await window.api.getOnboardingState?.() const onboarding = useOnboardingStore.getState() if (saved?.onboardingCompleted) { onboarding.setOnboardingCompleted() } else { onboarding.resetOnboarding() onboarding.incrementOnboardingStep() } } catch (e) { const suffix = context ? ` (${context})` : '' console.warn(`[useAuth] Failed to hydrate onboarding state${suffix}:`, e) } } // Check for token expiration on startup useEffect(() => { // Check if we have valid auth state stored const storedAuth = window.electron?.store?.get(STORE_KEYS.AUTH) const hasStoredTokens = storedAuth?.tokens?.access_token // Also check main store for tokens (backwards compatibility) const hasMainStoreToken = window.electron?.store?.get( STORE_KEYS.ACCESS_TOKEN, ) if ((hasStoredTokens || hasMainStoreToken) && !isAuthenticated) { console.log('Detected expired tokens on startup, clearing auth state') // Clear any remaining auth data clearAuth(true) // Track the automatic logout const currentUser = authUser if (currentUser) { analytics.trackAuth(ANALYTICS_EVENTS.AUTH_LOGOUT, { provider: currentUser.provider || 'unknown', is_returning_user: true, user_id: currentUser.id, complete_signout: false, session_duration_ms: analytics.getSessionDuration(), reason: 'token_expired_startup', }) } } }, [isAuthenticated, authUser, clearAuth]) useEffect(() => { if (authUser) { analytics.identifyUser( authUser.id, { user_id: authUser.id, email: authUser.email, name: authUser.name, provider: authUser.provider, created_at: authUser.lastSignInAt, }, authUser.provider, ) // Notify pill window of user authentication if (window.api?.notifyUserAuthUpdate) { window.api.notifyUserAuthUpdate({ id: authUser.id, email: authUser.email, name: authUser.name, provider: authUser.provider, }) } } else { // Notify pill window that user is not authenticated (logout/reset) if (window.api?.notifyUserAuthUpdate) { console.log('[useAuth] Notifying pill window of user reset') window.api.notifyUserAuthUpdate(null) } } }, [ authUser, auth0IsAuthenticated, auth0User, storeIsAuthenticated, storeUser, ]) // Handle auth code from protocol URL - only set up listener once globally useEffect(() => { if (!window.api?.on) { console.warn('window.api.on not available') return } // Check if listener is already set up if ((window as any).__authCodeListenerSetup) { return } // Mark that we've set up the listener ;(window as any).__authCodeListenerSetup = true const cleanup = window.api.on( 'auth-code-received', async (authCode: string, state: string) => { try { // Exchange authorization code for tokens via main process const result = await window.api.invoke('exchange-auth-code', { authCode, state, config: Auth0Config, }) if (!result.success) { throw new Error(result.error) } // Store tokens and user info in the auth store if (result.tokens && result.userInfo) { // Extract provider from Auth0 user ID (format: "provider|id") const providerId = result.userInfo.id || '' const provider = providerId.includes('|') ? providerId.split('|')[0] : 'unknown' // Check if this is a returning user const existingUser = useAuthStore.getState().user const isReturningUser = !!existingUser && existingUser.id === result.userInfo.id useAuthStore .getState() .setAuthData( result.tokens as AuthTokens, result.userInfo as AuthUser, provider, ) // Track successful signin analytics.trackAuth(ANALYTICS_EVENTS.AUTH_SIGNIN_COMPLETED, { provider, is_returning_user: isReturningUser, user_id: result.userInfo.id, }) useMainStore.getState().setCurrentPage('home') await window.api.notifyLoginSuccess( result.userInfo, result.tokens.id_token, result.tokens.access_token, ) // Hydrate per-user onboarding state from SQLite for the now-authenticated user await hydrateOnboardingState() } else { throw new Error('Missing tokens or user info in response') } } catch (error) { console.error('Error handling auth code from protocol URL:', error) // Track signin failure analytics.track(ANALYTICS_EVENTS.AUTH_SIGNIN_FAILED, { error_message: error instanceof Error ? error.message : 'Unknown error', auth_method: 'external_browser', }) alert( `Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, ) } }, ) return () => { cleanup() ;(window as any).__authCodeListenerSetup = false } }, []) // External browser authentication - now the primary method const openExternalAuth = useCallback( async ( connection?: string, options?: { email?: string; mode?: 'login' | 'signup' }, ) => { // Track signin attempt started const provider = connection || 'unknown' const eventType = options?.mode === 'signup' ? ANALYTICS_EVENTS.AUTH_SIGNUP_STARTED : ANALYTICS_EVENTS.AUTH_SIGNIN_STARTED analytics.trackAuth(eventType, { provider, is_returning_user: false, // We don't know yet auth_method: 'external_browser', }) let authState = useAuthStore.getState().state // Generate new auth state if not available if (!authState) { try { // Generate fresh auth state from the main process authState = await window.api.generateNewAuthState() if (!authState) { throw new Error('Generated auth state is null') } // Update the store with the new auth state useAuthStore.getState().updateState(authState) } catch (error) { console.error('Failed to generate new auth state:', error) // Track auth state generation failure analytics.track(ANALYTICS_EVENTS.AUTH_STATE_GENERATION_FAILED, { provider, error_message: error instanceof Error ? error.message : 'Unknown error', }) throw new Error( 'Failed to generate auth state. Please restart the app.', ) } } const params = new URLSearchParams({ response_type: 'code', client_id: Auth0Config.clientId, redirect_uri: Auth0Config.redirectUri, scope: Auth0Config.scope, prompt: 'select_account', state: authState.state, code_challenge: authState.codeChallenge, code_challenge_method: 'S256', }) // Add audience if configured if (Auth0Config.audience) { params.append('audience', Auth0Config.audience) } if (connection) { params.append('connection', connection) } if (options?.email) { params.append('login_hint', options.email) } if (options?.mode === 'signup') { params.append('screen_hint', 'signup') } const authUrl = `https://${Auth0Config.domain}/authorize?${params.toString()}` // Open in external browser if (window.api?.invoke) { await window.api.invoke('web-open-url', authUrl) } else { window.open(authUrl, '_blank') } }, [], ) // Helper function to reduce duplication in social auth methods const createSocialAuthMethod = useCallback( (connection: string, providerName: string) => { return async (email?: string) => { try { await openExternalAuth(connection, email ? { email } : undefined) } catch (error) { console.error(`${providerName} external auth failed:`, error) // Track auth method failure analytics.track(ANALYTICS_EVENTS.AUTH_METHOD_FAILED, { provider: providerName.toLowerCase(), error_message: error instanceof Error ? error.message : 'Unknown error', auth_method: 'external_browser', }) throw error } } }, [openExternalAuth], ) // Social authentication methods - now use external browser by default const loginWithGoogle = createSocialAuthMethod( Auth0Connections.google, 'Google', ) const loginWithMicrosoft = createSocialAuthMethod( Auth0Connections.microsoft, 'Microsoft', ) const loginWithApple = createSocialAuthMethod(Auth0Connections.apple, 'Apple') // GitHub authentication - now uses external browser const loginWithGitHub = createSocialAuthMethod( Auth0Connections.github, 'GitHub', ) // Email/password via Auth0 Database connection const loginWithEmail = useCallback( async (email?: string) => { try { await openExternalAuth( Auth0Connections.database, email ? { email, mode: 'login' } : { mode: 'login' }, ) } catch (error) { console.error('Email login failed:', error) analytics.track(ANALYTICS_EVENTS.AUTH_METHOD_FAILED, { provider: 'email', error_message: error instanceof Error ? error.message : 'Unknown error', auth_method: 'external_browser', }) throw error } }, [openExternalAuth], ) // Direct email/password login without opening a browser window const loginWithEmailPassword = useCallback( async ( email: string, password: string, options?: { skipNavigate?: boolean }, ) => { try { analytics.trackAuth(ANALYTICS_EVENTS.AUTH_SIGNIN_STARTED, { provider: 'email', is_returning_user: false, auth_method: 'password_realm', }) const result = await window.api.invoke('auth0-db-login', { email, password, }) if (!result?.success || !result?.tokens) { throw new Error(result?.error || 'Login failed') } const tokens = result.tokens as AuthTokens const idToken = tokens.id_token || '' let userInfo: AuthUser | null = null try { const payloadPart = idToken.split('.')[1] const base64 = payloadPart.replace(/-/g, '+').replace(/_/g, '/') const padded = base64 + '==='.slice((base64.length + 3) % 4) const json = atob(padded) const payload = JSON.parse(json) userInfo = { id: payload.sub || '', email: payload.email, name: payload.name, picture: payload.picture, provider: typeof payload.sub === 'string' && payload.sub.includes('|') ? payload.sub.split('|')[0] : 'email', lastSignInAt: new Date().toISOString(), } } catch { console.warn( 'Failed to decode id_token, proceeding with minimal profile', ) userInfo = { id: 'email', email, provider: 'email', lastSignInAt: new Date().toISOString(), } } useAuthStore .getState() .setAuthData(tokens, userInfo as AuthUser, 'email') analytics.trackAuth(ANALYTICS_EVENTS.AUTH_SIGNIN_COMPLETED, { provider: 'email', is_returning_user: false, user_id: userInfo?.id, }) if (!options?.skipNavigate) { useMainStore.getState().setCurrentPage('home') } await window.api.notifyLoginSuccess( userInfo, tokens.id_token ?? null, tokens.access_token ?? null, ) // Hydrate per-user onboarding state await hydrateOnboardingState('email/password') } catch (error) { console.error('Email/password login failed:', error) analytics.track(ANALYTICS_EVENTS.AUTH_METHOD_FAILED, { provider: 'email', error_message: error instanceof Error ? error.message : 'Unknown error', auth_method: 'password_realm', }) throw error } }, [], ) const signupWithEmail = useCallback( async (email?: string) => { try { await openExternalAuth( Auth0Connections.database, email ? { email, mode: 'signup' } : { mode: 'signup' }, ) } catch (error) { console.error('Email signup failed:', error) analytics.track(ANALYTICS_EVENTS.AUTH_METHOD_FAILED, { provider: 'email', error_message: error instanceof Error ? error.message : 'Unknown error', auth_method: 'external_browser', }) throw error } }, [openExternalAuth], ) // Directly create a database user via Auth0 Authentication API (proxied via main to avoid CORS) const createDatabaseUser = useCallback( async (email: string, password: string, name: string) => { const result = await window.api.invoke('auth0-db-signup', { email, password, name, }) if (!result?.success) { throw new Error(result?.error || 'Signup failed') } return result.data }, [], ) // Self-hosted authentication - bypasses all external auth const loginWithSelfHosted = useCallback(async () => { try { // Track self-hosted signin attempt analytics.trackAuth(ANALYTICS_EVENTS.AUTH_SIGNIN_STARTED, { provider: 'self-hosted', is_returning_user: false, auth_method: 'self_hosted', }) setSelfHostedMode() // Notify main process about self-hosted login and wait for it to complete const selfHostedProfile = { id: 'self-hosted', provider: 'self-hosted', lastSignInAt: new Date().toISOString(), } await window.api.notifyLoginSuccess( selfHostedProfile, null, // No idToken for self-hosted null, // No accessToken for self-hosted ) // Hydrate per-user onboarding state await hydrateOnboardingState('self-hosted') // Track successful self-hosted signin analytics.trackAuth(ANALYTICS_EVENTS.AUTH_SIGNIN_COMPLETED, { provider: 'self-hosted', is_returning_user: false, user_id: 'self-hosted', auth_method: 'self_hosted', }) } catch (error) { console.error('Self-hosted mode activation error:', error) // Track self-hosted signin failure analytics.track(ANALYTICS_EVENTS.AUTH_SIGNIN_FAILED, { provider: 'self-hosted', error_message: error instanceof Error ? error.message : 'Unknown error', auth_method: 'self_hosted', }) throw error } }, [setSelfHostedMode]) // Get access token for API calls const getAccessToken = useCallback(async () => { try { // First try to get from our store (for external auth) if (tokens?.access_token) { return tokens.access_token } // Fallback to Auth0 silent auth (for popup/redirect auth) return await getAccessTokenSilently() } catch (error) { console.error('Error getting access token:', error) throw error } }, [getAccessTokenSilently, tokens]) // Manual token refresh const refreshTokens = useCallback(async () => { try { console.log('Manually refreshing tokens...') const result = await window.api.invoke('refresh-tokens') if (result.success) { console.log('Manual token refresh successful') return result } else { console.error('Manual token refresh failed:', result.error) throw new Error(result.error) } } catch (error) { console.error('Error during manual token refresh:', error) throw error } }, []) // Logout const logoutUser = useCallback( async (completelySignOut: boolean = false) => { try { // Track logout attempt const currentUser = authUser analytics.trackAuth(ANALYTICS_EVENTS.AUTH_LOGOUT, { provider: currentUser?.provider || 'unknown', is_returning_user: true, // If they're logging out, they were logged in user_id: currentUser?.id, complete_signout: completelySignOut, session_duration_ms: analytics.getSessionDuration(), }) // Clear main process store first await window.api.logout() // Clear our auth store, preserving user data by default clearAuth(!completelySignOut) // Also logout from Auth0 if using Auth0 session if (auth0IsAuthenticated) { logout({ logoutParams: { returnTo: window.location.origin, }, }) } } catch (error) { console.error('Error during logout:', error) // Track logout failure analytics.track(ANALYTICS_EVENTS.AUTH_LOGOUT_FAILED, { provider: authUser?.provider || 'unknown', error_message: error instanceof Error ? error.message : 'Unknown error', complete_signout: completelySignOut, }) // Still try to clear local auth even if main process logout fails clearAuth(!completelySignOut) } }, [logout, clearAuth, auth0IsAuthenticated, authUser], ) // Handle auth token events from main process useEffect(() => { if (!window.api?.on) { console.warn('window.api.on not available') return } // Check if listener is already set up if ((window as any).__authTokenListenerSetup) { return } // Mark that we've set up the listener ;(window as any).__authTokenListenerSetup = true // Handle token refresh success const cleanupTokensRefreshed = window.api.on( 'tokens-refreshed', async (newTokens: AuthTokens) => { console.log('Tokens refreshed successfully, updating auth store') try { // Update the auth store with refreshed tokens const currentUser = authUser if (currentUser) { useAuthStore .getState() .setAuthData(newTokens, currentUser, currentUser.provider) // Track successful token refresh analytics.track(ANALYTICS_EVENTS.AUTH_SIGNIN_COMPLETED, { provider: currentUser.provider || 'unknown', user_id: currentUser.id, is_returning_user: true, reason: 'token_refresh', }) } } catch (error) { console.error( 'Error updating auth store with refreshed tokens:', error, ) } }, ) // Handle token expiration (when refresh fails or no refresh token available) const cleanupTokenExpired = window.api.on( 'auth-token-expired', async () => { console.log('Auth token expired, automatically signing out user') try { // Track automatic logout due to token expiration const currentUser = authUser analytics.trackAuth(ANALYTICS_EVENTS.AUTH_LOGOUT, { provider: currentUser?.provider || 'unknown', is_returning_user: true, user_id: currentUser?.id, complete_signout: false, session_duration_ms: analytics.getSessionDuration(), reason: 'token_expired', }) logoutUser(false) // Auth state will automatically redirect to welcome page } catch (error) { console.error('Error during automatic logout:', error) // Still try to clear local auth even if main process logout fails clearAuth(true) } }, ) return () => { cleanupTokensRefreshed() cleanupTokenExpired() ;(window as any).__authTokenListenerSetup = false } }, [logout, clearAuth, auth0IsAuthenticated, authUser, logoutUser]) return { // Auth state user: authUser, isAuthenticated, isLoading, error, // Authentication methods loginWithGoogle, loginWithMicrosoft, loginWithApple, loginWithGitHub, loginWithEmail, loginWithEmailPassword, signupWithEmail, createDatabaseUser, loginWithSelfHosted, logoutUser, // Utilities getAccessToken, getIdTokenClaims, refreshTokens, } } ================================================ FILE: app/components/home/HomeKit.tsx ================================================ import { Home, BookOpen, FileText, CogFour, InfoCircle, } from '@mynaui/icons-react' import { ItoIcon } from '../icons/ItoIcon' import { useMainStore } from '@/app/store/useMainStore' import { useUserMetadataStore } from '@/app/store/useUserMetadataStore' import { useOnboardingStore } from '@/app/store/useOnboardingStore' import { useAuth } from '@/app/components/auth/useAuth' import useBillingState from '@/app/hooks/useBillingState' import { PaidStatus } from '@/lib/main/sqlite/models' import { useEffect, useState, useRef } from 'react' import { NavItem } from '../ui/nav-item' import HomeContent from './contents/HomeContent' import DictionaryContent from './contents/DictionaryContent' import NotesContent from './contents/NotesContent' import SettingsContent from './contents/SettingsContent' import AboutContent from './contents/AboutContent' export default function HomeKit() { const { navExpanded, currentPage, setCurrentPage } = useMainStore() const { metadata } = useUserMetadataStore() const { onboardingCompleted } = useOnboardingStore() const { isAuthenticated, user } = useAuth() const billingState = useBillingState() const [showText, setShowText] = useState(navExpanded) const hasStartedTrialRef = useRef(false) const previousUserIdRef = useRef(undefined) const [isStartingTrial, setIsStartingTrial] = useState(false) const isPro = metadata?.paid_status === PaidStatus.PRO || metadata?.paid_status === PaidStatus.PRO_TRIAL || billingState.proStatus === 'active_pro' || billingState.proStatus === 'free_trial' // Reset flags when user changes useEffect(() => { const currentUserId = user?.id const previousUserId = previousUserIdRef.current if (currentUserId && currentUserId !== previousUserId) { // User changed - reset trial start flag hasStartedTrialRef.current = false setIsStartingTrial(false) previousUserIdRef.current = currentUserId } else if (currentUserId && previousUserId === undefined) { // First time setting userId previousUserIdRef.current = currentUserId } }, [user?.id]) // Start trial for users who don't have one yet // Case 1: New users after onboarding completes // Case 2: Existing users who completed onboarding but haven't started trial yet useEffect(() => { // Skip if still loading billing state or not authenticated if (billingState.isLoading || !isAuthenticated) return // Only proceed if onboarding is completed if (!onboardingCompleted) return // Check if user has a trial or subscription const hasTrialOrSubscription = billingState.proStatus === 'free_trial' || billingState.proStatus === 'active_pro' || isPro // Start trial if: // 1. User hasn't started trial yet (tracked by ref) // 2. User doesn't have a trial or subscription // 3. User has completed onboarding if (!hasStartedTrialRef.current && !hasTrialOrSubscription) { hasStartedTrialRef.current = true setIsStartingTrial(true) // Set flag to indicate trial is being started // Start trial window.api.trial.startAfterOnboarding().catch(err => { console.error('Failed to start trial:', err) // Reset flag so we can retry if needed hasStartedTrialRef.current = false setIsStartingTrial(false) }) } }, [ onboardingCompleted, isAuthenticated, billingState.isLoading, billingState.proStatus, isPro, ]) // Listen for trial-started event to refresh billing state useEffect(() => { const offTrialStarted = window.api.on('trial-started', async () => { // Trial started successfully - refresh billing state // HomeContent will handle showing the dialog based on billing state transition await billingState.refresh() setIsStartingTrial(false) // Reset flag after trial starts }) return () => { offTrialStarted?.() } }, [billingState]) // Reset trial start flag when onboarding resets useEffect(() => { if (!onboardingCompleted) { hasStartedTrialRef.current = false setIsStartingTrial(false) } }, [onboardingCompleted]) // Listen for billing deep-link events and finalize subscription useEffect(() => { const offSuccess = window.api.on( 'billing-session-completed', async (sessionId: string) => { try { if (sessionId) { await window.api.billing.confirmSession(sessionId) } // Ensure trial is completed locally and on server await window.api.trial.complete() } catch (err) { console.error('Failed to finalize billing session', err) } }, ) const offCancel = window.api.on('billing-session-cancelled', () => { // No-op for now; could show a toast in the future }) return () => { offSuccess?.() offCancel?.() } }, []) // Handle text and positioning animation timing useEffect(() => { if (navExpanded) { // When expanding: slide right first, then show text const timer = setTimeout(() => { setShowText(true) // Show text after slide starts }, 75) return () => clearTimeout(timer) } else { // When collapsing: hide text immediately, then center icons after slide completes setShowText(false) // Return no-op function return () => {} } }, [navExpanded]) // Render the appropriate content based on current page const renderContent = () => { switch (currentPage) { case 'home': return case 'dictionary': return case 'notes': return case 'settings': return case 'about': return default: return } } return (
{/* Sidebar */}
{/* Logo and Plan */}
ito {isPro && showText && ( PRO )}
{/* Nav */}
} label="Home" isActive={currentPage === 'home'} showText={showText} onClick={() => setCurrentPage('home')} /> } label="Dictionary" isActive={currentPage === 'dictionary'} showText={showText} onClick={() => setCurrentPage('dictionary')} /> } label="Notes" isActive={currentPage === 'notes'} showText={showText} onClick={() => setCurrentPage('notes')} /> } label="Settings" isActive={currentPage === 'settings'} showText={showText} onClick={() => setCurrentPage('settings')} /> } label="About" isActive={currentPage === 'about'} showText={showText} onClick={() => setCurrentPage('about')} />
{/* Main Content */}
{renderContent()}
) } ================================================ FILE: app/components/home/ProUpgradeDialog.tsx ================================================ import React, { useState, useEffect } from 'react' import { Check } from '@mynaui/icons-react' import { Dialog, DialogContent, DialogFooter } from '@/app/components/ui/dialog' import { Button } from '@/app/components/ui/button' import proBannerImage from '@/app/assets/pro-banner.png' import useBillingState from '@/app/hooks/useBillingState' interface ProUpgradeDialogProps { open: boolean onOpenChange: (open: boolean) => void } export function ProUpgradeDialog({ open, onOpenChange, }: ProUpgradeDialogProps) { const billingState = useBillingState() const [checkoutLoading, setCheckoutLoading] = useState(false) const [checkoutError, setCheckoutError] = useState(null) // Refresh billing state when checkout session completes useEffect(() => { const offSuccess = window.api.on('billing-session-completed', async () => { // Refresh billing state to reflect the new subscription await billingState.refresh() setCheckoutError(null) // Close the dialog after successful checkout onOpenChange(false) }) return () => { offSuccess?.() } }, [billingState, onOpenChange]) const handleCheckout = async () => { setCheckoutLoading(true) setCheckoutError(null) try { const res = await window.api.billing.createCheckoutSession() if (res?.success && res?.url) { await window.api.invoke('web-open-url', res.url) } else { setCheckoutError( res?.error || 'Failed to create checkout session. Please try again.', ) } } catch (err: any) { setCheckoutError( err?.message || 'Failed to create checkout session. Please try again.', ) } finally { setCheckoutLoading(false) } } return ( {/* Banner Header with Image */}
{/* PRO Badge */}
PRO
{/* Content */}

Congrats! You have been{' '} upgraded to Ito Pro for free!

Enjoy all Pro features for{' '} 14 days.

{/* Error Message */} {checkoutError && (
{checkoutError}
)} {/* Features List */}
{/* Buttons */}
) } function FeatureItem({ text }: { text: string }) { return (
{text}
) } ================================================ FILE: app/components/home/contents/AboutContent.tsx ================================================ import { Button } from '@/app/components/ui/button' import DiscordIcon from '@/app/components/icons/DiscordIcon' import XIcon from '@/app/components/icons/XIcon' import GitHubIcon from '@/app/components/icons/GitHubIcon' import { Globe, Telephone } from '@mynaui/icons-react' import { EXTERNAL_LINKS } from '@/lib/constants/external-links' import ItoIcon from '../../icons/ItoIcon' interface AboutCardProps { icon: React.ReactNode title: string description: string buttonText: string onClick: () => void } function AboutCard({ icon, title, description, buttonText, onClick, }: AboutCardProps) { return (
{icon}

{title}

{description}

) } export default function AboutContent() { const handleDiscordClick = () => { window.open(EXTERNAL_LINKS.DISCORD, '_blank') } const handleTeamCallClick = () => { window.open(EXTERNAL_LINKS.TEAM_CALL, '_blank') } const handleXClick = () => { window.open(EXTERNAL_LINKS.X_TWITTER, '_blank') } const handleGitHubClick = () => { window.open(EXTERNAL_LINKS.GITHUB, '_blank') } const handleWebsiteClick = () => { window.open(EXTERNAL_LINKS.WEBSITE, '_blank') } return (

About

{/* First Row: 3 items */}
} title="Discord" description="Join the community, share feedback, and grow with Ito." buttonText="Join Discord" onClick={handleDiscordClick} /> } title="Team Call" description="Got feedback or ideas? Book a quick call with the Ito team." buttonText="Book a Call" onClick={handleTeamCallClick} /> } title="X (Twitter)" description="Get updates, tips, and behind-the-scenes insights from the Ito team." buttonText="Follow on X" onClick={handleXClick} />
{/* Second Row: 2 items */}
} title="GitHub" description="Check out the code, contribute, or star the repo." buttonText="View on GitHub" onClick={handleGitHubClick} /> } title="ito.ai" description="Learn more about Ito, explore features, and see what's next." buttonText="Go to Website" onClick={handleWebsiteClick} />
ito

Version {import.meta.env.VITE_ITO_VERSION}

Made with 🩷 in San Francisco.

) } ================================================ FILE: app/components/home/contents/DictionaryContent.tsx ================================================ import { useEffect, useRef, useState } from 'react' import { ArrowUp, Pencil, Trash, Plus } from '@mynaui/icons-react' import { Tooltip, TooltipTrigger, TooltipContent } from '../../ui/tooltip' import { Switch } from '../../ui/switch' import { StatusIndicator } from '../../ui/status-indicator' import { useDictionaryStore } from '../../../store/useDictionaryStore' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '../../ui/dialog' import { Button } from '../../ui/button' export default function DictionaryContent() { const { entries, loadEntries, addEntry, addReplacement, updateEntry, deleteEntry, } = useDictionaryStore() const [showScrollToTop, setShowScrollToTop] = useState(false) const [hoveredRow, setHoveredRow] = useState(null) const [editingEntry, setEditingEntry] = useState<{ id: string type: 'normal' | 'replacement' content?: string from?: string to?: string } | null>(null) const [editContent, setEditContent] = useState('') const [editFrom, setEditFrom] = useState('') const [editTo, setEditTo] = useState('') const [showAddDialog, setShowAddDialog] = useState(false) const [newEntryContent, setNewEntryContent] = useState('') const [newFrom, setNewFrom] = useState('') const [newTo, setNewTo] = useState('') const [isReplacement, setIsReplacement] = useState(false) const [statusIndicator, setStatusIndicator] = useState< 'success' | 'error' | null >(null) const [errorMessage, setErrorMessage] = useState('') const [successMessage, setSuccessMessage] = useState('') const containerRef = useRef(null) const editInputRef = useRef(null) const editFromRef = useRef(null) const addInputRef = useRef(null) const addFromRef = useRef(null) useEffect(() => { loadEntries() }, [loadEntries]) // Handle scroll events useEffect(() => { const handleScroll = () => { if (containerRef.current) { const scrollTop = containerRef.current.scrollTop setShowScrollToTop(scrollTop > 200) // Show button after scrolling 200px } } const container = containerRef.current if (container) { container.addEventListener('scroll', handleScroll) return () => container.removeEventListener('scroll', handleScroll) } return undefined }, []) const scrollToTop = () => { if (containerRef.current) { containerRef.current.scrollTo({ top: 0, behavior: 'smooth', }) } } const getDisplayText = (entry: (typeof entries)[0]) => { if (entry.type === 'replacement') { return `${entry.from} → ${entry.to}` } return entry.content } const handleEdit = (id: string) => { const entry = entries.find(e => e.id === id) if (entry) { if (entry.type === 'normal') { setEditingEntry({ id, type: 'normal', content: entry.content }) setEditContent(entry.content) setEditFrom('') setEditTo('') // Focus the input after the dialog opens setTimeout(() => { editInputRef.current?.focus() }, 100) } else { setEditingEntry({ id, type: 'replacement', from: entry.from, to: entry.to, }) setEditContent('') setEditFrom(entry.from) setEditTo(entry.to) // Focus the first input after the dialog opens setTimeout(() => { editFromRef.current?.focus() }, 100) } } } const handleSaveEdit = async () => { if (!editingEntry) return try { if (editingEntry.type === 'normal' && editContent.trim() !== '') { await updateEntry(editingEntry.id, { type: 'normal', content: editContent.trim(), } as any) setEditingEntry(null) setEditContent('') setErrorMessage('') setSuccessMessage(`"${editContent.trim()}" updated successfully`) setStatusIndicator('success') } else if ( editingEntry.type === 'replacement' && editFrom.trim() !== '' && editTo.trim() !== '' ) { await updateEntry(editingEntry.id, { type: 'replacement', from: editFrom.trim(), to: editTo.trim(), } as any) setEditingEntry(null) setEditFrom('') setEditTo('') setErrorMessage('') setSuccessMessage( `"${editFrom.trim()}" → "${editTo.trim()}" updated successfully`, ) setStatusIndicator('success') } } catch (error: any) { console.error('Failed to update dictionary entry:', error) const errorMsg = error?.message || 'Failed to update dictionary entry' setErrorMessage(errorMsg) setStatusIndicator('error') } } const handleCancelEdit = () => { setEditingEntry(null) setEditContent('') setEditFrom('') setEditTo('') } const handleDelete = async (id: string) => { const entryToDelete = entries.find(e => e.id === id) if (entryToDelete) { const deletedItemText = getDisplayText(entryToDelete) try { await deleteEntry(id) setErrorMessage('') setSuccessMessage(`"${deletedItemText}" deleted successfully`) setStatusIndicator('success') } catch (error) { console.error('Failed to delete dictionary entry:', error) setErrorMessage(`Failed to delete "${deletedItemText}"`) setStatusIndicator('error') } } } const handleAddNew = () => { setShowAddDialog(true) setNewEntryContent('') setNewFrom('') setNewTo('') setIsReplacement(false) // Focus the input after the dialog opens setTimeout(() => { addInputRef.current?.focus() }, 100) } const handleSaveNew = async () => { try { if (isReplacement) { if (newFrom.trim() !== '' && newTo.trim() !== '') { await addReplacement(newFrom.trim(), newTo.trim()) setShowAddDialog(false) setNewFrom('') setNewTo('') setErrorMessage('') setSuccessMessage( `"${newFrom.trim()}" → "${newTo.trim()}" added successfully`, ) setStatusIndicator('success') } } else { if (newEntryContent.trim() !== '') { await addEntry(newEntryContent.trim()) setShowAddDialog(false) setNewEntryContent('') setErrorMessage('') setSuccessMessage(`"${newEntryContent.trim()}" added successfully`) setStatusIndicator('success') } } } catch (error: any) { console.error('Failed to add dictionary entry:', error) const errorMsg = error?.message || 'Failed to add dictionary entry' setErrorMessage(errorMsg) setStatusIndicator('error') } } const handleCancelNew = () => { setShowAddDialog(false) setNewEntryContent('') setNewFrom('') setNewTo('') setIsReplacement(false) } const handleReplacementToggle = (checked: boolean) => { setIsReplacement(checked) // Focus appropriate input when toggling setTimeout(() => { if (checked) { addFromRef.current?.focus() } else { addInputRef.current?.focus() } }, 100) } // Handle keyboard shortcuts in dialogs const handleEditKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() handleSaveEdit() } else if (e.key === 'Escape') { e.preventDefault() handleCancelEdit() } } const handleAddKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() handleSaveNew() } else if (e.key === 'Escape') { e.preventDefault() handleCancelNew() } } const noEntries = entries.length === 0 return (

Dictionary

{noEntries && (

No entries yet

Dictionary entries make the transcription more accurate

)} {!noEntries && (
{entries.map((entry, index) => (
setHoveredRow(index)} onMouseLeave={() => setHoveredRow(null)} >
{getDisplayText(entry)}
{/* Action Icons - shown on hover */}
Edit Delete
))}
)} {/* Scroll to Top Button */} {showScrollToTop && ( )} {/* Status Indicator */} { setStatusIndicator(null) setErrorMessage('') setSuccessMessage('') }} successMessage={successMessage || 'Dictionary entry added successfully'} errorMessage={errorMessage || 'Failed to add dictionary entry'} /> {/* Edit Entry Dialog */} !open && handleCancelEdit()} > {editingEntry?.type === 'replacement' ? 'Edit replacement' : 'Edit Dictionary Entry'}

{editingEntry?.type === 'replacement' ? 'Edit replacement' : 'Edit entry'}

{editingEntry?.type === 'normal' ? ( setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} className="w-full p-4 rounded-md resize-none focus:outline-none border border-neutral-200" placeholder="Enter dictionary entry..." /> ) : (
setEditFrom(e.target.value)} onKeyDown={handleEditKeyDown} className="flex-1 p-4 rounded-md resize-none focus:outline-none border border-neutral-200" placeholder="Misspelling" /> setEditTo(e.target.value)} onKeyDown={handleEditKeyDown} className="flex-1 p-4 rounded-md resize-none focus:outline-none border border-neutral-200" placeholder="Correct spelling" />
)}
{/* Add New Entry Dialog */} !open && handleCancelNew()} > Add to vocabulary

Add to vocabulary

Make it a replacement
{!isReplacement ? ( setNewEntryContent(e.target.value)} onKeyDown={handleAddKeyDown} className="w-full p-4 rounded-md resize-none focus:outline-none border border-neutral-200" placeholder="Enter dictionary entry..." /> ) : (
setNewFrom(e.target.value)} onKeyDown={handleAddKeyDown} className="flex-1 p-4 rounded-md resize-none focus:outline-none border border-neutral-200" placeholder="Misspelling" /> setNewTo(e.target.value)} onKeyDown={handleAddKeyDown} className="flex-1 p-4 rounded-md resize-none focus:outline-none border border-neutral-200" placeholder="Correct spelling" />
)}
) } ================================================ FILE: app/components/home/contents/HomeContent.tsx ================================================ import React, { useCallback, useEffect, useState } from 'react' import { ChartNoAxesColumn, InfoCircle, Play, Stop, Copy, Check, Download, } from '@mynaui/icons-react' import { EXTERNAL_LINKS } from '@/lib/constants/external-links' import { useSettingsStore } from '../../../store/useSettingsStore' import { Tooltip, TooltipTrigger, TooltipContent } from '../../ui/tooltip' import { useAuthStore } from '@/app/store/useAuthStore' import { Interaction } from '@/lib/main/sqlite/models' import { TotalWordsIcon } from '../../icons/TotalWordsIcon' import { SpeedIcon } from '../../icons/SpeedIcon' import { STREAK_MESSAGES, SPEED_MESSAGES, TOTAL_WORDS_MESSAGES, getStreakLevel, getSpeedLevel, getTotalWordsLevel, getActivityMessage, } from './activityMessages' import { ItoMode } from '@/app/generated/ito_pb' import { getKeyDisplay } from '@/app/utils/keyboard' import { createStereo48kWavFromMonoPCM } from '@/app/utils/audioUtils' import { KeyName } from '@/lib/types/keyboard' import { usePlatform } from '@/app/hooks/usePlatform' import { ProUpgradeDialog } from '../ProUpgradeDialog' import useBillingState from '@/app/hooks/useBillingState' // Interface for interaction statistics interface InteractionStats { streakDays: number totalWords: number averageWPM: number } const StatCard = ({ title, value, description, icon, }: { title: string value: string description: string icon: React.ReactNode }) => { return (
{title}
{value}
{icon}
{description}
) } interface HomeContentProps { isStartingTrial?: boolean } export default function HomeContent({ isStartingTrial = false, }: HomeContentProps) { const { getItoModeShortcuts } = useSettingsStore() const keyboardShortcut = getItoModeShortcuts(ItoMode.TRANSCRIBE)[0].keys const { user } = useAuthStore() const firstName = user?.name?.split(' ')[0] const platform = usePlatform() const [interactions, setInteractions] = useState([]) const [loading, setLoading] = useState(true) const [playingAudio, setPlayingAudio] = useState(null) const [audioInstances, setAudioInstances] = useState< Map >(new Map()) const [copiedItems, setCopiedItems] = useState>(new Set()) const [openTooltipKey, setOpenTooltipKey] = useState(null) const [stats, setStats] = useState({ streakDays: 0, totalWords: 0, averageWPM: 0, }) const [showProDialog, setShowProDialog] = useState(false) const billingState = useBillingState() // Persist "has shown trial dialog" flag in electron-store to survive remounts const [hasShownTrialDialog, setHasShownTrialDialogState] = useState(() => { try { const authStore = window.electron?.store?.get('auth') || {} const value = authStore?.hasShownTrialDialog === true return value } catch { return false } }) const setHasShownTrialDialog = useCallback((value: boolean) => { try { setHasShownTrialDialogState(value) window.api.send('electron-store-set', 'auth.hasShownTrialDialog', value) } catch { console.warn('Failed to persist hasShownTrialDialog flag') } }, []) // Show trial dialog when trial starts useEffect(() => { if ( billingState.isTrialActive && billingState.proStatus === 'free_trial' && !hasShownTrialDialog && !billingState.isLoading ) { setShowProDialog(true) setHasShownTrialDialog(true) } }, [ billingState.isTrialActive, billingState.proStatus, billingState.isLoading, isStartingTrial, hasShownTrialDialog, setHasShownTrialDialog, ]) // Listen for trial start event to refresh billing state useEffect(() => { const offTrialStarted = window.api.on('trial-started', async () => { await billingState.refresh() }) const offBillingSuccess = window.api.on( 'billing-session-completed', async () => { await billingState.refresh() }, ) return () => { offTrialStarted?.() offBillingSuccess?.() } }, [billingState]) // Reset dialog flag when trial is no longer active or user becomes pro // Only reset if we're certain the trial has ended (not just during loading/refreshing) useEffect(() => { if (billingState.isLoading) { // Don't reset during loading to avoid race conditions return } const shouldReset = billingState.proStatus === 'active_pro' || (billingState.proStatus === 'none' && !billingState.isTrialActive) if (shouldReset && hasShownTrialDialog) { setHasShownTrialDialog(false) } }, [ billingState.proStatus, billingState.isTrialActive, billingState.isLoading, hasShownTrialDialog, setHasShownTrialDialog, ]) // Calculate statistics from interactions const calculateStats = useCallback( (interactions: Interaction[]): InteractionStats => { if (interactions.length === 0) { return { streakDays: 0, totalWords: 0, averageWPM: 0 } } // Calculate streak (consecutive days with interactions) const streakDays = calculateStreak(interactions) // Calculate total words from transcripts const totalWords = calculateTotalWords(interactions) // Calculate average WPM (estimate based on average speaking rate) const averageWPM = calculateAverageWPM(interactions) return { streakDays, totalWords, averageWPM } }, [], ) const calculateStreak = (interactions: Interaction[]): number => { if (interactions.length === 0) return 0 // Group interactions by date const dateGroups = new Map() interactions.forEach(interaction => { const date = new Date(interaction.created_at).toDateString() if (!dateGroups.has(date)) { dateGroups.set(date, []) } dateGroups.get(date)!.push(interaction) }) // Sort dates in descending order (most recent first) const sortedDates = Array.from(dateGroups.keys()).sort( (a, b) => new Date(b).getTime() - new Date(a).getTime(), ) let streak = 0 const today = new Date() for (let i = 0; i < sortedDates.length; i++) { const currentDate = new Date(sortedDates[i]) const expectedDate = new Date(today) expectedDate.setDate(today.getDate() - i) // Check if current date matches expected date (allowing for today or previous consecutive days) if (currentDate.toDateString() === expectedDate.toDateString()) { streak++ } else { break } } return streak } const calculateTotalWords = (interactions: Interaction[]): number => { return interactions.reduce((total, interaction) => { const transcript = interaction.asr_output?.transcript?.trim() if (transcript) { // Count words by splitting on whitespace and filtering out empty strings const words = transcript.split(/\s+/).filter(word => word.length > 0) return total + words.length } return total }, 0) } const calculateAverageWPM = (interactions: Interaction[]): number => { const validInteractions = interactions.filter( interaction => interaction.asr_output?.transcript?.trim() && interaction.duration_ms, ) if (validInteractions.length === 0) return 0 let totalWords = 0 let totalDurationMs = 0 validInteractions.forEach(interaction => { const transcript = interaction.asr_output?.transcript?.trim() if (transcript && interaction.duration_ms) { // Count words by splitting on whitespace and filtering out empty strings const words = transcript.split(/\s+/).filter(word => word.length > 0) totalWords += words.length totalDurationMs += interaction.duration_ms } }) if (totalDurationMs === 0) return 0 // Calculate WPM: (total words / total duration in minutes) const totalMinutes = totalDurationMs / (1000 * 60) const wpm = totalWords / totalMinutes // Round to nearest integer and ensure it's reasonable return Math.round(Math.max(1, wpm)) } const formatStreakText = (days: number): string => { if (days === 0) return '0 days' if (days === 1) return '1 day' if (days < 7) return `${days} days` if (days < 14) return '1 week' if (days < 30) return `${Math.floor(days / 7)} weeks` if (days < 60) return '1 month' return `${Math.floor(days / 30)} months` } const loadInteractions = useCallback(async () => { try { const allInteractions = await window.api.interactions.getAll() // Sort by creation date (newest first) - remove the slice(0, 10) to show all interactions const sortedInteractions = allInteractions.sort( (a: Interaction, b: Interaction) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), ) setInteractions(sortedInteractions) // Calculate and set statistics const calculatedStats = calculateStats(sortedInteractions) setStats(calculatedStats) } catch (error) { console.error('Failed to load interactions:', error) } finally { setLoading(false) } }, [calculateStats]) useEffect(() => { loadInteractions() // Listen for new interactions const handleInteractionCreated = () => { loadInteractions() } const unsubscribe = window.api.on( 'interaction-created', handleInteractionCreated, ) // Cleanup listener on unmount return unsubscribe }, [loadInteractions]) // Cleanup audio instances on unmount useEffect(() => { return () => { audioInstances.forEach(audio => { try { audio.pause() audio.currentTime = 0 // Best-effort release of object URL if used if (audio.src?.startsWith('blob:')) { URL.revokeObjectURL(audio.src) } } catch { /* ignore */ } }) } }, [audioInstances]) const formatTime = (dateString: string) => { const date = new Date(dateString) return date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }) } const formatDate = (dateString: string) => { const date = new Date(dateString) const today = new Date() const yesterday = new Date() yesterday.setDate(today.getDate() - 1) const isToday = date.toDateString() === today.toDateString() const isYesterday = date.toDateString() === yesterday.toDateString() if (isToday) return 'TODAY' if (isYesterday) return 'YESTERDAY' return date .toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric', }) .toUpperCase() } const groupInteractionsByDate = (interactions: Interaction[]) => { const groups: { [key: string]: Interaction[] } = {} interactions.forEach(interaction => { const dateKey = formatDate(interaction.created_at) if (!groups[dateKey]) { groups[dateKey] = [] } groups[dateKey].push(interaction) }) return groups } const getDisplayText = (interaction: Interaction) => { // Check for errors first if (interaction.asr_output?.error) { // Prefer precise error code mapping when available const code = interaction.asr_output?.errorCode if (code === 'CLIENT_TRANSCRIPTION_QUALITY_ERROR') { return { text: 'Audio quality too low', isError: true, tooltip: 'Audio quality was too low to generate a reliable transcript', } } if ( interaction.asr_output.error.includes('No speech detected in audio.') || interaction.asr_output.error.includes('Unable to transcribe audio.') ) { return { text: 'Audio is silent', isError: true, tooltip: "Ito didn't detect any words so the transcript is empty", } } return { text: 'Transcription failed', isError: true, tooltip: interaction.asr_output.error, } } // Check for empty transcript const transcript = interaction.asr_output?.transcript?.trim() if (!transcript) { return { text: 'Audio is silent.', isError: true, tooltip: "Ito didn't detect any words so the transcript is empty", } } // Return the actual transcript return { text: transcript, isError: false, tooltip: null, } } const handleAudioPlayStop = async (interaction: Interaction) => { try { // If this interaction is currently playing, stop it if (playingAudio === interaction.id) { const current = audioInstances.get(interaction.id) if (current) { current.pause() current.currentTime = 0 if (current.src?.startsWith('blob:')) { URL.revokeObjectURL(current.src) } } setPlayingAudio(null) return } // Stop any other playing audio if (playingAudio) { const other = audioInstances.get(playingAudio) if (other) { other.pause() other.currentTime = 0 if (other.src?.startsWith('blob:')) { URL.revokeObjectURL(other.src) } } } if (!interaction.raw_audio) { console.warn('No audio data available for this interaction') return } // Set playing state immediately for responsive UI setPlayingAudio(interaction.id) // Reuse existing audio instance if available let audio = audioInstances.get(interaction.id) if (!audio) { const pcmData = new Uint8Array(interaction.raw_audio) try { // Convert raw PCM (mono, typically 16 kHz) to 48 kHz stereo WAV for smoother playback const wavBuffer = createStereo48kWavFromMonoPCM( pcmData, interaction.sample_rate || 16000, 48000, ) const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' }) const audioUrl = URL.createObjectURL(audioBlob) audio = new Audio(audioUrl) audio.onended = () => { setPlayingAudio(null) if (audio && audio.src?.startsWith('blob:')) { URL.revokeObjectURL(audio.src) } } audio.onerror = err => { console.error('Audio playback error:', err) setPlayingAudio(null) if (audio && audio.src?.startsWith('blob:')) { URL.revokeObjectURL(audio.src) } } setAudioInstances(prev => new Map(prev).set(interaction.id, audio!)) } catch (error) { console.error('Failed to create audio instance:', error) setPlayingAudio(null) return } } try { await audio.play() } catch (playError) { console.error('Failed to start audio playback:', playError) setPlayingAudio(null) } } catch (error) { console.error('Failed to play/stop audio:', error) setPlayingAudio(null) } } const groupedInteractions = groupInteractionsByDate(interactions) const copyToClipboard = async (text: string, interactionId: string) => { try { await navigator.clipboard.writeText(text) setCopiedItems(prev => new Set(prev).add(interactionId)) setOpenTooltipKey(`copy:${interactionId}`) // Keep tooltip open // Reset the copied state after 2 seconds setTimeout(() => { setCopiedItems(prev => { const newSet = new Set(prev) newSet.delete(interactionId) return newSet }) // Close tooltip if it's still open for this item (do not override if user hovered elsewhere) setOpenTooltipKey(prev => prev === `copy:${interactionId}` ? null : prev, ) }, 2000) } catch (error) { console.error('Failed to copy text:', error) } } const handleAudioDownload = async (interaction: Interaction) => { try { if (!interaction.raw_audio) { console.warn('No audio data available for download') return } const pcmData = new Uint8Array(interaction.raw_audio) // Convert raw PCM to WAV format const wavBuffer = createStereo48kWavFromMonoPCM( pcmData, interaction.sample_rate || 16000, 48000, ) const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' }) const audioUrl = URL.createObjectURL(audioBlob) // Format filename with timestamp (YYYYMMDD_HHMMSS) const date = new Date(interaction.created_at) const timestamp = date .toISOString() .replace(/[-:]/g, '') .replace('T', '_') .slice(0, 15) const filename = `ito-recording-${timestamp}.wav` // Create temporary link and trigger download const link = document.createElement('a') link.href = audioUrl link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) // Clean up the blob URL URL.revokeObjectURL(audioUrl) } catch (error) { console.error('Failed to download audio:', error) } } return (
{/* Fixed Header Content */}

Welcome back{firstName ? `, ${firstName}!` : '!'}

} />
} />
} />
{/* Dictation Info Box */}
Voice dictation in any app
Hold down the trigger key {keyboardShortcut.map((key, index) => ( {getKeyDisplay(key as KeyName, platform, { showDirectionalText: false, format: 'label', })} {index < keyboardShortcut.length - 1 && ' + '} ))} and speak into any textbox
{/* Recent Activity Header */}
Recent activity
{/* Scrollable Recent Activity Section */}
{loading ? (
Loading recent activity...
) : interactions.length === 0 ? (

No interactions yet

Try using voice dictation by pressing{' '} {keyboardShortcut.join(' + ')}

) : ( Object.entries(groupedInteractions).map( ([dateLabel, dateInteractions]) => (
{dateLabel}
{dateInteractions.map(interaction => { const displayInfo = getDisplayText(interaction) return (
{formatTime(interaction.created_at)}
{displayInfo.text} {displayInfo.tooltip && ( {displayInfo.tooltip} )}
{/* Copy, Download, and Play buttons - only show on hover or when playing */}
{/* Copy button */} {!displayInfo.isError && ( { if (open) { // Opening: exclusively show this tooltip setOpenTooltipKey(`copy:${interaction.id}`) } else { // Closing: if in copied state, keep it open until timer clears, // otherwise close normally if (!copiedItems.has(interaction.id)) { setOpenTooltipKey(prev => prev === `copy:${interaction.id}` ? null : prev, ) } } }} > {copiedItems.has(interaction.id) ? 'Copied 🎉' : 'Copy'} )} {/* Download button */} {interaction.raw_audio && ( { setOpenTooltipKey( open ? `download:${interaction.id}` : null, ) }} > Download audio )} {/* Play/Stop button with tooltip */} { setOpenTooltipKey( open ? `play:${interaction.id}` : null, ) }} > {!interaction.raw_audio ? 'No audio available' : playingAudio === interaction.id ? 'Stop' : 'Play'}
) })}
), ) )}
{/* Pro Upgrade Dialog */} ) } ================================================ FILE: app/components/home/contents/NotesContent.tsx ================================================ import { useEffect, useRef, useState } from 'react' import { useNotesStore } from '../../../store/useNotesStore' import { useSettingsStore } from '../../../store/useSettingsStore' import Masonry from '@mui/lab/Masonry' import { AudioIcon } from '../../icons/AudioIcon' import { ArrowUp, Grid, Rows, Search, X } from '@mynaui/icons-react' import { Note } from '../../ui/note' import { StatusIndicator } from '../../ui/status-indicator' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '../../ui/dialog' import { Button } from '../../ui/button' import { ItoMode } from '@/app/generated/ito_pb' import { getKeyDisplayInfo } from '@/lib/types/keyboard' import { usePlatform } from '@/app/hooks/usePlatform' export default function NotesContent() { const { notes, loadNotes, addNote, deleteNote, updateNote } = useNotesStore() const { getItoModeShortcuts } = useSettingsStore() const keyboardShortcut = getItoModeShortcuts(ItoMode.TRANSCRIBE)[0].keys const [creatingNote, setCreatingNote] = useState(false) const [showAddNoteButton, setShowAddNoteButton] = useState(false) const [noteContent, setNoteContent] = useState('') const [showScrollToTop, setShowScrollToTop] = useState(false) const [containerHeight, setContainerHeight] = useState(128) // 128px = h-32 const [showSearch, setShowSearch] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [showDropdown, setShowDropdown] = useState(null) const [statusIndicator, setStatusIndicator] = useState< 'success' | 'error' | null >(null) const [statusMessage, setStatusMessage] = useState('') const textareaRef = useRef(null) const searchInputRef = useRef(null) const containerRef = useRef(null) const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') const [editingNote, setEditingNote] = useState<{ id: string content: string } | null>(null) const [editContent, setEditContent] = useState('') const editTextareaRef = useRef(null) const platform = usePlatform() useEffect(() => { loadNotes() }, [loadNotes, addNote, notes.length]) const formatDate = (date: Date) => { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }) } const formatTime = (date: Date) => { return date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }) } const truncateContent = (content: string, maxLength: number = 100) => { if (content.length <= maxLength) { return content } return content.slice(0, maxLength) + '...' } const handleBlur = () => { // If the note isn't empty, don't close the input setTimeout(() => { if (textareaRef.current?.value.trim() === '') { setCreatingNote(false) } }, 200) } const updateNoteContent = (content: string) => { setNoteContent(content) const fmt = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3, hour12: false, }) const timestamp = fmt.format(new Date()) console.log(`${timestamp}: Pasted content: ${content}`) if (content.trim() !== '') { setShowAddNoteButton(true) } else { setShowAddNoteButton(false) } // Auto-resize textarea and container if (textareaRef.current) { textareaRef.current.style.height = 'auto' const scrollHeight = textareaRef.current.scrollHeight textareaRef.current.style.height = `${scrollHeight}px` // Calculate container height: textarea height + padding + button space const minHeight = 192 // min-h-48 = 192px const paddingAndButton = 48 + 40 // 48px padding + 40px for button space const newContainerHeight = Math.max( minHeight, scrollHeight + paddingAndButton, ) setContainerHeight(newContainerHeight) } } const toggleViewMode = () => { setViewMode(viewMode === 'grid' ? 'list' : 'grid') } const openSearch = () => { setShowSearch(true) // Focus the search input after the component updates setTimeout(() => { searchInputRef.current?.focus() }, 100) } const closeSearch = () => { setShowSearch(false) setSearchQuery('') } // Filter notes based on search query const filteredNotes = searchQuery.trim() === '' ? notes : notes.filter(note => note.content.toLowerCase().includes(searchQuery.toLowerCase()), ) const handleAddNote = async () => { if (noteContent.trim() !== '') { try { await addNote(noteContent.trim()) setNoteContent('') setCreatingNote(false) setShowAddNoteButton(false) setStatusMessage('Note saved') setStatusIndicator('success') } catch (error) { console.error('Failed to add note:', error) setStatusMessage('Failed to save note') setStatusIndicator('error') } } } const handleCopyToClipboard = async (content: string) => { try { await navigator.clipboard.writeText(content) setShowDropdown(null) // You could add a toast notification here } catch (err) { console.error('Failed to copy text: ', err) } } const handleDeleteNote = async (noteId: string) => { try { await deleteNote(noteId) setShowDropdown(null) setStatusMessage('Deleted note') setStatusIndicator('success') } catch (error) { console.error('Failed to delete note:', error) setStatusMessage('Failed to delete note') setStatusIndicator('error') } } const handleEditNote = (noteId: string) => { const note = notes.find(n => n.id === noteId) if (note) { setEditingNote({ id: noteId, content: note.content }) setEditContent(note.content) setShowDropdown(null) // Focus the textarea after the dialog opens setTimeout(() => { editTextareaRef.current?.focus() }, 100) } } const handleSaveEdit = async () => { if (editingNote && editContent.trim() !== '') { try { await updateNote(editingNote.id, editContent.trim()) setEditingNote(null) setEditContent('') setStatusMessage('Updated note') setStatusIndicator('success') } catch (error) { console.error('Failed to update note:', error) setStatusMessage('Failed to update note') setStatusIndicator('error') } } } const handleCancelEdit = () => { setEditingNote(null) setEditContent('') } // Handle keyboard shortcuts in edit dialog const handleEditKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault() handleSaveEdit() } else if (e.key === 'Escape') { e.preventDefault() handleCancelEdit() } } const toggleDropdown = (index: number, e: React.MouseEvent) => { e.stopPropagation() setShowDropdown(showDropdown === index ? null : index) } // Auto-resize on mount and when creatingNote changes useEffect(() => { if (creatingNote && textareaRef.current) { textareaRef.current.style.height = 'auto' const scrollHeight = textareaRef.current.scrollHeight textareaRef.current.style.height = `${scrollHeight}px` // Set container height for creating state const minHeight = 192 // min-h-48 = 192px const paddingAndButton = 48 // 48px padding for button space const newContainerHeight = Math.max( minHeight, scrollHeight + paddingAndButton, ) setContainerHeight(newContainerHeight) } else if (!creatingNote) { // Reset to default height when not creating setContainerHeight(128) // h-32 = 128px if (textareaRef.current) { textareaRef.current.style.height = '' } } }, [creatingNote]) // Handle scroll events useEffect(() => { const handleScroll = () => { if (containerRef.current) { const scrollTop = containerRef.current.scrollTop setShowScrollToTop(scrollTop > 200) // Show button after scrolling 200px } } const container = containerRef.current if (container) { container.addEventListener('scroll', handleScroll) return () => container.removeEventListener('scroll', handleScroll) } return () => {} }, []) // Handle escape key for closing search useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && showSearch) { closeSearch() } } if (showSearch) { document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) } return () => {} }, [showSearch]) // Handle clicks outside dropdown to close it useEffect(() => { const handleClickOutside = () => { setShowDropdown(null) } if (showDropdown !== null) { document.addEventListener('click', handleClickOutside) return () => document.removeEventListener('click', handleClickOutside) } return () => {} }, [showDropdown]) const scrollToTop = () => { if (containerRef.current) { containerRef.current.scrollTo({ top: 0, behavior: 'smooth', }) } } return (
{/* Header */} {showSearch ? (
setSearchQuery(e.target.value)} placeholder="Search your notes" className="flex-1 text-sm outline-none placeholder-gray-400" />
) : (

What's on your mind today?

)} {/* Text Input Area - Only show when not searching */} {!showSearch && (
{!creatingNote && (
Take a quick note with your voice
)}