diff --git a/.agents/skills/test-t3-mobile/scripts/pair-client.sh b/.agents/skills/test-t3-mobile/scripts/pair-client.sh index 6eeb14844a65..cbfe0024416d 100755 --- a/.agents/skills/test-t3-mobile/scripts/pair-client.sh +++ b/.agents/skills/test-t3-mobile/scripts/pair-client.sh @@ -40,11 +40,11 @@ const query = new URLSearchParams({ pairingUrl: process.env.PAIRING_URL, autoConnect: "1", }); -process.stdout.write(`t3code-dev://connections/new?${query}`); +process.stdout.write(`ottercode-dev://connections/new?${query}`); NODE )" -if ! "$agent_device_command" open com.t3tools.t3code.dev "$deep_link" "$@" \ +if ! "$agent_device_command" open dev.otterware.code.dev "$deep_link" "$@" \ >/dev/null 2>&1; then echo "AgentDevice could not open the pairing route. Check the Device panel and retry with a fresh credential." >&2 exit 1 diff --git a/.env.example b/.env.example index fc67dcef9478..98182896caae 100644 --- a/.env.example +++ b/.env.example @@ -1,31 +1,12 @@ -# Optional: T3 Connect source builds -# `cp .env.example .env` enables T3 Connect against the production deployment. -# These are the same public identifiers baked into official release builds, not -# secrets. Remove or comment them out to build with cloud features disabled. -# Do not add server-side secrets to this file. - -# Production Clerk instance. To use your own, get these from the Clerk Dashboard -# under API keys, JWT templates, and OAuth applications. -T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk -T3CODE_CLERK_JWT_TEMPLATE=t3-relay -T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r - -# Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API -# hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk -# returns a different RP ID or when multiple domains must be entitled. -# T3CODE_APPLE_TEAM_ID=ABC1234567 -# T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile -# T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com - -# Production relay. For a self-hosted relay, `infra/relay` deploys update it -# automatically. -T3CODE_RELAY_URL=https://relay.t3.codes - -# Optional: hosted app origin used by the CLI's out-of-band OAuth flow. -# Defaults to https://app.t3.codes; override to test against a staging deployment. -# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes - -# Public, ingest-only mobile OpenTelemetry configuration. +# Public Otter Accounts and Connect defaults are built into Otter Code. +# Override these together only for a separately registered staging deployment. +# Server-side credentials do not belong in this file. +T3CODE_ACCOUNTS_URL=https://accounts.otterware.app/v1/auth +T3CODE_ACCOUNTS_CLI_CLIENT_ID=otter-code-cli +T3CODE_RELAY_URL=https://relay.code.otterware.app +T3CODE_HOSTED_APP_URL=https://code.otterware.app + +# Optional public, ingest-only mobile tracing configuration. # T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces -# T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev +# T3CODE_MOBILE_OTLP_TRACES_DATASET=otter-code-mobile-traces-dev # T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-... diff --git a/.github/scripts/otter-adapt-upstream.sh b/.github/scripts/otter-adapt-upstream.sh new file mode 100755 index 000000000000..61d91542e969 --- /dev/null +++ b/.github/scripts/otter-adapt-upstream.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Adapts upstream files to the fork. Rerun this after every sync rebase and +# commit the result last in the fork stack (see OTTER.md), so upstream edits to +# these lines never cause rebase conflicts. Each edit is a no-op if upstream +# renames what it matches. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +# Blacksmith runners -> GitHub-hosted runners. +# macOS jobs use macos-15: GitHub's macos-26 image rejects electron-builder <26.16.1's +# keychain unlock (electron-userland/electron-builder#10066). Move to macos-26 once +# upstream pins electron-builder 26.16.1 or later. +find .github -name '*.yml' -print0 | xargs -0 sed -i.bak -E \ + -e 's/blacksmith-[0-9]+vcpu-ubuntu-2404/ubuntu-24.04/g' \ + -e 's/blacksmith-[0-9]+vcpu-macos-26/macos-15/g' \ + -e 's/blacksmith-[0-9]+vcpu-windows-2025/windows-2025/g' +find .github -name '*.yml.bak' -delete + +# Upstream sizes release job timeouts for 8-vCPU Blacksmith runners; the 4-vCPU +# GitHub-hosted runners need about twice as long (the release test suite alone +# takes ~10 minutes there). +perl -pi -e 's/^( timeout-minutes: )10$/${1}20/' .github/workflows/release.yml + +# Otter Code ships macOS and Linux only: skip the Windows desktop jobs, let the +# release proceed without them, and stop expecting .exe assets. +perl -0pi -e ' + s/(\n desktop_win_(?:x64|arm64):\n(?: .*\n)*? if: )\$\{\{.*?\}\}/$1\${{ false }}/g; + s/needs\.desktop_win_(x64|arm64)\.result == \x27success\x27/needs.desktop_win_$1.result != \x27failure\x27/g; + s/\n[ \t]*echo \x27release-assets\/\*\.exe\x27//g; +' .github/workflows/release.yml + +# Keep Cloudflare hosting at the fork's deployment boundary. Upstream retains +# its Vercel implementation; Otter stages a Worker version and promotes it only +# after the desktop/CLI release exists. +python3 - <<'PY' +from pathlib import Path +import re + +def fork_block(action): + return f'''\n if [[ "${{GITHUB_REPOSITORY}}" == "otterware-app/otter-code" ]]; then + node scripts/otter/web-hosting.mjs {action} + exit 0 + fi +''' + +def adapt_job(text, name, action, entries): + pattern = rf"(^ {name}:\n.*?)(?=^ [a-zA-Z0-9_]+:\n|\Z)" + match = re.search(pattern, text, re.M | re.S) + if not match: + raise SystemExit(f"Missing upstream hosting job: {name}") + job = match[0] + # Replace the previous Otter-only Vercel promotion guard during migration. + job = re.sub(r'\n if \[\[ "\$\{GITHUB_REPOSITORY\}" == "otterware-app/otter-code" \]\]; then\n node scripts/otter/promote-web.mjs\n exit 0\n fi\n', '', job) + if f"node scripts/otter/web-hosting.mjs {action}\n" not in job: + anchor = " set -euo pipefail\n" + if anchor not in job: + raise SystemExit(f"Missing upstream hosting step: {name}") + job = job.replace(anchor, anchor + fork_block(action), 1) + for section, key, value in entries: + if f" {key}:" not in job: + job = job.replace(f" {section}:\n", f" {section}:\n {key}: {value}\n", 1) + return text[:match.start()] + job + text[match.end():] + +release_path = Path('.github/workflows/release.yml') +release = release_path.read_text() +release = adapt_job(release, 'build_web', 'stage', [ + ('env', 'CLOUDFLARE_API_TOKEN', '${{ secrets.CLOUDFLARE_API_TOKEN }}'), + ('env', 'APP_VERSION', '${{ needs.preflight.outputs.version }}'), + ('env', 'VITE_HOSTED_APP_CHANNEL', "${{ needs.preflight.outputs.release_channel == 'stable' && 'latest' || 'nightly' }}"), + ('outputs', 'version_id', '${{ steps.deploy.outputs.version_id }}'), +]) +release = adapt_job(release, 'deploy_web', 'promote', [ + ('env', 'CLOUDFLARE_API_TOKEN', '${{ secrets.CLOUDFLARE_API_TOKEN }}'), + ('env', 'WEB_VERSION_ID', '${{ needs.build_web.outputs.version_id }}'), +]) +release_path.write_text(release) +preview_path = Path('.github/workflows/web-preview.yml') +preview_path.write_text(adapt_job(preview_path.read_text(), 'deploy', 'stage-preview', [ + ('env', 'CLOUDFLARE_API_TOKEN', '${{ secrets.CLOUDFLARE_API_TOKEN }}'), +])) +PY + +# Keep upstream's provisioning-profile path, but make it optional for Otter's +# existing Developer ID releases. Otter Accounts has no Clerk RP domain. +python3 - <<'PY' +from pathlib import Path + +path = Path('.github/workflows/release-desktop.yml') +text = path.read_text() +if ' MACOS_PROVISIONING_PROFILE:\n' not in text: + text = text.replace(' AZURE_TENANT_ID:\n', + ' MACOS_PROVISIONING_PROFILE:\n required: false\n AZURE_TENANT_ID:\n', 1) +if ' MACOS_PROVISIONING_PROFILE:' not in text: + text = text.replace(' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n', + ' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n' + ' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n', 1) +text = text.replace(' T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }}\n', '') +if ' T3CODE_MACOS_PASSKEY_RP_DOMAINS:' not in text: + text = text.replace(' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n', + ' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n' + ' T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }}\n', 1) +start_anchor = ' if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then\n' +end_anchor = ' echo "macOS signing enabled."\n' +start = text.index(start_anchor) + len(start_anchor) +end = text.index(end_anchor, start) +text = text[:start] + ''' key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then + if [[ -z "$APPLE_TEAM_ID" ]]; then + echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2 + exit 1 + fi + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + echo "macOS provisioning profile validated; entitlements selected by the packager." + else + echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements." + fi + +''' + text[end:] +path.write_text(text) +PY + +# Point agents at the fork guide from AGENTS.md, which every agent reads first. +perl -0pi -e ' + s/\A(# [^\n]*\n)/$1\n> **This is the Otter Code fork.** Read [OTTER.md](OTTER.md) first: it covers how the fork\n> stays in sync with upstream, its identities and infrastructure, releases, and moving machines.\n/ + unless /OTTER\.md/; +' AGENTS.md + +# Otter Code publishes its CLI with an npm token (no npm trusted publisher exists +# for new packages), and tags each nightly as latest so plain `npx otter-code` works. +OTTER_NPM_AUTH_STEP=' - name: Configure npm token (Otter Code) + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -n "$NPM_TOKEN" ]; then + printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" >> ~/.npmrc + fi + +' OTTER_NPM_TAG_STEP=' + - name: Tag nightly as latest (Otter Code) + if: needs.preflight.outputs.release_channel == '"'"'nightly'"'"' + run: npm dist-tag add "otter-code@${{ needs.preflight.outputs.version }}" latest +' perl -0pi -e ' + s/( - name: Build npm packages from CLI archives\n)/$ENV{OTTER_NPM_AUTH_STEP}$1/ unless /Configure npm token \(Otter Code\)/; + s/( - name: Publish CLI packages\n run: [^\n]*\n)/$1$ENV{OTTER_NPM_TAG_STEP}/ unless /Tag nightly as latest \(Otter Code\)/; +' .github/workflows/release.yml +perl -0pi -e ' + s/(node scripts\/build-npm-platform-packages\.ts [^\n]*--output-dir npm-packages)\n/$1 --allow-missing\n/ + unless /--output-dir npm-packages --allow-missing/; +' .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbdc1ec40e64..3315d0f17689 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: # passes only when every other job does. lint: name: Lint - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -57,7 +57,7 @@ jobs: typecheck: name: Typecheck - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -83,7 +83,7 @@ jobs: build: name: Build - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -137,7 +137,7 @@ jobs: # per runner is unchanged. test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -201,7 +201,7 @@ jobs: # started last and ran alone for about 40s after everything else finished. test_web: name: Test Web - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -230,7 +230,7 @@ jobs: # split files by recorded duration (apps/server/src/testUtils/weightedShardSequencer.ts). test_server: name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 strategy: fail-fast: false @@ -302,7 +302,7 @@ jobs: # On Blacksmith like every other job here: the GitHub-hosted queue is shared # with the PR bots and can leave this job waiting until it is abandoned, # which fails `Check` on an otherwise green run. - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Require transfer measurements @@ -315,7 +315,7 @@ jobs: # for checks that take under 3s, on the critical path of every PR. rust: name: Rust - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -349,7 +349,7 @@ jobs: # the diff cannot be resolved, the lint runs. mobile_native_changes: name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read @@ -427,7 +427,7 @@ jobs: # Skip only on an explicit "no": a gate job that failed or errored leaves the # output empty, and that must run the lint rather than silently skip it. if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -455,7 +455,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -500,7 +500,7 @@ jobs: mobile_native_static_analysis, release_smoke, ] - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Require every job to pass diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 400f7152c617..b4959d70c48a 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -28,7 +28,7 @@ jobs: name: Deploy production relay # A manual run from another branch would deploy that branch to production. if: github.ref == 'refs/heads/main' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: production @@ -41,12 +41,13 @@ jobs: RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} RELAY_TUNNEL_CLEANUP_MODE: ${{ vars.RELAY_TUNNEL_CLEANUP_MODE }} RELAY_LEGACY_TUNNEL_CLEANUP_MODE: ${{ vars.RELAY_LEGACY_TUNNEL_CLEANUP_MODE }} - CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + OTTER_ACCOUNTS_URL: ${{ vars.ACCOUNTS_URL }} APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} APNS_KEY_ID: ${{ vars.APNS_KEY_ID }} APNS_BUNDLE_ID: ${{ vars.APNS_BUNDLE_ID }} + LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} + HOSTED_APP_URL: ${{ vars.HOSTED_APP_URL }} ALCHEMY_TELEMETRY_DISABLED: "1" steps: - name: Checkout @@ -76,9 +77,10 @@ jobs: PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} + LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} + LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} - name: Publish relay deploy commit status uses: actions/github-script@v8 diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index bd6f59bc0f0b..58406485a8bb 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -48,9 +48,8 @@ jobs: pr_number: ${{ steps.pr.outputs.pr_number }} head_sha: ${{ steps.pr.outputs.head_sha }} version: ${{ steps.version.outputs.version }} - clerk_publishable_key: ${{ steps.version.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ steps.version.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ steps.version.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ steps.version.outputs.accounts_url }} + accounts_cli_client_id: ${{ steps.version.outputs.accounts_cli_client_id }} relay_url: ${{ steps.version.outputs.relay_url }} steps: - id: pr @@ -223,7 +222,7 @@ jobs: echo "version=${base_version}-pr.${PR_NUMBER}.${BUILD_RUN_NUMBER}" >> "$GITHUB_OUTPUT" head_file .env.example > "$RUNNER_TEMP/head.env.example" - for key in clerk_publishable_key:T3CODE_CLERK_PUBLISHABLE_KEY clerk_jwt_template:T3CODE_CLERK_JWT_TEMPLATE clerk_cli_oauth_client_id:T3CODE_CLERK_CLI_OAUTH_CLIENT_ID relay_url:T3CODE_RELAY_URL; do + for key in accounts_url:T3CODE_ACCOUNTS_URL accounts_cli_client_id:T3CODE_ACCOUNTS_CLI_CLIENT_ID relay_url:T3CODE_RELAY_URL; do output="${key%%:*}" name="${key##*:}" value="$(sed -n "s/^${name}=//p" "$RUNNER_TEMP/head.env.example" | head -n 1)" @@ -295,18 +294,16 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} with: version: ${{ needs.resolve.outputs.version }} ref: ${{ github.sha }} release_channel: preview relay_client_tracing: false - clerk_publishable_key: ${{ needs.resolve.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.resolve.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ needs.resolve.outputs.accounts_url }} + accounts_cli_client_id: ${{ needs.resolve.outputs.accounts_cli_client_id }} relay_url: ${{ needs.resolve.outputs.relay_url }} label: macOS arm64 preview - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: arm64 @@ -322,7 +319,7 @@ jobs: name: Publish anonymous download needs: [resolve, build] if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 # Its own group, so a publish never cancels a newer commit's signing job # (they would share the build group) and is never cancelled mid-upload. diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 43b6c8220aaf..6e690da2b674 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -30,7 +30,7 @@ jobs: build: name: Build preview JS bundle if: github.event.label.name == 'preview:mac' - runs-on: blacksmith-32vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 30 steps: # This repository is public, so Git needs no credentials. checkout's @@ -70,7 +70,7 @@ jobs: base_version="$(node -p "require('./apps/desktop/package.json').version")" node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - # Public T3 Connect identifiers (Clerk publishable key, relay URL). + # Public T3 Connect identifiers (Otter Accounts issuer, relay URL). cp .env.example .env - uses: ./.github/actions/setup-apt-mirrors diff --git a/.github/workflows/mobile-android-apk.yml b/.github/workflows/mobile-android-apk.yml index 9a81b25c6d5b..75a92d6678d2 100644 --- a/.github/workflows/mobile-android-apk.yml +++ b/.github/workflows/mobile-android-apk.yml @@ -20,7 +20,7 @@ concurrency: jobs: mirror: name: Mirror Play APK - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: write diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 107c741424b0..b6c5b157c8f2 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -10,7 +10,7 @@ jobs: if: | contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 concurrency: group: mobile-eas-preview-${{ github.event.pull_request.number }} cancel-in-progress: true diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 2ce1236c970b..9b66a0a483b5 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -92,7 +92,7 @@ concurrency: jobs: production: name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read env: diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index b8b872f019f6..bfeda857a8c6 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -24,7 +24,7 @@ concurrency: jobs: fingerprint: name: Native fingerprint diff - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read issues: write @@ -89,7 +89,9 @@ jobs: env: BASE_SHA: ${{ steps.changes.outputs.base_sha }} run: | - git checkout --quiet "$BASE_SHA" + # Filtered installs can rewrite tracked dependency metadata. Fingerprint + # artifacts are in RUNNER_TEMP; discard install edits in this CI checkout. + git checkout --quiet --force "$BASE_SHA" # Re-sync node_modules to the base commit's lockfile before # fingerprinting — a dep-changing PR must not fingerprint the base # against head's installed packages. diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index fc75c447c6db..f4f85a521b7e 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -45,7 +45,7 @@ jobs: ios: name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 + runs-on: macos-15 # Capturing every palette multiplies the device matrix by six, and only the # one native build is shared between them. timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} @@ -105,7 +105,7 @@ jobs: android: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 # Capturing every palette multiplies the device matrix by six, and only the # one native build is shared between them. timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} diff --git a/.github/workflows/otter-web-deploy.yml b/.github/workflows/otter-web-deploy.yml new file mode 100644 index 000000000000..7b5ad821452a --- /dev/null +++ b/.github/workflows/otter-web-deploy.yml @@ -0,0 +1,64 @@ +name: Deploy Otter Code web + +on: + workflow_dispatch: + inputs: + release_tag: + description: Published desktop/CLI release tag for this web-only deployment + required: true + type: string + +permissions: + contents: read + +concurrency: + group: otter-web-production + cancel-in-progress: false + +jobs: + deploy: + if: github.repository == 'otterware-app/otter-code' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: production + env: + RELEASE_TAG: ${{ inputs.release_tag }} + GH_TOKEN: ${{ github.token }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + T3CODE_ACCOUNTS_URL: ${{ vars.ACCOUNTS_URL }} + T3CODE_ACCOUNTS_CLI_CLIENT_ID: ${{ vars.ACCOUNTS_CLI_CLIENT_ID }} + T3CODE_RELAY_URL: https://relay.code.otterware.app + VITE_HOSTED_APP_CHANNEL: nightly + steps: + - uses: actions/checkout@v6 + - uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + - --filter=@t3tools/web... + - --filter=t3code-relay... + - name: Verify published release + run: | + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + echo "APP_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV" + - name: Read production relay tracing config + working-directory: infra/relay + run: | + set -euo pipefail + output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input | sed -n '/^{/,$p')" + tracing_token="$(jq -er '.clientTracingToken.__redacted__ // .clientTracingToken' <<< "$output")" + echo "::add-mask::$tracing_token" + echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=$tracing_token" >> "$GITHUB_ENV" + echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=$(jq -er '.clientTracingUrl' <<< "$output")" >> "$GITHUB_ENV" + echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=$(jq -er '.clientTracingDataset' <<< "$output")" >> "$GITHUB_ENV" + - id: stage + name: Stage web build on Cloudflare + run: node scripts/otter/web-hosting.mjs stage + - name: Promote verified web build + env: + WEB_VERSION_ID: ${{ steps.stage.outputs.version_id }} + run: node scripts/otter/web-hosting.mjs promote diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 62f8fd1f5470..0af9d7ea77ed 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -37,7 +37,7 @@ concurrency: jobs: publish: name: Validate and publish - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 30 container: image: archlinux:base-devel diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 7c2bcc89bb1a..ca123938c724 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -79,13 +79,10 @@ on: required: false default: true type: boolean - clerk_publishable_key: + accounts_url: required: true type: string - clerk_jwt_template: - required: true - type: string - clerk_cli_oauth_client_id: + accounts_cli_client_id: required: true type: string relay_url: @@ -105,9 +102,8 @@ jobs: # "Wait for Linux CLI archive"), so it gets that wait on top of the usual 30. timeout-minutes: ${{ inputs.platform == 'win' && 70 || 30 }} env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} + T3CODE_ACCOUNTS_URL: ${{ inputs.accounts_url }} + T3CODE_ACCOUNTS_CLI_CLIENT_ID: ${{ inputs.accounts_cli_client_id }} T3CODE_RELAY_URL: ${{ inputs.relay_url }} steps: # This repository is public, so Git needs no credentials. checkout's @@ -416,7 +412,7 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} - T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} + T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} @@ -445,20 +441,24 @@ jobs: if [[ "${{ inputs.platform }}" == "mac" ]]; then if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then - echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 - exit 1 - fi - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" printf '%s' "$APPLE_API_KEY" > "$key_path" export APPLE_API_KEY="$key_path" - profile_path="$RUNNER_TEMP/t3code.provisionprofile" - printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" - security cms -D -i "$profile_path" >/dev/null - export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" - export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then + if [[ -z "$APPLE_TEAM_ID" ]]; then + echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2 + exit 1 + fi + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + echo "macOS provisioning profile validated; entitlements selected by the packager." + else + echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements." + fi echo "macOS signing enabled." args+=(--signed) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb4927a86bdb..d8d2f2837e53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: # have already run. Scheduled runs also decide here whether a nightly is due. resolve_commit: name: Resolve release commit - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: ref: ${{ steps.resolve.outputs.ref }} @@ -104,8 +104,8 @@ jobs: if: | needs.resolve_commit.result == 'success' && (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} version: ${{ steps.release_meta.outputs.version }} @@ -203,7 +203,7 @@ jobs: echo "release_channel=stable" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" - echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" + echo "name=Otter Code v$version" >> "$GITHUB_OUTPUT" echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "is_prerelease=false" >> "$GITHUB_OUTPUT" @@ -234,8 +234,8 @@ jobs: name: Release quality checks needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 @@ -268,8 +268,8 @@ jobs: name: Release tests needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 @@ -304,8 +304,8 @@ jobs: name: Release server tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -344,23 +344,21 @@ jobs: if: | needs.resolve_commit.result == 'success' && (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 environment: name: production outputs: - clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ steps.public_config.outputs.accounts_url }} + accounts_cli_client_id: ${{ steps.public_config.outputs.accounts_cli_client_id }} relay_url: ${{ steps.public_config.outputs.relay_url }} env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} - CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} - CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} + ACCOUNTS_URL: ${{ vars.ACCOUNTS_URL }} + ACCOUNTS_CLI_CLIENT_ID: ${{ vars.ACCOUNTS_CLI_CLIENT_ID }} steps: - name: Checkout uses: actions/checkout@v6 @@ -426,9 +424,8 @@ jobs: fi required=( relay_domain - CLERK_PUBLISHABLE_KEY - CLERK_JWT_TEMPLATE - CLERK_CLI_OAUTH_CLIENT_ID + ACCOUNTS_URL + ACCOUNTS_CLI_CLIENT_ID ) missing=() for name in "${required[@]}"; do @@ -441,14 +438,13 @@ jobs: exit 1 fi - echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" - echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" - echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" + echo "accounts_url=$ACCOUNTS_URL" >> "$GITHUB_OUTPUT" + echo "accounts_cli_client_id=$ACCOUNTS_CLI_CLIENT_ID" >> "$GITHUB_OUTPUT" echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" # The platform-independent JS (server bundle, web client, Electron main) is # built exactly once here and handed to every platform job as `js-bundle`. - # The relay/Clerk values are baked into the bundle, so they belong to this + # The relay/Accounts values are baked into the bundle, so they belong to this # job rather than to the packaging jobs. build_bundle: name: Build JS bundle @@ -456,12 +452,11 @@ jobs: # this runs alongside preflight. See the condition comment there. needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: blacksmith-32vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 30 env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_ACCOUNTS_URL: ${{ needs.relay_public_config.outputs.accounts_url }} + T3CODE_ACCOUNTS_CLI_CLIENT_ID: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout @@ -553,12 +548,11 @@ jobs: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ needs.relay_public_config.outputs.accounts_url }} + accounts_cli_client_id: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: arm64 @@ -576,12 +570,11 @@ jobs: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ needs.relay_public_config.outputs.accounts_url }} + accounts_cli_client_id: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS x64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: x64 @@ -602,12 +595,11 @@ jobs: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ needs.relay_public_config.outputs.accounts_url }} + accounts_cli_client_id: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 + runner: ubuntu-24.04 platform: linux target: AppImage arch: x64 @@ -627,12 +619,11 @@ jobs: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ needs.relay_public_config.outputs.accounts_url }} + accounts_cli_client_id: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Linux arm64 - runner: blacksmith-16vcpu-ubuntu-2404-arm + runner: ubuntu-24.04-arm platform: linux target: AppImage arch: arm64 @@ -646,19 +637,18 @@ jobs: desktop_win_x64: name: Desktop Windows x64 needs: [preflight, relay_public_config, build_bundle] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + if: ${{ false }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ needs.relay_public_config.outputs.accounts_url }} + accounts_cli_client_id: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 + runner: windows-2025 platform: win target: nsis arch: x64 @@ -669,16 +659,15 @@ jobs: desktop_win_arm64: name: Desktop Windows arm64 needs: [preflight, relay_public_config, build_bundle] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + if: ${{ false }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + accounts_url: ${{ needs.relay_public_config.outputs.accounts_url }} + accounts_cli_client_id: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows arm64 runner: windows-11-arm @@ -707,8 +696,8 @@ jobs: desktop_win_x64, desktop_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result != 'failure' && needs.desktop_win_arm64.result != 'failure' }} + runs-on: ubuntu-24.04 # ubuntu-24.04 timeout-minutes: 15 permissions: contents: read @@ -740,8 +729,16 @@ jobs: merge-multiple: true path: release-cli + - name: Configure npm token (Otter Code) + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -n "$NPM_TOKEN" ]; then + printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" >> ~/.npmrc + fi + - name: Build npm packages from CLI archives - run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages --allow-missing # A dry run of every package first: an auth or scope error here (the # @t3code org missing, a package without a trusted publisher) fails @@ -756,6 +753,10 @@ jobs: - name: Publish CLI packages run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose + - name: Tag nightly as latest (Otter Code) + if: needs.preflight.outputs.release_channel == 'nightly' + run: npm dist-tag add "otter-code@${{ needs.preflight.outputs.version }}" latest + release: name: Publish GitHub Release needs: @@ -769,8 +770,8 @@ jobs: desktop_win_arm64, publish_cli, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result != 'failure' && needs.desktop_win_arm64.result != 'failure' && (needs.publish_cli.result == 'success' || needs.publish_cli.result == 'skipped') }} + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: write @@ -882,7 +883,6 @@ jobs: echo 'release-assets/*.zip' echo 'release-assets/*.AppImage' echo 'release-assets/*.deb' - echo 'release-assets/*.exe' if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then echo 'release-assets/*.blockmap' echo 'release-assets/*.yml' @@ -944,7 +944,7 @@ jobs: publish_aur: name: Publish AUR package needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} + if: ${{ github.repository == 'pingdotgg/t3code' && !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} uses: ./.github/workflows/publish-aur.yml with: release_tag: ${{ needs.preflight.outputs.tag }} @@ -961,14 +961,17 @@ jobs: name: Build hosted web app needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 outputs: + version_id: ${{ steps.deploy.outputs.version_id }} deployment_url: ${{ steps.deploy.outputs.deployment_url }} env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + VITE_HOSTED_APP_CHANNEL: ${{ needs.preflight.outputs.release_channel == 'stable' && 'latest' || 'nightly' }} + APP_VERSION: ${{ needs.preflight.outputs.version }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + T3CODE_ACCOUNTS_URL: ${{ needs.relay_public_config.outputs.accounts_url }} + T3CODE_ACCOUNTS_CLI_CLIENT_ID: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} @@ -1021,6 +1024,11 @@ jobs: run: | set -euo pipefail + if [[ "${GITHUB_REPOSITORY}" == "otterware-app/otter-code" ]]; then + node scripts/otter/web-hosting.mjs stage + exit 0 + fi + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 exit 1 @@ -1043,9 +1051,8 @@ jobs: --token "$VERCEL_TOKEN" \ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" \ --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ - --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ - --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ - --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ + --build-env "T3CODE_ACCOUNTS_URL=${T3CODE_ACCOUNTS_URL:-}" \ + --build-env "T3CODE_ACCOUNTS_CLI_CLIENT_ID=${T3CODE_ACCOUNTS_CLI_CLIENT_ID:-}" \ --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ @@ -1060,9 +1067,11 @@ jobs: name: Deploy hosted web app needs: [preflight, build_web, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_web.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 env: + WEB_VERSION_ID: ${{ needs.build_web.outputs.version_id }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} DEPLOYMENT_URL: ${{ needs.build_web.outputs.deployment_url }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} @@ -1093,6 +1102,11 @@ jobs: run: | set -euo pipefail + if [[ "${GITHUB_REPOSITORY}" == "otterware-app/otter-code" ]]; then + node scripts/otter/web-hosting.mjs promote + exit 0 + fi + if [[ -z "$DEPLOYMENT_URL" ]]; then echo "Build hosted web app did not report a deployment URL." >&2 exit 1 @@ -1133,9 +1147,9 @@ jobs: build_marketing: name: Build marketing site needs: [preflight] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + if: ${{ github.repository == 'pingdotgg/t3code' && !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 outputs: deployment_url: ${{ steps.deploy.outputs.deployment_url }} project_id: ${{ steps.deploy.outputs.project_id }} @@ -1197,9 +1211,9 @@ jobs: deploy_marketing: name: Deploy marketing site needs: [preflight, build_marketing, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_marketing.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 10 + if: ${{ github.repository == 'pingdotgg/t3code' && !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_marketing.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 env: DEPLOYMENT_URL: ${{ needs.build_marketing.outputs.deployment_url }} VERCEL_PROJECT_ID: ${{ needs.build_marketing.outputs.project_id }} @@ -1243,8 +1257,8 @@ jobs: name: Finalize release if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - id: app_token name: Mint release app token @@ -1321,7 +1335,7 @@ jobs: announce_discord: name: Announce release on Discord if: | - always() && !cancelled() && + github.repository == 'pingdotgg/t3code' && always() && !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel != 'preview' && needs.relay_public_config.result == 'success' && @@ -1329,8 +1343,8 @@ jobs: needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') needs: [preflight, relay_public_config, release, deploy_web, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index f9cc3b063fcd..41992fc3b4bd 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -5,7 +5,7 @@ name: Web Preview # non-aliased) deploy into the existing hosted-web Vercel project, so the # latest/nightly channel aliases are never touched. # -# The build intentionally omits the T3 Connect cloud config (Clerk keys, relay +# The build intentionally omits the T3 Connect cloud config (account URLs, relay # URL): previews boot as the hosted-static app with manual pairing only. Pair a # server into a preview with `t3 pair --tailscale` (or any reachable HTTPS # backend) and open the pairing URL against the preview origin. @@ -37,9 +37,10 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, 'preview:web') && (github.event.action != 'labeled' || github.event.label.name == 'preview:web') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -70,6 +71,11 @@ jobs: run: | set -euo pipefail + if [[ "${GITHUB_REPOSITORY}" == "otterware-app/otter-code" ]]; then + node scripts/otter/web-hosting.mjs stage-preview + exit 0 + fi + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 exit 1 diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 3a70ad5a26a0..21a61e91cc8e 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -26,7 +26,7 @@ permissions: jobs: test: name: Test (${{ inputs.package || 'all non-server' }}) - runs-on: blacksmith-8vcpu-windows-2025 + runs-on: windows-2025 timeout-minutes: 45 steps: - name: Checkout diff --git a/AGENTS.md b/AGENTS.md index f0202081de83..6ce2f7d4d661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,8 @@ # T3 Code +> **This is the Otter Code fork.** Read [OTTER.md](OTTER.md) first: it covers how the fork +> stays in sync with upstream, its identities and infrastructure, releases, and moving machines. + T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. diff --git a/OTTER.md b/OTTER.md new file mode 100644 index 000000000000..73c3c67dc363 --- /dev/null +++ b/OTTER.md @@ -0,0 +1,311 @@ +# Otter Code + +Otter Code is a fork of [T3 Code](https://github.com/pingdotgg/t3code) with its own name, app IDs, +relay, and release channel. Everything in `AGENTS.md` still applies. This file covers what is +different and how to keep the fork aligned with upstream. + +## How the fork stays in sync + +- **Upstream is `pingdotgg/t3code`'s `main`.** Julius's orchestration V2 PR #2829 merged on + 2026-10-02. Do not fetch its PR head or use the former V2 branch for new syncs. +- `upstream-base` records the exact upstream `main` commit used by the fork. It must be an + ancestor of Otter's `main`. Everything after it is Otter-owned work. +- Keep a linear stack: one commit per fork feature, then one squash commit per past sync. The + current stack is: + 1. `chore(otter): brand fork as Otter Code`: names, IDs, domains, icons, relay values, this file, + and `scripts/otter/`. + 2. `ci(otter): release Otter Code`: release gating and `.github/scripts/otter-adapt-upstream.sh`. + 3. `feat(otter): Linear integration`: linked issues, the Linear agent app, and issue sync. + 4. `feat(otter): scoped diff review`: commit scopes, file navigation, Viewed marks, and file counts. + 5. `feat(otter): code intelligence`: file and diff editor language services, settings, and runtime setup. + 6. `chore(otter): adapt upstream files (generated)`: output of the adaptation script, after the + features. Never edit it by hand. Every sync drops and regenerates it so runner labels, skipped + Windows jobs, and the pointer in `AGENTS.md` do not conflict with upstream edits. + 7. `chore(otter): changes before the sync (#N)`, oldest first: everything committed to + `main` between two syncs, squashed by that sync's PR. Keep each one as its own commit; never + fold them into each other or into the features. +- **Changing the fork:** land every change on `main` through a pull request, and merge it only + once CI is green. Never push to `main` directly. Agents run only targeted checks and leave the + full suite to CI (see `AGENTS.md`), so the PR is what keeps a missed test out of `main` and out + of the nightly release. Asking an agent to change the fork counts as asking it for that PR. The + next sync squashes the merged commits into one new sync commit; publishing a sync (below) is the + only direct write to `main`. Never merge upstream into Otter's `main`. + +### Preparing a sync + +Work in a clean worktree on a dedicated sync branch. Configure the remotes once: + +```sh +git remote set-url origin https://github.com/otterware-app/otter-code.git +git remote add -t main --no-tags upstream https://github.com/pingdotgg/t3code.git +``` + +If `upstream` already exists, verify its URL instead of adding it again. Then: + +```sh +git fetch origin main upstream-base +git fetch upstream main +old_main=$(git rev-parse origin/main) +old_base=$(git rev-parse origin/upstream-base) +new_base=$(git rev-parse upstream/main) +sync_date=$(date +%F) +git merge-base --is-ancestor "$old_base" "$old_main" +git merge-base --is-ancestor "$old_base" "$new_base" +last_sync=$(git log -1 --format=%H --grep='^chore(otter): changes before the .* sync' "$old_main") +# Commits since the last sync wait here; they become this sync's squash commit. +git branch "otter/sync-$sync_date" "$old_main" +git switch -c "otter/stack-$sync_date" "$last_sync" +git rebase -i --onto "$new_base" "$old_base" +``` + +In the rebase todo, drop the generated adaptation commit; it is regenerated below and goes back +between the features and the sync commits. Resolve conflicts using upstream's current behavior and +retain only the additional Otter functionality; drop fork changes upstream now covers. Fold fixes +that upstream changes require into the owning commit with `git commit --fixup` and +`git rebase -i --autosquash "$new_base"`. + +If upstream added relay migrations, its snapshot chain and the fork's can branch from the same +parent. Fold a merge migration into the Linear commit, recording both heads as parents without +changing the schema: + +```sh +cd infra/relay +pnpm exec drizzle-kit generate --custom --name otter_upstream_merge \ + --dialect postgresql --schema ./src/persistence/schema.ts --out ./migrations/postgres +``` + +From the repository root, regenerate the adaptation commit on the last feature commit, then +replay the sync commits on top of it: + +```sh +sync_commits=$(git rev-list --reverse --grep='^chore(otter): changes before the .* sync' "$new_base"..HEAD) +git switch --detach "$(git rev-parse "$(echo "$sync_commits" | head -1)^")" +.github/scripts/otter-adapt-upstream.sh +git add -u +git diff --cached --quiet || git commit -m "chore(otter): adapt upstream files (generated)" +git cherry-pick $sync_commits +git branch -f "otter/stack-$sync_date" HEAD && git switch "otter/stack-$sync_date" +git merge-base --is-ancestor "$new_base" HEAD +git rev-list --left-right --count "$new_base"...HEAD +git log --oneline "$new_base"..HEAD +git diff --check "$new_base"..HEAD +``` + +The count should show zero upstream-only commits, and on Otter's side only the features, the +generated adaptation, and the sync commits. Then move the waiting commits onto the new stack: + +```sh +git rebase --onto "otter/stack-$sync_date" "$last_sync" "otter/sync-$sync_date" +``` + +Run focused tests and typechecks for conflicted code and the preserved features before publishing. + +### Publishing a prepared sync + +A sync rewrites Otter's shared history. Update `main` and `upstream-base` together with explicit +leases; the squash commits are the record of the fork's changes, so no backup branches are kept. +If another writer moved either ref, stop +and prepare against the new state instead of overriding it: + +```sh +git push --atomic origin \ + --force-with-lease="refs/heads/main:$old_main" \ + --force-with-lease="refs/heads/upstream-base:$old_base" \ + "otter/stack-$sync_date:refs/heads/main" "$new_base:refs/heads/upstream-base" +git push origin "otter/sync-$sync_date" +gh pr create --base main --head "otter/sync-$sync_date" \ + --title "chore(otter): changes before the $sync_date sync" +gh pr merge --squash +``` + +The PR's branch is already rebased onto the new `main`, so its squash commit carries only the +fork's own changes. Never merge a branch that still contains the old base. Existing worktrees and feature branches +still point at the old stack: rebase only their own work onto the new `origin/main`, using their +previous base explicitly. Do not automatically rebase or reset other active worktrees. + +Publish a nightly with `gh workflow run release.yml -f channel=nightly`. Scheduled nightlies only +release commits ahead of the last nightly tag, and a rebased `main` has diverged from it. + +**Keep the diff small:** prefer repository variables and secrets over code, and new files over +edits to upstream files. Leave internal names (`@t3tools/*`, `T3CODE_*`, code identifiers) alone. +`scripts/otter/rebrand-ui-text.sh` renames client UI text; it runs only by hand, and its exclusions +list text that must keep matching what the server or agents emit. + +## Identities and infrastructure + +| What | Value | +| -------------------- | --------------------------------------------------------------------------------------- | +| App and bundle ID | `dev.otterware.code` (`.dev`, `.preview`; iOS extensions `.widgets`, `.sharing`) | +| URL scheme | `ottercode` (`ottercode-dev`, `ottercode-preview`) | +| Data home | `~/.otter-code` (never `~/.t3`, so it can sit beside an installed T3 Code) | +| Background service | `otter-code.service` (systemd user unit), `dev.otterware.code.service` (launchd) | +| Releases and updates | `otterware-app/otterware` Releases, `channel-code` (bridge release) | +| Relay | `https://relay.code.otterware.app` (Cloudflare Worker, deployed from `infra/relay`) | +| Tunnels | `prod-.otterware.app`, one per linked machine | +| Hosted web app | `https://code.otterware.app` (Cloudflare Worker `otter-code-web`) | +| Sign-in | Otter Accounts at `accounts.otterware.app` | +| Apple | Team `YNJ5WLH965`, App Store app `6815697255` | +| Mobile builds | EAS project `@clary-so/otter-code` | +| CLI on npm | `otter-code` (command `otter-code`), platform builds `@otterware/otter-code-` | +| App icons | `assets/otter` (production), `assets/otter-nightly`, `assets/otter-dev`: see below | + +Secrets live in the repository's Actions secrets and its `production` environment. + +The icons come from the Otterware brand kit (October 2026): the Otter Code symbol, a charcoal +(`#2c2f31`) "‹›" swoosh, on an ivory (`#f8f5ec`) tile. Each Icon Composer project has two layers, +`background.svg` and `symbol.svg`, both full 1024pt canvases. Production is the kit's light icon, +with a dark appearance (ivory symbol on charcoal) set through `fill-specializations`; iOS derives +the tinted one. Dev is the blueprint variant (blue gradient, grid and crop marks, white symbol) and +nightly the night sky (indigo to violet, stars and clouds, ivory symbol). Each folder also holds +`-web-favicon.svg`, drawn by hand and copied as is: production's switches with +`prefers-color-scheme`. `vp run icons:export` regenerates the iOS, Linux, Windows and web exports +as `assets/README.md` describes; the macOS PNGs still need Icon Composer's `macOS pre-Tahoe` +export (an 824pt tile inset 100, with shadow). The tracked exports were rendered from the kit's +SVGs with resvg, which matches those framings: rounded tiles (radius 230 of 1024) for the iOS, +universal and web files. + +`apps/mobile/assets/otter/` holds the Android art, rendered from the same layers: the symbol as the +adaptive foreground at half the 108dp canvas (inside the 66dp safe circle), on ivory for production +and on the variant's background image for dev and nightly (`-dev` and `-nightly` files), the +Android 12+ splash as both layers on the 288dp canvas, and a white symbol for the monochrome +launcher layer and the notification icon. The in-app symbol (`T3Wordmark` on web and mobile, the +widget's `T3Mark.svg`, `agent_activity_mark.xml`, the DMG backgrounds) is the kit's +`symbol-tight.svg`: viewBox `123 127 780 780`, coordinates rounded to 0.1. + +## Releases + +- **Desktop and server runtimes:** `release.yml` (T3's pipeline) builds macOS arm64/x64 and Linux + x64/arm64 on GitHub-hosted runners and publishes a nightly GitHub Release that installed apps + offer in their update dialog. Windows, AUR, marketing, and Discord are skipped. + Start one by hand with `gh workflow run release.yml -f channel=nightly`. +- **CLI on npm:** the same run publishes `otter-code` and its `@otterware/otter-code-` + packages with the `NPM_TOKEN` secret (an npm account that is a member of the `otterware` org), + tagged `nightly` and also `latest`, so `npx otter-code` and `npm i -g otter-code` get the newest + build. The token is needed because new packages have no npm trusted publisher yet. +- **macOS passkeys:** `MACOS_PROVISIONING_PROFILE` is the base64 Developer ID profile for + `dev.otterware.code`, signed by Clary GmbH's team `YNJ5WLH965` (`APPLE_TEAM_ID`). The + Otterware branch must pass its own `OTTERWARE_MACOS_PROVISIONING_PROFILE` secret into the + reusable workflow's `MACOS_PROVISIONING_PROFILE` input and use app ID `dev.otterware.suite`. + The packager derives the WebAuthn keychain group from the team and app IDs and claims only + used profile grants. Apple's managed `com.apple.developer.web-browser.public-key-credential` + capability is pending approval; after approval, regenerate both profiles and update their + secrets. Associated Domains are optional (`MACOS_PASSKEY_RP_DOMAINS`, comma-separated) and + unnecessary for browser passkeys; no Clerk key is required. Without a profile, existing + signing and notarization continue. Otter's policy lives in `scripts/otter/macos-profile.ts`; + regenerate workflow adaptations with `.github/scripts/otter-adapt-upstream.sh`. +- **Hosted web:** the release stages a Cloudflare Worker version, then promotes it after the + desktop/CLI release publishes. `scripts/otter/web/` owns its routing and static assets; + the adaptation script keeps upstream's Vercel path separate. The `Deploy Otter Code web` + workflow can ship hosting fixes from `main` with an already-published release tag, without + rebuilding desktop or mobile. Cloudflare manages the `.app` aliases and `.dev` redirects. + PR previews use the separate `otter-code-web-preview` Worker and immutable version URLs. +- **Relay:** `Deploy T3 Connect relay` runs on every push to `main`. The relay adopts the + existing `otterware.app` zone and a PlanetScale database with a retain policy. Never run + `alchemy destroy` against `prod`. Its PlanetScale service token (`PLANETSCALE_API_TOKEN*`) + needs `delete_production_branch_password` and `delete_branch_password` on the database. + Alchemy runs migrations as a temporary role and deletes it with `postgres` as successor, which + hands the new tables to `postgres`. Without those accesses the deletion fails with only a + warning, the tables stay owned by the expired role, and a later `ALTER TABLE` migration fails + with `must be owner of table`. +- **iOS** is built and uploaded locally, not in CI: + + ```sh + cd apps/mobile + pnpm dlx eas-cli@latest build --platform ios --profile production --local --non-interactive \ + --output ../../../otter-code-builds/otter-code-ios.ipa --apiIssuer # key from API_PRIVATE_KEYS_DIR + ``` + + Use a **release** Xcode; App Store Connect rejects beta toolchains (error 90534). Public build + config (`T3CODE_ACCOUNTS_URL`, `T3CODE_RELAY_URL`) is stored as EAS environment variables. Don't put + it in a gitignored `.env.local`: EAS builds from a clean copy of the repo, and the runtime + fingerprints would not match. + +## Moving a machine from T3 Code + +`scripts/otter/migrate-from-t3.sh` copies a T3 Code home (V2 preview or T3 Nightly) into +`~/.otter-code`. Threads, projects, settings, secrets, attachments, and the environment ID +come along, so saved connections to that machine keep matching. It never modifies the +source. Run it with `--dry-run` first. + +- **Desktop:** quit both apps first (the script refuses while a database is open), then: + + ```sh + scripts/otter/migrate-from-t3.sh --from ~/.t3/orchestrator-preview-2829 + open -a "Otter Code" + ``` + +- **Server with a systemd unit:** install the Otter Code runtime into the new home, then let the + script stop the old unit, copy, install `otter-code.service`, and restore the old unit if + anything fails: + + ```sh + curl -fsSL https://raw.githubusercontent.com/otterware-app/otter-code/main/scripts/install.sh \ + | T3CODE_HOME=~/.otter-code T3CODE_CHANNEL=nightly sh + scripts/otter/migrate-from-t3.sh --from ~/.t3/orchestrator-preview-2829 \ + --stop-unit t3code-pr2829.service --wait-idle 10 --install-service + ``` + + The installer links the command as `otter-code`, so it never replaces an installed T3 Code's + `t3`. The script refuses to touch T3 Nightly's `t3code.service`. + +Deliberately not copied: + +- Desktop client files encrypted with the old app's Keychain key (`connection-catalog.json`, + `clerk-tokens.json`, `cloud-auth-token.json`). Otter Code can't read them, and its window + would stay empty. +- The stored T3 Connect link (`secrets/cloud-*`), which points at T3's relay and account. + +## T3 Connect + +- **Desktop:** Settings → Connections → T3 Connect. +- **Stale link:** if a machine says it is "already linked to a different cloud account", clear the + old link with `t3 connect logout --base-dir ~/.otter-code`. On the desktop, run the CLI through + the app binary with `ELECTRON_RUN_AS_NODE=1` and `Contents/Resources/app.asar/apps/server/dist/bin.mjs`. +- **SSH-only hosts:** run `otter-code connect` on the host and approve its connection code at + Otter Accounts. No SSH callback forwarding is needed. Restart the host service after linking. +- **Account cutover:** old provider tokens and links are not migrated by email. Update the app, + clear an old link with `otter-code connect logout --base-dir ~/.otter-code`, sign in to Otter + Accounts and reconnect. Local projects and thread history stay in place. + +- **Relay reaching tunnels:** the relay Worker needs the `global_fetch_strictly_public` + compatibility flag (`infra/relay/src/worker.ts`), because tunnels share the relay's zone. + Without it, the relay's calls to a tunnel fail with Cloudflare 530 and phones report + `endpoint_request_failed`. + +## Linear agent app + +Delegating Linear issues to Otter needs one Linear OAuth app owned by Otter, configured on the relay. +Without it the relay reports Linear as unavailable and clients hide the section. + +- Create the app at `linear.app/settings/api/applications/new` with distribution **public**, callback + URL `https://relay.code.otterware.app/v1/linear/oauth/callback`, webhooks on, webhook URL + `https://relay.code.otterware.app/v1/linear/webhook`, and the **Agent session events**, **Issues**, + **Comments**, and **OAuth app revoked** categories. Issues and Comments are what make linked + issues update right away; without them, machines fall back to polling. +- Store the client ID as the `LINEAR_CLIENT_ID` repository variable, and the client secret and webhook + signing secret as the `LINEAR_CLIENT_SECRET` and `LINEAR_WEBHOOK_SECRET` secrets in the `production` + environment. `HOSTED_APP_URL` is optional and defaults to `https://code.otterware.app`. +- The relay generates its own keys for sealing Linear tokens and signing OAuth state. Rotating them + forces every workspace to reinstall and every user to relink. + +## Other traps + +- **Electron as Node:** agent shells inside Otter Code can inherit `ELECTRON_RUN_AS_NODE=1`, and + `open` passes it on, so an app launched from such a shell runs as plain Node and exits. Launch + it with `env -u ELECTRON_RUN_AS_NODE open -a "Otter Code"`. +- **macOS runners:** macOS jobs run on `macos-15`. On GitHub's `macos-26` image, electron-builder + below 26.16.1 fails to unlock its signing keychain. Move to `macos-26` in the generated script + once upstream pins 26.16.1 or later. + +The account integration is an Otter-owned boundary. Upstream continues to supply the editor, providers, orchestration and transport; its Clerk dependencies and account screens must not return during a sync. Code uses the shared account IDs through OAuth PKCE on every client, with online session validation at the relay. Keep the OAuth clients and issuer aligned with Otter Accounts when resolving upstream auth changes. + +## Monorepo migration bridge (ADR 0015) + +This final legacy release bakes `otterware-app/otterware`'s `channel-code` feed into desktop +apps. The CLI and service launcher resolve only Code-profile immutable releases there; +`scripts/install.sh` discovers the active version from `channel-code/SHA256SUMS` and verifies +its download against the immutable build's checksums. npm packages built by this release +carry that same update logic. The bridge must wait for the developer's go and the first +all-profile monorepo release. Do not merge or release it automatically. diff --git a/apps/desktop/gnome-extension/captureFeedback.js b/apps/desktop/gnome-extension/captureFeedback.js index 1278101b57ca..9093549051ad 100644 --- a/apps/desktop/gnome-extension/captureFeedback.js +++ b/apps/desktop/gnome-extension/captureFeedback.js @@ -110,7 +110,7 @@ export class CaptureFeedback { check(); })); if (this._owned(sender) !== session || !target) - throw new Error("T3 Code's window is not available for activation."); + throw new Error("Otter Code's window is not available for activation."); session.target = target; Main.activateWindow(target, global.get_current_time()); } diff --git a/apps/desktop/gnome-extension/captureService.js b/apps/desktop/gnome-extension/captureService.js index 3b436afd5e22..7df0c08fa447 100644 --- a/apps/desktop/gnome-extension/captureService.js +++ b/apps/desktop/gnome-extension/captureService.js @@ -2,6 +2,8 @@ export const CLIENT_NAMES = [ "com.t3tools.T3Code.SnapShot", "com.t3tools.T3Code.Development.SnapShot", + "dev.otterware.code.SnapShot", + "dev.otterware.code.dev.SnapShot", ]; export function isWaylandSession(meta) { @@ -43,7 +45,7 @@ export class CaptureService { break; } } - if (!allowed) throw new Error("Only T3 Code may request a snapshot."); + if (!allowed) throw new Error("Only Otter Code may request a snapshot."); const pid = options ? await this._getProcessId(sender) : undefined; this._checkSession(); const snapshot = await this._takeSnapshot(options?.animate ?? false); diff --git a/apps/desktop/gnome-extension/captureService.test.js b/apps/desktop/gnome-extension/captureService.test.js index b8313f920f72..4be4e734a74b 100644 --- a/apps/desktop/gnome-extension/captureService.test.js +++ b/apps/desktop/gnome-extension/captureService.test.js @@ -23,7 +23,7 @@ function fixture(overrides = {}) { it("rejects other clients before looking at the active window", async () => { const { service, snapshots } = fixture(); - await expect(service.capture(":1.99")).rejects.toThrow("Only T3 Code"); + await expect(service.capture(":1.99")).rejects.toThrow("Only Otter Code"); expect(snapshots()).toBe(0); expect(await service.capture(":1.23")).toBe("pixels"); }); @@ -38,7 +38,7 @@ it("rechecks the name owner on each capture", async () => { const { service } = fixture({ getNameOwner: async () => owner }); expect(await service.capture(owner)).toBe("pixels"); owner = ":1.24"; - await expect(service.capture(":1.23")).rejects.toThrow("Only T3 Code"); + await expect(service.capture(":1.23")).rejects.toThrow("Only Otter Code"); }); it("rejects locked and non-Wayland sessions without taking a screenshot", async () => { @@ -94,7 +94,7 @@ it("rejects concurrent requests and clears busy after a failed capture", async ( await expect(service.capture(":1.23")).rejects.toThrow("already in progress"); finish(new Error("gone")); await expect(first).rejects.toThrow("gone"); - await expect(service.capture(":1.99")).rejects.toThrow("Only T3 Code"); + await expect(service.capture(":1.99")).rejects.toThrow("Only Otter Code"); }); it("prepares focus/effects only after pixels and identity have been captured", async () => { diff --git a/apps/desktop/gnome-extension/feedbackGeometry.test.js b/apps/desktop/gnome-extension/feedbackGeometry.test.js index c236981b9fca..0a869afc2f59 100644 --- a/apps/desktop/gnome-extension/feedbackGeometry.test.js +++ b/apps/desktop/gnome-extension/feedbackGeometry.test.js @@ -4,9 +4,9 @@ import { captureDestinationFrame, findCaptureDestination } from "./feedbackGeome const window = (pid, title) => ({ get_pid: () => pid, get_title: () => title }); it("activates only a window of the process authenticated by D-Bus", () => { - const target = window(42, "T3 Code"); - expect(findCaptureDestination([window(99, "T3 Code"), target], 42, "T3 Code")).toBe(target); - expect(findCaptureDestination([window(99, "T3 Code")], 42, "T3 Code")).toBeUndefined(); + const target = window(42, "Otter Code"); + expect(findCaptureDestination([window(99, "Otter Code"), target], 42, "Otter Code")).toBe(target); + expect(findCaptureDestination([window(99, "Otter Code")], 42, "Otter Code")).toBeUndefined(); expect(findCaptureDestination([target], 42, "Title before navigation")).toBe(target); }); diff --git a/apps/desktop/gnome-extension/metadata.json b/apps/desktop/gnome-extension/metadata.json index ff51dbb9a486..e424fe961684 100644 --- a/apps/desktop/gnome-extension/metadata.json +++ b/apps/desktop/gnome-extension/metadata.json @@ -1,7 +1,7 @@ { "uuid": "snap-shot@t3.codes", - "name": "T3 Code SnapShots", - "description": "Allows T3 Code to capture the focused window, identify it for optional accessibility text, and animate it into T3 Code. Disable this extension to revoke access.", + "name": "Otter Code SnapShots", + "description": "Allows Otter Code to capture the focused window, identify it for optional accessibility text, and animate it into Otter Code. Disable this extension to revoke access.", "shell-version": ["45", "46", "47", "48", "49", "50"], "version": 2, "url": "https://github.com/pingdotgg/t3code" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8e736a7d5410..dd64596c4e67 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,8 +12,6 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { - "@clerk/electron": "catalog:", - "@clerk/electron-passkeys": "catalog:", "@crowecawcaw/xa11y": "0.13.0", "@effect/platform-node": "catalog:", "@napi-rs/keyring": "^1.3.0", @@ -25,7 +23,7 @@ "dbus-next": "0.10.2", "effect": "catalog:", "electron": "44.4.5", - "electron-store": "^8.2.0", + "electron-chrome-web-store": "^0.13.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", "ffi-rs": "1.3.2", @@ -42,5 +40,5 @@ "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "Otter Code" } diff --git a/apps/desktop/resources/dmg/dmg-background-latest.svg b/apps/desktop/resources/dmg/dmg-background-latest.svg index 2c300aa52423..0d0949daabd2 100644 --- a/apps/desktop/resources/dmg/dmg-background-latest.svg +++ b/apps/desktop/resources/dmg/dmg-background-latest.svg @@ -16,11 +16,11 @@ - - - + + + - Drag T3 Code into Applications. + Drag Otter Code into Applications. Once copied, open it from Applications. diff --git a/apps/desktop/resources/dmg/dmg-background-nightly.svg b/apps/desktop/resources/dmg/dmg-background-nightly.svg index 6ae74b3b51ef..3f78a8e7e3d9 100644 --- a/apps/desktop/resources/dmg/dmg-background-nightly.svg +++ b/apps/desktop/resources/dmg/dmg-background-nightly.svg @@ -22,12 +22,12 @@ - - - - NIGHTLY - Drag T3 Code into Applications. + + + + + Drag Otter Code into Applications. Once copied, open it from Applications. diff --git a/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml b/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml index 0ae54a9849fc..cc17c04747d9 100644 --- a/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml +++ b/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml @@ -3,14 +3,14 @@ com.t3tools.t3code CC0-1.0 MIT - T3 Code + Otter Code Desktop GUI for coding agents T3 Tools Inc.

- T3 Code is an open-source desktop app for coding agents. Work with your + Otter Code is an open-source desktop app for coding agents. Work with your existing agent subscriptions, review code changes, and run commands in your projects.

diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 48f66ab76b56..bc3ba881a041 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -15,19 +15,19 @@ const repoRoot = NodePath.resolve(desktopDir, "..", ".."); const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_DISPLAY_NAME = isDevelopment ? "Otter Code (Dev)" : "Otter Code"; const APP_BUNDLE_ID = isDevelopment - ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` - : "com.t3tools.t3code"; -const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; + ? `dev.otterware.code.dev.${devBundleIdSuffix || "local"}` + : "dev.otterware.code"; +const APP_PROTOCOL_SCHEMES = isDevelopment ? ["ottercode-dev"] : ["ottercode"]; const LAUNCHER_VERSION = 19; const developmentMacIconPngPath = NodePath.join( repoRoot, "assets", - "dev", - "blueprint-macos-1024.png", + "otter-dev", + "otter-dev-macos-1024.png", ); -const productionMacIconPngPath = NodePath.join(repoRoot, "assets", "prod", "black-macos-1024.png"); +const productionMacIconPngPath = NodePath.join(repoRoot, "assets", "otter", "otter-macos-1024.png"); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. const hostPlatform = NodeOS.platform(); @@ -270,8 +270,9 @@ export function resolveMacBundleInfoPlistStrings(executableName) { CFBundleExecutable: executableName, CFBundleIconFile: "icon.icns", NSScreenCaptureUsageDescription: - "T3 Code captures the active window when you use the snapshot shortcut.", - NSDocumentsFolderUsageDescription: "T3 Code reads project files you open in the desktop app.", + "Otter Code captures the active window when you use the snapshot shortcut.", + NSDocumentsFolderUsageDescription: + "Otter Code reads project files you open in the desktop app.", }; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 84d08cd9b6b8..d1c2d8112fae 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -76,18 +76,18 @@ describe("electron development launcher", () => { it("keeps the native Electron executable name inside the branded macOS bundle", () => { const paths = resolveMacLauncherPaths( - "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app", - "T3 Code (Dev)", + "/repo/apps/desktop/.electron-runtime/Otter Code (Dev).app", + "Otter Code (Dev)", ); - assert.equal(paths.launcherExecutableName, "T3 Code (Dev) Launcher"); + assert.equal(paths.launcherExecutableName, "Otter Code (Dev) Launcher"); assert.equal( paths.launcherBinaryPath, - "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/T3 Code (Dev) Launcher", + "/repo/apps/desktop/.electron-runtime/Otter Code (Dev).app/Contents/MacOS/Otter Code (Dev) Launcher", ); assert.equal( paths.runtimeElectronBinaryPath, - "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron", + "/repo/apps/desktop/.electron-runtime/Otter Code (Dev).app/Contents/MacOS/Electron", ); const script = makeDevelopmentLauncherScript({ @@ -98,32 +98,32 @@ describe("electron development launcher", () => { }); assert.include( script, - "exec '/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron'", + "exec '/repo/apps/desktop/.electron-runtime/Otter Code (Dev).app/Contents/MacOS/Electron'", ); assert.notInclude(script, "node_modules/electron"); }); it("declares why the macOS app needs protected access", () => { - const values = resolveMacBundleInfoPlistStrings("T3 Code (Dev) Launcher"); + const values = resolveMacBundleInfoPlistStrings("Otter Code (Dev) Launcher"); assert.equal( values.NSScreenCaptureUsageDescription, - "T3 Code captures the active window when you use the snapshot shortcut.", + "Otter Code captures the active window when you use the snapshot shortcut.", ); assert.equal( values.NSDocumentsFolderUsageDescription, - "T3 Code reads project files you open in the desktop app.", + "Otter Code reads project files you open in the desktop app.", ); }); it("ad-hoc signs the complete development app bundle", () => { - assert.deepEqual(resolveMacCodeSignArguments("/runtime/T3 Code (Dev).app"), [ + assert.deepEqual(resolveMacCodeSignArguments("/runtime/Otter Code (Dev).app"), [ "--force", "--deep", "--sign", "-", "--timestamp=none", - "/runtime/T3 Code (Dev).app", + "/runtime/Otter Code (Dev).app", ]); }); @@ -146,9 +146,9 @@ describe("electron development launcher", () => { const production = resolveMacLauncherIconPaths("/runtime", false); // The source icons are real repo paths, joined for the host. - assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); + assert.match(development.sourceIconPath, /assets[\\/]otter-dev[\\/]otter-dev-macos-1024\.png$/); assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); - assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); + assert.match(production.sourceIconPath, /assets[\\/]otter[\\/]otter-macos-1024\.png$/); assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns"); }); }); diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs index f6976220d07f..d21a98c660f1 100644 --- a/apps/desktop/scripts/verify-preload-bundle.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.mjs @@ -7,12 +7,14 @@ import * as NodeVM from "node:vm"; import { parse } from "acorn"; const expectedDesktopBridgeApis = [ + "getAccountSession", + "setAccountSession", + "authorizeAccount", "getClientPlatform", "getLocalEnvironmentBootstraps", "getPathForFile", "pickFolder", ]; -const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; const preloadExecutionTimeoutMs = 1_000; const desktopPackage = JSON.parse( NodeFS.readFileSync(new URL("../package.json", import.meta.url), "utf8"), @@ -107,6 +109,8 @@ const executeBundle = (source, sandboxModules) => { source, { process: sandboxProcess, + window: { addEventListener: () => {} }, + document: { readyState: "loading" }, require: requireSandboxModule, }, { @@ -137,7 +141,6 @@ export const verifyPreloadBundle = (source) => { (api) => typeof desktopBridge?.[api] !== "function", ); if (!exposedGlobals.has("desktopBridge")) missingApis.unshift("desktopBridge exposure"); - if (!exposedGlobals.has(clerkPasskeysGlobal)) missingApis.push(`${clerkPasskeysGlobal} exposure`); if (missingApis.length > 0) { throw new Error(`Desktop preload bundle is missing executable APIs: ${missingApis.join(", ")}`); diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs index d2055e2c6817..efe69d475f6f 100644 --- a/apps/desktop/scripts/verify-preload-bundle.test.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -5,8 +5,10 @@ import { verifyPreloadBundle } from "./verify-preload-bundle.mjs"; const validPreload = ` const electron = require("electron"); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; - electron.contextBridge.exposeInMainWorld("__clerk_internal_electron_passkeys", {}); electron.contextBridge.exposeInMainWorld("desktopBridge", { + getAccountSession: async () => null, + setAccountSession: async () => {}, + authorizeAccount: async () => ({}), getClientPlatform: () => process.platform, getLocalEnvironmentBootstraps: () => [], getPathForFile: () => "", @@ -20,7 +22,6 @@ describe("desktop preload bundle verifier", () => { () => verifyPreloadBundle(` "desktopBridge getClientPlatform getLocalEnvironmentBootstraps pickFolder"; - "__clerk_internal_electron_passkeys"; require("electron"); `), /missing executable APIs/, diff --git a/apps/desktop/src/app/CodexAuthCallback.test.ts b/apps/desktop/src/app/CodexAuthCallback.test.ts index 6d8a905885ac..38f418d7fd07 100644 --- a/apps/desktop/src/app/CodexAuthCallback.test.ts +++ b/apps/desktop/src/app/CodexAuthCallback.test.ts @@ -56,7 +56,8 @@ describe("desktop Codex callback helper", () => { const expected = callback(authorizationUrl); const input = { authorizationUrl, - returnUrl: "https://app.t3.codes/settings/providers?environmentId=remote-one&instanceId=work", + returnUrl: + "https://code.otterware.app/settings/providers?environmentId=remote-one&instanceId=work", environmentId: EnvironmentId.make("remote-one"), instanceId: ProviderInstanceId.make("work"), flowId: "flow-one", diff --git a/apps/desktop/src/app/DesktopAccountSession.test.ts b/apps/desktop/src/app/DesktopAccountSession.test.ts new file mode 100644 index 000000000000..df90d58c91b2 --- /dev/null +++ b/apps/desktop/src/app/DesktopAccountSession.test.ts @@ -0,0 +1,125 @@ +// @effect-diagnostics globalFetchInEffect:off -- A system-browser stub sends real HTTP callbacks to the scoped native listener. +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Account from "./DesktopAccountSession.ts"; +import * as Environment from "./DesktopEnvironment.ts"; +import * as SafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as Shell from "../electron/ElectronShell.ts"; +const saved = JSON.stringify({ + version: 1, + accountsUrl: "https://accounts.otterware.app/v1/auth", + resource: "https://relay.code.otterware.app", + clientId: "otter-code-desktop", + accessToken: "private-access", + refreshToken: "private-refresh", + expiresAt: 900000, + user: { id: "canonical", email: "person@example.test", name: null, image: null }, +}); +function dependencies( + stateDir: string, + backend: string | null = null, + open: (url: unknown) => Effect.Effect = () => Effect.succeed(true), +) { + return Layer.mergeAll( + NodeServices.layer, + Layer.succeed(Environment.DesktopEnvironment, { + stateDir, + } as Environment.DesktopEnvironment["Service"]), + Layer.succeed(SafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: Effect.succeed(true), + selectedStorageBackend: Effect.succeed(Option.fromNullishOr(backend)), + encryptString: (value) => + Effect.sync(() => { + expect(value).toBe(saved); + return new Uint8Array([0xff, 0, 0x81]); + }), + decryptString: (value) => + Effect.sync(() => { + expect(Array.from(value)).toEqual([0xff, 0, 0x81]); + return saved; + }), + }), + Layer.mock(Shell.ElectronShell)({ openExternal: open }), + ); +} +it.live( + "stores only encrypted bytes, restores the session and removes credentials on sign-out", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "otter-account-test-" }); + yield* Effect.gen(function* () { + const account = yield* Account.DesktopAccountSession; + expect(yield* account.read).toBeNull(); + yield* account.write(saved); + expect(Array.from(yield* fs.readFile(`${stateDir}/otter-account-session.bin`))).toEqual([ + 0xff, 0, 0x81, + ]); + expect(yield* account.read).toBe(saved); + yield* account.write(null); + expect(yield* account.read).toBeNull(); + }).pipe(Effect.provide(Account.layer.pipe(Layer.provide(dependencies(stateDir))))); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); +it.live("refuses Linux plaintext storage and malformed credentials", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "otter-account-test-" }); + yield* Effect.gen(function* () { + const account = yield* Account.DesktopAccountSession; + expect((yield* account.write(saved).pipe(Effect.result))._tag).toBe("Failure"); + expect((yield* account.write("{}").pipe(Effect.result))._tag).toBe("Failure"); + expect(yield* fs.exists(`${stateDir}/otter-account-session.bin`)).toBe(false); + }).pipe( + Effect.provide(Account.layer.pipe(Layer.provide(dependencies(stateDir, "basic_text")))), + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); +it.live( + "accepts only the state and issuer of its loopback authorization and closes the listener", + () => + Effect.gen(function* () { + let callbackUrl = ""; + const open = (raw: unknown) => + Effect.promise(async () => { + const authorization = new URL(String(raw)); + expect(authorization.searchParams.get("client_id")).toBe("otter-code-desktop"); + expect(authorization.searchParams.get("resource")).toBe( + "https://relay.code.otterware.app", + ); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + callbackUrl = authorization.searchParams.get("redirect_uri")!; + const callback = new URL(callbackUrl); + callback.search = new URLSearchParams({ + code: "code", + state: "wrong", + iss: "https://accounts.otterware.app/v1/auth", + }).toString(); + expect((await fetch(callback)).status).toBe(400); + callback.searchParams.set("state", "state"); + callback.searchParams.set("iss", "https://other.example/v1/auth"); + expect((await fetch(callback)).status).toBe(400); + callback.searchParams.set("iss", "https://accounts.otterware.app/v1/auth"); + expect((await fetch(callback)).status).toBe(200); + return true; + }); + const result = yield* Effect.gen(function* () { + const account = yield* Account.DesktopAccountSession; + return yield* account.authorize({ state: "state", challenge: "challenge" }); + }).pipe( + Effect.provide(Account.layer.pipe(Layer.provide(dependencies("/unused", null, open)))), + ); + expect(result).toEqual({ code: "code", redirectUri: callbackUrl }); + yield* Effect.promise(async () => { + await expect(fetch(callbackUrl)).rejects.toThrow(); + }); + }), +); diff --git a/apps/desktop/src/app/DesktopAccountSession.ts b/apps/desktop/src/app/DesktopAccountSession.ts new file mode 100644 index 000000000000..574409dc0698 --- /dev/null +++ b/apps/desktop/src/app/DesktopAccountSession.ts @@ -0,0 +1,183 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Electron owns the native OAuth loopback listener. +import * as NodeHttp from "node:http"; +import { OtterAccountSession } from "@t3tools/contracts/accounts"; +import { + ACCOUNT_CLIENT_IDS, + OTTER_ACCOUNTS_URL, + OTTER_CODE_RELAY_URL, + normalizeAccountsUrl, + accountAuthorizationUrl, +} from "@t3tools/shared/otterAccounts"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; + +declare const __T3CODE_BUILD_ACCOUNTS_URL__: string | undefined; +declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; +const accountsUrl = + normalizeAccountsUrl( + typeof __T3CODE_BUILD_ACCOUNTS_URL__ === "undefined" + ? OTTER_ACCOUNTS_URL + : (__T3CODE_BUILD_ACCOUNTS_URL__ ?? ""), + ) ?? OTTER_ACCOUNTS_URL; +const relayUrl = + normalizeSecureRelayUrl( + typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" + ? OTTER_CODE_RELAY_URL + : (__T3CODE_BUILD_RELAY_URL__ ?? ""), + ) ?? OTTER_CODE_RELAY_URL; + +export class DesktopAccountSessionError extends Schema.TaggedError()( + "DesktopAccountSessionError", + { operation: Schema.Literals(["read", "write", "authorize"]), cause: Schema.Defect() }, +) { + override get message() { + return `Could not ${this.operation} the Otter account session.`; + } +} + +export class DesktopAccountSession extends Context.Service< + DesktopAccountSession, + { + readonly read: Effect.Effect; + readonly write: (value: string | null) => Effect.Effect; + readonly authorize: (request: { + readonly state: string; + readonly challenge: string; + }) => Effect.Effect< + { + readonly code: string; + readonly redirectUri: string; + }, + DesktopAccountSessionError + >; + } +>()("@t3tools/desktop/app/DesktopAccountSession") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const shell = yield* ElectronShell.ElectronShell; + const lock = yield* Semaphore.make(1); + const file = path.join(environment.stateDir, "otter-account-session.bin"); + const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(OtterAccountSession)); + return DesktopAccountSession.of({ + read: Effect.gen(function* () { + if (!(yield* fs.exists(file))) return null; + return yield* safeStorage.decryptString(yield* fs.readFile(file)); + }).pipe( + Effect.mapError((cause) => new DesktopAccountSessionError({ operation: "read", cause })), + ), + write: (value) => + lock + .withPermits(1)( + Effect.gen(function* () { + if (value === null) { + yield* fs.remove(file, { force: true }); + return; + } + yield* decode(value); + const backend = yield* safeStorage.selectedStorageBackend; + if ( + Option.getOrNull(backend) === "basic_text" || + !(yield* safeStorage.isEncryptionAvailable) + ) + return yield* Effect.fail("encryption_unavailable"); + const encrypted = yield* safeStorage.encryptString(value); + yield* fs.makeDirectory(environment.stateDir, { recursive: true }); + const temporary = `${file}.tmp`; + yield* fs.writeFile(temporary, encrypted, { mode: 0o600 }); + yield* fs.rename(temporary, file); + }), + ) + .pipe( + Effect.mapError((cause) => new DesktopAccountSessionError({ operation: "write", cause })), + ), + authorize: (request) => + Effect.scoped( + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => NodeHttp.createServer()), + (server) => + Effect.sync(() => { + server.closeAllConnections(); + server.close(); + }), + ); + const redirectUri = yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("missing_listener")); + return; + } + server.removeListener("error", reject); + resolve(`http://127.0.0.1:${address.port}/callback`); + }); + }), + catch: (cause) => new DesktopAccountSessionError({ operation: "authorize", cause }), + }); + const callback = new Promise((resolve, reject) => { + server.on("request", (incoming, response) => { + const url = new URL(incoming.url ?? "/", redirectUri); + if ( + incoming.method !== "GET" || + url.pathname !== "/callback" || + url.searchParams.get("state") !== request.state || + url.searchParams.get("iss") !== accountsUrl + ) { + response.writeHead(400); + response.end("Invalid sign-in request."); + return; + } + const code = url.searchParams.get("code"); + response.setHeader("content-type", "text/plain; charset=utf-8"); + response.setHeader("cache-control", "no-store"); + response.end( + code + ? "You can return to Otter Code." + : "Sign-in was cancelled. Return to Otter Code to try again.", + ); + if (code) resolve(code); + else reject(new Error("sign_in_cancelled")); + }); + }); + void callback.catch(() => {}); + yield* shell.openExternal( + accountAuthorizationUrl({ + accountsUrl, + resource: relayUrl, + clientId: ACCOUNT_CLIENT_IDS.desktop, + redirectUri, + ...request, + }), + ); + const code = yield* Effect.tryPromise({ + try: () => callback, + catch: (cause) => new DesktopAccountSessionError({ operation: "authorize", cause }), + }).pipe(Effect.timeout("10 minutes")); + return { code, redirectUri }; + }), + ).pipe( + Effect.mapError( + (cause) => new DesktopAccountSessionError({ operation: "authorize", cause }), + ), + ), + }); +}); + +export const layer = Layer.effect(DesktopAccountSession, make); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index a6906099dbd5..c314012f6146 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -14,7 +14,7 @@ import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; -import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopProtocols from "./DesktopProtocols.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; @@ -22,6 +22,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopThreadLinks from "./DesktopThreadLinks.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; @@ -136,7 +137,7 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (!wasQuitting) { yield* electronDialog.showErrorBox( - "T3 Code failed to start", + "Otter Code failed to start", `Stage: ${stage}\n${message}${detail}`, ); } @@ -179,7 +180,6 @@ const bootstrap = Effect.gen(function* () { ...(environment.isDevelopment ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } : { assetDirectory: environment.clientAssetsDir }), - clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, }); yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); @@ -271,7 +271,7 @@ const startup = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; - const clerk = yield* DesktopClerk.DesktopClerk; + const protocols = yield* DesktopProtocols.DesktopProtocols; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; @@ -320,7 +320,8 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; - yield* clerk.configure; + yield* DesktopThreadLinks.register; + yield* protocols.configure; yield* electronApp.whenReady.pipe( Effect.withSpan("desktop.electron.whenReady"), diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 19f24460106e..891a6f2e8574 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -171,7 +171,10 @@ export async function startDesktopAppControlServer(input: { activeRequestId = parsed.requestId; void input.handle(parsed).then(finish, () => { finish( - invalidResponse(parsed.requestId, "T3 Code could not process the desktop app request."), + invalidResponse( + parsed.requestId, + "Otter Code could not process the desktop app request.", + ), ); }); }); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts index 221df9ca86dd..c70750ce55b5 100644 --- a/apps/desktop/src/app/DesktopAppActivationBroker.ts +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -45,7 +45,7 @@ export class DesktopAppActivationBroker { request(request: DesktopAppActivationRequest): Promise { if (this.#closed) { return Promise.resolve( - failure(request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + failure(request.requestId, "renderer-unavailable", "Otter Code is shutting down."), ); } if (this.#pending.has(request.requestId)) { @@ -90,7 +90,7 @@ export class DesktopAppActivationBroker { failure( pending.request.requestId, "renderer-unavailable", - "The T3 Code window closed before it opened the project.", + "The Otter Code window closed before it opened the project.", ), ); } @@ -103,7 +103,7 @@ export class DesktopAppActivationBroker { cancel(requestId: string): void { this.#settle( - failure(requestId, "renderer-unavailable", "The command closed before T3 Code was ready."), + failure(requestId, "renderer-unavailable", "The command closed before Otter Code was ready."), ); } @@ -112,7 +112,7 @@ export class DesktopAppActivationBroker { this.#renderer = null; for (const pending of this.#pending.values()) { this.#settle( - failure(pending.request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + failure(pending.request.requestId, "renderer-unavailable", "Otter Code is shutting down."), ); } } diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index cc0277d3ed10..6c392e575689 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -22,9 +22,9 @@ const defaultEnvironmentInput = { platform: "darwin", processArch: "arm64", appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/Applications/Otter Code.app/Contents/Resources/app.asar", isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/Applications/Otter Code.app/Contents/Resources", runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; @@ -41,8 +41,9 @@ interface ElectronAppCalls { const layerElectronApp = (calls: ElectronAppCalls) => Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("T3 Code"), + name: Effect.succeed("Otter Code"), systemLocale: Effect.succeed("en-US"), + requestSingleInstanceLock: Effect.succeed(true), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, @@ -132,7 +133,7 @@ const withIdentity = ( input.legacyPathProbeError ? Effect.fail(input.legacyPathProbeError) : Effect.succeed( - input.legacyPathExists === true && /T3 Code \((Alpha|Dev)\)/.test(path), + input.legacyPathExists === true && /Otter Code( \(Dev\))?$/.test(path), ), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), @@ -153,7 +154,7 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const userDataPath = yield* identity.resolveUserDataPath; - assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code-v2"); + assert.equal(userDataPath, "/Users/alice/Library/Application Support/otter-code"); }), { legacyPathExists: true }, ), @@ -165,7 +166,7 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; assert.equal( yield* identity.resolveUserDataPath, - "/Users/alice/Library/Application Support/T3 Code (Dev)", + "/Users/alice/Library/Application Support/Otter Code (Dev)", ); }), { @@ -176,7 +177,7 @@ describe("DesktopAppIdentity", () => { ); it.effect("preserves failures while inspecting the legacy userData path", () => { - const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Dev)"; + const legacyPath = "/Users/alice/Library/Application Support/Otter Code (Dev)"; const cause = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -217,8 +218,8 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code Alpha"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.deepEqual(calls.setName, ["Otter Code"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "Otter Code"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); // Packaged: the bundle's own icon stands, so a custom one the user @@ -238,37 +239,44 @@ describe("DesktopAppIdentity", () => { }); it.effect.each([ - { stage: "Alpha", environment: {} }, + { stage: "Alpha", name: "Otter Code", aboutName: "Otter Code", environment: {} }, { stage: "Nightly", + name: "Otter Code", + aboutName: "Otter Code", environment: { appVersion: "0.0.43-nightly.20260929.2428" }, }, { stage: "Dev", + name: "Otter Code Dev", + aboutName: "Otter Code (Dev)", environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, }, - ])("uses a valid native User-Agent product name for $stage", ({ stage, environment }) => { - const calls: ElectronAppCalls = { - setAboutPanelOptions: [], - setDockIcon: [], - setName: [], - }; + ])( + "uses a valid native User-Agent product name for $stage", + ({ name, aboutName, environment }) => { + const calls: ElectronAppCalls = { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - yield* identity.configure; + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; - const runtimeName = calls.setName[0]; - assert.isDefined(runtimeName); - assert.equal(runtimeName, `T3 Code ${stage}`); - // RFC 9110's token grammar, after Electron removes ASCII spaces. - assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`); - }), - { calls, environment }, - ); - }); + const runtimeName = calls.setName[0]; + assert.isDefined(runtimeName); + assert.equal(runtimeName, name); + // RFC 9110's token grammar, after Electron removes ASCII spaces. + assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, aboutName); + }), + { calls, environment }, + ); + }, + ); it.effect("sets the dock icon only when running unpackaged", () => { const calls: ElectronAppCalls = { diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 395c06141d64..a1cb2cd11985 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -94,9 +94,8 @@ export const make = Effect.gen(function* () { // Electron removes spaces from this name to build the native User-Agent // product token, but leaves parentheses intact. Keep the runtime name valid // without rewriting preview sessions (which breaks Turnstile, #7110). - yield* electronApp.setName( - `${environment.branding.baseName} ${environment.branding.stageLabel}`, - ); + // Otter Code keeps one product name per channel, so only dev's "(Dev)" needs it. + yield* electronApp.setName(environment.displayName.replaceAll(/[()]/g, "")); yield* electronApp.setAboutPanelOptions({ applicationName: environment.displayName, applicationVersion: environment.appVersion, diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts index 6d2179f0256a..1c03b7a64c57 100644 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -17,9 +17,9 @@ const layerEnvironment = DesktopEnvironment.layer({ platform: "darwin", processArch: "arm64", appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/Applications/Otter Code.app/Contents/Resources/app.asar", isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/Applications/Otter Code.app/Contents/Resources", runningUnderArm64Translation: false, }).pipe( Layer.provide( @@ -50,7 +50,7 @@ describe("DesktopAssets", () => { ), ); const layerFileSystem = FileSystem.layerNoop({ - exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")), + exists: (path) => Effect.succeed(String(path).includes("/assets/otter-dev/")), }); const assets = yield* DesktopAssets.DesktopAssets.pipe( Effect.provide( @@ -62,8 +62,11 @@ describe("DesktopAssets", () => { const icons = yield* assets.iconPaths; - assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/); - assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/); + assert.match(Option.getOrThrow(icons.ico), /assets\/otter-dev\/otter-dev-windows\.ico$/); + assert.match( + Option.getOrThrow(icons.png), + /assets\/otter-dev\/otter-dev-universal-1024\.png$/, + ); assert.isTrue(Option.isNone(icons.icns)); }), ); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 9c81b8ab1c04..70cdbb58e64f 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -63,14 +63,16 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func const sourceTreeIconFileNames = { dev: { - ico: "blueprint-windows.ico", - macPng: "blueprint-macos-1024.png", - universalPng: "blueprint-universal-1024.png", + dir: "otter-dev", + ico: "otter-dev-windows.ico", + macPng: "otter-dev-macos-1024.png", + universalPng: "otter-dev-universal-1024.png", }, prod: { - ico: "t3-black-windows.ico", - macPng: "black-macos-1024.png", - universalPng: "black-universal-1024.png", + dir: "otter", + ico: "otter-windows.ico", + macPng: "otter-macos-1024.png", + universalPng: "otter-universal-1024.png", }, } as const; @@ -87,7 +89,7 @@ function resolveSourceTreeIconPath( : environment.platform === "darwin" ? fileNames.macPng : fileNames.universalPng; - return environment.path.join(environment.rootDir, "assets", brand, fileName); + return environment.path.join(environment.rootDir, "assets", fileNames.dir, fileName); } const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index b2305fe9840a..f536107bd25e 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -101,19 +101,19 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const command = yield* commandIn({ home }); - const link = path.join(home, ".local", "bin", "t3"); + const link = path.join(home, ".local", "bin", "otter-code"); expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); // Install writes the launcher itself, even when no local backend ever did. const installed = yield* command.install; expect(installed.installedPath).toBe(link); - expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); + expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "otter-code")); expect((yield* command.install).installedPath).toBe(link); expect((yield* command.uninstall).installedPath).toBeNull(); expect(yield* fs.exists(link)).toBe(false); // The launcher itself stays for setup commands. - expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); + expect(yield* fs.exists(path.join(home, ".t3", "bin", "otter-code"))).toBe(true); }).pipe(Effect.scoped), ); @@ -123,18 +123,20 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const command = yield* commandIn({ home }); - const theirs = path.join(home, ".local", "bin", "t3"); + const theirs = path.join(home, ".local", "bin", "otter-code"); yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); yield* fs.writeFileString(theirs, "npm's t3\n"); // Even a broken link in the next folder is someone else's. yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); - yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); + yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "otter-code")); const error = yield* Effect.flip(command.install); - expect(error.message).toContain("Another t3 command is already installed"); + expect(error.message).toContain("Another otter-code command is already installed"); yield* command.uninstall; expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); - expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + expect(yield* fs.readLink(path.join(home, "bin", "otter-code"))).toBe( + path.join(home, "gone"), + ); }).pipe(Effect.scoped), ); @@ -150,10 +152,10 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* after.state).installedPath).toBe(link); // Installing again points the link at this home's launcher. expect((yield* after.install).installedPath).toBe(link); - expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); + expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "otter-code")); yield* after.uninstall; - expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); - expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false); + expect(yield* fs.exists(path.join(home, ".local", "bin", "otter-code"))).toBe(false); + expect(yield* fs.exists(path.join(home, "bin", "otter-code"))).toBe(false); }).pipe(Effect.scoped), ); @@ -166,11 +168,11 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const binary = path.join(home, "native-t3"); yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); - yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); + yield* fs.symlink(binary, path.join(home, ".local", "bin", "otter-code")); const command = yield* commandIn({ home }); expect((yield* command.state).installedPath).toBeNull(); yield* command.uninstall; - expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); + expect(yield* fs.readLink(path.join(home, ".local", "bin", "otter-code"))).toBe(binary); }).pipe(Effect.scoped), ); @@ -181,22 +183,22 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const home = yield* fs.makeTempDirectoryScoped(); const shadow = path.join(home, "shadow"); yield* fs.makeDirectory(shadow); - yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + yield* fs.writeFileString(path.join(shadow, "otter-code"), "#!/bin/sh\n", { mode: 0o755 }); process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); const command = yield* commandIn({ home }); - const theirs = path.join(shadow, "t3"); + const theirs = path.join(shadow, "otter-code"); expect((yield* command.state).shadowedBy).toBe(theirs); // A link behind it would never run, so nothing is created. const error = yield* Effect.flip(command.install); expect(error.message).toContain(theirs); - expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.exists(path.join(home, ".local", "bin", "otter-code"))).toBe(false); yield* fs.remove(theirs); const installed = yield* command.install; expect(installed).toMatchObject({ onPath: true, - installedPath: path.join(home, ".local", "bin", "t3"), + installedPath: path.join(home, ".local", "bin", "otter-code"), }); expect(installed.shadowedBy).toBeUndefined(); }).pipe(Effect.scoped), @@ -218,7 +220,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { registry.failReads = false; const launcherDir = DesktopCliShim.launcherPath( environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }), - ).replace(/[\\/]t3\.cmd$/, ""); + ).replace(/[\\/]otter-code\.cmd$/, ""); yield* command.install; expect(registry.path).toBe(`${userPath};${launcherDir}`); yield* command.uninstall; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index d79cab92f7cc..6eb7cafa8233 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopCliShim from "./DesktopCliShim.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -// Settings → Install `t3` command, like VS Code's "Install 'code' command". +// Settings → Install `otter-code` command, like VS Code's "Install 'code' command". // The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH // by default. Installing links it into a folder on the user's PATH, or on // Windows adds the launcher's folder to the user's PATH. Removing undoes only @@ -140,7 +140,7 @@ export const make = Effect.gen(function* () { /** The `t3` a new shell runs, by PATH order, or none. */ const firstOnPath = Effect.gen(function* () { for (const directory of pathEntries(process.env.PATH, ":")) { - const candidate = path.join(directory, "t3"); + const candidate = path.join(directory, DesktopCliShim.COMMAND); if (yield* exists(candidate)) return Option.some(candidate); } return Option.none(); @@ -164,7 +164,7 @@ export const make = Effect.gen(function* () { : Option.none(); } for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { - const link = path.join(directory, "t3"); + const link = path.join(directory, DesktopCliShim.COMMAND); if (yield* isOurLink(link)) return Option.some(link); } return Option.none(); @@ -193,14 +193,18 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.flatMap( Option.match({ - onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)), + onNone: () => + Effect.fail( + fail(`Could not set up the ${DesktopCliShim.COMMAND} launcher at ${launcher}.`), + ), onSome: () => Effect.void, }), ), ); const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () { - if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); + if (!environment.isPackaged) + return yield* fail(`The ${DesktopCliShim.COMMAND} command needs an installed app.`); yield* ensureLauncher; if (windows) { const entries = pathEntries(yield* readUserPath, ";"); @@ -208,7 +212,11 @@ export const make = Effect.gen(function* () { yield* writeUserPath([...entries, binDirectory].join(";")); yield* fs .writeFileString(ownedPathMarker, `${binDirectory}\n`) - .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it."))); + .pipe( + Effect.mapError(() => + fail(`Added ${DesktopCliShim.COMMAND} to your PATH but could not record it.`), + ), + ); } return yield* state; } @@ -225,7 +233,7 @@ export const make = Effect.gen(function* () { const shadowedBy = yield* foreignFirstOnPath; if (Option.isSome(shadowedBy)) { return yield* fail( - `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, + `Another ${DesktopCliShim.COMMAND} at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, ); } const onPath = pathEntries(process.env.PATH, ":"); @@ -235,7 +243,7 @@ export const make = Effect.gen(function* () { ...candidates.filter((candidate) => onPath.includes(candidate)), ...candidates.filter((candidate) => !onPath.includes(candidate)), ]) { - const link = path.join(directory, "t3"); + const link = path.join(directory, DesktopCliShim.COMMAND); const created = (yield* exists(directory)) ? yield* writableDirectory(directory) : yield* fs.makeDirectory(directory, { recursive: true }).pipe( @@ -251,7 +259,7 @@ export const make = Effect.gen(function* () { if (linked) return yield* state; } return yield* fail( - `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, + `Another ${DesktopCliShim.COMMAND} command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, ); }).pipe(Effect.withSpan("desktop.cliCommand.install")); @@ -266,7 +274,7 @@ export const make = Effect.gen(function* () { return yield* state; } for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { - const link = path.join(directory, "t3"); + const link = path.join(directory, DesktopCliShim.COMMAND); if (yield* isOurLink(link)) { yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); } diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 5cd942f66ced..0a575a15563a 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -14,6 +14,9 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // so its launcher mounts the AppImage itself instead of pointing into it. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); +/** The command a person types; never `t3`, so it sits beside an installed T3 Code. */ +export const COMMAND = "otter-code"; + export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; /** Server entry inside the app, relative to its server root (an asar archive when packaged). */ @@ -125,12 +128,12 @@ export const renderCliShim = (input: { ].join("\n"); }; -/** Where the packaged app keeps its launcher: `/bin/t3`, `t3.cmd` on Windows. */ +/** Where the packaged app keeps its launcher: `/bin/otter-code`, `otter-code.cmd` on Windows. */ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => environment.path.join( environment.baseDir, "bin", - environment.platform === "win32" ? "t3.cmd" : "t3", + environment.platform === "win32" ? `${COMMAND}.cmd` : COMMAND, ); /** diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b311a9e25ed5..a9752b35a785 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -83,13 +83,13 @@ describe("DesktopEarlyElectronStartup", () => { assert.deepEqual(options, { isDevelopment: true, - linuxWmClass: "t3code-dev", - linuxDesktopEntryName: "com.t3tools.T3Code.Development.desktop", + linuxWmClass: "otter-code-dev", + linuxDesktopEntryName: "dev.otterware.code.dev.desktop", passwordStore: "gnome-libsecret", }); }); - it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { + it("keeps implicit development state under ~/.otter-code/dev when T3CODE_HOME is unset", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ env: { VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", @@ -97,7 +97,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.otter-code/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "kwallet" }); }, }); @@ -114,7 +114,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.otter-code/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); }, }); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index c4adf8f3c75e..57a9ede5e7b7 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -32,7 +32,7 @@ export interface EarlyLinuxElectronOptions { } export const resolveLinuxDesktopEntryName = (isDevelopment: boolean): string => - isDevelopment ? "com.t3tools.T3Code.Development.desktop" : "com.t3tools.T3Code.desktop"; + isDevelopment ? "dev.otterware.code.dev.desktop" : "dev.otterware.code.desktop"; const trimNonEmpty = (value: string | undefined): string | null => { const trimmed = value?.trim(); @@ -160,7 +160,7 @@ export function resolveEarlyLinuxElectronOptions( const isDevelopment = isDevelopmentEnvironment(input.env); return { isDevelopment, - linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + linuxWmClass: isDevelopment ? "otter-code-dev" : "otter-code", linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment), passwordStore: resolveLinuxPasswordStoreSwitch({ preference, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index fb6c5a8b1afd..5befca55d25f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -14,9 +14,9 @@ const defaultInput = { platform: "darwin", processArch: "arm64", appVersion: "0.0.22", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/Applications/Otter Code.app/Contents/Resources/app.asar", isPackaged: false, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/Applications/Otter Code.app/Contents/Resources", runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; @@ -76,9 +76,9 @@ describe("DesktopEnvironment", () => { assert.equal(environment.serverRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); - assert.equal(environment.linuxWmClass, "t3code-dev"); - assert.equal(environment.linuxDesktopEntryName, "com.t3tools.T3Code.Development.desktop"); + assert.equal(environment.appUserModelId, "dev.otterware.code.dev"); + assert.equal(environment.linuxWmClass, "otter-code-dev"); + assert.equal(environment.linuxDesktopEntryName, "dev.otterware.code.dev.desktop"); assert.deepEqual( Option.map(environment.devServerUrl, (url) => url.href), Option.some("http://localhost:5173/"), @@ -150,7 +150,7 @@ describe("DesktopEnvironment", () => { resourcesPath: "/tmp/.mount_t3code/resources", }); - assert.equal(environment.linuxDesktopEntryName, "com.t3tools.T3Code.desktop"); + assert.equal(environment.linuxDesktopEntryName, "dev.otterware.code.desktop"); }), ); @@ -162,8 +162,8 @@ describe("DesktopEnvironment", () => { ); const production = yield* makeEnvironment(); - assert.equal(development.stateDir, "/Users/alice/.t3/dev"); - assert.equal(production.stateDir, "/Users/alice/.t3/userdata"); + assert.equal(development.stateDir, "/Users/alice/.otter-code/dev"); + assert.equal(production.stateDir, "/Users/alice/.otter-code/userdata"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 6a8bc8e0ea49..27bc5528bc65 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -93,7 +93,7 @@ export class DesktopEnvironment extends Context.Service< } >()("@t3tools/desktop/app/DesktopEnvironment") {} -const APP_BASE_NAME = "T3 Code"; +const APP_BASE_NAME = "Otter Code"; function resolveDesktopAppStageLabel(input: { readonly isDevelopment: boolean; @@ -114,7 +114,8 @@ export function resolveDesktopAppBranding(input: { return { baseName: APP_BASE_NAME, stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, + // Otter Code ships one product name per channel; only dev builds carry a suffix. + displayName: input.isDevelopment ? `${APP_BASE_NAME} (Dev)` : APP_BASE_NAME, }; } @@ -236,10 +237,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => - isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + isDevelopment ? "dev.otterware.code.dev" : "dev.otterware.code", ), linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment), - linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + linuxWmClass: isDevelopment ? "otter-code-dev" : "otter-code", linuxApplicationsDir, appImagePath: config.appImagePath, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 5caa18a53aab..5fa5ae08c030 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -32,8 +32,9 @@ function layerElectronApp( return Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("T3 Code"), + name: Effect.succeed("Otter Code"), systemLocale: Effect.succeed("en-US"), + requestSingleInstanceLock: Effect.succeed(true), whenReady: Effect.void, quit, exit: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index cbabe14dd6fc..dd1bc2c1e64c 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -28,7 +28,7 @@ const makeEnvironment = (path: Path.Path, overrides: Record = { platform: "linux", isPackaged: true, isDevelopment: false, - displayName: "T3 Code (Alpha)", + displayName: "Otter Code (Alpha)", linuxDesktopEntryName: "com.t3tools.T3Code.desktop", linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", @@ -160,14 +160,14 @@ const emptyRecording = (): RecordedRegistration => ({ describe("DesktopLinuxUrlHandler", () => { it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code (Nightly)", + displayName: "Otter Code (Nightly)", execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', - scheme: "t3code", + scheme: "ottercode", iconPath: "/home/al ice/icons/T3\\x.png", }); assert.include(entry, "[Desktop Entry]"); - assert.include(entry, "Name=T3 Code (Nightly)"); + assert.include(entry, "Name=Otter Code (Nightly)"); // Exec composes both escaping layers: a literal backslash becomes four // backslashes in the file, a quote three characters, a dollar sign two // backslashes plus the sign. @@ -177,20 +177,20 @@ describe("DesktopLinuxUrlHandler", () => { ); assert.include(entry, "NoDisplay=true"); assert.notInclude(entry, "StartupWMClass="); - assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + assert.include(entry, "MimeType=x-scheme-handler/ottercode;"); assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); it("carries structured context on registration errors", () => { const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "write-desktop-entry", - scheme: "t3code", + scheme: "ottercode", desktopEntryPath: "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", cause: new Error("boom"), }); assert.equal( writeError.message, - "Failed to register the t3code:// URL handler (step: write-desktop-entry).", + "Failed to register the ottercode:// URL handler (step: write-desktop-entry).", ); assert.equal( writeError.desktopEntryPath, @@ -199,12 +199,12 @@ describe("DesktopLinuxUrlHandler", () => { const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "set-default-handler", - scheme: "t3code", + scheme: "ottercode", exitCode: 4, }); assert.equal( exitError.message, - "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", + "Failed to register the ottercode:// URL handler (step: set-default-handler, xdg-mime exit code 4).", ); }); @@ -226,7 +226,7 @@ describe("DesktopLinuxUrlHandler", () => { recorded.files[0]?.content, 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', ); - assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/ottercode;"); assert.deepEqual(recorded.commands, [ { command: "update-desktop-database", @@ -234,7 +234,7 @@ describe("DesktopLinuxUrlHandler", () => { }, { command: "xdg-mime", - args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/ottercode"], }, ]); }); @@ -260,9 +260,9 @@ describe("DesktopLinuxUrlHandler", () => { return Effect.gen(function* () { yield* runRegister(recorded, { existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code (Alpha)", + displayName: "Otter Code (Alpha)", execTarget: "/home/alice/Applications/T3-Code.AppImage", - scheme: "t3code", + scheme: "ottercode", iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png", }), }); @@ -276,7 +276,7 @@ describe("DesktopLinuxUrlHandler", () => { }, { command: "xdg-mime", - args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/ottercode"], }, ]); }); @@ -289,9 +289,9 @@ describe("DesktopLinuxUrlHandler", () => { yield* runRegister(recorded, { iconSource: "/tmp/.mount_T3/resources/icon.png", existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code (Alpha)", + displayName: "Otter Code (Alpha)", execTarget: "/home/alice/Applications/T3-Code.AppImage", - scheme: "t3code", + scheme: "ottercode", iconPath, }), }); diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts index 542f6b987594..82bff871ab30 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts @@ -22,12 +22,12 @@ it.layer(NodeServices.layer)("DesktopPreReadyFileSystem", (it) => { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pre-ready-fs-" }); - yield* fileSystem.makeDirectory(path.join(root, "T3 Code (Alpha)")); - yield* fileSystem.writeFileString(path.join(root, "T3 Code (Alpha)", "Local State"), "keys"); + yield* fileSystem.makeDirectory(path.join(root, "Otter Code")); + yield* fileSystem.writeFileString(path.join(root, "Otter Code", "Local State"), "keys"); const userData = yield* resolveWindowsUserData(root); - assert.equal(userData, path.join(root, "t3code-v2")); + assert.equal(userData, path.join(root, "otter-code")); assert.equal(yield* fileSystem.readFileString(path.join(userData, "Local State")), "keys"); }), ); diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 8ce58f7ebbb3..d5ea5e50a93d 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Effect's Node FileSystem is async, and pre-ready startup must not yield before the Clerk bridge registers its privileged scheme. +// @effect-diagnostics nodeBuiltinImport:off -- Effect's Node FileSystem is async, and pre-ready startup must not yield before the Otter Accounts bridge registers its privileged scheme. import * as NodeFS from "node:fs"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 29fa706698e0..52cc5faa253c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -151,13 +151,13 @@ describe("DesktopPreReadyPlatform", () => { let desktopEntry = previousEntry; let iconInstalled = false; copyFileSyncMock.mockImplementation((_source: string, destination: string) => { - iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; + iconInstalled = destination === "/xdg/icons/dev.otterware.code.desktop.png"; }); setDesktopNameMock.mockImplementation((name: string) => { desktopName = name; }); writeFileSyncMock.mockImplementation((path: string, contents: string) => { - if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; + if (path === "/xdg/applications/dev.otterware.code.desktop") desktopEntry = contents; }); return Effect.scoped( @@ -173,13 +173,13 @@ describe("DesktopPreReadyPlatform", () => { ), ); const identity = yield* Effect.promise(() => portalIdentity); - assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); + assert.equal(identity.desktopName, "dev.otterware.code.desktop"); assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); - assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); - assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); + assert.include(identity.desktopEntry ?? "", "Name=Otter Code"); + assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/ottercode;"); assert.include( identity.desktopEntry ?? "", - "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", + "Icon=/xdg/icons/dev.otterware.code.desktop.png", ); assert.isTrue(identity.iconInstalled); }), @@ -206,18 +206,18 @@ describe("DesktopPreReadyPlatform", () => { return Effect.gen(function* () { yield* DesktopPreReadyPlatform.make; const contents = writeFileSyncMock.mock.calls[0]?.[1]; - assert.include(contents, "MimeType=x-scheme-handler/t3code;"); + assert.include(contents, "MimeType=x-scheme-handler/ottercode;"); assert.include(contents, "Icon="); assert.equal(setDesktopNameMock.mock.calls.length, 1); }).pipe(Effect.provideService(HostProcess.Platform, "linux")); }); it.effect( - "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", + "acquires a synchronous pre-ready layer before an asynchronous Otter account-shaped layer", () => Effect.gen(function* () { - class ClerkShaped extends Context.Service()( - "@t3tools/desktop/app/DesktopPreReadyPlatform.test/ClerkShaped", + class ProtocolSetup extends Context.Service()( + "@t3tools/desktop/app/DesktopPreReadyPlatform.test/ProtocolSetup", ) {} const events: Array = []; @@ -229,34 +229,34 @@ describe("DesktopPreReadyPlatform", () => { Layer.provide(Layer.succeed(HostProcess.Platform, "darwin")), ); - const layerClerkShaped = Layer.effect( - ClerkShaped, + const layerProtocolSetup = Layer.effect( + ProtocolSetup, Effect.promise(() => Promise.resolve()).pipe( Effect.map(() => { - events.push("clerk"); + events.push("account"); return { ready: true as const }; }), ), ); - const layerRuntime = layerClerkShaped.pipe( - Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)), + const layerRuntime = layerProtocolSetup.pipe( + Layer.flatMap((protocolContext) => Layer.succeedContext(protocolContext)), Layer.provideMerge(layerPreReady), ); const result = yield* Effect.all({ - clerk: ClerkShaped, + account: ProtocolSetup, preReady: DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, }).pipe(Effect.provide(layerRuntime)); assert.deepEqual(result, { - clerk: { ready: true }, + account: { ready: true }, preReady: { linux: null, linuxPasswordStoreCommandLine: null, }, }); - assert.deepEqual(events, ["pre-ready", "clerk"]); + assert.deepEqual(events, ["pre-ready", "account"]); assert.equal(registerSchemesMock.mock.calls.length, 1); assert.equal(appendSwitchMock.mock.calls.length, 0); assert.equal(setDesktopNameMock.mock.calls.length, 0); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopProtocols.test.ts similarity index 59% rename from apps/desktop/src/app/DesktopClerk.test.ts rename to apps/desktop/src/app/DesktopProtocols.test.ts index b3aa2d14a0f7..2b059dbbf6a8 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopProtocols.test.ts @@ -5,28 +5,9 @@ import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/code import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; - -const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ - createClerkBridgeMock: vi.fn(), - storageAdapter: { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }, - storageMock: vi.fn(), -})); - -vi.mock("@clerk/electron", () => ({ - createClerkBridge: createClerkBridgeMock, -})); - -vi.mock("@clerk/electron/storage", () => ({ - storage: storageMock, -})); +import { vi } from "vite-plus/test"; import * as Option from "effect/Option"; import * as Exit from "effect/Exit"; @@ -34,24 +15,24 @@ import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopProtocols from "./DesktopProtocols.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; -/** Clerk forwards web links; tests that are not about links ignore them. */ +/** Tests that are not about web links ignore them. */ const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({ receive: () => Effect.void, setRendererReady: () => Effect.void, }); -const layerDesktopClerk = ( +const layerDesktopProtocols = ( isDevelopment = true, events: string[] = [], platform: NodeJS.Platform = "darwin", fileSystemLayer: Layer.Layer = FileSystem.layerNoop({ exists: () => Effect.succeed(false), }), + primary = true, shell: ElectronShell.ElectronShell["Service"] = { openExternal: () => Effect.succeed(true), openSystemSettings: () => Effect.succeed(false), @@ -66,13 +47,14 @@ const layerDesktopClerk = ( } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); const electronApp = { + requestSingleInstanceLock: Effect.succeed(primary), setPath: (name: string, value: string) => Effect.sync(() => { events.push(`setPath:${name}:${value}`); }), } as unknown as ElectronApp.ElectronApp["Service"]; - return DesktopClerk.layer.pipe( + return DesktopProtocols.layer.pipe( Layer.provide( Layer.mergeAll( NodePath.layerPosix, @@ -85,131 +67,8 @@ const layerDesktopClerk = ( ); }; -describe("DesktopClerk", () => { - beforeEach(() => { - createClerkBridgeMock.mockReset(); - storageMock.mockReset(); - }); - - it.effect("acquires and releases the SDK bridge with the layer", () => { - const cleanup = vi.fn(); - const events: string[] = []; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementation(() => { - events.push("createClerkBridge"); - return { cleanup, isPrimaryInstance: true }; - }); - - return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(layerDesktopClerk(true, events))); - - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - passkeys: true, - renderer: { scheme: "t3code-dev", host: "app" }, - }, - ], - ]); - assert.equal(cleanup.mock.calls.length, 1); - // The bridge acquires Electron's single-instance lock at creation, and - // the lock both lives in and creates the userData directory — so the - // real path must be set before the bridge exists. - assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); - }); - - it.each([ - { - name: "packaged Windows", - isDevelopment: false, - platform: "win32" as const, - userData: "/tmp/app-data/t3code-v2", - }, - { - name: "development", - isDevelopment: true, - platform: "win32" as const, - userData: "/tmp/app-data/t3code-dev", - }, - ])( - "creates the bridge before startup can yield to the event loop ($name)", - ({ isDevelopment, platform, userData }) => { - const events: string[] = []; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementation(() => { - events.push("createClerkBridge"); - return { cleanup: vi.fn(), isPrimaryInstance: true }; - }); - // runSync throws if the layer ever suspends, which would let Electron emit - // ready before the bridge exists. main.ts provides the same FileSystem. - // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- The assertion IS that the layer builds synchronously; it.effect would mask a regression to async. - Effect.runSync( - Effect.scoped( - Layer.build( - layerDesktopClerk(isDevelopment, events, platform, DesktopPreReadyFileSystem.layer), - ), - ), - ); - - assert.deepEqual(events, [`setPath:userData:${userData}`, "createClerkBridge"]); - }, - ); - - it.effect("preserves bridge initialization failures", () => { - const cause = new Error("bridge initialization failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementationOnce(() => { - throw cause; - }); - - return Effect.gen(function* () { - const error = yield* Effect.scoped(Layer.build(layerDesktopClerk())).pipe(Effect.flip); - - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, true); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', - ); - }); - }); - - it.effect("preserves bridge cleanup failures", () => { - const cause = new Error("bridge cleanup failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ - cleanup: () => { - throw cause; - }, - }); - - return Effect.gen(function* () { - const exit = yield* Effect.exit(Effect.scoped(Layer.build(layerDesktopClerk(false)))); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, false); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', - ); - } - }); - }); - +describe("DesktopProtocols", () => { it.effect("registers the second-instance handler in the primary instance", () => { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const quit = vi.fn(); const registeredEvents: string[] = []; const electronApp = { @@ -222,14 +81,14 @@ describe("DesktopClerk", () => { const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + const protocols = yield* DesktopProtocols.DesktopProtocols; + const exit = yield* Effect.exit(Effect.scoped(protocols.configure)); assert.isTrue(Exit.isSuccess(exit)); assert.equal(quit.mock.calls.length, 0); assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]); }).pipe( - Effect.provide(layerDesktopClerk()), + Effect.provide(layerDesktopProtocols()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), @@ -237,8 +96,6 @@ describe("DesktopClerk", () => { }); it.effect("quits and interrupts startup in a secondary instance", () => { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: false }); const quit = vi.fn(); const registeredEvents: string[] = []; const electronApp = { @@ -251,14 +108,14 @@ describe("DesktopClerk", () => { const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + const protocols = yield* DesktopProtocols.DesktopProtocols; + const exit = yield* Effect.exit(Effect.scoped(protocols.configure)); assert.isTrue(Exit.hasInterrupts(exit)); assert.equal(quit.mock.calls.length, 1); assert.deepEqual(registeredEvents, []); }).pipe( - Effect.provide(layerDesktopClerk()), + Effect.provide(layerDesktopProtocols(true, [], "darwin", undefined, false)), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), @@ -267,10 +124,8 @@ describe("DesktopClerk", () => { }); it.effect( - "provider auth deep links navigate and reveal the running desktop without handling Clerk URLs", + "provider auth deep links navigate and reveal the running desktop and ignore unrelated callbacks", () => { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const listeners = new Map void>(); const revealed = Promise.withResolvers(); const loadURL = vi.fn(async (_url: string) => undefined); @@ -286,26 +141,26 @@ it.effect( reveal: () => Effect.sync(() => revealed.resolve()), } as unknown as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - yield* clerk.configure; + const protocols = yield* DesktopProtocols.DesktopProtocols; + yield* protocols.configure; const event = { preventDefault: vi.fn() }; - listeners.get("open-url")!(event, "t3code-dev://app/auth/callback?code=clerk-code"); - listeners.get("open-url")!(event, "t3code://app/welcome"); + listeners.get("open-url")!(event, "ottercode-dev://app/auth/callback?code=unrelated-code"); + listeners.get("open-url")!(event, "ottercode://app/welcome"); assert.equal(loadURL.mock.calls.length, 0); assert.equal(event.preventDefault.mock.calls.length, 0); listeners.get("second-instance")!({}, [ "t3", - "t3code-dev://app/settings/providers?instanceId=work&code=never-forward", + "ottercode-dev://app/settings/providers?instanceId=work&code=never-forward", ]); yield* Effect.promise(() => revealed.promise); assert.deepEqual(loadURL.mock.calls, [ - ["t3code-dev://app/settings/providers?instanceId=work"], + ["ottercode-dev://app/settings/providers?instanceId=work"], ]); - listeners.get("open-url")!(event, "t3code-dev://app/welcome#agents:machine-id"); + listeners.get("open-url")!(event, "ottercode-dev://app/welcome#agents:machine-id"); assert.equal(event.preventDefault.mock.calls.length, 1); }).pipe( Effect.scoped, - Effect.provide(layerDesktopClerk()), + Effect.provide(layerDesktopProtocols()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), @@ -317,8 +172,6 @@ it.effect.each(["startup", "open-url"] as const)( "receives hosted web sign-in through the desktop %s handler", (entry) => Effect.gen(function* () { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const port = yield* Effect.promise(async () => { const server = NodeHttp.createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -338,7 +191,7 @@ it.effect.each(["startup", "open-url"] as const)( }).toString(); const request = { authorizationUrl: authorize.toString(), - returnUrl: "https://app.t3.codes/welcome#agents:remote-one", + returnUrl: "https://code.otterware.app/welcome#agents:remote-one", environmentId: EnvironmentId.make("remote-one"), instanceId: ProviderInstanceId.make("work"), flowId: "flow-one", @@ -371,8 +224,8 @@ it.effect.each(["startup", "open-url"] as const)( }), } as unknown as ElectronApp.ElectronApp["Service"]; yield* Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - yield* clerk.configure; + const protocols = yield* DesktopProtocols.DesktopProtocols; + yield* protocols.configure; if (entry === "open-url") { const event = { preventDefault: vi.fn() }; listeners.get("open-url")!(event, link); @@ -384,7 +237,7 @@ it.effect.each(["startup", "open-url"] as const)( assert.strictEqual(delivery?.flowId, request.flowId); assert.strictEqual(delivery?.returnUrl, request.returnUrl); }).pipe( - Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, shell)), + Effect.provide(layerDesktopProtocols(true, [], "darwin", undefined, true, shell)), Effect.provideService(HostProcess.Arguments, entry === "startup" ? ["t3", link] : ["t3"]), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService( @@ -398,8 +251,6 @@ it.effect.each(["startup", "open-url"] as const)( it.effect("hands web and app links to the renderer and leaves other links alone", () => Effect.gen(function* () { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const listeners = new Map void>(); const electronApp = { whenReady: Effect.void, @@ -410,8 +261,8 @@ it.effect("hands web and app links to the renderer and leaves other links alone" } as unknown as ElectronApp.ElectronApp["Service"]; const received: Array = []; yield* Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - yield* clerk.configure; + const protocols = yield* DesktopProtocols.DesktopProtocols; + yield* protocols.configure; const open = (url: string) => { const event = { preventDefault: vi.fn() }; listeners.get("open-url")!(event, url); @@ -421,20 +272,20 @@ it.effect("hands web and app links to the renderer and leaves other links alone" assert.strictEqual(open("https://example.com/page"), 1); assert.strictEqual(open("http://localhost:3000/"), 1); // Prompt and thread links ride the same queue; the renderer routes them in the app. - assert.strictEqual(open("t3code://new?prompt=hi"), 1); - assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); + assert.strictEqual(open("ottercode://new?prompt=hi"), 1); + assert.strictEqual(open("ottercode://threads/env-1/thread-1"), 1); // Anything else is not a web page; Electron keeps its own handling. assert.strictEqual(open("mailto:hello@example.com"), 0); - assert.strictEqual(open("t3code://somewhere-else"), 0); + assert.strictEqual(open("ottercode://somewhere-else"), 0); yield* Effect.yieldNow; assert.deepStrictEqual(received, [ "https://example.com/page", "http://localhost:3000/", - "t3code://new?prompt=hi", - "t3code://threads/env-1/thread-1", + "ottercode://new?prompt=hi", + "ottercode://threads/env-1/thread-1", ]); }).pipe( - Effect.provide(layerDesktopClerk(true, [], "darwin")), + Effect.provide(layerDesktopProtocols(true, [], "darwin")), Effect.provideService(HostProcess.Arguments, ["t3"]), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService( diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopProtocols.ts similarity index 61% rename from apps/desktop/src/app/DesktopClerk.ts rename to apps/desktop/src/app/DesktopProtocols.ts index 10eb76d7d9a5..bfcef7f4800c 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopProtocols.ts @@ -1,11 +1,8 @@ -import { createClerkBridge } from "@clerk/electron"; import * as NodeURL from "node:url"; -import { storage } from "@clerk/electron/storage"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff"; @@ -14,7 +11,6 @@ import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -22,36 +18,8 @@ import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; -declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; - -export class DesktopClerkBridgeInitializationError extends Schema.TaggedError()( - "DesktopClerkBridgeInitializationError", - { - stateDir: Schema.String, - isDevelopment: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; - } -} - -export class DesktopClerkBridgeCleanupError extends Schema.TaggedError()( - "DesktopClerkBridgeCleanupError", - { - stateDir: Schema.String, - isDevelopment: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; - } -} - -export class DesktopClerk extends Context.Service< - DesktopClerk, +export class DesktopProtocols extends Context.Service< + DesktopProtocols, { readonly configure: Effect.Effect< void, @@ -62,37 +30,7 @@ export class DesktopClerk extends Context.Service< | Scope.Scope >; } ->()("@t3tools/desktop/app/DesktopClerk") {} - -function resolveDesktopClerkFrontendApiHostname( - publishableKey: string | undefined, -): string | undefined { - const normalizedKey = publishableKey?.trim(); - if (!normalizedKey) return undefined; - - try { - return clerkFrontendApiHostnameFromPublishableKey(normalizedKey); - } catch { - return undefined; - } -} - -export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname( - typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" - ? undefined - : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, -); - -function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { - return createClerkBridge({ - storage: storage({ path: stateDir }), - passkeys: true, - renderer: { - scheme: ElectronProtocol.getDesktopScheme(isDevelopment), - host: ElectronProtocol.DESKTOP_HOST, - }, - }); -} +>()("@t3tools/desktop/app/DesktopProtocols") {} /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { @@ -100,34 +38,14 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const shell = yield* ElectronShell.ElectronShell; - // The SDK bridge acquires Electron's profile-scoped single-instance lock. - // Must not yield: the bridge registers a scheme Electron rejects once ready. + // The application acquires Electron's profile-scoped single-instance lock. + // Set the profile before acquiring its lock or Electron becomes ready. const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); - const bridge = yield* Effect.acquireRelease( - Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), - catch: (cause) => - new DesktopClerkBridgeInitializationError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, - }), - }), - (bridge) => - Effect.try({ - try: () => bridge.cleanup(), - catch: (cause) => - new DesktopClerkBridgeCleanupError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, - }), - }).pipe(Effect.orDie), - ); + const primary = yield* electronApp.requestSingleInstanceLock; - return DesktopClerk.of({ + return DesktopProtocols.of({ configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -135,12 +53,8 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - // The SDK bridge holds Electron's single-instance lock (acquired at - // bridge creation) so OAuth deep-link callbacks on Windows/Linux are - // forwarded to the running app. In a secondary instance the bridge has - // already begun quitting the app; app.quit() is asynchronous, so stop - // bootstrap here before whenReady can fire. - if (!bridge.isPrimaryInstance) { + // Secondary instances hand their URL to the running application. + if (!primary) { yield* electronApp.quit; return yield* Effect.interrupt; } @@ -189,7 +103,7 @@ export const make = Effect.gen(function* () { ); return true; }; - // Prompt links and thread links (`t3code://new?…`, `t3code://threads/…`) reach the + // Prompt links and thread links (`ottercode://new?…`, `ottercode://threads/…`) reach the // renderer through the same queue as web links; it routes them in the app. const openAppLink = (value: string | undefined) => { if (!value || promptLinkAppPath(value) === null) return false; @@ -198,7 +112,7 @@ export const make = Effect.gen(function* () { }; const args = yield* HostProcess.Arguments; args.some((value) => startProviderAuthHandoff(value) || openAppLink(value)); - // As the default browser, macOS hands T3 Code every web link through the same event. + // As the default browser, macOS hands Otter Code every web link through the same event. const openWebLink = (url: string) => { if (!DesktopWebLinks.isWebLink(url)) return false; void runPromise(webLinks.receive(url)); @@ -234,8 +148,8 @@ export const make = Effect.gen(function* () { }), ); }); - }).pipe(Effect.withSpan("desktop.clerk.configure")), + }).pipe(Effect.withSpan("desktop.protocols.configure")), }); }); -export const layer = Layer.effect(DesktopClerk, make); +export const layer = Layer.effect(DesktopProtocols, make); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts index 006dd97092d4..01d0ffb5fdbf 100644 --- a/apps/desktop/src/app/DesktopStatePaths.ts +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -16,7 +16,7 @@ export function resolveDesktopBaseDir(input: { readonly t3Home: Option.Option; }): string { return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => - input.joinPath(input.homeDirectory, ".t3"), + input.joinPath(input.homeDirectory, ".otter-code"), ); } diff --git a/apps/desktop/src/app/DesktopThreadLinks.test.ts b/apps/desktop/src/app/DesktopThreadLinks.test.ts new file mode 100644 index 000000000000..16e1661571c6 --- /dev/null +++ b/apps/desktop/src/app/DesktopThreadLinks.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { threadPathFromDeepLink } from "./DesktopThreadLinks.ts"; + +describe("threadPathFromDeepLink", () => { + it("reads a thread link for this app's scheme", () => { + expect(threadPathFromDeepLink("ottercode://app/env-1/linear-session%3Aabc", "ottercode")).toBe( + "/env-1/linear-session%3Aabc", + ); + }); + + it("ignores unrelated callbacks, other schemes, and other paths", () => { + for (const url of [ + "ottercode://app/", + "ottercode://app/?code=1", + "ottercode-dev://app/env-1/thread-1", + "ottercode://other/env-1/thread-1", + "ottercode://app/env-1/thread-1/extra", + "/Applications/Otter Code.app", + "--inspect", + ]) { + expect(threadPathFromDeepLink(url, "ottercode"), url).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopThreadLinks.ts b/apps/desktop/src/app/DesktopThreadLinks.ts new file mode 100644 index 000000000000..114ee595729d --- /dev/null +++ b/apps/desktop/src/app/DesktopThreadLinks.ts @@ -0,0 +1,74 @@ +import * as Effect from "effect/Effect"; +import * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { DESKTOP_HOST, getDesktopScheme } from "../electron/ElectronProtocol.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +/** Tells an open window to take the pending link; see `takePendingThreadLink`. */ +const OPEN_THREAD_LINK_ACTION = "open-thread-link"; + +/** + * `ottercode://app//` as the in-app thread path, or + * null for any other URL. Account sign-in uses a separate loopback callback. + */ +export function threadPathFromDeepLink(url: string, scheme: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== `${scheme}:` || parsed.host !== DESKTOP_HOST) return null; + const segments = parsed.pathname.split("/").filter((segment) => segment.length > 0); + if (segments.length !== 2) return null; + return `/${segments.map((segment) => encodeURIComponent(decodeURIComponent(segment))).join("/")}`; + } catch { + return null; + } +} + +// The window may not exist yet when a link launches the app, so the renderer +// pulls the link once it can navigate instead of being sent it. +let pendingThreadPath: string | null = null; + +export function takePendingThreadLink(): string | null { + const path = pendingThreadPath; + pendingThreadPath = null; + return path; +} + +/** + * Opens threads from `ottercode://` links, such as Linear's "Open in Otter + * Code". macOS delivers them through `open-url`; Windows and Linux start the + * app, or a second instance, with the link as an argument. + */ +export const register = Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const scheme = getDesktopScheme(environment.isDevelopment); + + const receive = (url: string): boolean => { + const path = threadPathFromDeepLink(url, scheme); + if (path === null) return false; + pendingThreadPath = path; + // Before `ready` no window can exist; the renderer takes the link on start. + if (Electron.app.isReady()) { + void runPromise( + desktopWindow.dispatchMenuAction(OPEN_THREAD_LINK_ACTION).pipe(Effect.ignoreCause), + ); + } + return true; + }; + + for (const argument of process.argv) receive(argument); + yield* electronApp.on("open-url", (event: Electron.Event, url: string) => { + if (receive(url)) event.preventDefault(); + }); + yield* electronApp.on( + "second-instance", + (_event: Electron.Event, argv: ReadonlyArray) => { + for (const argument of argv) receive(argument); + }, + ); +}); diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 41e497463a0d..f977beec5ea3 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -8,7 +8,7 @@ import * as PlatformError from "effect/PlatformError"; import { resolveUserDataPath } from "./DesktopUserData.ts"; it.effect("identifies a failed source read and preserves its cause", () => { - const sourceState = "/profiles/t3code/Local State"; + const sourceState = "/profiles/Otter Code/Local State"; const cause = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -37,7 +37,7 @@ it.effect("identifies a failed source read and preserves its cause", () => { ); }); -it.effect.each(["t3code", "T3 Code (Alpha)"])( +it.effect.each(["Otter Code"])( "preserves Windows credential keys from %s without copying browser databases", (sourceName) => Effect.gen(function* () { @@ -45,9 +45,9 @@ it.effect.each(["t3code", "T3 Code (Alpha)"])( const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); const source = path.join(directory, sourceName); - const destination = path.join(directory, "t3code-v2"); + const destination = path.join(directory, "otter-code"); const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; - yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); + yield* fs.makeDirectory(path.join(directory, "Otter Code"), { recursive: true }); yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); yield* fs.writeFileString(path.join(source, "Local State"), state); yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index 3306b244dc72..2c6c9262135b 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -41,8 +41,8 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const names = input.isDevelopment - ? { current: "t3code-dev", legacy: "T3 Code (Dev)" } - : { current: "t3code-v2", legacy: "T3 Code (Alpha)" }; + ? { current: "otter-code-dev", legacy: "Otter Code (Dev)" } + : { current: "otter-code", legacy: "Otter Code" }; const destinationPath = path.join(input.appDataDirectory, names.current); const legacyPath = path.join(input.appDataDirectory, names.legacy); const inspect = (resourcePath: string) => @@ -63,7 +63,7 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa const legacyState = path.join(legacyPath, "Local State"); const sourceState = (yield* inspect(legacyState)) ? legacyState - : path.join(input.appDataDirectory, "t3code", "Local State"); + : path.join(input.appDataDirectory, "otter-code", "Local State"); if (!(yield* inspect(sourceState))) return destinationPath; // Windows safeStorage keys live here. Copy only these preferences, never locked databases. const state = yield* fs diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 139c0ef49bbc..6a49bbd3504f 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -245,7 +245,7 @@ export const layer = Layer.effect( ); yield* electronDialog.showErrorBox( "WSL backend is still unavailable", - `${reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.`, + `${reason}\n\nOtter Code will use the Windows backend for this launch and retry WSL the next time the app starts.`, ); yield* appSettings.applyWslWindowsFallbackInMemory; return true; @@ -256,7 +256,7 @@ export const layer = Layer.effect( }); yield* electronDialog.showErrorBox( "WSL backend couldn't start", - `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, + `${reason}\n\nFalling back to the Windows backend so Otter Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, ); // Fully disable the WSL backend — both flags, matching the "Switch to // Windows" recovery path — so the manager's next restart re-resolves the diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index d137d502e6c5..d32587800193 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -104,7 +104,7 @@ export const make = Effect.gen(function* () { httpBaseUrl: config.httpBaseUrl.href, credential, clientMetadata: { - label: "T3 Code Desktop", + label: "Otter Code Desktop", deviceType: "desktop", }, }).pipe( diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 84f37b6f5f53..c535dcda7e8d 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -63,7 +63,7 @@ vi.mock("electron", () => ({ getSystemLocale: getSystemLocaleMock, getVersion: getVersionMock, isPackaged: true, - name: "T3 Code", + name: "Otter Code", on: onMock, quit: quitMock, relaunch: relaunchMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index f75ea51552e7..afb19e042eaf 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -52,6 +52,7 @@ export class ElectronApp extends Context.Service< readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly quit: Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; readonly exit: (code: number) => Effect.Effect; readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; readonly setPath: ( @@ -126,6 +127,7 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), + requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), // macOS derives this from NSLocale, which uses POSIX-style identifiers // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so // the tag is normalized here rather than in the renderer that consumes it. diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 3f0be7cc7b6d..f11cdeccec0c 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -44,7 +44,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code", assetDirectory: directory, - clerkFrontendApiHostname: undefined, }); const request = (pathname: string, init?: RequestInit) => Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); @@ -84,7 +83,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -103,7 +101,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -147,7 +145,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); }), @@ -174,7 +171,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:5733/"), - clerkFrontendApiHostname: undefined, }); const fiber = yield* Effect.forkChild( Effect.promise(() => handler!(new Request("t3code-dev://app/"))), @@ -207,7 +203,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:5733/"), - clerkFrontendApiHostname: undefined, }); const fiber = yield* Effect.forkChild( Effect.promise(() => @@ -245,7 +240,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -269,7 +263,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: undefined, }), ), ); @@ -289,7 +282,6 @@ describe("ElectronProtocol", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( policy.split("; ").map((directive) => { @@ -298,13 +290,7 @@ describe("ElectronProtocol", () => { }), ); - assert.deepEqual(directives["script-src"], [ - "'self'", - "'unsafe-inline'", - "'wasm-unsafe-eval'", - "https://clerk.t3.codes", - "https://challenges.cloudflare.com", - ]); + assert.deepEqual(directives["script-src"], ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'"]); assert.deepEqual(directives["connect-src"], [ "'self'", "blob:", diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7b2bf8a59fc9..30d56dd4302f 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -13,8 +13,8 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; export const DESKTOP_HOST = "app"; -const DESKTOP_PRODUCTION_SCHEME = "t3code"; -const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; +const DESKTOP_PRODUCTION_SCHEME = "ottercode"; +const DESKTOP_DEVELOPMENT_SCHEME = "ottercode-dev"; export function getDesktopScheme(isDevelopment: boolean): string { return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; @@ -56,7 +56,6 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError()("@t3tools/desktop/electron/ElectronProtocol") {} export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { - const clerkOrigin = input.clerkFrontendApiHostname - ? `https://${input.clerkFrontendApiHostname}` - : undefined; - const scriptSources = [ - "'self'", - "'unsafe-inline'", - "'wasm-unsafe-eval'", - ...(clerkOrigin ? [clerkOrigin] : []), - "https://challenges.cloudflare.com", - ]; + const scriptSources = ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'"]; // The renderer connects directly to user-configured environments in addition to - // the build-configured Clerk, relay, and OTLP endpoints. Those environment + // the build-configured account, relay, and OTLP endpoints. Those environment // origins are not known when this response policy is created, so restrict // connections by the network schemes the client supports instead of by host. // GLTFLoader fetches embedded textures through blob URLs after parsing the model. diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab..310964e2291d 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -97,20 +97,21 @@ export const make = ElectronUpdater.of({ autoUpdater.autoInstallOnAppQuit = value; return Effect.void; }), - setChannel: (channel) => + setChannel: (_channel) => Effect.suspend(() => { - autoUpdater.channel = channel; + // The bridge follows one profile feed, whose manifest is always latest. + autoUpdater.channel = "latest"; return Effect.void; }), - setAllowPrerelease: (value) => + setAllowPrerelease: (_value) => Effect.suspend(() => { - autoUpdater.allowPrerelease = value; + autoUpdater.allowPrerelease = true; return Effect.void; }), allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => + setAllowDowngrade: (_value) => Effect.suspend(() => { - autoUpdater.allowDowngrade = value; + autoUpdater.allowDowngrade = false; return Effect.void; }), setFullChangelog: (value) => diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index ef94312ed6f0..a13066790427 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,7 @@ function makeWindowsRevealWindow() { show: vi.fn(), moveTop: vi.fn(), focus: vi.fn(), - getTitle: vi.fn(() => "T3 Code (Dev)"), + getTitle: vi.fn(() => "Otter Code (Dev)"), getBounds: vi.fn(() => ({ x: 100, y: 50, width: 1_200, height: 800 })), getContentBounds: vi.fn(() => ({ x: 108, y: 50, width: 1_184, height: 792 })), getNativeWindowHandle: vi.fn(() => Buffer.from([41, 0, 0, 0])), @@ -116,7 +116,7 @@ describe("ElectronWindow", () => { throw cause; }); const options = { - title: "T3 Code", + title: "Otter Code", width: 1100, height: 780, minWidth: 840, @@ -143,7 +143,7 @@ describe("ElectronWindow", () => { assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); assert.deepEqual(error.options, { - title: "T3 Code", + title: "Otter Code", width: 1100, height: 780, minWidth: 840, @@ -166,7 +166,10 @@ describe("ElectronWindow", () => { assert.isFalse("icon" in error.options); assert.isFalse("spellcheck" in error.options.webPreferences); assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to create Electron BrowserWindow "T3 Code" (1100x780).'); + assert.equal( + error.message, + 'Failed to create Electron BrowserWindow "Otter Code" (1100x780).', + ); assert.notInclude(error.message, cause.message); assert.deepEqual(browserWindowMock.mock.calls, [[options]]); }).pipe(Effect.provide(layerTest)), @@ -302,7 +305,7 @@ describe("ElectronWindow", () => { { windowId: 41, processId: process.pid, - title: "T3 Code (Dev)", + title: "Otter Code (Dev)", bounds: { x: 100, y: 50, width: 1_200, height: 800 }, contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, }, @@ -327,7 +330,7 @@ describe("ElectronWindow", () => { { windowId: 41, processId: process.pid, - title: "T3 Code (Dev)", + title: "Otter Code (Dev)", bounds: { x: 100, y: 50, width: 1_200, height: 800 }, contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, }, diff --git a/apps/desktop/src/electron/WindowsForeground.ts b/apps/desktop/src/electron/WindowsForeground.ts index b90081d20cde..d4175b699914 100644 --- a/apps/desktop/src/electron/WindowsForeground.ts +++ b/apps/desktop/src/electron/WindowsForeground.ts @@ -216,7 +216,7 @@ export function loadWindowsForegroundApi(): Promise { export async function activateWindowsForeground(handleBuffer: Buffer): Promise { const api = await loadWindowsForegroundApi(); if (activateWindowsForegroundWithApi(handleBuffer, api)) return; - throw new Error("Windows refused to activate the T3 Code window."); + throw new Error("Windows refused to activate the Otter Code window."); } export async function isWindowsShellHostedForeground(): Promise { diff --git a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts index 46193ca2c341..747052caf62d 100644 --- a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts @@ -23,7 +23,7 @@ let worker = makeWorker(); const target = { windowId: 7, processId: 42, - title: "T3 Code (Dev)", + title: "Otter Code (Dev)", bounds: { x: 100, y: 50, width: 1_200, height: 800 }, contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, }; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e8..fd390b98f2ca 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,4 +1,12 @@ +import { + getAccountSession, + setAccountSession, + authorizeAccount, +} from "./methods/accountSession.ts"; import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import { nativeMessagingDirectories } from "../preview/extensions/NativeMessagingLocations.ts"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; @@ -69,12 +77,14 @@ import { setSnapShotShortcutSuppressed, } from "./methods/snapShot.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as PreviewExtensions from "../preview/extensions/PreviewExtensions.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; import * as WebLinksIpc from "./methods/webLinks.ts"; import { completeLegacyLocalStorage, takeLegacyLocalStorage, } from "./methods/legacyLocalStorage.ts"; +import * as ThreadLinksIpc from "./methods/threadLinks.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; import { getCliCommandState, @@ -86,6 +96,14 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" const ipc = yield* DesktopIpc.DesktopIpc; yield* installNotificationBadge(); yield* PreviewIpc.installPreviewEventForwarding(); + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + yield* Effect.sync(() => + PreviewExtensions.installPreviewExtensions({ + preloadPath: `${__dirname}/preview-extensions-preload.cjs`, + nativeMessagingDirectories: nativeMessagingDirectories(platform, environment.XDG_CONFIG_HOME), + }), + ); yield* ipc.handle(AppActivationIpc.setReady); yield* ipc.handle(AppActivationIpc.complete); @@ -104,6 +122,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); yield* ipc.handle(getConnectionCatalog); + yield* ipc.handle(getAccountSession); + yield* ipc.handle(setAccountSession); + yield* ipc.handle(authorizeAccount); yield* ipc.handle(getSnapShotState); yield* ipc.handle(setupSnapShot); yield* ipc.handle(previewSnapShotConfig); @@ -150,6 +171,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); + yield* ipc.handle(ThreadLinksIpc.takePending); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a6b617385b57..7dbb17e5dded 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -9,6 +9,7 @@ export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; +export const TAKE_PENDING_THREAD_LINK_CHANNEL = "desktop:take-pending-thread-link"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; diff --git a/apps/desktop/src/ipc/methods/accountSession.ts b/apps/desktop/src/ipc/methods/accountSession.ts new file mode 100644 index 000000000000..aa3311c0b309 --- /dev/null +++ b/apps/desktop/src/ipc/methods/accountSession.ts @@ -0,0 +1,43 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as DesktopAccountSession from "../../app/DesktopAccountSession.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const getAccountSession = DesktopIpc.makeIpcMethod({ + channel: "otter-account:get-session", + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: () => + Effect.gen(function* () { + return yield* (yield* DesktopAccountSession.DesktopAccountSession).read; + }), +}); +export const setAccountSession = DesktopIpc.makeIpcMethod({ + channel: "otter-account:set-session", + payload: Schema.NullOr(Schema.String), + result: Schema.Void, + handler: (value) => + Effect.gen(function* () { + yield* (yield* DesktopAccountSession.DesktopAccountSession).write(value); + }), +}); +export const authorizeAccount = DesktopIpc.makeIpcMethod({ + channel: "otter-account:authorize", + payload: Schema.Struct({ + state: Schema.String.check( + Schema.isMinLength(32), + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z0-9_-]+$/), + ), + challenge: Schema.String.check( + Schema.isMinLength(32), + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z0-9_-]+$/), + ), + }), + result: Schema.Struct({ code: Schema.String, redirectUri: Schema.String }), + handler: (request) => + Effect.gen(function* () { + return yield* (yield* DesktopAccountSession.DesktopAccountSession).authorize(request); + }), +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 94c56464f7c4..ce9a512128fa 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -32,6 +32,7 @@ import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; +import * as PreviewExtensions from "../../preview/extensions/PreviewExtensions.ts"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; @@ -313,7 +314,9 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ // Creating the session first is what installs the UA rewrite and permission // handlers; a guest that attached to an untouched partition would run with // Electron's default UA and Chromium's default permission behaviour. - yield* manager.getBrowserSession(scope, persistent, namespace); + const session = yield* manager.getBrowserSession(scope, persistent, namespace); + // Chrome extensions run in every persistent profile, as in Chrome's own. + if (persistent) yield* Effect.sync(() => PreviewExtensions.enablePreviewExtensions(session)); return { partition: yield* manager.getBrowserPartition(scope, persistent, namespace), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, diff --git a/apps/desktop/src/ipc/methods/snapShot.test.ts b/apps/desktop/src/ipc/methods/snapShot.test.ts index 138bcbc36d30..54e9f8718308 100644 --- a/apps/desktop/src/ipc/methods/snapShot.test.ts +++ b/apps/desktop/src/ipc/methods/snapShot.test.ts @@ -184,7 +184,7 @@ describe("window capture IPC", () => { borderWidth: 1, cornerRadius: 8, details: { - appName: "T3 Code", + appName: "Otter Code", windowTitle: "Capture animation", appIconDataUrl: "data:image/png;base64,aWNvbg==", }, @@ -202,7 +202,7 @@ describe("window capture IPC", () => { cornerRadius: 10, scaleFactor: 1.25, details: { - appName: "T3 Code", + appName: "Otter Code", windowTitle: "Capture animation", appIconDataUrl: "data:image/png;base64,aWNvbg==", }, diff --git a/apps/desktop/src/ipc/methods/threadLinks.ts b/apps/desktop/src/ipc/methods/threadLinks.ts new file mode 100644 index 000000000000..230e2c86645e --- /dev/null +++ b/apps/desktop/src/ipc/methods/threadLinks.ts @@ -0,0 +1,14 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { takePendingThreadLink } from "../../app/DesktopThreadLinks.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const takePending = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.TAKE_PENDING_THREAD_LINK_CHANNEL, + payload: Schema.Undefined, + result: Schema.NullOr(Schema.String), + handler: () => + Effect.sync(takePendingThreadLink).pipe(Effect.withSpan("desktop.ipc.threadLinks.takePending")), +}); diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 644597eaf451..a1653b2f2375 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -66,7 +66,9 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ), Layer.succeed( ElectronApp.ElectronApp, - ElectronApp.ElectronApp.of({} as ElectronApp.ElectronApp["Service"]), + ElectronApp.ElectronApp.of({ + requestSingleInstanceLock: Effect.succeed(true), + } as ElectronApp.ElectronApp["Service"]), ), Layer.succeed( ElectronTheme.ElectronTheme, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef00c4d66ed8..f6761007ec25 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import * as DesktopAccountSession from "./app/DesktopAccountSession.ts"; import * as MacPermissions from "./permissions/MacPermissions.ts"; for (const stream of [process.stdout, process.stderr]) { stream.on("error", (err: NodeJS.ErrnoException) => { @@ -38,8 +39,8 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; -import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; +import * as DesktopProtocols from "./app/DesktopProtocols.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -150,6 +151,7 @@ const layerDesktopFoundation = Layer.mergeAll( DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), + DesktopAccountSession.layer, DesktopAssets.layer, DesktopObservability.layer, DesktopRendererHistory.layer, @@ -228,8 +230,8 @@ const layerDesktopApplication = Layer.mergeAll( Layer.provideMerge(layerDesktopLocalEnvironmentAuth), ); -// Clerk resolves userData before Electron is ready, so it gets the synchronous FileSystem. -const layerDesktopClerk = DesktopClerk.layer.pipe( +// Otter Accounts resolves userData before Electron is ready, so it gets the synchronous FileSystem. +const layerDesktopProtocols = DesktopProtocols.layer.pipe( Layer.provide(DesktopPreReadyFileSystem.layer), Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(layerDesktopEnvironment), @@ -244,11 +246,11 @@ const layerDesktopApplicationRuntime = layerDesktopApplication.pipe( Layer.provideMerge(layerElectron), ); -// Acquire strict pre-ready setup before Clerk. Nothing before the Clerk bridge -// may yield, or Electron can emit ready before Clerk registers its scheme. -const layerDesktopRuntime = layerDesktopClerk.pipe( - Layer.flatMap((clerkContext) => - layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), +// Acquire strict pre-ready setup before Otter Accounts. Nothing before the Otter Accounts bridge +// may yield, or Electron can emit ready before Otter Accounts registers its scheme. +const layerDesktopRuntime = layerDesktopProtocols.pipe( + Layer.flatMap((protocolContext) => + layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(protocolContext))), ), Layer.provideMerge(DesktopPreReadyPlatform.layer), ); diff --git a/apps/desktop/src/permissions/MacPermissionHelper.test.ts b/apps/desktop/src/permissions/MacPermissionHelper.test.ts index e1160d9bbe38..05a245a5925a 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.test.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.test.ts @@ -61,7 +61,7 @@ vi.mock("electron", async () => { } return { app: { - getPath: () => "/Applications/T3 Code (Nightly).app/Contents/MacOS/T3 Code", + getPath: () => "/Applications/Otter Code (Nightly).app/Contents/MacOS/Otter Code", }, nativeImage: { createFromPath: mocks.createFromPath }, BrowserWindow: class extends MockWindow {}, @@ -124,11 +124,11 @@ function send(action: string, trusted = true) { describe("macAppBundlePath", () => { it("resolves bundles with spaces and refuses non-bundle executables", () => { - expect(macAppBundlePath("/Applications/T3 Code.app/Contents/MacOS/T3 Code")).toBe( - "/Applications/T3 Code.app", + expect(macAppBundlePath("/Applications/Otter Code.app/Contents/MacOS/Otter Code")).toBe( + "/Applications/Otter Code.app", ); expect(macAppBundlePath("/usr/local/bin/electron")).toBeUndefined(); - expect(macAppBundlePath("/Applications/T3 Code.app/other/MacOS/T3 Code")).toBeUndefined(); + expect(macAppBundlePath("/Applications/Otter Code.app/other/MacOS/Otter Code")).toBeUndefined(); }); }); it("drags the running app bundle only for the helper's own renderer", async () => { @@ -138,11 +138,11 @@ it("drags the running app bundle only for the helper's own renderer", async () = send("drag"); expect(mocks.createFromPath).toHaveBeenCalledWith("/bundle/prod-resources/icon.png"); expect(mocks.startDrag).toHaveBeenCalledWith({ - file: "/Applications/T3 Code (Nightly).app", + file: "/Applications/Otter Code (Nightly).app", icon: mocks.createFromPath.mock.results[0]!.value.resize(), }); send("finder"); - expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); + expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/Otter Code (Nightly).app"); }); it("rechecks permissions and releases resources when granted", async () => { await open(); @@ -168,7 +168,7 @@ it("does not open for a permission already granted", async () => { }); it("does not show a helper with a missing packaged icon", async () => { mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true }); - await expect(open()).rejects.toThrow("packaged T3 Code icon is missing"); + await expect(open()).rejects.toThrow("packaged Otter Code icon is missing"); expect(windows).toHaveLength(0); }); it("cleans up when the helper page fails to load", async () => { @@ -184,7 +184,7 @@ it("offers the Finder fallback when native dragging fails", async () => { throw new Error("drag failed"); }); send("drag"); - expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); + expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/Otter Code (Nightly).app"); expect(windows[0]!.destroyed).toBe(false); }); diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index 3f395c81ffc4..e5fcf17b67c3 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -60,8 +60,8 @@ button:focus-visible { outline: 2px solid #007aff; outline-offset: -2px; } img { width: 32px; height: 32px; pointer-events: none; }
-
↑ Drag T3 Code into the list above
- +
↑ Drag Otter Code into the list above
+
`; } @@ -95,7 +95,7 @@ export class MacPermissionHelper { const appIcon = iconPaths .map((iconPath) => Electron.nativeImage.createFromPath(iconPath)) .find((image) => !image.isEmpty()); - if (!appIcon) throw new Error("The packaged T3 Code icon is missing."); + if (!appIcon) throw new Error("The packaged Otter Code icon is missing."); const icon = appIcon.resize({ width: 64, height: 64 }); const window = new Electron.BrowserWindow({ width: 560, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index efd49ffa3864..c2316ac615fc 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -7,11 +7,11 @@ import type { DesktopPreviewTabState, DesktopSnapShotEvent, } from "@t3tools/contracts"; -import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; +import * as PreviewExtensionChannels from "./preview/extensions/channels.ts"; const SNAP_SHOT_EVENT_TYPES = new Set([ "requested", @@ -30,8 +30,6 @@ function isSnapShotEvent(value: unknown): value is DesktopSnapShotEvent { ); } -exposeClerkBridge({ passkeys: true }); - // Runs before any app script reads localStorage. See DesktopLegacyLocalStorage. try { const legacyItems: unknown = ipcRenderer.sendSync(IpcChannels.TAKE_LEGACY_LOCAL_STORAGE_CHANNEL); @@ -53,7 +51,7 @@ if (clientPlatform === "darwin") { const syncWindowControlInset = () => { document.documentElement.style.setProperty( "--desktop-window-controls-inset", - `${90 / webFrame.getZoomFactor()}px`, + `${82 / webFrame.getZoomFactor()}px`, ); }; window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true }); @@ -136,6 +134,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { dismissSnapShotAnimation: (id) => ipcRenderer.invoke(IpcChannels.DISMISS_SNAP_SHOT_ANIMATION_CHANNEL, id), acknowledgeSnapShot: (id) => ipcRenderer.invoke(IpcChannels.ACKNOWLEDGE_SNAP_SHOT_CHANNEL, id), + getAccountSession: () => ipcRenderer.invoke("otter-account:get-session"), + setAccountSession: (session) => ipcRenderer.invoke("otter-account:set-session", session), + authorizeAccount: (request) => ipcRenderer.invoke("otter-account:authorize", request), getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), @@ -207,6 +208,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), + takePendingThreadLink: () => + ipcRenderer.invoke(IpcChannels.TAKE_PENDING_THREAD_LINK_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; @@ -439,4 +442,34 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); }, }, + previewExtensions: { + list: () => ipcRenderer.invoke(PreviewExtensionChannels.LIST_CHANNEL), + actions: (webContentsId) => + ipcRenderer.invoke(PreviewExtensionChannels.ACTIONS_CHANNEL, webContentsId), + runAction: (input) => ipcRenderer.invoke(PreviewExtensionChannels.RUN_ACTION_CHANNEL, input), + setActiveTab: (webContentsId) => + ipcRenderer.invoke(PreviewExtensionChannels.SET_ACTIVE_TAB_CHANNEL, webContentsId), + openOptions: (extensionId) => + ipcRenderer.invoke(PreviewExtensionChannels.OPEN_OPTIONS_CHANNEL, extensionId), + openWebStore: (url) => ipcRenderer.invoke(PreviewExtensionChannels.OPEN_WEB_STORE_CHANNEL, url), + setEnabled: (extensionId, enabled) => + ipcRenderer.invoke(PreviewExtensionChannels.SET_ENABLED_CHANNEL, extensionId, enabled), + remove: (extensionId) => + ipcRenderer.invoke(PreviewExtensionChannels.REMOVE_CHANNEL, extensionId), + loadUnpacked: () => ipcRenderer.invoke(PreviewExtensionChannels.LOAD_UNPACKED_CHANNEL), + onChanged: (listener) => { + const wrappedListener = () => listener(); + ipcRenderer.on(PreviewExtensionChannels.CHANGED_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(PreviewExtensionChannels.CHANGED_CHANNEL, wrappedListener); + }, + onOpenTab: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, url: unknown) => { + if (typeof url === "string") listener(url); + }; + ipcRenderer.on(PreviewExtensionChannels.OPEN_TAB_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(PreviewExtensionChannels.OPEN_TAB_CHANNEL, wrappedListener); + }, + }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview-extensions-preload.ts b/apps/desktop/src/preview-extensions-preload.ts new file mode 100644 index 000000000000..2282508e512b --- /dev/null +++ b/apps/desktop/src/preview-extensions-preload.ts @@ -0,0 +1,438 @@ +// @effect-diagnostics globalConsole:off -- Runs in extensions' own pages and workers, without Effect. +// @effect-diagnostics globalRandom:off -- Chrome makes up ids for menus and notifications; so does this. +/** + * Preload for the browser preview's extensions (preview/extensions/ + * PreviewExtensions.ts): their service workers and their own pages (popups, + * options). It gives them what Chrome has and Electron doesn't: + * `chrome.action` (the toolbar button), the rest of `chrome.tabs`, + * `chrome.windows`, and friends, each answered by the main process. Web pages + * in the preview get nothing from it, except the Chrome Web Store (below). + */ + +import { contextBridge, ipcRenderer } from "electron"; + +import { installNativeMessaging } from "./preview-native-messaging-preload.ts"; + +// The preview's pages run without context isolation (its element picker reads +// the page's globals), and there contextBridge refuses to work. The Web +// Store's own preload (electron-chrome-web-store), which runs after this one, +// uses it to add its install button's API: on the Web Store, an API it +// exposes goes on the page's window, which this world is. +if ( + !process.contextIsolated && + typeof location !== "undefined" && + location.origin === "https://chromewebstore.google.com" +) { + contextBridge.exposeInMainWorld = (key: string, api: unknown) => { + (globalThis as Record)[key] = api; + }; +} + +/** The extension's id from its address: a page's, or (no `location` in a worker's preload) its worker's. */ +function ownExtensionId(): string | null { + const own = (where: { protocol: string; host: string }) => + where.protocol === "chrome-extension:" ? where.host : null; + if (typeof location !== "undefined") return own(location); + try { + return contextBridge.executeInMainWorld({ + func: () => (self.location.protocol === "chrome-extension:" ? self.location.host : null), + }) as string | null; + } catch { + return null; + } +} + +const extensionId = ownExtensionId(); + +if (extensionId) { + const nativeListeners = new Map void>(); + ipcRenderer.on("crx:nativeEvent", (_event, id: string, kind: string, payload: unknown) => { + nativeListeners.get(id)?.(kind, payload); + }); + contextBridge.executeInMainWorld({ + func: installNativeMessaging, + args: [ + { + call: (method: string, args: unknown[]) => + ipcRenderer.invoke("crx:nativeCall", extensionId, method, args), + listen: (id: string, listener: (kind: string, payload: unknown) => void) => { + nativeListeners.set(id, listener); + }, + forget: (id: string) => { + nativeListeners.delete(id); + }, + }, + ], + }); + const listeners = new Map void>>(); + ipcRenderer.on("crx:event", (_event, name: string, args: unknown[]) => { + for (const listener of listeners.get(name) ?? []) listener(...args); + }); + + const bridge = { + call: (name: string, args: unknown[]) => + ipcRenderer.invoke("crx:call", extensionId, name, args), + listen: (name: string, listener: (...args: unknown[]) => void) => { + const set = listeners.get(name) ?? new Set(); + listeners.set(name, set); + set.add(listener); + void ipcRenderer.invoke("crx:listen", extensionId, name); + }, + }; + + // Runs in the extension's own world: everything it needs is inside it. + const install = (bridge: { + call: (name: string, args: unknown[]) => Promise; + listen: (name: string, listener: (...args: unknown[]) => void) => void; + }) => { + type Namespace = Record>; + // Chromium has `browser` beside `chrome` (the cross-browser name), its own + // object: extensions read either, so both get the same APIs. + const { chrome, browser } = globalThis as { chrome?: Namespace; browser?: Namespace }; + const roots = [...new Set([chrome, browser])].filter((root): root is Namespace => !!root); + if (roots.length === 0) return; + + // Electron's own API objects may not take plain assignment. + const put = (target: Record, values: Record) => { + for (const [key, value] of Object.entries(values)) { + try { + target[key] = value; + } catch { + // falls through to defineProperty + } + if (target[key] === value) continue; + try { + Object.defineProperty(target, key, { + value, + writable: true, + configurable: true, + enumerable: true, + }); + } catch (error) { + console.error(`Couldn't add chrome API ${key}:`, error); + } + } + }; + + // Chrome's two styles: a trailing callback, or a promise. + const method = + (name: string, prepare?: (args: unknown[]) => unknown[]) => + (...args: unknown[]) => { + const callback = + typeof args[args.length - 1] === "function" + ? (args.pop() as (value?: unknown) => void) + : null; + const result = bridge.call(name, prepare ? prepare(args) : args); + if (!callback) return result; + result.then( + (value) => callback(value), + (error: unknown) => { + console.error(`chrome.${name}:`, error); + callback(); + }, + ); + return undefined; + }; + + const event = (name: string) => { + const listeners = new Set<(...args: unknown[]) => void>(); + let listening = false; + return { + addListener(listener: (...args: unknown[]) => void) { + listeners.add(listener); + if (listening) return; + listening = true; + bridge.listen(name, (...args) => { + for (const each of listeners) { + try { + each(...args); + } catch (error) { + console.error(error); + } + } + }); + }, + removeListener: (listener: (...args: unknown[]) => void) => listeners.delete(listener), + hasListener: (listener: (...args: unknown[]) => void) => listeners.has(listener), + hasListeners: () => listeners.size > 0, + }; + }; + + // ImageData doesn't cross into the main process: its pixels do. + const pixels = (details: Record) => { + const imageData = details.imageData; + if (!imageData) return details; + const sets = + imageData instanceof ImageData + ? { [imageData.width]: imageData } + : (imageData as Record); + return { + ...details, + imageData: Object.fromEntries( + Object.entries(sets).map(([size, data]) => [ + size, + { width: data.width, height: data.height, data: Array.from(data.data) }, + ]), + ), + }; + }; + + const action = { + setTitle: method("action.setTitle"), + getTitle: method("action.getTitle"), + setIcon: method("action.setIcon", ([details, ...rest]) => [ + pixels(details as Record), + ...rest, + ]), + setPopup: method("action.setPopup"), + getPopup: method("action.getPopup"), + setBadgeText: method("action.setBadgeText"), + getBadgeText: method("action.getBadgeText"), + setBadgeBackgroundColor: method("action.setBadgeBackgroundColor"), + getBadgeBackgroundColor: method("action.getBadgeBackgroundColor"), + setBadgeTextColor: method("action.setBadgeTextColor"), + getBadgeTextColor: method("action.getBadgeTextColor"), + enable: method("action.enable"), + disable: method("action.disable"), + isEnabled: method("action.isEnabled"), + openPopup: method("action.openPopup"), + getUserSettings: method("action.getUserSettings"), + onClicked: event("action.onClicked"), + onUserSettingsChanged: event("action.onUserSettingsChanged"), + }; + const patch = (chrome: Namespace) => { + put(chrome, { action }); + // Manifest V2's name for it, which some still read. + if (!chrome.browserAction) put(chrome, { browserAction: action }); + + // Electron's chrome.tabs keeps what it does well (messaging, scripting); + // the tab strip's side is this app's. + if (!chrome.tabs) put(chrome, { tabs: {} }); + put(chrome.tabs!, { + TAB_ID_NONE: -1, + get: method("tabs.get"), + getCurrent: method("tabs.getCurrent"), + query: method("tabs.query"), + create: method("tabs.create"), + update: method("tabs.update"), + remove: method("tabs.remove"), + goBack: method("tabs.goBack"), + goForward: method("tabs.goForward"), + captureVisibleTab: method("tabs.captureVisibleTab"), + onCreated: event("tabs.onCreated"), + onUpdated: event("tabs.onUpdated"), + onActivated: event("tabs.onActivated"), + onRemoved: event("tabs.onRemoved"), + onHighlighted: event("tabs.onHighlighted"), + onReplaced: event("tabs.onReplaced"), + onMoved: event("tabs.onMoved"), + onAttached: event("tabs.onAttached"), + onDetached: event("tabs.onDetached"), + onZoomChange: event("tabs.onZoomChange"), + }); + + put(chrome, { + windows: { + WINDOW_ID_NONE: -1, + WINDOW_ID_CURRENT: -2, + get: method("windows.get"), + getCurrent: method("windows.getCurrent"), + getLastFocused: method("windows.getLastFocused"), + getAll: method("windows.getAll"), + create: method("windows.create"), + update: method("windows.update"), + remove: method("windows.remove"), + onCreated: event("windows.onCreated"), + onRemoved: event("windows.onRemoved"), + onFocusChanged: event("windows.onFocusChanged"), + onBoundsChanged: event("windows.onBoundsChanged"), + }, + }); + + // Electron has none of these; extensions read them as they start. + const random = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2)}`; + if (!chrome.contextMenus) { + put(chrome, { + contextMenus: { + ContextType: { + ALL: "all", + PAGE: "page", + FRAME: "frame", + SELECTION: "selection", + LINK: "link", + EDITABLE: "editable", + IMAGE: "image", + VIDEO: "video", + AUDIO: "audio", + ACTION: "action", + }, + ItemType: { + NORMAL: "normal", + CHECKBOX: "checkbox", + RADIO: "radio", + SEPARATOR: "separator", + }, + ACTION_MENU_TOP_LEVEL_LIMIT: 6, + // Returns its id at once, as Chrome's does. + create: (properties: Record = {}, callback?: () => void) => { + const { onclick: _onclick, ...rest } = properties; + const id = (rest.id as string | undefined) ?? random("menu"); + bridge.call("contextMenus.create", [{ ...rest, id }]).then( + () => callback?.(), + (error: unknown) => { + console.error(error); + callback?.(); + }, + ); + return id; + }, + update: method("contextMenus.update"), + remove: method("contextMenus.remove"), + removeAll: method("contextMenus.removeAll"), + onClicked: event("contextMenus.onClicked"), + }, + }); + } + if (!chrome.notifications) { + put(chrome, { + notifications: { + TemplateType: { BASIC: "basic", IMAGE: "image", LIST: "list", PROGRESS: "progress" }, + PermissionLevel: { GRANTED: "granted", DENIED: "denied" }, + create: (...args: unknown[]) => { + const callback = + typeof args[args.length - 1] === "function" + ? (args.pop() as (id: string) => void) + : null; + const [first, second] = args; + const id = typeof first === "string" ? first : random("notification"); + const options = typeof first === "string" ? second : first; + const result = bridge.call("notifications.create", [id, options]).then(() => id); + if (!callback) return result; + result.then(callback, () => callback(id)); + return undefined; + }, + update: method("notifications.update"), + clear: method("notifications.clear"), + getAll: method("notifications.getAll"), + getPermissionLevel: method("notifications.getPermissionLevel"), + onClicked: event("notifications.onClicked"), + onClosed: event("notifications.onClosed"), + onButtonClicked: event("notifications.onButtonClicked"), + onPermissionLevelChanged: event("notifications.onPermissionLevelChanged"), + onShowSettings: event("notifications.onShowSettings"), + }, + }); + } + if (!chrome.downloads) { + put(chrome, { + downloads: { + download: method("downloads.download"), + search: method("downloads.search"), + pause: method("downloads.pause"), + resume: method("downloads.resume"), + cancel: method("downloads.cancel"), + erase: method("downloads.erase"), + open: method("downloads.open"), + show: method("downloads.show"), + showDefaultFolder: method("downloads.showDefaultFolder"), + onCreated: event("downloads.onCreated"), + onChanged: event("downloads.onChanged"), + onErased: event("downloads.onErased"), + onDeterminingFilename: event("downloads.onDeterminingFilename"), + }, + }); + } + if (!chrome.privacy) { + // Chrome's ChromeSetting objects, kept per extension in the main process. + const setting = (name: string) => ({ + get: method("privacy.get", (args) => [name, ...args]), + set: method("privacy.set", (args) => [name, ...args]), + clear: method("privacy.clear", (args) => [name, ...args]), + onChange: event(`privacy.onChange:${name}`), + }); + const group = (prefix: string, names: string[]) => + Object.fromEntries(names.map((name) => [name, setting(`${prefix}.${name}`)])); + put(chrome, { + privacy: { + services: group("services", [ + "alternateErrorPagesEnabled", + "autofillAddressEnabled", + "autofillCreditCardEnabled", + "autofillEnabled", + "passwordSavingEnabled", + "safeBrowsingEnabled", + "safeBrowsingExtendedReportingEnabled", + "searchSuggestEnabled", + "spellingServiceEnabled", + "translationServiceEnabled", + ]), + network: group("network", ["networkPredictionEnabled", "webRTCIPHandlingPolicy"]), + websites: group("websites", [ + "doNotTrackEnabled", + "hyperlinkAuditingEnabled", + "protectedContentEnabled", + "referrersEnabled", + "thirdPartyCookiesAllowed", + ]), + }, + }); + } + if (!chrome.webNavigation) { + put(chrome, { + webNavigation: { + getFrame: method("webNavigation.getFrame"), + getAllFrames: method("webNavigation.getAllFrames"), + onBeforeNavigate: event("webNavigation.onBeforeNavigate"), + onCommitted: event("webNavigation.onCommitted"), + onDOMContentLoaded: event("webNavigation.onDOMContentLoaded"), + onCompleted: event("webNavigation.onCompleted"), + onErrorOccurred: event("webNavigation.onErrorOccurred"), + onCreatedNavigationTarget: event("webNavigation.onCreatedNavigationTarget"), + onHistoryStateUpdated: event("webNavigation.onHistoryStateUpdated"), + onReferenceFragmentUpdated: event("webNavigation.onReferenceFragmentUpdated"), + onTabReplaced: event("webNavigation.onTabReplaced"), + }, + }); + } + + if (!chrome.permissions) { + put(chrome, { + permissions: { + contains: method("permissions.contains"), + getAll: method("permissions.getAll"), + request: method("permissions.request"), + remove: method("permissions.remove"), + addHostAccessRequest: method("permissions.addHostAccessRequest"), + removeHostAccessRequest: method("permissions.removeHostAccessRequest"), + onAdded: event("permissions.onAdded"), + onRemoved: event("permissions.onRemoved"), + }, + }); + } + if (!chrome.commands) { + put(chrome, { + commands: { + getAll: method("commands.getAll"), + update: method("commands.update"), + reset: method("commands.reset"), + onCommand: event("commands.onCommand"), + }, + }); + } + if (chrome.extension && !chrome.extension.getViews) { + put(chrome.extension, { getViews: () => [] }); + } + if (chrome.management && !chrome.management.setEnabled) { + put(chrome.management, { setEnabled: method("management.setEnabled") }); + } + if (chrome.runtime && !chrome.runtime.openOptionsPage) { + put(chrome.runtime, { openOptionsPage: method("runtime.openOptionsPage") }); + } + }; + for (const root of roots) patch(root); + }; + + // A page without context isolation shares this world already. + if (process.contextIsolated) contextBridge.executeInMainWorld({ func: install, args: [bridge] }); + else install(bridge); +} diff --git a/apps/desktop/src/preview-native-messaging-preload.test.ts b/apps/desktop/src/preview-native-messaging-preload.test.ts new file mode 100644 index 000000000000..3f1d166cbd56 --- /dev/null +++ b/apps/desktop/src/preview-native-messaging-preload.test.ts @@ -0,0 +1,180 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Native messaging subprocess and IPC boundary tests. +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { installNativeMessaging } from "./preview-native-messaging-preload.ts"; + +type Event = { + addListener(listener: (...args: Args) => void): void; + removeListener(listener: (...args: Args) => void): void; +}; +type Port = { + onMessage: Event<[unknown, Port]>; + onDisconnect: Event<[Port]>; + postMessage(message: unknown): void; + disconnect(): void; +}; +type Runtime = { + lastError?: { message: string }; + connectNative(name: string): Port; + sendNativeMessage( + name: string, + message: unknown, + callback?: (response?: unknown) => void, + ): Promise | undefined; +}; + +afterEach(() => vi.unstubAllGlobals()); + +function setup( + call: (method: string, args: unknown[]) => Promise = async () => undefined, +) { + const listeners = new Map void>(); + const runtime = {} as Runtime; + const browserRuntime = {} as Runtime; + vi.stubGlobal("chrome", { runtime }); + vi.stubGlobal("browser", { runtime: browserRuntime }); + const bridge = { + call: vi.fn(call), + listen: (id: string, listener: (kind: string, payload: unknown) => void) => { + listeners.set(id, listener); + }, + forget: (id: string) => { + listeners.delete(id); + }, + }; + installNativeMessaging(bridge); + const deliver = (kind: string, value: unknown) => { + const listener = listeners.values().next().value; + if (!listener) throw new Error("No native port is listening."); + listener(kind, value); + }; + return { runtime, browserRuntime, bridge, listeners, deliver }; +} + +it("returns a port synchronously and queues writes until the host connects", async () => { + let open!: () => void; + const opening = new Promise((resolve) => { + open = resolve; + }); + let sent!: () => void; + const sending = new Promise((resolve) => { + sent = resolve; + }); + const { runtime, bridge } = setup(async (method) => { + if (method === "connect") await opening; + if (method === "postMessage") sent(); + }); + const port = runtime.connectNative("com.otter.test"); + port.postMessage({ text: "🔑" }); + expect(bridge.call.mock.calls.map(([method]) => method)).toEqual(["connect"]); + open(); + await sending; + expect(bridge.call.mock.calls[1]?.[1][1]).toEqual({ text: "🔑" }); +}); + +it("delivers messages only to the owning port, removes listeners, and scopes lastError to disconnect", () => { + const { runtime, browserRuntime, listeners, deliver } = setup(); + const first = runtime.connectNative("com.otter.test"); + const second = browserRuntime.connectNative("com.otter.test"); + const message = vi.fn(); + const other = vi.fn(); + first.onMessage.addListener(message); + second.onMessage.addListener(other); + deliver("message", { value: 42 }); + expect(message).toHaveBeenCalledWith({ value: 42 }, first); + expect(other).not.toHaveBeenCalled(); + first.onMessage.removeListener(message); + deliver("message", { value: 43 }); + expect(message).toHaveBeenCalledTimes(1); + let observed: unknown; + first.onDisconnect.addListener((port) => { + expect(port).toBe(first); + observed = [runtime.lastError?.message, browserRuntime.lastError?.message]; + }); + deliver("disconnect", "Access to the native messaging host is forbidden."); + expect(observed).toEqual([ + "Access to the native messaging host is forbidden.", + "Access to the native messaging host is forbidden.", + ]); + expect(runtime.lastError).toBeUndefined(); + expect(listeners.size).toBe(1); + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- This is a Chrome extension Port. + expect(() => first.postMessage({})).toThrow("disconnected port"); + second.disconnect(); + expect(listeners.size).toBe(0); +}); + +it("resolves one-shot messages with the first response and closes their port", async () => { + const { runtime, listeners, deliver } = setup(); + const result = runtime.sendNativeMessage("com.otter.test", { request: true }); + deliver("message", { response: true }); + expect(await result).toEqual({ response: true }); + expect(listeners.size).toBe(0); +}); + +it("reports one-shot failures as a rejected promise or callback lastError", async () => { + const { runtime, deliver } = setup(); + const result = runtime.sendNativeMessage("com.otter.test", {}); + deliver("disconnect", "Specified native messaging host not found."); + await expect(result).rejects.toThrow("host not found"); + let completed!: () => void; + const done = new Promise((resolve) => { + completed = resolve; + }); + let observed: unknown; + expect( + runtime.sendNativeMessage("com.otter.test", {}, (response) => { + observed = [response, runtime.lastError?.message]; + completed(); + }), + ).toBeUndefined(); + deliver("disconnect", "Specified native messaging host not found."); + await done; + expect(observed).toEqual([undefined, "Specified native messaging host not found."]); + expect(runtime.lastError).toBeUndefined(); +}); + +it("disconnects a port when its connection request fails", async () => { + const { runtime, listeners } = setup(async () => { + throw new Error("The nativeMessaging permission is required."); + }); + const port = runtime.connectNative("com.otter.test"); + const error = await new Promise((resolve) => { + port.onDisconnect.addListener(() => resolve(runtime.lastError?.message)); + }); + expect(error).toContain("nativeMessaging permission"); + expect(listeners.size).toBe(0); +}); + +it("closes the native host when a queued write fails", async () => { + let closed!: () => void; + const closing = new Promise((resolve) => { + closed = resolve; + }); + const { runtime, listeners } = setup(async (method) => { + if (method === "postMessage") throw new Error("Native messaging message is too large."); + if (method === "disconnect") closed(); + }); + const port = runtime.connectNative("com.otter.test"); + const disconnected = new Promise((resolve) => { + port.onDisconnect.addListener(() => resolve(runtime.lastError?.message)); + }); + port.postMessage({}); + expect(await disconnected).toContain("too large"); + await closing; + expect(listeners.size).toBe(0); +}); + +it("rejects non-JSON messages synchronously and suppresses local disconnect events", () => { + const { runtime } = setup(); + const port = runtime.connectNative("com.otter.test"); + const circular: { self?: unknown } = {}; + circular.self = circular; + expect(() => port.postMessage(circular)).toThrow(); + expect(() => port.postMessage(undefined)).toThrow("JSON serializable"); + const disconnected = vi.fn(); + port.onDisconnect.addListener(disconnected); + port.disconnect(); + port.disconnect(); + expect(disconnected).not.toHaveBeenCalled(); +}); diff --git a/apps/desktop/src/preview-native-messaging-preload.ts b/apps/desktop/src/preview-native-messaging-preload.ts new file mode 100644 index 000000000000..d938d50f6cc5 --- /dev/null +++ b/apps/desktop/src/preview-native-messaging-preload.ts @@ -0,0 +1,161 @@ +// @effect-diagnostics globalConsole:off -- Chrome event listeners run in the extension's own world. +// @effect-diagnostics cryptoRandomUUID:off -- Port ids are generated inside the extension, outside Effect. +type NativeBridge = { + call(method: string, args: unknown[]): Promise; + listen(id: string, listener: (kind: string, payload: unknown) => void): void; + forget(id: string): void; +}; + +/** Self-contained: Electron executes this function in an extension's own world. */ +export function installNativeMessaging(bridge: NativeBridge): void { + type Runtime = Record; + const { chrome, browser } = globalThis as { + chrome?: { runtime?: Runtime }; + browser?: { runtime?: Runtime }; + }; + const runtimes = [...new Set([chrome?.runtime, browser?.runtime])].filter( + (runtime): runtime is Runtime => !!runtime, + ); + let lastError: { message: string } | undefined; + for (const runtime of runtimes) { + const original = Object.getOwnPropertyDescriptor(runtime, "lastError"); + Object.defineProperty(runtime, "lastError", { + configurable: true, + enumerable: true, + get: () => lastError ?? original?.get?.call(runtime) ?? original?.value, + }); + } + const withError = (message: string | undefined, callback: () => void) => { + const previous = lastError; + lastError = message ? { message } : undefined; + try { + callback(); + } finally { + lastError = previous; + } + }; + const event = () => { + const listeners = new Set<(...args: Args) => void>(); + return { + addListener: (listener: (...args: Args) => void) => { + listeners.add(listener); + }, + removeListener: (listener: (...args: Args) => void) => { + listeners.delete(listener); + }, + hasListener: (listener: (...args: Args) => void) => listeners.has(listener), + hasListeners: () => listeners.size > 0, + emit(...args: Args) { + for (const listener of listeners) { + try { + listener(...args); + } catch (error) { + console.error(error); + } + } + }, + }; + }; + type Port = { + name: string; + error: { message: string } | undefined; + onMessage: ReturnType>; + onDisconnect: ReturnType>; + postMessage(message: unknown): void; + disconnect(): void; + }; + const connectNative = (name: string): Port => { + if (typeof name !== "string") throw new TypeError("A native messaging host name is required."); + const id = crypto.randomUUID(); + let disconnected = false; + const onMessage = event<[unknown, Port]>(); + const onDisconnect = event<[Port]>(); + function makePort(): Port { + return { + name, + error: undefined as { message: string } | undefined, + onMessage, + onDisconnect, + postMessage(message: unknown) { + if (disconnected) throw new Error("Attempting to use a disconnected port object."); + // Match Chrome's JSON serialization, and fail synchronously for circular data. + const json = JSON.stringify(message); + if (json === undefined) throw new TypeError("Native messages must be JSON serializable."); + const value: unknown = JSON.parse(json); + void ready + .then(() => { + if (!disconnected) return bridge.call("postMessage", [id, value]); + return undefined; + }) + .catch((error: unknown) => { + finish(String(error instanceof Error ? error.message : error)); + void bridge.call("disconnect", [id]).catch(() => {}); + }); + }, + disconnect() { + if (disconnected) return; + disconnected = true; + bridge.forget(id); + void ready.then(() => bridge.call("disconnect", [id])).catch(() => {}); + }, + }; + } + const port = makePort(); + const finish = (error?: string) => { + if (disconnected) return; + disconnected = true; + bridge.forget(id); + port.error = error ? { message: error } : undefined; + withError(error, () => onDisconnect.emit(port)); + }; + bridge.listen(id, (kind, payload) => { + if (disconnected) return; + if (kind === "message") onMessage.emit(payload, port); + else if (kind === "disconnect") finish(typeof payload === "string" ? payload : undefined); + }); + const ready = bridge.call("connect", [id, name]).catch((error: unknown) => { + finish(String(error instanceof Error ? error.message : error)); + }); + return port; + }; + const sendNativeMessage = ( + name: string, + message: unknown, + callback?: (response?: unknown) => void, + ) => { + const result = new Promise((resolve, reject) => { + const port = connectNative(name); + port.onMessage.addListener((response) => { + port.disconnect(); + resolve(response); + }); + port.onDisconnect.addListener(() => + reject(new Error(port.error?.message ?? "Native host has exited.")), + ); + try { + port.postMessage(message); + } catch (error) { + port.disconnect(); + reject(error); + } + }); + if (!callback) return result; + void result.then( + (response) => callback(response), + (error: unknown) => + withError(String(error instanceof Error ? error.message : error), () => callback()), + ); + return undefined; + }; + for (const runtime of runtimes) { + Object.defineProperties(runtime, { + connectNative: { value: connectNative, configurable: true, writable: true, enumerable: true }, + sendNativeMessage: { + value: sendNativeMessage, + configurable: true, + writable: true, + enumerable: true, + }, + }); + } +} diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index edd4e981dee5..9aef84e3a408 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -66,7 +66,7 @@ const withImporter = Effect.fnUntraced(function* () { Layer.provide(layerRejectedBeforeSession), Layer.provide(layerEnvironment), Layer.provide(Layer.succeed(HostProcess.Platform, "darwin")), - Layer.provide(Layer.succeed(HostProcess.ExecutablePath, "/Applications/T3 Code.app")), + Layer.provide(Layer.succeed(HostProcess.ExecutablePath, "/Applications/Otter Code.app")), Layer.provide(NodeServices.layer), ), ), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 9799d88fdc9c..adc427df5d3c 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -17,6 +17,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly setPermissionRequestHandler: ReturnType; readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; + readonly webRequest: { readonly onBeforeSendHeaders: ReturnType }; } >(), })); @@ -63,6 +64,7 @@ describe("BrowserSession", () => { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -145,6 +147,7 @@ describe("BrowserSession", () => { setUserAgent: vi.fn((next: string) => { userAgent = next; }), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -160,6 +163,33 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("drops the Electron token only on requests to Google's sign-in", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + const [filter, listener] = browserSession.webRequest.onBeforeSendHeaders.mock.calls[0] ?? []; + assert.deepStrictEqual(filter, { urls: ["https://accounts.google.com/*"] }); + assert.isFunction(listener); + + let headers: Record | undefined; + listener( + { requestHeaders: { Accept: "text/html", "User-Agent": "Mozilla/5.0 Electron/41.5.0" } }, + (response: { requestHeaders: Record }) => { + headers = response.requestHeaders; + }, + ); + assert.deepStrictEqual(headers, { + Accept: "text/html", + "User-Agent": "Mozilla/5.0 t3code/0.0.27", + }); + assert.strictEqual(browserSession.setUserAgent.mock.calls.length, 0); + }).pipe(Effect.provide(layer)), + ); + it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 10e2a2575f38..a341a5cf0ca3 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -303,6 +303,21 @@ export const make = Effect.gen(function* BrowserSessionMake() { // the challenge every few seconds, so logins behind it never complete // (#5002). Re-setting the unchanged native string is harmless, so it // is the rewritten string itself that trips the check. + // + // Google's sign-in is the one exception. Given a User-Agent that names + // Electron, it serves a lite page (flowName=WebLiteSignIn) that never + // asks for passkeys, such as 1Password's, until you go looking for + // them. So only requests to accounts.google.com drop the Electron + // token: Google then serves Chrome's page, which offers passkeys as it + // loads, and every other site still sees the native string. + const googleUserAgent = browserSession.getUserAgent().replace(/ Electron\/\S+/, ""); + browserSession.webRequest.onBeforeSendHeaders( + { urls: ["https://accounts.google.com/*"] }, + (details, callback) => + callback({ + requestHeaders: { ...details.requestHeaders, "User-Agent": googleUserAgent }, + }), + ); browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { if (permission === "openExternal") { diff --git a/apps/desktop/src/preview/extensions/NativeMessaging.test.ts b/apps/desktop/src/preview/extensions/NativeMessaging.test.ts new file mode 100644 index 000000000000..7997b6d9da1a --- /dev/null +++ b/apps/desktop/src/preview/extensions/NativeMessaging.test.ts @@ -0,0 +1,207 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Native messaging subprocess and IPC boundary tests. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { connectNativeHost, findNativeHost, type NativePort } from "./NativeMessaging.ts"; + +const ID = "aeblfdkhhhdcdjpifhhbdiojplfjncoa"; +const HOST = "com.otter.test"; +const directories: string[] = []; +const ports: NativePort[] = []; + +afterEach(async () => { + for (const port of ports.splice(0)) port.disconnect(); + await Promise.all( + directories + .splice(0) + .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })), + ); +}); + +async function fixture() { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "otter-native-")); + directories.push(directory); + const binary = NodePath.join(directory, "host with spaces"); + await NodeFSP.writeFile( + binary, + `#!${process.execPath} +const os = require('node:os'); +const little = os.endianness() === 'LE'; +function frame(value) { + const body = Buffer.from(JSON.stringify(value)); + const header = Buffer.alloc(4); + if (little) header.writeUInt32LE(body.length); else header.writeUInt32BE(body.length); + return Buffer.concat([header, body]); +} +let pending = Buffer.alloc(0); +process.stdin.on('data', chunk => { + pending = Buffer.concat([pending, chunk]); + while (pending.length >= 4) { + const size = little ? pending.readUInt32LE(0) : pending.readUInt32BE(0); + if (pending.length < 4 + size) return; + const message = JSON.parse(pending.subarray(4, size + 4).toString()); + pending = pending.subarray(size + 4); + if (message.mode === 'exit') { process.exit(0); } + if (message.mode === 'oversize') { + const header = Buffer.alloc(4); + if (little) header.writeUInt32LE(1048577); else header.writeUInt32BE(1048577); + process.stdout.write(header); + } else if (message.mode === 'invalid') { + const header = Buffer.alloc(4); + if (little) header.writeUInt32LE(1); else header.writeUInt32BE(1); + process.stdout.write(Buffer.concat([header, Buffer.from('!')])); + } else if (message.mode === 'partial') { + process.stdout.end(frame('cut short').subarray(0, 6)); + } else if (message.mode === 'multiple') { + const frames = Buffer.concat([frame('first'), frame({ text: '🔑 café' })]); + process.stdout.write(frames.subarray(0, 3)); + setImmediate(() => process.stdout.write(frames.subarray(3))); + } else { + process.stderr.write('private host diagnostics'); + process.stdout.write(frame({ received: message, origin: process.argv[2], cwd: process.cwd() })); + } + } +}); +`, + { mode: 0o700 }, + ); + const manifest = { + name: HOST, + type: "stdio", + path: binary, + allowed_origins: [`chrome-extension://${ID}/`], + }; + const manifestPath = NodePath.join(directory, `${HOST}.json`); + await NodeFSP.writeFile(manifestPath, JSON.stringify(manifest)); + return { directory, manifest, manifestPath }; +} + +describe("native host discovery", () => { + it("finds the first installed host, including after a missing directory", async () => { + const { directory, manifest } = await fixture(); + expect( + await findNativeHost([NodePath.join(directory, "missing"), directory], HOST, ID), + ).toEqual(manifest); + }); + + it("rejects traversal and unregistered extensions", async () => { + const { directory } = await fixture(); + await expect(findNativeHost([directory], "../com.otter.test", ID)).rejects.toThrow( + "Invalid native messaging host name", + ); + await expect(findNativeHost([directory], HOST, "another-extension")).rejects.toThrow( + "forbidden", + ); + await expect(findNativeHost([directory], "com.missing", ID)).rejects.toThrow("not found"); + }); + + it.each([ + { type: "socket" }, + { name: "com.other" }, + { path: "relative-script" }, + { allowed_origins: "*" }, + ])("refuses an invalid manifest: %j", async (override) => { + const { directory, manifest, manifestPath } = await fixture(); + await NodeFSP.writeFile(manifestPath, JSON.stringify({ ...manifest, ...override })); + await expect(findNativeHost([directory], HOST, ID)).rejects.toThrow( + "Invalid native messaging host manifest", + ); + }); + + it("doesn't bypass a higher-priority host's origin restrictions", async () => { + const { directory, manifest, manifestPath } = await fixture(); + const second = NodePath.join(directory, "second"); + await NodeFSP.mkdir(second); + await NodeFSP.writeFile(NodePath.join(second, `${HOST}.json`), JSON.stringify(manifest)); + await NodeFSP.writeFile(manifestPath, JSON.stringify({ ...manifest, allowed_origins: [] })); + await expect(findNativeHost([directory, second], HOST, ID)).rejects.toThrow("forbidden"); + }); +}); + +describe("native host transport", () => { + it("round-trips JSON through a real subprocess with the extension origin and host's cwd", async () => { + const { directory } = await fixture(); + let received!: (value: unknown) => void; + const response = new Promise((resolve) => { + received = resolve; + }); + const port = await connectNativeHost([directory], HOST, ID, received, () => {}); + ports.push(port); + port.postMessage({ text: "🔑 café", nested: [1, true, null] }); + expect(await response).toEqual({ + received: { text: "🔑 café", nested: [1, true, null] }, + origin: `chrome-extension://${ID}/`, + cwd: await NodeFSP.realpath(directory), + }); + }); + + it("reads split headers and several messages in one chunk", async () => { + const { directory } = await fixture(); + const messages: unknown[] = []; + let complete!: () => void; + const response = new Promise((resolve) => { + complete = resolve; + }); + const port = await connectNativeHost( + [directory], + HOST, + ID, + (message) => { + messages.push(message); + if (messages.length === 2) complete(); + }, + () => {}, + ); + ports.push(port); + port.postMessage({ mode: "multiple" }); + await response; + expect(messages).toEqual(["first", { text: "🔑 café" }]); + }); + + it.each([ + ["oversize", "Invalid native messaging message size"], + ["invalid", "Invalid JSON"], + ["partial", "Incomplete native messaging message"], + ["exit", "Native host has exited"], + ])("disconnects on %s and rejects later writes", async (mode, error) => { + const { directory } = await fixture(); + let closed!: (error?: string) => void; + const disconnected = new Promise((resolve) => { + closed = resolve; + }); + const port = await connectNativeHost([directory], HOST, ID, () => {}, closed); + ports.push(port); + port.postMessage({ mode }); + expect(await disconnected).toContain(error); + expect(() => port.postMessage({})).toThrow("disconnected port"); + }); + + it("reports a failed launch and disconnects only once", async () => { + const { directory, manifest, manifestPath } = await fixture(); + await NodeFSP.writeFile( + manifestPath, + JSON.stringify({ ...manifest, path: NodePath.join(directory, "missing") }), + ); + let closed!: (error?: string) => void; + let count = 0; + const disconnected = new Promise((resolve) => { + closed = resolve; + }); + const port = await connectNativeHost( + [directory], + HOST, + ID, + () => {}, + (error) => { + count++; + closed(error); + }, + ); + ports.push(port); + expect(await disconnected).toContain("Failed to start"); + port.disconnect(); + expect(count).toBe(1); + }); +}); diff --git a/apps/desktop/src/preview/extensions/NativeMessaging.ts b/apps/desktop/src/preview/extensions/NativeMessaging.ts new file mode 100644 index 000000000000..d43779b706b3 --- /dev/null +++ b/apps/desktop/src/preview/extensions/NativeMessaging.ts @@ -0,0 +1,137 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Native host processes use Chrome's stdio protocol at the Electron boundary. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +const HOST_NAME = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/; +const MAX_HOST_MESSAGE = 1024 * 1024; +const MAX_BROWSER_MESSAGE = 64 * 1024 * 1024; +const littleEndian = NodeOS.endianness() === "LE"; + +type HostManifest = { + name: string; + path: string; + type: string; + allowed_origins: string[]; +}; + +/** Only installed manifests may choose an executable; extensions supply a host name. */ +export async function findNativeHost( + directories: readonly string[], + name: string, + extensionId: string, +): Promise { + if (!HOST_NAME.test(name)) throw new Error("Invalid native messaging host name specified."); + for (const directory of directories) { + let source: string; + try { + source = await NodeFSP.readFile(NodePath.join(directory, `${name}.json`), "utf8"); + } catch (error) { + if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; + throw new Error("Couldn't read the native messaging host manifest.", { cause: error }); + } + let manifest: HostManifest; + try { + manifest = JSON.parse(source) as HostManifest; + if ( + !manifest || + manifest.name !== name || + manifest.type !== "stdio" || + typeof manifest.path !== "string" || + !NodePath.isAbsolute(manifest.path) || + !Array.isArray(manifest.allowed_origins) + ) { + throw new Error(); + } + } catch { + throw new Error("Invalid native messaging host manifest."); + } + if (!manifest.allowed_origins.includes(`chrome-extension://${extensionId}/`)) { + throw new Error("Access to the native messaging host is forbidden."); + } + return manifest; + } + throw new Error("Specified native messaging host not found."); +} + +export type NativePort = { + postMessage(message: unknown): void; + disconnect(): void; +}; + +/** Chrome's length-prefixed JSON protocol, without a shell or access to message contents in logs. */ +export async function connectNativeHost( + directories: readonly string[], + name: string, + extensionId: string, + onMessage: (message: unknown) => void, + onDisconnect: (error?: string) => void, +): Promise { + const manifest = await findNativeHost(directories, name, extensionId); + const child = NodeChildProcess.spawn(manifest.path, [`chrome-extension://${extensionId}/`], { + cwd: NodePath.dirname(manifest.path), + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + let closed = false; + let pending = Buffer.alloc(0); + const finish = (error?: string) => { + if (closed) return; + closed = true; + child.stdin.destroy(); + child.kill(); + onDisconnect(error); + }; + // Host diagnostics can contain private data. Drain them without logging them. + child.stderr.resume(); + child.on("error", () => finish("Failed to start native messaging host.")); + child.stdin.on("error", () => finish("Error when communicating with the native messaging host.")); + child.stdout.on("error", () => + finish("Error when communicating with the native messaging host."), + ); + child.stdout.on("data", (chunk: Buffer) => { + if (closed) return; + pending = Buffer.concat([pending, chunk]); + while (pending.length >= 4) { + const length = littleEndian ? pending.readUInt32LE(0) : pending.readUInt32BE(0); + if (length === 0 || length > MAX_HOST_MESSAGE) { + finish("Invalid native messaging message size."); + return; + } + if (pending.length < length + 4) return; + let message: unknown; + try { + const json = new TextDecoder("utf-8", { fatal: true }).decode( + pending.subarray(4, length + 4), + ); + message = JSON.parse(json); + } catch { + finish("Invalid JSON from the native messaging host."); + return; + } + pending = pending.subarray(length + 4); + onMessage(message); + if (closed) return; + } + }); + child.stdout.on("end", () => + finish(pending.length ? "Incomplete native messaging message." : "Native host has exited."), + ); + child.on("close", () => finish("Native host has exited.")); + return { + postMessage(message) { + if (closed) throw new Error("Attempting to use a disconnected port object."); + const json = JSON.stringify(message); + if (json === undefined) throw new Error("Native messages must be JSON serializable."); + const body = Buffer.from(json, "utf8"); + if (body.length > MAX_BROWSER_MESSAGE) + throw new Error("Native messaging message is too large."); + const header = Buffer.alloc(4); + if (littleEndian) header.writeUInt32LE(body.length); + else header.writeUInt32BE(body.length); + child.stdin.write(Buffer.concat([header, body])); + }, + disconnect: () => finish(), + }; +} diff --git a/apps/desktop/src/preview/extensions/NativeMessagingIPC.test.ts b/apps/desktop/src/preview/extensions/NativeMessagingIPC.test.ts new file mode 100644 index 000000000000..a773f110e1bd --- /dev/null +++ b/apps/desktop/src/preview/extensions/NativeMessagingIPC.test.ts @@ -0,0 +1,168 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Native messaging subprocess and IPC boundary tests. +import * as NodeEvents from "node:events"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + onQuit: vi.fn(), + connect: vi.fn(), +})); +vi.mock("electron", () => ({ ipcMain: { handle: mocks.handle }, app: { on: mocks.onQuit } })); +vi.mock("./NativeMessaging.ts", () => ({ connectNativeHost: mocks.connect })); + +const ID = "aeblfdkhhhdcdjpifhhbdiojplfjncoa"; +const extension = { id: ID, manifest: { permissions: ["nativeMessaging"] } }; +type Invoke = ( + event: unknown, + extensionId: string, + method: string, + args: unknown[], +) => Promise; + +beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.connect.mockImplementation(async (_directories, _host, _id, _message, close) => ({ + postMessage: vi.fn(), + disconnect: vi.fn(() => close()), + })); +}); + +async function setup() { + const { enableNativeMessaging } = await import("./NativeMessagingIPC.ts"); + const workers = new Map>(); + const extensions = Object.assign(new NodeEvents.EventEmitter(), { + getExtension: vi.fn((id) => (id === ID ? extension : null)), + }); + const serviceWorkers = Object.assign(new NodeEvents.EventEmitter(), { + getWorkerFromVersionID: (id: number) => workers.get(id), + }); + const session = { extensions, serviceWorkers }; + enableNativeMessaging(session as unknown as Parameters[0], [ + "/registered-hosts", + ]); + const invoke = mocks.handle.mock.calls[0]![1] as Invoke; + return { session, workers, serviceWorkers, extensions, invoke, enableNativeMessaging }; +} + +function worker(versionId = 1) { + const end = vi.fn(); + return { + versionId, + scope: `chrome-extension://${ID}/`, + send: vi.fn(), + isDestroyed: () => false, + startTask: vi.fn(() => ({ end })), + end, + ipc: { handle: vi.fn() }, + }; +} + +it("rejects a spoofed origin, a foreign session, and missing nativeMessaging permission", async () => { + const { session, invoke, extensions } = await setup(); + const owner = worker(); + owner.scope = "https://untrusted.example/"; + const event = { type: "service-worker", session, serviceWorker: owner }; + await expect(invoke(event, ID, "connect", ["port", "com.otter.test"])).rejects.toThrow( + "Not an extension", + ); + owner.scope = `chrome-extension://${ID}/`; + await expect( + invoke({ ...event, session: { ...session } }, ID, "connect", ["port", "com.otter.test"]), + ).rejects.toThrow("Not an extension"); + extensions.getExtension.mockReturnValue({ id: ID, manifest: { permissions: [] } }); + await expect(invoke(event, ID, "connect", ["port", "com.otter.test"])).rejects.toThrow( + "nativeMessaging permission", + ); + expect(mocks.connect).not.toHaveBeenCalled(); +}); + +it("keeps a worker alive, sends only to it, and ends its task once on disconnect", async () => { + const { session, workers, serviceWorkers, invoke } = await setup(); + const owner = worker(); + workers.set(1, owner); + serviceWorkers.emit("running-status-changed", { versionId: 1, runningStatus: "starting" }); + expect(owner.ipc.handle).toHaveBeenCalledWith("crx:nativeCall", expect.any(Function)); + const event = { type: "service-worker", session, serviceWorker: owner }; + await invoke(event, ID, "connect", ["port", "com.otter.test"]); + expect(owner.startTask).toHaveBeenCalledTimes(1); + const [, , , message] = mocks.connect.mock.calls[0]!; + message({ hello: true }); + expect(owner.send).toHaveBeenCalledWith("crx:nativeEvent", "port", "message", { hello: true }); + await invoke(event, ID, "postMessage", ["port", { hello: true }]); + await invoke(event, ID, "disconnect", ["port"]); + expect(owner.end).toHaveBeenCalledTimes(1); + expect(owner.send).toHaveBeenCalledWith("crx:nativeEvent", "port", "disconnect", undefined); + await expect(invoke(event, ID, "postMessage", ["port", {}])).rejects.toThrow("disconnected port"); +}); + +it("isolates ports between workers and rejects duplicate ids within one owner", async () => { + const { session, invoke } = await setup(); + const first = { type: "service-worker", session, serviceWorker: worker(1) }; + const second = { type: "service-worker", session, serviceWorker: worker(2) }; + await invoke(first, ID, "connect", ["same-id", "com.otter.test"]); + await expect(invoke(first, ID, "connect", ["same-id", "com.otter.test"])).rejects.toThrow( + "already exists", + ); + await expect(invoke(second, ID, "postMessage", ["same-id", {}])).rejects.toThrow( + "disconnected port", + ); + await invoke(second, ID, "connect", ["same-id", "com.otter.test"]); + expect(mocks.connect).toHaveBeenCalledTimes(2); +}); + +it("closes hosts and releases keepalive tasks when extensions unload", async () => { + const { session, invoke, extensions } = await setup(); + const owner = worker(); + await invoke({ type: "service-worker", session, serviceWorker: owner }, ID, "connect", [ + "port", + "com.otter.test", + ]); + const port = await mocks.connect.mock.results[0]!.value; + extensions.emit("extension-unloaded", {}, extension); + expect(port.disconnect).toHaveBeenCalledTimes(1); + expect(owner.end).toHaveBeenCalledTimes(1); +}); + +it("closes a host whose worker stops while its manifest is still loading", async () => { + const { session, invoke, workers, serviceWorkers } = await setup(); + const owner = worker(); + workers.set(1, owner); + serviceWorkers.emit("running-status-changed", { versionId: 1, runningStatus: "starting" }); + let finish!: (port: { postMessage: () => void; disconnect: () => void }) => void; + const opening = new Promise((resolve) => { + finish = resolve; + }); + mocks.connect.mockReturnValueOnce(opening); + const connecting = invoke( + { type: "service-worker", session, serviceWorker: owner }, + ID, + "connect", + ["port", "com.otter.test"], + ); + serviceWorkers.emit("running-status-changed", { versionId: 1, runningStatus: "stopped" }); + expect(owner.end).toHaveBeenCalledTimes(1); + const port = { postMessage: vi.fn(), disconnect: vi.fn() }; + finish(port); + await connecting; + expect(port.disconnect).toHaveBeenCalledTimes(1); + expect(owner.end).toHaveBeenCalledTimes(1); +}); + +it("closes a page's port when its document navigates", async () => { + const { session, invoke } = await setup(); + const frame = { + url: `chrome-extension://${ID}/options.html`, + frameTreeNodeId: 7, + isDestroyed: () => false, + send: vi.fn(), + }; + const contents = Object.assign(new NodeEvents.EventEmitter(), { session, mainFrame: frame }); + const event = { type: "frame", sender: contents, senderFrame: frame }; + await invoke(event, ID, "connect", ["port", "com.otter.test"]); + const port = await mocks.connect.mock.results[0]!.value; + contents.emit("did-start-navigation", { isSameDocument: true, frame }); + expect(port.disconnect).not.toHaveBeenCalled(); + contents.emit("did-start-navigation", { isSameDocument: false, frame }); + expect(port.disconnect).toHaveBeenCalledTimes(1); +}); diff --git a/apps/desktop/src/preview/extensions/NativeMessagingIPC.ts b/apps/desktop/src/preview/extensions/NativeMessagingIPC.ts new file mode 100644 index 000000000000..c62a9177b538 --- /dev/null +++ b/apps/desktop/src/preview/extensions/NativeMessagingIPC.ts @@ -0,0 +1,200 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Native host processes use Chrome's stdio protocol at the Electron boundary. +import { + app, + ipcMain, + type IpcMainInvokeEvent, + type IpcMainServiceWorkerInvokeEvent, + type ServiceWorkerMain, + type Session, + type WebContents, + type WebFrameMain, +} from "electron"; + +import { connectNativeHost, type NativePort } from "./NativeMessaging.ts"; + +type Owner = ServiceWorkerMain | WebFrameMain; +type Connection = { extensionId: string; port?: NativePort; release(): void }; +type Profile = { directories: string[]; owners: Map> }; +type CallEvent = IpcMainInvokeEvent | IpcMainServiceWorkerInvokeEvent; +const profiles = new Map(); +const watchedPages = new WeakSet(); +let wired = false; + +function disconnectOwner(profile: Profile, owner: Owner): void { + const ports = profile.owners.get(owner); + profile.owners.delete(owner); + for (const [id, connection] of ports ?? []) { + ports!.delete(id); + connection.port?.disconnect(); + connection.release(); + } +} + +async function call(event: CallEvent, extensionId: unknown, method: unknown, args: unknown) { + const worker = event.type === "service-worker" ? event.serviceWorker : null; + const frame = event.type === "frame" ? event.senderFrame : null; + const session = event.type === "service-worker" ? event.session : event.sender.session; + const profile = profiles.get(session); + const owner = worker ?? frame; + const url = worker?.scope ?? frame?.url; + const extension = + typeof extensionId === "string" ? session.extensions.getExtension(extensionId) : null; + if ( + !profile || + !owner || + owner.isDestroyed() || + !extension || + !url?.startsWith(`chrome-extension://${extension.id}/`) + ) { + throw new Error("Not an extension of this browser."); + } + const permissions = (extension.manifest as { permissions?: string[] }).permissions; + if (!permissions?.includes("nativeMessaging")) + throw new Error("The nativeMessaging permission is required."); + if ( + !Array.isArray(args) || + typeof args[0] !== "string" || + !/^[a-zA-Z0-9-]{1,100}$/.test(args[0]) + ) { + throw new Error("Invalid native messaging port."); + } + const [id, value] = args as [string, unknown]; + let ports = profile.owners.get(owner); + if (method === "connect") { + if (typeof value !== "string") throw new Error("Invalid native messaging host name specified."); + if (ports?.has(id)) throw new Error("Native messaging port already exists."); + if (!ports) { + ports = new Map(); + profile.owners.set(owner, ports); + } + if (frame && event.type === "frame" && !watchedPages.has(event.sender)) { + const contents = event.sender; + watchedPages.add(contents); + const closePage = () => { + for (const each of profile.owners.keys()) { + if ( + "frameTreeNodeId" in each && + (each.isDestroyed() || each.top === contents.mainFrame) + ) { + disconnectOwner(profile, each); + } + } + }; + contents.on("render-process-gone", closePage); + contents.once("destroyed", () => { + for (const each of profile.owners.keys()) { + if ("frameTreeNodeId" in each && each.isDestroyed()) disconnectOwner(profile, each); + } + }); + contents.on("did-start-navigation", (details) => { + if (details.isSameDocument) return; + for (const each of profile.owners.keys()) { + if ( + "frameTreeNodeId" in each && + each.frameTreeNodeId === details.frame?.frameTreeNodeId + ) { + disconnectOwner(profile, each); + } + } + }); + } + const task = worker?.startTask(); + const connections = ports; + const connection: Connection = { extensionId: extension.id, release: () => task?.end() }; + connections.set(id, connection); + const send = (kind: string, payload?: unknown) => { + if (owner.isDestroyed() || ("detached" in owner && owner.detached)) { + disconnectOwner(profile, owner); + return; + } + try { + owner.send("crx:nativeEvent", id, kind, payload); + } catch { + disconnectOwner(profile, owner); + } + }; + const close = (error?: string) => { + if (connections.get(id) !== connection) return; + connections.delete(id); + if (connections.size === 0) profile.owners.delete(owner); + connection.release(); + send("disconnect", error); + }; + try { + const port = await connectNativeHost( + profile.directories, + value, + extension.id, + (message) => send("message", message), + close, + ); + if (connections.get(id) !== connection) port.disconnect(); + else connection.port = port; + } catch (error) { + close( + error instanceof Error ? error.message : "Couldn't connect to the native messaging host.", + ); + } + return; + } + const connection = ports?.get(id); + if (!connection || connection.extensionId !== extension.id) + throw new Error("Attempting to use a disconnected port object."); + if (method === "postMessage") { + if (!connection.port) throw new Error("Native messaging port is still connecting."); + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Chrome's native Port has no target origin argument. + connection.port.postMessage(value); + } else if (method === "disconnect") { + if (connection.port) connection.port.disconnect(); + else { + ports!.delete(id); + if (ports!.size === 0) profile.owners.delete(owner); + connection.release(); + } + } else throw new Error("Unknown native messaging method."); +} + +/** One transport per browser profile, with ports private to the calling frame or worker. */ +export function enableNativeMessaging(session: Session, directories: string[]): void { + if (profiles.has(session)) return; + const profile: Profile = { directories, owners: new Map() }; + profiles.set(session, profile); + if (!wired) { + wired = true; + ipcMain.handle("crx:nativeCall", (event, ...args) => + call(event as CallEvent, ...(args as [unknown, unknown, unknown])), + ); + app.on("will-quit", () => { + for (const each of profiles.values()) { + for (const owner of each.owners.keys()) disconnectOwner(each, owner); + } + }); + } + const workers = new Map(); + session.serviceWorkers.on("running-status-changed", ({ versionId, runningStatus }) => { + if (runningStatus === "stopped") { + const worker = workers.get(versionId); + if (worker) disconnectOwner(profile, worker); + workers.delete(versionId); + return; + } + if ((runningStatus !== "starting" && runningStatus !== "running") || workers.has(versionId)) + return; + const worker = session.serviceWorkers.getWorkerFromVersionID(versionId); + if (!worker || !worker.scope.startsWith("chrome-extension://")) return; + workers.set(versionId, worker); + worker.ipc.handle("crx:nativeCall", (event, ...args) => + call(event, ...(args as [unknown, unknown, unknown])), + ); + }); + session.extensions.on("extension-unloaded", (_event, extension) => { + for (const [owner, ports] of profile.owners) { + for (const connection of ports.values()) { + if (connection.extensionId === extension.id) { + disconnectOwner(profile, owner); + break; + } + } + } + }); +} diff --git a/apps/desktop/src/preview/extensions/NativeMessagingLocations.ts b/apps/desktop/src/preview/extensions/NativeMessagingLocations.ts new file mode 100644 index 000000000000..fccdb4ed87ce --- /dev/null +++ b/apps/desktop/src/preview/extensions/NativeMessagingLocations.ts @@ -0,0 +1,32 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Chrome's native host locations at the Electron boundary. +import { app } from "electron"; +import * as NodePath from "node:path"; + +export function nativeMessagingDirectories( + platform: NodeJS.Platform, + configHome?: string, +): string[] { + const own = NodePath.join(app.getPath("userData"), "NativeMessagingHosts"); + if (platform === "darwin") { + const support = NodePath.join(app.getPath("home"), "Library/Application Support"); + return [ + own, + NodePath.join(support, "Google/Chrome/NativeMessagingHosts"), + NodePath.join(support, "Chromium/NativeMessagingHosts"), + "/Library/Google/Chrome/NativeMessagingHosts", + "/Library/Application Support/Chromium/NativeMessagingHosts", + ]; + } + if (platform === "linux") { + const config = configHome || NodePath.join(app.getPath("home"), ".config"); + return [ + own, + NodePath.join(config, "google-chrome/NativeMessagingHosts"), + NodePath.join(config, "chromium/NativeMessagingHosts"), + "/etc/opt/chrome/native-messaging-hosts", + "/etc/chromium/native-messaging-hosts", + "/etc/chromium-browser/native-messaging-hosts", + ]; + } + return [own]; +} diff --git a/apps/desktop/src/preview/extensions/PreviewExtensions.test.ts b/apps/desktop/src/preview/extensions/PreviewExtensions.test.ts new file mode 100644 index 000000000000..d1774b811c94 --- /dev/null +++ b/apps/desktop/src/preview/extensions/PreviewExtensions.test.ts @@ -0,0 +1,98 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Tests the Electron extension IPC boundary. +import * as NodeEvents from "node:events"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +import { OPEN_WEB_STORE_CHANNEL } from "./channels.ts"; + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + loadURL: vi.fn(), + windows: vi.fn(), + enableNativeMessaging: vi.fn(), + installChromeWebStore: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { on: vi.fn(), getPath: () => "/test-user-data" }, + ipcMain: { handle: mocks.handle }, + webContents: { getAllWebContents: () => [] }, + nativeTheme: { shouldUseDarkColors: false }, + BrowserWindow: class { + static fromWebContents() { + return null; + } + readonly webContents = Object.assign(new NodeEvents.EventEmitter(), { + id: 10, + setWindowOpenHandler: vi.fn(), + }); + readonly loadURL = mocks.loadURL; + constructor(options: unknown) { + mocks.windows(options); + } + }, +})); + +vi.mock("./NativeMessagingIPC.ts", () => ({ + enableNativeMessaging: mocks.enableNativeMessaging, +})); + +vi.mock("electron-chrome-web-store", () => ({ + installChromeWebStore: mocks.installChromeWebStore, +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +async function setup() { + const { installPreviewExtensions, enablePreviewExtensions } = + await import("./PreviewExtensions.ts"); + installPreviewExtensions({ + preloadPath: "/test-extension-preload.cjs", + nativeMessagingDirectories: ["/test-native-hosts"], + }); + const session = { + registerPreloadScript: vi.fn(), + serviceWorkers: Object.assign(new NodeEvents.EventEmitter(), { + getWorkerFromVersionID: vi.fn(), + }), + extensions: Object.assign(new NodeEvents.EventEmitter(), { + getAllExtensions: () => [], + }), + }; + enablePreviewExtensions(session as unknown as Parameters[0]); + const open = mocks.handle.mock.calls.find( + ([channel]) => channel === OPEN_WEB_STORE_CHANNEL, + )![1] as (event: unknown, url?: string) => void; + const event = { + sender: { getType: () => "window", session: {}, once: vi.fn() }, + }; + return { session, open: (url?: string) => open(event, url) }; +} + +it("opens an extension listing in the local extension-enabled session", async () => { + const { session, open } = await setup(); + const url = "https://chromewebstore.google.com/detail/1password/aeblfdkhhhdcdjpifhhbdiojplfjncoa"; + open(url); + + expect(mocks.loadURL).toHaveBeenCalledWith(url); + expect(mocks.windows).toHaveBeenCalledWith( + expect.objectContaining({ webPreferences: expect.objectContaining({ session }) }), + ); + expect(mocks.enableNativeMessaging).toHaveBeenCalledWith(session, ["/test-native-hosts"]); +}); + +it("keeps the default store page for callers that do not supply a listing", async () => { + const { open } = await setup(); + open(); + expect(mocks.loadURL).toHaveBeenCalledWith( + "https://chromewebstore.google.com/category/extensions", + ); +}); + +it("refuses to open other origins as an extension store", async () => { + const { open } = await setup(); + expect(() => open("https://example.com/")).toThrow("Expected a Chrome Web Store URL."); + expect(mocks.windows).not.toHaveBeenCalled(); +}); diff --git a/apps/desktop/src/preview/extensions/PreviewExtensions.ts b/apps/desktop/src/preview/extensions/PreviewExtensions.ts new file mode 100644 index 000000000000..05b46badcd11 --- /dev/null +++ b/apps/desktop/src/preview/extensions/PreviewExtensions.ts @@ -0,0 +1,1629 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Extensions are folders Electron loads by path. +// @effect-diagnostics globalTimers:off -- Electron's callbacks (workers, popups, IPC) run outside any Effect fiber. +// @effect-diagnostics globalDate:off -- A failed worker's retry delay, at the same native boundary. +// @effect-diagnostics globalConsole:off -- Extensions' failures, logged where Electron reports them. +/** + * Chrome extensions in the browser preview. + * + * Electron runs an extension's worker, its pages and its content scripts, but + * none of Chrome's toolbar, tab strip or windows. This adds them: the Chrome + * Web Store installs extensions (electron-chrome-web-store), the preview's + * toolbar shows their buttons and popups, and what Electron lacks of + * `chrome.action`, `chrome.tabs`, `chrome.windows` and friends arrives here + * from preview-extensions-preload.ts. + * + * Each persistent preview partition (one per environment and profile) is a + * Chrome profile: the same extensions load into all of them, and each runs its + * own workers and sees only its own tabs. Incognito partitions get none, as in + * Chrome. Tabs are the preview's webview guests, plus the windows extensions + * open their own pages in, each known by its webContents id. + */ + +import { + BrowserWindow, + Menu, + Notification, + app, + dialog, + ipcMain, + nativeImage, + nativeTheme, + shell, + webContents as allWebContents, + webFrameMain, + type ContextMenuParams, + type Extension, + type IpcMainInvokeEvent, + type IpcMainServiceWorkerInvokeEvent, + type MenuItemConstructorOptions, + type ServiceWorkerMain, + type Session, + type WebContents, + type WebFrameMain, +} from "electron"; +import type { + DesktopPreviewExtension, + DesktopPreviewExtensionAction, + DesktopPreviewExtensionAnchor, +} from "@t3tools/contracts"; +import type { installChromeWebStore } from "electron-chrome-web-store"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as Channels from "./channels.ts"; +import { enableNativeMessaging } from "./NativeMessagingIPC.ts"; + +/** The parts of an extension's manifest.json read here. */ +type Manifest = { + name?: string; + version?: string; + description?: string; + default_locale?: string; + icons?: { [size: number]: string }; + action?: { + default_title?: string; + default_icon?: string | { [size: number]: string }; + default_popup?: string; + }; + options_page?: string; + options_ui?: { page?: string }; + permissions?: string[]; + host_permissions?: string[]; + content_scripts?: { matches?: string[] }[]; + commands?: Record }>; + background?: { service_worker?: string }; +}; + +const manifestOf = (extension: Extension) => extension.manifest as Manifest; + +const WEB_STORE_URL = "https://chromewebstore.google.com/category/extensions"; + +// ── Profiles: the partitions extensions run in ─────────────────────── + +type Profile = { + readonly session: Session; + /** The events each extension listens to (its worker wakes for those). */ + readonly listening: Map>; + /** Extension pages (popups, options) that listen, by extension. */ + readonly pages: Map>; + /** Waits for an extension to (re)start listening to an event, by `${id} ${name}`. */ + readonly listenWaiters: Map void>>; + /** Workers that just failed to start, and when: they aren't retried for a while. */ + readonly failedWorkers: Map; + readonly starting: Map>; + /** The tab last shown in this profile: Chrome's active tab. */ + activeTabId: number; +}; + +const profiles = new Map(); + +const firstProfile = (): Profile | undefined => profiles.values().next().value; + +// ── Tabs ───────────────────────────────────────────────────────────── + +type Tab = { readonly contents: WebContents; readonly profile: Profile; favicon: string | null }; + +const tabs = new Map(); + +const tabIdsOf = (profile: Profile) => + [...tabs.entries()].filter(([, tab]) => tab.profile === profile).map(([tabId]) => tabId); + +/** The window a tab shows in: a webview's host window, or a page window's own. */ +const windowOfContents = (contents: WebContents) => + BrowserWindow.fromWebContents(contents.hostWebContents ?? contents); + +/** A page shows its address to extensions that may read it (Chrome's rule). */ +function seesPage(extension: Extension | null, tabId: number): boolean { + if (!extension) return true; + const manifest = manifestOf(extension); + const permissions = manifest.permissions ?? []; + return ( + permissions.includes("tabs") || + (manifest.host_permissions ?? []).length > 0 || + (permissions.includes("activeTab") && tabId === tabs.get(tabId)?.profile.activeTabId) + ); +} + +function tabOf(tabId: number, extension: Extension | null = null) { + const tab = tabs.get(tabId); + if (!tab || tab.contents.isDestroyed()) return null; + const { contents, profile, favicon } = tab; + const active = tabId === profile.activeTabId; + return { + id: tabId, + index: tabIdsOf(profile).indexOf(tabId), + windowId: windowOfContents(contents)?.id ?? -1, + active, + highlighted: active, + selected: active, + pinned: false, + incognito: false, + discarded: false, + autoDiscardable: true, + frozen: false, + groupId: -1, + status: contents.isLoading() ? "loading" : "complete", + audible: contents.isCurrentlyAudible(), + mutedInfo: { muted: contents.isAudioMuted() }, + ...(seesPage(extension, tabId) + ? { url: contents.getURL(), title: contents.getTitle(), favIconUrl: favicon ?? undefined } + : {}), + }; +} + +function windowOf( + window: BrowserWindow, + profile: Profile, + populate: boolean, + extension: Extension | null, +) { + const bounds = window.getBounds(); + const windowTabs = tabIdsOf(profile).filter( + (tabId) => windowOfContents(tabs.get(tabId)!.contents) === window, + ); + return { + id: window.id, + focused: window.isFocused(), + top: bounds.y, + left: bounds.x, + width: bounds.width, + height: bounds.height, + incognito: false, + alwaysOnTop: false, + type: windowTabs.length > 0 ? "normal" : "popup", + state: window.isFullScreen() + ? "fullscreen" + : window.isMinimized() + ? "minimized" + : window.isMaximized() + ? "maximized" + : "normal", + ...(populate + ? { tabs: windowTabs.map((tabId) => tabOf(tabId, extension)).filter(Boolean) } + : {}), + }; +} + +/** The app's windows: the renderers that drew a toolbar or Settings. */ +const appRenderers = new Set(); + +function appWindow(profile?: Profile): BrowserWindow | null { + const host = + (profile && tabs.get(profile.activeTabId)?.contents.hostWebContents) ?? + [...tabs.values()].find((tab) => tab.profile === profile && tab.contents.hostWebContents) + ?.contents.hostWebContents; + const hostWindow = host ? BrowserWindow.fromWebContents(host) : null; + if (hostWindow) return hostWindow; + const windows = [...appRenderers] + .map((renderer) => BrowserWindow.fromWebContents(renderer)) + .filter((window): window is BrowserWindow => window !== null && !window.isDestroyed()); + return windows.find((window) => window.isFocused()) ?? windows[0] ?? null; +} + +/** The window an extension's call means by "current": the caller's own, or its profile's active tab's. */ +function currentWindow(caller: Caller): BrowserWindow | null { + const own = caller.sender && tabs.has(caller.sender.id) ? caller.sender : null; + const active = tabs.get(caller.profile.activeTabId)?.contents; + const contents = own ?? active; + return (contents && windowOfContents(contents)) ?? appWindow(caller.profile); +} + +/** Tabs an extension opened, waiting for their page to arrive. */ +const creating: Array<{ + readonly profile: Profile; + readonly url: string; + readonly resolve: (tabId: number | null) => void; +}> = []; + +function trackTab(contents: WebContents, profile: Profile): void { + const tabId = contents.id; + if (tabs.has(tabId)) return; + tabs.set(tabId, { contents, profile, favicon: null }); + const updated = (change: Record) => + emit(profile, (extension) => ["tabs.onUpdated", [tabId, change, tabOf(tabId, extension)]]); + let fresh = true; + contents.on("did-start-loading", () => updated({ status: "loading" })); + contents.on("did-stop-loading", () => updated({ status: "complete" })); + contents.on("did-navigate", (_event, url) => { + updated({ url }); + if (!fresh) return; + fresh = false; + const waiting = creating.findIndex( + (pending) => pending.profile === profile && pending.url === URL.parse(url)?.href, + ); + if (waiting >= 0) creating.splice(waiting, 1)[0]!.resolve(tabId); + }); + contents.on("did-navigate-in-page", (_event, url, isMainFrame) => { + if (isMainFrame) updated({ url }); + }); + contents.on("page-title-updated", (_event, title) => updated({ title })); + contents.on("page-favicon-updated", (_event, favicons) => { + const tab = tabs.get(tabId); + if (tab) tab.favicon = favicons[0] ?? null; + updated({ favIconUrl: favicons[0] }); + }); + // webNavigation, frame by frame (Chrome's frame ids: 0 for the page itself). + const navigation = (name: string, frame: WebFrameMain | null | undefined, extra = {}) => { + if (!frame) return; + emit(profile, () => [ + `webNavigation.${name}`, + [ + { + tabId, + frameId: frameIdOf(contents, frame), + parentFrameId: frame.parent ? frameIdOf(contents, frame.parent) : -1, + url: frame.url, + processId: frame.processId, + timeStamp: Date.now(), + ...extra, + }, + ], + ]); + }; + contents.on("did-start-navigation", (details) => { + if (!details.isSameDocument) { + navigation("onBeforeNavigate", details.frame, { url: details.url }); + } + }); + contents.on( + "did-frame-navigate", + (_event, url, _code, _status, isMainFrame, processId, routingId) => + navigation("onCommitted", frameOf(processId, routingId), { + url, + transitionType: isMainFrame ? "link" : "auto_subframe", + transitionQualifiers: [], + }), + ); + contents.on("dom-ready", () => navigation("onDOMContentLoaded", contents.mainFrame)); + contents.on("did-frame-finish-load", (_event, _isMainFrame, processId, routingId) => + navigation("onCompleted", frameOf(processId, routingId)), + ); + contents.on( + "did-fail-load", + (_event, code, description, url, _isMainFrame, processId, routingId) => { + // -3 is an aborted load: the page went elsewhere. + if (code !== -3) { + navigation("onErrorOccurred", frameOf(processId, routingId), { url, error: description }); + } + }, + ); + contents.on("did-navigate-in-page", (_event, url, isMainFrame, processId, routingId) => { + navigation( + URL.parse(url)?.hash ? "onReferenceFragmentUpdated" : "onHistoryStateUpdated", + isMainFrame ? contents.mainFrame : frameOf(processId, routingId), + { url, transitionType: "link", transitionQualifiers: [] }, + ); + }); + contents.once("destroyed", () => { + tabs.delete(tabId); + for (const state of actions.values()) state.tabs.delete(tabId); + if (profile.activeTabId === tabId) profile.activeTabId = -1; + emit(profile, () => ["tabs.onRemoved", [tabId, { windowId: -1, isWindowClosing: false }]]); + notifyChanged(); + }); + emit(profile, (extension) => ["tabs.onCreated", [tabOf(tabId, extension)]]); +} + +function frameIdOf(contents: WebContents, frame: WebFrameMain): number { + return frame === contents.mainFrame ? 0 : frame.frameTreeNodeId; +} + +function frameOf(processId: number, routingId: number): WebFrameMain | undefined { + return webFrameMain.fromId(processId, routingId) ?? undefined; +} + +function frameInfo(contents: WebContents, frame: WebFrameMain) { + return { + frameId: frameIdOf(contents, frame), + parentFrameId: frame.parent ? frameIdOf(contents, frame.parent) : -1, + url: frame.url, + processId: frame.processId, + errorOccurred: false, + }; +} + +function setActiveTab(tabId: number): void { + const tab = tabs.get(tabId); + if (!tab || tab.profile.activeTabId === tabId) return; + tab.profile.activeTabId = tabId; + emit(tab.profile, () => [ + "tabs.onActivated", + [{ tabId, windowId: windowOfContents(tab.contents)?.id ?? -1 }], + ]); + notifyChanged(); +} + +/** + * A tab an extension opens: a web page in a preview tab beside the thread + * showing (the renderer opens it), an extension's own page in a window. + */ +function createTab(profile: Profile, rawUrl: string, active = true): Promise { + const url = URL.parse(rawUrl)?.href; + if (url?.startsWith("chrome-extension:")) { + return Promise.resolve(openPageWindow(profile, url, { show: active }).webContents.id); + } + const window = appWindow(profile); + if (!url || !/^https?:/.test(url) || !window) return Promise.resolve(null); + window.webContents.send(Channels.OPEN_TAB_CHANNEL, url); + return new Promise((resolve) => { + const pending = { profile, url, resolve }; + creating.push(pending); + setTimeout(() => { + const at = creating.indexOf(pending); + if (at < 0) return; + creating.splice(at, 1); + resolve(null); + }, 5_000); + }); +} + +/** An extension's own page (its options, a window it asked for), in a window of its profile. */ +function openPageWindow( + profile: Profile, + url: string, + options: { width?: number; height?: number; show?: boolean } = {}, +): BrowserWindow { + const window = new BrowserWindow({ + width: options.width ?? 1024, + height: options.height ?? 768, + show: options.show ?? true, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#202020" : "#ffffff", + webPreferences: { + session: profile.session, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + preparePage(profile, window.webContents); + trackTab(window.webContents, profile); + void window.loadURL(url); + return window; +} + +/** Around an extension's page: links open as tabs, and a context menu to edit and inspect. */ +function preparePage(profile: Profile, contents: WebContents): void { + contents.setWindowOpenHandler(({ url }) => { + void createTab(profile, url); + return { action: "deny" }; + }); + contents.on("context-menu", (_event, params) => { + const window = BrowserWindow.fromWebContents(contents); + Menu.buildFromTemplate([ + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { type: "separator" }, + { label: "Inspect", click: () => contents.inspectElement(params.x, params.y) }, + ]).popup(window ? { window } : {}); + }); +} + +/** Resolves an extension's relative URL ("options.html") against its own origin. */ +function absolute(extensionId: string, url: string): string { + return new URL(url, `chrome-extension://${extensionId}/`).href; +} + +// ── Events, to extensions' workers and pages ───────────────────────── + +function onListen(profile: Profile, extensionId: string, name: string, page?: WebContents): void { + const names = profile.listening.get(extensionId) ?? new Set(); + profile.listening.set(extensionId, names); + names.add(name); + if (page) { + const set = profile.pages.get(extensionId) ?? new Set(); + profile.pages.set(extensionId, set); + if (!set.has(page)) { + set.add(page); + page.once("destroyed", () => set.delete(page)); + } + } + const key = `${extensionId} ${name}`; + for (const resolve of profile.listenWaiters.get(key) ?? []) resolve(); + profile.listenWaiters.delete(key); +} + +function runningWorker(profile: Profile, extensionId: string): ServiceWorkerMain | undefined { + const scope = `chrome-extension://${extensionId}/`; + const workers = profile.session.serviceWorkers; + for (const [versionId, info] of Object.entries(workers.getAllRunning())) { + if (info.scope === scope) return workers.getWorkerFromVersionID(Number(versionId)); + } + return undefined; +} + +const RETRY_FAILED_WORKER_MS = 60_000; + +/** The extension's background worker, started if it sleeps (it listens again as it starts). */ +async function wakeWorker( + profile: Profile, + extensionId: string, + name: string, +): Promise { + const running = runningWorker(profile, extensionId); + if (running) return running; + if (Date.now() - (profile.failedWorkers.get(extensionId) ?? 0) < RETRY_FAILED_WORKER_MS) { + return null; + } + const extension = profile.session.extensions.getExtension(extensionId); + if (!extension || !manifestOf(extension).background?.service_worker) return null; + const key = `${extensionId} ${name}`; + const listened = new Promise((resolve) => { + profile.listenWaiters.set(key, [...(profile.listenWaiters.get(key) ?? []), resolve]); + setTimeout(resolve, 2_000); + }); + // Events arriving together share one start. + let start = profile.starting.get(extensionId); + if (!start) { + start = profile.session.serviceWorkers + .startWorkerForScope(`chrome-extension://${extensionId}/`) + .catch((error: unknown) => { + profile.failedWorkers.set(extensionId, Date.now()); + console.warn("Couldn't start an extension's worker", { extensionId, error: String(error) }); + return null; + }) + .finally(() => profile.starting.delete(extensionId)); + profile.starting.set(extensionId, start); + } + const worker = await start; + if (worker) await listened; + return worker; +} + +async function deliver( + profile: Profile, + extensionId: string, + name: string, + args: unknown[], +): Promise { + for (const page of profile.pages.get(extensionId) ?? []) { + if (!page.isDestroyed()) page.send(Channels.CRX_EVENT_CHANNEL, name, args); + } + const worker = await wakeWorker(profile, extensionId, name); + worker?.send(Channels.CRX_EVENT_CHANNEL, name, args); +} + +/** Sends an event to every extension of `profile` listening for it; `make` tailors it to each. */ +function emit(profile: Profile, make: (extension: Extension) => [string, unknown[]]): void { + for (const [extensionId, names] of profile.listening) { + const extension = profile.session.extensions.getExtension(extensionId); + if (!extension) continue; + const [name, args] = make(extension); + if (names.has(name)) void deliver(profile, extensionId, name, args); + } +} + +// ── The toolbar: chrome.action ─────────────────────────────────────── + +type ActionDetails = { + title?: string; + icon?: string | null; + popup?: string; + badgeText?: string; + badgeBackgroundColor?: string; + badgeTextColor?: string; + enabled?: boolean; +}; + +/** What each extension changed, for every tab and for single tabs. */ +const actions = new Map }>(); + +function actionState(extensionId: string) { + const state = actions.get(extensionId) ?? { all: {}, tabs: new Map() }; + actions.set(extensionId, state); + return state; +} + +/** The icon file for about `size` px from a manifest's `{ "16": …, "32": … }` (or one path). */ +function iconFile(icons: string | { [size: number]: string } | undefined, size: number) { + if (!icons || typeof icons === "string") return icons; + const sizes = Object.keys(icons) + .map(Number) + .toSorted((a, b) => a - b); + const best = sizes.find((each) => each >= size) ?? sizes.at(-1); + return best === undefined ? undefined : icons[best]; +} + +function iconDataUrl(dir: string, file: string | undefined): string | null { + if (!file) return null; + const image = nativeImage.createFromPath(NodePath.join(dir, file.replace(/^\//, ""))); + return image.isEmpty() ? null : image.toDataURL(); +} + +/** A manifest string, with Chrome's `__MSG_name__` looked up in the extension's default locale. */ +function localized(dir: string, manifest: Manifest, value: string | undefined) { + const key = value?.match(/^__MSG_(\w+)__$/)?.[1]?.toLowerCase(); + if (!key) return value; + try { + const file = NodePath.join(dir, "_locales", manifest.default_locale ?? "en", "messages.json"); + const messages = JSON.parse(NodeFS.readFileSync(file, "utf8")) as Record< + string, + { message?: string } + >; + return ( + Object.entries(messages).find(([name]) => name.toLowerCase() === key)?.[1].message ?? value + ); + } catch { + return value; + } +} + +function resolvedAction(extension: Extension, tabId: number) { + const manifest = manifestOf(extension); + const state = actionState(extension.id); + const changed = { ...state.all, ...state.tabs.get(tabId) }; + return { + title: + changed.title ?? + localized(extension.path, manifest, manifest.action?.default_title) ?? + extension.name, + icon: + changed.icon ?? + iconDataUrl(extension.path, iconFile(manifest.action?.default_icon ?? manifest.icons, 32)), + popup: changed.popup ?? manifest.action?.default_popup ?? "", + badgeText: changed.badgeText ?? "", + badgeBackgroundColor: changed.badgeBackgroundColor ?? "#5f6368", + badgeTextColor: changed.badgeTextColor ?? "#ffffff", + enabled: changed.enabled ?? true, + }; +} + +/** Chrome takes a color as a string or [r, g, b, a]. */ +function cssColor(color: unknown): string | undefined { + if (typeof color === "string") return color; + if (Array.isArray(color) && color.length >= 3) { + const [r, g, b, a = 255] = color as number[]; + return `rgba(${r}, ${g}, ${b}, ${a / 255})`; + } + return undefined; +} + +/** `setIcon`'s pixels (RGBA from an ImageData) or path, as a data: URL. */ +function iconFromDetails(extension: Extension, details: Record): string | null { + const imageData = details.imageData as + | Record + | { width: number; height: number; data: number[] } + | undefined; + if (imageData) { + const sets = + "data" in imageData && Array.isArray(imageData.data) + ? [imageData as { width: number; height: number; data: number[] }] + : Object.values( + imageData as Record, + ); + const largest = sets.toSorted((a, b) => b.width - a.width)[0]; + if (!largest) return null; + const bgra = Buffer.alloc(largest.data.length); + for (let i = 0; i < largest.data.length; i += 4) { + bgra[i] = largest.data[i + 2]!; + bgra[i + 1] = largest.data[i + 1]!; + bgra[i + 2] = largest.data[i]!; + bgra[i + 3] = largest.data[i + 3]!; + } + const image = nativeImage.createFromBitmap(bgra, { + width: largest.width, + height: largest.height, + }); + return image.isEmpty() ? null : image.toDataURL(); + } + const file = iconFile(details.path as string | { [size: number]: string } | undefined, 32); + return iconDataUrl(extension.path, file); +} + +let changedTimer: NodeJS.Timeout | null = null; + +/** Tells the app's windows to read the extensions and their buttons again (once per burst). */ +function notifyChanged(): void { + if (changedTimer) return; + changedTimer = setTimeout(() => { + changedTimer = null; + for (const renderer of appRenderers) { + if (!renderer.isDestroyed()) renderer.send(Channels.CHANGED_CHANNEL); + } + }, 30); +} + +function extensionActions(tabId: number): DesktopPreviewExtensionAction[] { + const profile = tabs.get(tabId)?.profile ?? firstProfile(); + if (!profile) return []; + return profile.session.extensions + .getAllExtensions() + .map((extension) => { + const action = resolvedAction(extension, tabId); + return { + id: extension.id, + name: extension.name, + title: action.title, + icon: action.icon, + badgeText: action.badgeText, + badgeBackgroundColor: action.badgeBackgroundColor, + badgeTextColor: action.badgeTextColor, + hasPopup: Boolean(action.popup), + enabled: action.enabled, + hasOptions: optionsUrlOf(extension.id, manifestOf(extension)) !== null, + }; + }) + .toSorted((a, b) => a.name.localeCompare(b.name)); +} + +/** A toolbar button clicked: its popup under `anchor`, or the extension's onClicked. */ +function runAction( + owner: BrowserWindow, + extensionId: string, + tabId: number, + anchor: DesktopPreviewExtensionAnchor, +): void { + const tab = tabs.get(tabId); + const extension = tab?.profile.session.extensions.getExtension(extensionId); + if (!tab || !extension) return; + setActiveTab(tabId); + const action = resolvedAction(extension, tabId); + if (!action.enabled) return; + if (action.popup) { + openPopup(owner, tab.profile, extension, action.popup, anchor); + return; + } + void deliver(tab.profile, extensionId, "action.onClicked", [tabOf(tabId, extension)]); +} + +// ── Popups ─────────────────────────────────────────────────────────── + +let popup: BrowserWindow | null = null; +/** Where the last popup hung from, for `chrome.action.openPopup`. */ +let lastPopup: { owner: BrowserWindow; anchor: DesktopPreviewExtensionAnchor } | null = null; + +/** An extension's popup: a borderless window under its button, sized to its page, gone on blur. */ +function openPopup( + owner: BrowserWindow, + profile: Profile, + extension: Extension, + page: string, + anchor: DesktopPreviewExtensionAnchor, +): void { + popup?.close(); + lastPopup = { owner, anchor }; + // The anchor is in the app's CSS pixels; the app may be zoomed. + const zoom = owner.webContents.getZoomFactor(); + const content = owner.getContentBounds(); + const right = content.x + Math.round((anchor.x + anchor.width) * zoom); + const top = content.y + Math.round((anchor.y + anchor.height) * zoom) + 4; + const window = new BrowserWindow({ + parent: owner, + show: false, + frame: false, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + hasShadow: true, + roundedCorners: true, + // Starts small, as Chrome's do, so the page's own size shows through. + x: right - 25, + y: top, + width: 25, + height: 25, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#202020" : "#ffffff", + webPreferences: { + session: profile.session, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + enablePreferredSizeMode: true, + }, + }); + popup = window; + // Chrome's bounds for a popup: 25×25 to 800×600, right-aligned to its button. + const place = (width: number, height: number) => { + const w = Math.min(800, Math.max(25, Math.ceil(width))); + const h = Math.min(600, Math.max(25, Math.ceil(height))); + window.setBounds({ x: Math.max(content.x, right - w), y: top, width: w, height: h }); + if (!window.isVisible()) window.show(); + }; + window.webContents.on("preferred-size-changed", (_event, size) => { + if (!window.isDestroyed()) place(size.width, size.height); + }); + window.webContents.once("did-finish-load", () => { + setTimeout(() => { + if (!window.isDestroyed() && !window.isVisible()) window.show(); + }, 200); + }); + window.on("blur", () => { + if (!window.isDestroyed()) window.close(); + }); + window.on("closed", () => { + if (popup === window) popup = null; + }); + preparePage(profile, window.webContents); + void window.loadURL(absolute(extension.id, page)); +} + +// ── Context menus, notifications ───────────────────────────────────── + +type MenuItem = { + id: string; + title?: string; + type?: string; + checked?: boolean; + contexts?: string[]; + parentId?: string; + documentUrlPatterns?: string[]; + targetUrlPatterns?: string[]; + enabled?: boolean; + visible?: boolean; +}; + +/** Each extension's context menu items, in the order made. */ +const menus = new Map>(); + +const menuOf = (extensionId: string) => { + const items = menus.get(extensionId) ?? new Map(); + menus.set(extensionId, items); + return items; +}; + +/** The extensions' items for a right-click in a preview page, for its context menu. */ +export function extensionMenuItems( + page: WebContents, + params: ContextMenuParams, +): MenuItemConstructorOptions[] { + const profile = profiles.get(page.session); + if (!profile) return []; + const contexts = new Set(["all"]); + if (params.isEditable) contexts.add("editable"); + if (params.selectionText.trim()) contexts.add("selection"); + if (params.linkURL) contexts.add("link"); + if ( + params.mediaType === "image" || + params.mediaType === "video" || + params.mediaType === "audio" + ) { + contexts.add(params.mediaType); + } + if (contexts.size === 1) contexts.add("page"); + if (params.frameURL) contexts.add("frame"); + const pageUrl = page.getURL(); + const target = params.linkURL || params.srcURL; + const shown = (item: MenuItem) => + item.visible !== false && + (item.contexts ?? ["page"]).some((context) => contexts.has(context)) && + (!item.documentUrlPatterns || + item.documentUrlPatterns.some((pattern) => urlMatches(pattern, pageUrl))) && + (!item.targetUrlPatterns || + (target !== "" && item.targetUrlPatterns.some((pattern) => urlMatches(pattern, target)))); + const groups: MenuItemConstructorOptions[] = []; + for (const [extensionId, items] of menus) { + const extension = profile.session.extensions.getExtension(extensionId); + if (!extension) continue; + const build = (parentId?: string): MenuItemConstructorOptions[] => + [...items.values()] + .filter((item) => item.parentId === parentId && shown(item)) + .map((item) => { + if (item.type === "separator") return { type: "separator" as const }; + const children = build(item.id); + const info = { + menuItemId: item.id, + ...(item.parentId ? { parentMenuItemId: item.parentId } : {}), + editable: params.isEditable, + pageUrl, + ...(params.frameURL ? { frameUrl: params.frameURL } : {}), + ...(params.linkURL ? { linkUrl: params.linkURL } : {}), + ...(params.srcURL ? { srcUrl: params.srcURL } : {}), + ...(params.selectionText ? { selectionText: params.selectionText } : {}), + ...(params.mediaType !== "none" ? { mediaType: params.mediaType } : {}), + }; + return { + label: (item.title ?? extension.name).replace(/%s/g, params.selectionText.slice(0, 40)), + enabled: item.enabled !== false, + ...(item.type === "checkbox" || item.type === "radio" + ? { type: item.type, checked: Boolean(item.checked) } + : {}), + ...(children.length > 0 + ? { submenu: children } + : { + click: () => + void deliver(profile, extensionId, "contextMenus.onClicked", [ + info, + tabOf(page.id, extension), + ]), + }), + }; + }); + const top = build(); + if (top.length === 0) continue; + // One item shows as is; more go under the extension's name, as in Chrome. + groups.push(top.length === 1 ? top[0]! : { label: extension.name, submenu: top }); + } + return groups; +} + +const notifications = new Map(); + +function showNotification( + profile: Profile, + extension: Extension, + id: string, + options: Record, +) { + notifications.get(id)?.close(); + const iconUrl = typeof options.iconUrl === "string" ? options.iconUrl : ""; + const icon = iconUrl.startsWith("data:") + ? nativeImage.createFromDataURL(iconUrl) + : iconUrl + ? nativeImage.createFromPath(NodePath.join(extension.path, iconUrl.replace(/^\//, ""))) + : undefined; + const notification = new Notification({ + title: String(options.title ?? extension.name), + body: String(options.message ?? ""), + silent: Boolean(options.silent), + ...(icon && !icon.isEmpty() ? { icon } : {}), + }); + notification.on( + "click", + () => void deliver(profile, extension.id, "notifications.onClicked", [id]), + ); + notification.on("close", () => { + notifications.delete(id); + void deliver(profile, extension.id, "notifications.onClosed", [id, true]); + }); + notifications.set(id, notification); + notification.show(); +} + +/** chrome.privacy's settings, per extension (the preview keeps none of Chrome's). */ +const privacy = new Map(); + +let downloadId = 0; + +// ── chrome.* calls from extensions ─────────────────────────────────── + +/** Who's calling: an extension page in a profile (`sender`), or its worker (no sender). */ +type Caller = { readonly profile: Profile; readonly sender: WebContents | null }; + +type Call = (extension: Extension, args: unknown[], caller: Caller) => unknown; + +const tabIdOf = (value: unknown, caller: Caller) => + typeof value === "number" ? value : caller.profile.activeTabId; + +/** `tabs.query`'s filters the preview can answer. */ +function matches( + tab: NonNullable>, + query: Record, + caller: Caller, +) { + if (query.active !== undefined && tab.active !== query.active) return false; + if (query.highlighted !== undefined && tab.highlighted !== query.highlighted) return false; + if ( + (query.currentWindow || query.lastFocusedWindow) && + tab.windowId !== currentWindow(caller)?.id + ) { + return false; + } + if (typeof query.windowId === "number" && query.windowId >= 0 && tab.windowId !== query.windowId) + return false; + if (typeof query.status === "string" && tab.status !== query.status) return false; + if (typeof query.title === "string" && tab.title !== query.title) return false; + if (query.audible !== undefined && tab.audible !== query.audible) return false; + if (query.url !== undefined) { + const patterns = (Array.isArray(query.url) ? query.url : [query.url]) as string[]; + if (!tab.url || !patterns.some((pattern) => urlMatches(pattern, tab.url!))) return false; + } + return true; +} + +/** Chrome's match patterns: , scheme://host/path with * wildcards. */ +function urlMatches(pattern: string, url: string): boolean { + if (pattern === "") return /^(https?|file|ftp):/.test(url); + const escaped = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/^\\\*:/, "(https?):") + .replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`).test(url); +} + +function setAction( + key: keyof ActionDetails, + value: (details: Record, extension: Extension) => unknown, +): Call { + return (extension, [details]) => { + const record = (details ?? {}) as Record; + const state = actionState(extension.id); + let target = state.all; + if (typeof record.tabId === "number") { + target = state.tabs.get(record.tabId) ?? {}; + state.tabs.set(record.tabId, target); + } + (target as Record)[key] = value(record, extension); + notifyChanged(); + }; +} + +function getAction(key: keyof ReturnType): Call { + return (extension, [details]) => { + const tabId = (details as { tabId?: number } | undefined)?.tabId; + return resolvedAction(extension, tabId ?? -1)[key]; + }; +} + +function windowsInfo(info: unknown) { + return Boolean((info as { populate?: boolean } | undefined)?.populate); +} + +const calls: Record = { + "action.setTitle": setAction("title", (d) => d.title), + "action.getTitle": getAction("title"), + "action.setIcon": setAction("icon", (d, extension) => iconFromDetails(extension, d)), + "action.setPopup": setAction("popup", (d) => d.popup), + "action.getPopup": (extension, [details]) => { + const page = resolvedAction(extension, (details as { tabId?: number })?.tabId ?? -1).popup; + return page ? absolute(extension.id, page) : ""; + }, + "action.setBadgeText": setAction("badgeText", (d) => d.text ?? ""), + "action.getBadgeText": getAction("badgeText"), + "action.setBadgeBackgroundColor": setAction("badgeBackgroundColor", (d) => cssColor(d.color)), + "action.getBadgeBackgroundColor": getAction("badgeBackgroundColor"), + "action.setBadgeTextColor": setAction("badgeTextColor", (d) => cssColor(d.color)), + "action.getBadgeTextColor": getAction("badgeTextColor"), + "action.enable": (extension, [tabId], caller) => + setAction("enabled", () => true)(extension, [{ tabId }], caller), + "action.disable": (extension, [tabId], caller) => + setAction("enabled", () => false)(extension, [{ tabId }], caller), + "action.isEnabled": (extension, [tabId]) => + resolvedAction(extension, typeof tabId === "number" ? tabId : -1).enabled, + "action.openPopup": (extension, _args, caller) => { + const page = resolvedAction(extension, caller.profile.activeTabId).popup; + const owner = lastPopup?.owner.isDestroyed() === false ? lastPopup.owner : appWindow(); + if (!page || !owner) return; + // Without a button clicked yet, it hangs from the window's top right corner. + const anchor = lastPopup?.anchor ?? { + x: owner.getContentBounds().width / owner.webContents.getZoomFactor() - 40, + y: 0, + width: 32, + height: 40, + }; + openPopup(owner, caller.profile, extension, page, anchor); + }, + "action.getUserSettings": () => ({ isOnToolbar: true }), + + "tabs.get": (extension, [tabId], caller) => tabOf(tabIdOf(tabId, caller), extension), + "tabs.getCurrent": (extension, _args, caller) => + caller.sender && tabs.has(caller.sender.id) ? tabOf(caller.sender.id, extension) : undefined, + "tabs.query": (extension, [query], caller) => + tabIdsOf(caller.profile) + .map((tabId) => tabOf(tabId, extension)) + .filter((tab): tab is NonNullable => tab !== null) + .filter((tab) => matches(tab, (query ?? {}) as Record, caller)), + "tabs.create": async (extension, [properties], caller) => { + const { url = "about:blank", active = true } = (properties ?? {}) as { + url?: string; + active?: boolean; + }; + const tabId = await createTab(caller.profile, absolute(extension.id, url), active); + return tabId === null ? undefined : tabOf(tabId, extension); + }, + "tabs.update": async (extension, args, caller) => { + const [first, second] = args; + const tabId = typeof first === "number" ? first : caller.profile.activeTabId; + const properties = ((typeof first === "number" ? second : first) ?? {}) as { + url?: string; + active?: boolean; + muted?: boolean; + }; + const contents = tabs.get(tabId)?.contents; + if (!contents) return undefined; + if (properties.url) { + await contents.loadURL(absolute(extension.id, properties.url)).catch(() => undefined); + } + if (properties.muted !== undefined) contents.setAudioMuted(properties.muted); + if (properties.active) setActiveTab(tabId); + return tabOf(tabId, extension); + }, + // An extension closes the windows it opened; preview tabs are the user's to close. + "tabs.remove": (_extension, [ids]) => { + for (const tabId of Array.isArray(ids) ? ids : [ids]) { + const contents = tabs.get(tabId as number)?.contents; + if (contents && !contents.hostWebContents) BrowserWindow.fromWebContents(contents)?.close(); + } + }, + "tabs.goBack": (_extension, [tabId], caller) => + tabs.get(tabIdOf(tabId, caller))?.contents.navigationHistory.goBack(), + "tabs.goForward": (_extension, [tabId], caller) => + tabs.get(tabIdOf(tabId, caller))?.contents.navigationHistory.goForward(), + "tabs.captureVisibleTab": async (_extension, args, caller) => { + const options = (args.find((arg) => typeof arg === "object" && arg !== null) ?? {}) as { + format?: string; + quality?: number; + }; + const contents = tabs.get(caller.profile.activeTabId)?.contents; + if (!contents) throw new Error("No tab is showing."); + const image = await contents.capturePage(); + return options.format === "jpeg" + ? `data:image/jpeg;base64,${image.toJPEG(options.quality ?? 92).toString("base64")}` + : image.toDataURL(); + }, + + "windows.get": (extension, [windowId, info], caller) => { + const window = BrowserWindow.fromId(windowId as number) ?? currentWindow(caller); + return window ? windowOf(window, caller.profile, windowsInfo(info), extension) : undefined; + }, + "windows.getCurrent": (extension, [info], caller) => { + const window = currentWindow(caller); + return window ? windowOf(window, caller.profile, windowsInfo(info), extension) : undefined; + }, + "windows.getLastFocused": (extension, [info], caller) => { + const window = currentWindow(caller); + return window ? windowOf(window, caller.profile, windowsInfo(info), extension) : undefined; + }, + "windows.getAll": (extension, [info], caller) => { + const windows = new Set( + tabIdsOf(caller.profile) + .map((tabId) => windowOfContents(tabs.get(tabId)!.contents)) + .filter((window): window is BrowserWindow => window !== null), + ); + return [...windows].map((window) => + windowOf(window, caller.profile, windowsInfo(info), extension), + ); + }, + // A web page opens as a preview tab; an extension's own page in a window of its own. + "windows.create": async (extension, [data], caller) => { + const { url, type, width, height, focused } = (data ?? {}) as { + url?: string | string[]; + type?: string; + width?: number; + height?: number; + focused?: boolean; + }; + const first = Array.isArray(url) ? url[0] : url; + if (!first) return undefined; + const target = absolute(extension.id, first); + if (type !== "popup" && type !== "panel" && /^https?:/.test(target)) { + await createTab(caller.profile, target); + const window = currentWindow(caller); + return window ? windowOf(window, caller.profile, true, extension) : undefined; + } + const window = openPageWindow(caller.profile, target, { + width: width ?? 420, + height: height ?? 640, + show: focused !== false, + }); + return windowOf(window, caller.profile, true, extension); + }, + "windows.update": (extension, [windowId, info], caller) => { + const window = BrowserWindow.fromId(windowId as number); + if (window && (info as { focused?: boolean } | undefined)?.focused) window.focus(); + return window ? windowOf(window, caller.profile, false, extension) : undefined; + }, + // Only the windows extensions opened: never the app's. + "windows.remove": (_extension, [windowId]) => { + const window = BrowserWindow.fromId(windowId as number); + if (window && profiles.has(window.webContents.session)) window.close(); + }, + + "contextMenus.create": (extension, [properties]) => { + const item = properties as MenuItem; + menuOf(extension.id).set(item.id, item); + }, + "contextMenus.update": (extension, [id, properties]) => { + const items = menuOf(extension.id); + const item = items.get(String(id)); + if (item) items.set(item.id, { ...item, ...(properties as Partial), id: item.id }); + }, + "contextMenus.remove": (extension, [id]) => { + const items = menuOf(extension.id); + const drop = (each: string) => { + items.delete(each); + for (const child of [...items.values()].filter((item) => item.parentId === each)) { + drop(child.id); + } + }; + drop(String(id)); + }, + "contextMenus.removeAll": (extension) => menus.delete(extension.id), + + "notifications.create": (extension, [id, options], caller) => + showNotification( + caller.profile, + extension, + String(id), + (options ?? {}) as Record, + ), + "notifications.update": (extension, [id, options], caller) => { + if (!notifications.has(String(id))) return false; + showNotification( + caller.profile, + extension, + String(id), + (options ?? {}) as Record, + ); + return true; + }, + "notifications.clear": (_extension, [id]) => { + const notification = notifications.get(String(id)); + notification?.close(); + return Boolean(notification); + }, + "notifications.getAll": () => + Object.fromEntries([...notifications.keys()].map((id) => [id, true])), + "notifications.getPermissionLevel": () => "granted", + + "downloads.download": (_extension, [options], caller) => { + const { url } = (options ?? {}) as { url?: string }; + if (!url || !/^https?:/.test(url)) throw new Error("Can't download that address."); + caller.profile.session.downloadURL(url); + return ++downloadId; + }, + "downloads.search": () => [], + "downloads.pause": () => undefined, + "downloads.resume": () => undefined, + "downloads.cancel": () => undefined, + "downloads.erase": () => [], + "downloads.open": () => undefined, + "downloads.show": () => undefined, + "downloads.showDefaultFolder": () => void shell.openPath(app.getPath("downloads")), + + "privacy.get": (extension, [name]) => { + const key = `${extension.id} ${String(name)}`; + return { + value: privacy.has(key) ? privacy.get(key) : true, + levelOfControl: "controllable_by_this_extension", + }; + }, + "privacy.set": (extension, [name, details], caller) => { + const value = (details as { value?: unknown } | undefined)?.value; + privacy.set(`${extension.id} ${String(name)}`, value); + void deliver(caller.profile, extension.id, `privacy.onChange:${String(name)}`, [ + { value, levelOfControl: "controlled_by_this_extension" }, + ]); + }, + "privacy.clear": (extension, [name]) => privacy.delete(`${extension.id} ${String(name)}`), + + "webNavigation.getAllFrames": (_extension, [details]) => { + const contents = tabs.get((details as { tabId?: number })?.tabId ?? -1)?.contents; + return contents + ? contents.mainFrame.framesInSubtree.map((frame) => frameInfo(contents, frame)) + : null; + }, + "webNavigation.getFrame": (_extension, [details]) => { + const { tabId, frameId } = (details ?? {}) as { tabId?: number; frameId?: number }; + const contents = tabs.get(tabId ?? -1)?.contents; + const frame = contents?.mainFrame.framesInSubtree.find( + (each) => frameIdOf(contents, each) === frameId, + ); + return contents && frame ? frameInfo(contents, frame) : null; + }, + + // What the manifest asked for is what it has; nothing optional is granted later. + "permissions.contains": (extension, [request]) => { + const manifest = manifestOf(extension); + const { permissions = [], origins = [] } = (request ?? {}) as { + permissions?: string[]; + origins?: string[]; + }; + const hosts = manifest.host_permissions ?? []; + return ( + permissions.every((each) => (manifest.permissions ?? []).includes(each)) && + origins.every((origin) => + hosts.some((host) => host === origin || urlMatches(host, origin.replace(/\*/g, "x"))), + ) + ); + }, + "permissions.getAll": (extension) => ({ + permissions: manifestOf(extension).permissions ?? [], + origins: manifestOf(extension).host_permissions ?? [], + }), + "permissions.request": (extension, args, caller) => + calls["permissions.contains"]!(extension, args, caller), + "permissions.remove": () => false, + "permissions.addHostAccessRequest": () => undefined, + "permissions.removeHostAccessRequest": () => undefined, + + "commands.getAll": (extension) => + Object.entries(manifestOf(extension).commands ?? {}).map(([name, command]) => ({ + name, + description: command.description ?? "", + shortcut: command.suggested_key?.mac ?? command.suggested_key?.default ?? "", + })), + "commands.update": () => undefined, + "commands.reset": () => undefined, + + "management.setEnabled": () => { + throw new Error("Extensions can't turn others on or off here."); + }, + + "runtime.openOptionsPage": (extension, _args, caller) => { + const url = optionsUrlOf(extension.id, manifestOf(extension)); + if (url) void createTab(caller.profile, url); + }, +}; + +/** An event from an extension's page (any webContents) or from its worker. */ +type CrxEvent = IpcMainInvokeEvent | IpcMainServiceWorkerInvokeEvent; + +function callerOf(event: CrxEvent, extensionId: unknown): Caller | null { + if (typeof extensionId !== "string") return null; + const own = `chrome-extension://${extensionId}/`; + if (event.type === "service-worker") { + const profile = profiles.get(event.session); + return profile && event.serviceWorker.scope.startsWith(own) ? { profile, sender: null } : null; + } + const profile = profiles.get(event.sender.session); + return profile && event.senderFrame?.url.startsWith(own) + ? { profile, sender: event.sender } + : null; +} + +function wire(target: { handle: typeof ipcMain.handle }): void { + target.handle( + Channels.CRX_CALL_CHANNEL, + async (event, extensionId: unknown, name: unknown, args: unknown) => { + const caller = callerOf(event as CrxEvent, extensionId); + const extension = caller?.profile.session.extensions.getExtension(extensionId as string); + if (!caller || !extension) throw new Error("Not an extension of this browser."); + const call = typeof name === "string" ? calls[name] : undefined; + if (!call) throw new Error(`chrome.${String(name)} isn't available here.`); + return call(extension, Array.isArray(args) ? args : [], caller); + }, + ); + target.handle(Channels.CRX_LISTEN_CHANNEL, (event, extensionId: unknown, name: unknown) => { + const caller = callerOf(event as CrxEvent, extensionId); + if (!caller || typeof name !== "string") return; + onListen(caller.profile, extensionId as string, name, caller.sender ?? undefined); + }); +} + +// ── Installing, turning off, on, and away ──────────────────────────── + +const extensionsDir = () => NodePath.join(app.getPath("userData"), "Extensions"); +/** Turned-off extensions wait here, where the Web Store's loader doesn't look. */ +const disabledDir = () => NodePath.join(app.getPath("userData"), "Disabled Extensions"); + +/** The folder holding an extension's manifest.json under `root//` (its version's). */ +function installedVersion(root: string, id: string): string | null { + const dir = NodePath.join(root, id); + try { + for (const entry of NodeFS.readdirSync(dir).toSorted().toReversed()) { + const candidate = NodePath.join(dir, entry); + if (NodeFS.existsSync(NodePath.join(candidate, "manifest.json"))) return candidate; + } + } catch { + // not there + } + return null; +} + +/** What an extension may touch, in a sentence (Chrome lists its permissions when adding one). */ +function reachOf(manifest: Manifest): string { + const hosts = [ + ...(manifest.host_permissions ?? []), + ...(manifest.content_scripts ?? []).flatMap((script) => script.matches ?? []), + ]; + if (hosts.some((host) => host === "" || /^\*:\/\/\*\/|^https?:\/\/\*\//.test(host))) { + return "It can read and change what's on every site you visit in the browser."; + } + const names = [...new Set(hosts.map((host) => host.replace(/^[^:]+:\/\/|\/.*$/g, "")))]; + return names.length > 0 + ? `It can read and change what's on ${names.slice(0, 3).join(", ")}${names.length > 3 ? " and other sites" : ""}.` + : "It doesn't ask to read the sites you visit."; +} + +function optionsUrlOf(id: string, manifest: Manifest): string | null { + const page = manifest.options_ui?.page ?? manifest.options_page; + return page ? absolute(id, page) : null; +} + +function readInstalled(root: string, enabled: boolean): DesktopPreviewExtension[] { + let ids: string[] = []; + try { + ids = NodeFS.readdirSync(root); + } catch { + return []; + } + return ids.flatMap((id) => { + const dir = installedVersion(root, id); + if (!dir) return []; + try { + const manifest = JSON.parse( + NodeFS.readFileSync(NodePath.join(dir, "manifest.json"), "utf8"), + ) as Manifest; + const icon = iconDataUrl(dir, iconFile(manifest.icons, 64)); + return [ + { + id, + name: localized(dir, manifest, manifest.name) ?? id, + version: manifest.version ?? "", + description: localized(dir, manifest, manifest.description) ?? "", + icon, + hasOptions: enabled && optionsUrlOf(id, manifest) !== null, + enabled, + siteAccess: reachOf(manifest), + permissions: manifest.permissions ?? [], + }, + ]; + } catch { + return []; + } + }); +} + +/** Every installed extension, on or off. */ +function listExtensions(): DesktopPreviewExtension[] { + return [...readInstalled(extensionsDir(), true), ...readInstalled(disabledDir(), false)].toSorted( + (a, b) => a.name.localeCompare(b.name), + ); +} + +function forget(extensionId: string): void { + actions.delete(extensionId); + menus.delete(extensionId); + for (const profile of profiles.values()) { + profile.listening.delete(extensionId); + profile.pages.delete(extensionId); + } +} + +async function setExtensionEnabled(id: string, enabled: boolean): Promise { + if (!enabled) { + forget(id); + for (const profile of profiles.values()) { + if (profile.session.extensions.getExtension(id)) + profile.session.extensions.removeExtension(id); + } + if (!NodeFS.existsSync(NodePath.join(extensionsDir(), id))) return; + NodeFS.mkdirSync(disabledDir(), { recursive: true }); + NodeFS.rmSync(NodePath.join(disabledDir(), id), { recursive: true, force: true }); + NodeFS.renameSync(NodePath.join(extensionsDir(), id), NodePath.join(disabledDir(), id)); + } else { + if (NodeFS.existsSync(NodePath.join(disabledDir(), id))) { + NodeFS.mkdirSync(extensionsDir(), { recursive: true }); + NodeFS.renameSync(NodePath.join(disabledDir(), id), NodePath.join(extensionsDir(), id)); + } + const dir = installedVersion(extensionsDir(), id); + if (!dir) throw new Error("The extension's files are missing."); + // Loading it in one profile loads it in the rest (`share`). + const profile = firstProfile(); + if (profile && !profile.session.extensions.getExtension(id)) { + await profile.session.extensions.loadExtension(dir); + } + } + notifyChanged(); +} + +async function removeExtension(id: string): Promise { + forget(id); + for (const profile of profiles.values()) { + if (profile.session.extensions.getExtension(id)) profile.session.extensions.removeExtension(id); + } + await NodeFS.promises.rm(NodePath.join(extensionsDir(), id), { recursive: true, force: true }); + await NodeFS.promises.rm(NodePath.join(disabledDir(), id), { recursive: true, force: true }); + notifyChanged(); +} + +/** Developer mode: an unpacked extension from a folder, copied in beside the Web Store's. */ +async function loadUnpacked(owner: BrowserWindow | null): Promise { + const profile = firstProfile(); + if (!profile) throw new Error("Open a browser tab first."); + const options = { + title: "Load an unpacked extension", + buttonLabel: "Select", + properties: ["openDirectory"] as "openDirectory"[], + }; + const { canceled, filePaths } = owner + ? await dialog.showOpenDialog(owner, options) + : await dialog.showOpenDialog(options); + const dir = filePaths[0]; + if (canceled || !dir) return null; + if (!NodeFS.existsSync(NodePath.join(dir, "manifest.json"))) { + throw new Error("That folder has no manifest.json."); + } + const extensions = profile.session.extensions; + const extension = await extensions.loadExtension(dir, { allowFileAccess: true }); + const target = NodePath.join(extensionsDir(), extension.id, extension.version || "0"); + if (NodePath.resolve(dir) !== NodePath.resolve(target)) { + extensions.removeExtension(extension.id); + NodeFS.rmSync(NodePath.join(extensionsDir(), extension.id), { recursive: true, force: true }); + NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); + NodeFS.cpSync(dir, target, { recursive: true }); + await extensions.loadExtension(target, { allowFileAccess: true }); + } + notifyChanged(); + return listExtensions().find((each) => each.id === extension.id) ?? null; +} + +type InstallRequest = Parameters< + NonNullable[0]>["beforeInstall"]> +>[0]; + +/** The Web Store's "Add to Chrome": asks before anything is installed. */ +async function confirmInstall(details: InstallRequest): Promise<{ action: "allow" | "deny" }> { + const window = details.browserWindow ?? BrowserWindow.getFocusedWindow(); + const options = { + message: `Add “${details.localizedName}”?`, + detail: reachOf(details.manifest as Manifest), + ...(details.icon.isEmpty() ? {} : { icon: details.icon }), + buttons: ["Add Extension", "Cancel"], + defaultId: 0, + cancelId: 1, + }; + const { response } = window + ? await dialog.showMessageBox(window, options) + : await dialog.showMessageBox(options); + return { action: response === 0 ? "allow" : "deny" }; +} + +/** + * An extension loaded in one profile loads in every other: one installed from + * the Web Store, updated, or turned on. + */ +async function share(from: Profile, extension: Extension): Promise { + for (const profile of profiles.values()) { + if (profile === from) continue; + const extensions = profile.session.extensions; + const loaded = extensions.getExtension(extension.id); + if (loaded?.path === extension.path) continue; + try { + if (loaded) extensions.removeExtension(extension.id); + await extensions.loadExtension(extension.path); + } catch (error) { + console.warn("Couldn't load an extension in another browser profile", { + extensionId: extension.id, + error: String(error), + }); + } + } +} + +// ── Setup ──────────────────────────────────────────────────────────── + +let preloadPath = ""; +let nativeDirectories: string[] = []; + +/** The preview's toolbar and Settings: what the renderer asks for, and new tabs to track. */ +export function installPreviewExtensions(options: { + readonly preloadPath: string; + readonly nativeMessagingDirectories: string[]; +}): void { + if (preloadPath) return; + preloadPath = options.preloadPath; + nativeDirectories = options.nativeMessagingDirectories; + wire(ipcMain); + + // The app's renderer only: never a preview page or an extension's. + const fromApp = (event: IpcMainInvokeEvent) => { + if (event.sender.getType() !== "window" || profiles.has(event.sender.session)) return false; + if (!appRenderers.has(event.sender)) { + appRenderers.add(event.sender); + event.sender.once("destroyed", () => appRenderers.delete(event.sender)); + } + return true; + }; + const owner = (event: IpcMainInvokeEvent) => BrowserWindow.fromWebContents(event.sender); + const handle = ( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: any[]) => unknown, + ) => + ipcMain.handle(channel, (event, ...args) => { + if (!fromApp(event)) throw new Error("Not the app's window."); + return handler(event, ...args); + }); + + handle(Channels.LIST_CHANNEL, () => listExtensions()); + handle(Channels.ACTIONS_CHANNEL, (_event, webContentsId: unknown) => + typeof webContentsId === "number" ? extensionActions(webContentsId) : [], + ); + handle(Channels.RUN_ACTION_CHANNEL, (event, input: unknown) => { + const { extensionId, webContentsId, anchor } = (input ?? {}) as { + extensionId?: unknown; + webContentsId?: unknown; + anchor?: DesktopPreviewExtensionAnchor; + }; + const window = owner(event); + if (typeof extensionId !== "string" || typeof webContentsId !== "number" || !anchor || !window) + return; + runAction(window, extensionId, webContentsId, anchor); + }); + handle(Channels.SET_ACTIVE_TAB_CHANNEL, (_event, webContentsId: unknown) => { + if (typeof webContentsId === "number") setActiveTab(webContentsId); + }); + handle(Channels.OPEN_OPTIONS_CHANNEL, (_event, extensionId: unknown) => { + if (typeof extensionId !== "string") return; + for (const profile of profiles.values()) { + const extension = profile.session.extensions.getExtension(extensionId); + const url = extension ? optionsUrlOf(extension.id, manifestOf(extension)) : null; + if (url) { + openPageWindow(profile, url); + return; + } + } + }); + handle(Channels.OPEN_WEB_STORE_CHANNEL, (_event, url: unknown) => { + const target = new URL(typeof url === "string" ? url : WEB_STORE_URL); + if (target.origin !== "https://chromewebstore.google.com") { + throw new Error("Expected a Chrome Web Store URL."); + } + const profile = firstProfile(); + if (!profile) throw new Error("Open a browser tab first."); + openPageWindow(profile, target.href); + }); + handle(Channels.SET_ENABLED_CHANNEL, (_event, extensionId: unknown, enabled: unknown) => + typeof extensionId === "string" + ? setExtensionEnabled(extensionId, enabled === true) + : undefined, + ); + handle(Channels.REMOVE_CHANNEL, (_event, extensionId: unknown) => + typeof extensionId === "string" ? removeExtension(extensionId) : undefined, + ); + handle(Channels.LOAD_UNPACKED_CHANNEL, (event) => loadUnpacked(owner(event))); + + app.on("web-contents-created", (_event, contents) => { + if (contents.getType() !== "webview") return; + const profile = profiles.get(contents.session); + if (profile) trackTab(contents, profile); + }); + const focusChanged = (windowId: number) => { + for (const profile of profiles.values()) { + emit(profile, () => ["windows.onFocusChanged", [windowId]]); + } + }; + app.on("browser-window-focus", (_event, window) => focusChanged(window.id)); + app.on("browser-window-blur", () => focusChanged(-1)); +} + +/** + * Gives a persistent preview partition extensions: the Web Store's install + * button, every installed extension, and their preload. Once per partition, + * before its first webview attaches. + */ +export function enablePreviewExtensions(session: Session): void { + if (!preloadPath || profiles.has(session)) return; + enableNativeMessaging(session, nativeDirectories); + const profile: Profile = { + session, + listening: new Map(), + pages: new Map(), + listenWaiters: new Map(), + failedWorkers: new Map(), + starting: new Map(), + activeTabId: -1, + }; + profiles.set(session, profile); + // Before the Web Store's own preload, which this one lets work in the + // preview's pages (see preview-extensions-preload.ts). + for (const type of ["frame", "service-worker"] as const) { + session.registerPreloadScript({ id: `otter-extensions-${type}`, type, filePath: preloadPath }); + } + // A worker's calls may arrive on its own ipc rather than ipcMain. + const wired = new WeakSet(); + session.serviceWorkers.on("running-status-changed", ({ versionId, runningStatus }) => { + if (runningStatus !== "starting" && runningStatus !== "running") return; + const worker = session.serviceWorkers.getWorkerFromVersionID(versionId); + if (!worker || wired.has(worker) || !worker.scope.startsWith("chrome-extension://")) return; + wired.add(worker); + wire(worker.ipc as unknown as { handle: typeof ipcMain.handle }); + }); + session.extensions.on("extension-loaded", (_event, extension) => { + void share(profile, extension); + notifyChanged(); + }); + session.extensions.on("extension-unloaded", () => notifyChanged()); + for (const contents of allWebContents.getAllWebContents()) { + if (contents.session === session && contents.getType() === "webview") { + trackTab(contents, profile); + } + } + // One profile checks for updates; `share` hands them to the rest. + const checksForUpdates = profiles.size === 1; + // Loaded here, not at the top: the library reaches into Electron as it loads. + void import("electron-chrome-web-store") + .then((webStore) => + webStore.installChromeWebStore({ + session, + extensionsPath: extensionsDir(), + beforeInstall: confirmInstall, + autoUpdate: checksForUpdates, + }), + ) + .catch((error: unknown) => + console.error("Chrome Web Store setup failed", { error: String(error) }), + ); +} diff --git a/apps/desktop/src/preview/extensions/channels.ts b/apps/desktop/src/preview/extensions/channels.ts new file mode 100644 index 000000000000..6931763b81ee --- /dev/null +++ b/apps/desktop/src/preview/extensions/channels.ts @@ -0,0 +1,17 @@ +// The renderer's toolbar and Settings (preload.ts's `previewExtensions`). +export const LIST_CHANNEL = "desktop:preview-extensions:list"; +export const ACTIONS_CHANNEL = "desktop:preview-extensions:actions"; +export const RUN_ACTION_CHANNEL = "desktop:preview-extensions:run-action"; +export const SET_ACTIVE_TAB_CHANNEL = "desktop:preview-extensions:set-active-tab"; +export const OPEN_OPTIONS_CHANNEL = "desktop:preview-extensions:open-options"; +export const OPEN_WEB_STORE_CHANNEL = "desktop:preview-extensions:open-web-store"; +export const SET_ENABLED_CHANNEL = "desktop:preview-extensions:set-enabled"; +export const REMOVE_CHANNEL = "desktop:preview-extensions:remove"; +export const LOAD_UNPACKED_CHANNEL = "desktop:preview-extensions:load-unpacked"; +export const CHANGED_CHANNEL = "desktop:preview-extensions:changed"; +export const OPEN_TAB_CHANNEL = "desktop:preview-extensions:open-tab"; + +// Extensions' own workers and pages (preview-extensions-preload.ts). +export const CRX_CALL_CHANNEL = "crx:call"; +export const CRX_LISTEN_CHANNEL = "crx:listen"; +export const CRX_EVENT_CHANNEL = "crx:event"; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 485ed2aaa0f7..0cb08b7203d9 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -938,9 +938,9 @@ it.effect.each(["win32", "darwin", "linux"] as const)( const bounds = { x: 10, y: 20, width: 800, height: 600 }; const t3 = { id: 42, - title: "T3 Code", + title: "Otter Code", appIdentifier: "com.t3tools.T3Code.desktop", - owner: { name: "T3 Code", processId: 123 }, + owner: { name: "Otter Code", processId: 123 }, bounds, png: Buffer.from([1, 2, 3]), }; @@ -1614,7 +1614,7 @@ it.effect( focusedWindowMock.mockReturnValue(undefined); const destination = { getBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }), - getTitle: () => "T3 Code", + getTitle: () => "Otter Code", isDestroyed: () => false, isVisible: () => true, isMinimized: () => false, @@ -1630,7 +1630,7 @@ it.effect( const warning = logs.find( (message) => Array.isArray(message) && - message[0] === "The compositor could not activate T3 Code after the snapshot", + message[0] === "The compositor could not activate Otter Code after the snapshot", ); assert.strictEqual(Array.isArray(warning) ? warning[1] : undefined, activationFailure); const pending = yield* decodePendingMetadata(saved); @@ -3161,7 +3161,7 @@ it.effect("flags revoked macOS permissions on read and re-registers once they re const revoked = yield* service.state; assert.equal( revoked.message, - "Allow Screen Recording in System Settings, then restart T3 Code.", + "Allow Screen Recording in System Settings, then restart Otter Code.", ); assert.deepEqual(revoked.macPermissions, { screenRecording: false, accessibility: true }); @@ -3174,7 +3174,7 @@ it.effect("flags revoked macOS permissions on read and re-registers once they re const blocked = yield* service.state; assert.equal( blocked.message, - "Allow Screen Recording in System Settings, then restart T3 Code.", + "Allow Screen Recording in System Settings, then restart Otter Code.", ); assert.isFalse(blocked.shortcutRegistered); @@ -3880,7 +3880,7 @@ it.effect.each([false, true])( platform: "macos", id: 42, title: "Setup", - owner: { name: "T3 Code", processId: 123, path: "/Applications/T3 Code.app" }, + owner: { name: "Otter Code", processId: 123, path: "/Applications/Otter Code.app" }, bounds: { x: 0, y: 0, width: 800, height: 600 }, }; activeWindowMock.mockReset().mockResolvedValue(active); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 71fe47d3f6ca..636847c609a9 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -101,11 +101,11 @@ const FLASH_FRAME_INTERVAL_MS = 16; const FLASH_PEAK_OPACITY = 0.08; const MAC_SCREEN_CAPTURE_SETTINGS_URL = MAC_PERMISSION_SETTINGS_URLS["screen-recording"]; const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE = - "Allow Screen Recording in System Settings, then restart T3 Code."; + "Allow Screen Recording in System Settings, then restart Otter Code."; const MAC_ACCESSIBILITY_PERMISSION_MESSAGE = - "Allow Accessibility in System Settings, then restart T3 Code."; + "Allow Accessibility in System Settings, then restart Otter Code."; const MAC_BOTH_PERMISSIONS_MESSAGE = - "Allow Accessibility and Screen Recording in System Settings, then restart T3 Code."; + "Allow Accessibility and Screen Recording in System Settings, then restart Otter Code."; const MAC_PERMISSION_MESSAGES = new Set([ MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE, MAC_ACCESSIBILITY_PERMISSION_MESSAGE, @@ -898,7 +898,7 @@ export const make = Effect.gen(function* () { const capturedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); if (snapshot.linuxActivationFailure) { yield* Effect.logWarning( - "The compositor could not activate T3 Code after the snapshot", + "The compositor could not activate Otter Code after the snapshot", snapshot.linuxActivationFailure.cause, ); } @@ -1018,7 +1018,7 @@ export const make = Effect.gen(function* () { if (mode === "portal" && niriSocketPath()) { return { available: false, - message: "Configure the capture shortcut in your Niri config, not in T3 Code.", + message: "Configure the capture shortcut in your Niri config, not in Otter Code.", }; } if (mode === "portal" && isHyprlandCaptureSession()) { @@ -1168,7 +1168,7 @@ export const make = Effect.gen(function* () { const { startNiriCaptureShortcut } = await import("./NiriCaptureShortcut.ts"); return startNiriCaptureShortcut(linuxAppId, onCurrentShortcut, () => { void runPromise( - setShortcutFailure("The Niri capture endpoint disconnected. Restart T3 Code."), + setShortcutFailure("The Niri capture endpoint disconnected. Restart Otter Code."), ).catch(() => undefined); }); }).pipe( @@ -1190,7 +1190,7 @@ export const make = Effect.gen(function* () { shortcutActionRegistered: registered, shortcutMessage: registered ? "Set up the shortcut to add it to your Niri config." - : "Could not start the Niri capture endpoint. Another T3 Code instance may be using it.", + : "Could not start the Niri capture endpoint. Another Otter Code instance may be using it.", message: null, }); return; diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts index 429a9986d94c..d4382d92df2b 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts @@ -56,7 +56,7 @@ export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths await NodeFSP.readFile(NodePath.join(target, "metadata.json"), "utf8"), ); if (installed.version > metadata.version) - throw new Error("A newer extension is installed. Update T3 Code instead of replacing it."); + throw new Error("A newer extension is installed. Update Otter Code instead of replacing it."); } const staged = await NodeFSP.mkdtemp(NodePath.join(parent, ".t3-capture-install-")); let backup: string | undefined; @@ -184,7 +184,7 @@ export class GnomeCaptureSetup { return { status: "restart-required", message: - "Installed. Save your work, sign out of GNOME and sign back in, then return here to enable the extension. Restarting T3 Code alone is not enough.", + "Installed. Save your work, sign out of GNOME and sign back in, then return here to enable the extension. Restarting Otter Code alone is not enough.", }; if ((installed?.version ?? info.version?.value ?? 0) < bundled.version) return { @@ -196,12 +196,12 @@ export class GnomeCaptureSetup { return { status: "extensions-disabled", message: - "GNOME has disabled user extensions. Turn on Extensions in the GNOME Extensions app, then check again. T3 Code will not enable your other extensions for you.", + "GNOME has disabled user extensions. Turn on Extensions in the GNOME Extensions app, then check again. Otter Code will not enable your other extensions for you.", }; if (info.state?.value === 1) return { status: "enabled", - message: "The T3 Code extension is running. Active-window snapshots are available.", + message: "The Otter Code extension is running. Active-window snapshots are available.", }; if (info.state?.value === 3 || info.state?.value === 4) return { @@ -213,7 +213,7 @@ export class GnomeCaptureSetup { return { status: "disabled", message: - "Enable the T3 Code extension to allow active-window snapshots. You can disable it here at any time.", + "Enable the Otter Code extension to allow active-window snapshots. You can disable it here at any time.", }; } catch (error) { return { diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.ts index 4dc57e44448d..c64e47416f52 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.ts @@ -96,7 +96,7 @@ export class HyprlandCaptureSetup { const bundle = await regularFile(this.paths.bundle); if (!bundle) throw new Error( - "The Hyprland capture helper is missing from this build. Update or reinstall T3 Code.", + "The Hyprland capture helper is missing from this build. Update or reinstall Otter Code.", ); if (!installed.equals(bundle)) return { diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 9b8c8bb90dc0..841b67467e97 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -33,7 +33,7 @@ export function kdeCaptureDesktopEntry(executable: string): string { return [ "[Desktop Entry]", "Type=Application", - "Name=T3 Code SnapShots", + "Name=Otter Code SnapShots", "NoDisplay=true", `Exec=${escapeDesktopEntryExecArgument(executable)} check`, // KService reads this custom property as a KConfig list, not an XDG ';' list. @@ -116,7 +116,7 @@ export class KdeCaptureSetup { if (!bundle) return { status: "error", - message: "The capture helper is missing from this build. Update or reinstall T3 Code.", + message: "The capture helper is missing from this build. Update or reinstall Otter Code.", }; if (!installed.equals(bundle) || entry.toString() !== kdeCaptureDesktopEntry(executable)) return { @@ -162,7 +162,7 @@ export class KdeCaptureSetup { const bundle = await regularFile(this.paths.bundle); if (!bundle) throw new Error( - "The capture helper is missing from this build. Update or reinstall T3 Code.", + "The capture helper is missing from this build. Update or reinstall Otter Code.", ); await NodeFSP.mkdir(directory, { recursive: true }); await NodeFSP.mkdir(NodePath.dirname(desktop), { recursive: true }); diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts index dca715a4a8af..fccc697097b0 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts @@ -194,11 +194,11 @@ it.runIf(hasDbus)("captures through real D-Bus marshalling on a private bus", as animate: true, }); expect(snapshot.feedback?.animationStarted).toBe(true); - await snapshot.feedback!.activate("T3 Code"); + await snapshot.feedback!.activate("Otter Code"); await snapshot.feedback!.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 }); await snapshot.feedback!.complete(); expect(feedbackArgs).toEqual([true, true]); - expect(activateTitle).toBe("T3 Code"); + expect(activateTitle).toBe("Otter Code"); expect(animateFrame).toEqual([0.1, 0.8, 0.2, 0.1]); vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); const kde = connect(); diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.test.ts index 0ea9c08f935c..1c18c4e70e34 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.test.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.test.ts @@ -265,7 +265,7 @@ it("retains the authenticated connection until the compositor flight has landed" const feedback = snapshot!.feedback!; expect(snapshot).toMatchObject({ png, window: metadata, feedback: { animationStarted: true } }); expect(bus.disconnect).not.toHaveBeenCalled(); - await feedback.activate("T3 Code"); + await feedback.activate("Otter Code"); const landed = Promise.withResolvers(); bus.animation = landed.promise; const flight = feedback.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 }); @@ -277,7 +277,7 @@ it("retains the authenticated connection until the compositor flight has landed" bus.calls .filter((call) => ["CaptureWithFeedback", "Activate", "Animate"].includes(call.member)) .map((call) => call.body), - ).toEqual([[false, true], ["T3 Code"], [0.1, 0.8, 0.2, 0.1]]); + ).toEqual([[false, true], ["Otter Code"], [0.1, 0.8, 0.2, 0.1]]); expect(bus.disconnect).toHaveBeenCalledOnce(); feedback.close(); expect(bus.disconnect).toHaveBeenCalledOnce(); @@ -301,7 +301,7 @@ it("can activate without animation, and expires a renderer-abandoned capture", a const snapshot = await captureLinuxWindow(appId, { flash: false, animate: false }); expect(snapshot!.feedback!.animationStarted).toBe(false); bus.activationError = new Error("No window"); - await expect(snapshot!.feedback!.activate("T3 Code")).rejects.toThrow("No window"); + await expect(snapshot!.feedback!.activate("Otter Code")).rejects.toThrow("No window"); expect(snapshot!.png).toEqual(png); await vi.advanceTimersByTimeAsync(15_000); expect(bus.disconnect).toHaveBeenCalledOnce(); diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.ts b/apps/desktop/src/snapShot/LinuxSnapShot.ts index dffd2a71f36c..8d622331185f 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.ts @@ -306,7 +306,7 @@ export class LinuxCaptureConnection { this.bus.requestName(`${appId}.SnapShot`, NameFlag.DO_NOT_QUEUE), ); if (result !== RequestNameReply.PRIMARY_OWNER) { - throw new Error("Another T3 Code instance is capturing a window. Try again."); + throw new Error("Another Otter Code instance is capturing a window. Try again."); } const withFeedback = this.feedbackAvailable && options !== undefined; const reply = await this.call({ diff --git a/apps/desktop/src/snapShot/NiriCaptureShortcut.ts b/apps/desktop/src/snapShot/NiriCaptureShortcut.ts index bae9c2b962af..ebeedec5d4b0 100644 --- a/apps/desktop/src/snapShot/NiriCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/NiriCaptureShortcut.ts @@ -55,7 +55,7 @@ export async function startNiriCaptureShortcut( }), ]); if (result !== RequestNameReply.PRIMARY_OWNER) - throw new Error("Another T3 Code instance already owns the capture shortcut."); + throw new Error("Another Otter Code instance already owns the capture shortcut."); return close; } catch (error) { close(); diff --git a/apps/desktop/src/snapShot/NiriSnapShot.test.ts b/apps/desktop/src/snapShot/NiriSnapShot.test.ts index c25030650afd..8cf669d73424 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.test.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.test.ts @@ -133,11 +133,11 @@ it("does not activate T3 until requested, then matches PID and title", async () const snapshot = await captureNiriWindow(socketPath); expect(calls.some((call) => typeof call !== "string" && call.Action.FocusWindow)).toBe(false); windows = [ - { ...window, id: 1, pid: 999, title: "T3 Code" }, + { ...window, id: 1, pid: 999, title: "Otter Code" }, { ...window, id: 2, pid: process.pid, title: "Other T3" }, - { ...window, id: 3, pid: process.pid, title: "T3 Code" }, + { ...window, id: 3, pid: process.pid, title: "Otter Code" }, ]; - await snapshot.feedback!.activate("T3 Code"); + await snapshot.feedback!.activate("Otter Code"); expect(calls).toContainEqual({ Action: { FocusWindow: { id: 3 } } }); }); @@ -148,17 +148,19 @@ it("waits for the restored T3 window to map instead of polling", async () => { await original(request, socket); if (request === "EventStream") send(socket, { - WindowOpenedOrChanged: { window: { ...window, id: 4, pid: process.pid, title: "T3 Code" } }, + WindowOpenedOrChanged: { + window: { ...window, id: 4, pid: process.pid, title: "Otter Code" }, + }, }); }; - await snapshot.feedback!.activate("T3 Code"); + await snapshot.feedback!.activate("Otter Code"); expect(calls).toContainEqual({ Action: { FocusWindow: { id: 4 } } }); }); it("rejects ambiguous activation targets", async () => { const snapshot = await captureNiriWindow(socketPath); - windows = [1, 2].map((id) => ({ ...window, id, pid: process.pid, title: "T3 Code" })); - await expect(snapshot.feedback!.activate("T3 Code")).rejects.toThrow("More than one"); + windows = [1, 2].map((id) => ({ ...window, id, pid: process.pid, title: "Otter Code" })); + await expect(snapshot.feedback!.activate("Otter Code")).rejects.toThrow("More than one"); }); it("cancels pending activation when capture feedback is closed", async () => { @@ -169,7 +171,7 @@ it("cancels pending activation when capture feedback is closed", async () => { await original(request, socket); if (request === "EventStream") started.resolve(); }; - const activation = expect(snapshot.feedback!.activate("T3 Code")).rejects.toThrow("cancelled"); + const activation = expect(snapshot.feedback!.activate("Otter Code")).rejects.toThrow("cancelled"); await started.promise; snapshot.feedback!.close(); await activation; diff --git a/apps/desktop/src/snapShot/NiriSnapShot.ts b/apps/desktop/src/snapShot/NiriSnapShot.ts index 87133aded548..56e30a6452e2 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.ts @@ -175,7 +175,7 @@ async function activateNiriWindow(path: string, title: string, signal: AbortSign (window) => window.pid === process.pid && window.title === title, ); if (matches.length > 1) - throw new Error("More than one T3 Code window matches the capture destination."); + throw new Error("More than one Otter Code window matches the capture destination."); return matches[0]; }); connection.send("EventStream"); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts index eb20a4a041ee..1cd44d3050a3 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts @@ -318,7 +318,7 @@ it("guides users to manual desktop settings when the portal cannot open them", a expect(client.hasSession).toBe(true); expect(client.state.shortcutCanRetry).toBe(false); expect(client.state.shortcutMessage).toBe( - "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.", + "Shortcut permission wasn't granted. Allow Otter Code in your desktop's shortcut settings.", ); await expect(client.configure()).rejects.toThrow("Open your desktop's shortcut settings"); expect(bus.calls.some((message) => message.member === "ConfigureShortcuts")).toBe(false); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts index 7487e2f582ba..8c612572cb4d 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts @@ -135,7 +135,7 @@ export class PortalCaptureShortcut { throw new Error("Change the capture binding in your Hyprland config, then save it."); if (!this.hasSession || this.version < 2) throw new Error( - "Open your desktop's shortcut settings and allow T3 Code's capture shortcut.", + "Open your desktop's shortcut settings and allow Otter Code's capture shortcut.", ); await this.call({ destination: this.owner, @@ -164,7 +164,7 @@ export class PortalCaptureShortcut { // This failed session is closing, so retry can register a fresh one. shortcutCanRetry: !this.managedByHyprland, shortcutMessage: this.managedByHyprland - ? "Couldn't connect to Hyprland shortcuts. Make sure xdg-desktop-portal-hyprland is running, then restart T3 Code." + ? "Couldn't connect to Hyprland shortcuts. Make sure xdg-desktop-portal-hyprland is running, then restart Otter Code." : error instanceof Error ? error.message : "Could not register the capture shortcut.", @@ -304,7 +304,7 @@ export class PortalCaptureShortcut { shortcutMessage: this.version >= 2 ? "Shortcut permission wasn't granted. Open shortcut permissions to allow it." - : "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.", + : "Shortcut permission wasn't granted. Allow Otter Code in your desktop's shortcut settings.", }); return undefined; } @@ -327,7 +327,7 @@ export class PortalCaptureShortcut { shortcutPending: false, shortcutMessage: shortcut ? "Managed by Hyprland. Add the binding to your config and save it." - : "Hyprland did not register the capture action. Check that xdg-desktop-portal-hyprland is running, then restart T3 Code.", + : "Hyprland did not register the capture action. Check that xdg-desktop-portal-hyprland is running, then restart Otter Code.", }); return; } diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 01532d0e26c4..1817b57cc75a 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -114,7 +114,7 @@ function createWindow( resizable: false, show: false, skipTaskbar: true, - title: "T3 Code Snapshot Animation", + title: "Otter Code Snapshot Animation", transparent: true, webPreferences: { backgroundThrottling: false, diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 2cfa406d1781..3266997be837 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -447,7 +447,7 @@ export function hideAndWaitForBlur(window: { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { window.removeListener("blur", onBlur); - reject(new Error("Timed out waiting for T3 Code to lose focus.")); + reject(new Error("Timed out waiting for Otter Code to lose focus.")); }, WINDOW_BLUR_TIMEOUT_MS); const onBlur = () => { clearTimeout(timeout); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 1fe2b86aae7b..f1c556815240 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -30,7 +30,7 @@ describe("sshEnvironment", () => { assert.equal(cause.message, "Failed to present SSH password prompt for devbox."); assert.equal( DesktopSshEnvironment.toSshPasswordPromptError(cause).message, - "T3 Code window is not available for SSH authentication.", + "Otter Code window is not available for SSH authentication.", ); }); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 08dd48b718f9..8808af2e204e 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -97,7 +97,7 @@ export function toSshPasswordPromptError( break; case "DesktopSshPromptWindowUnavailableError": case "DesktopSshPromptPresentationError": - message = "T3 Code window is not available for SSH authentication."; + message = "Otter Code window is not available for SSH authentication."; break; case "DesktopSshPromptTimedOutError": message = `SSH authentication timed out for ${cause.destination}.`; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 5c0bf0998f40..b4f041f68816 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -63,7 +63,7 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedError Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f111dac25db8..5ef27d2a9164 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -29,8 +29,9 @@ const environmentInput = { const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("T3 Code"), + name: Effect.succeed("Otter Code"), systemLocale: Effect.succeed("en-US"), + requestSingleInstanceLock: Effect.succeed(true), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, @@ -142,21 +143,21 @@ describe("DesktopApplicationMenu", () => { const template = yield* Deferred.await(applicationMenuTemplate); const applicationMenu = template[0]; assert.isDefined(applicationMenu); - assert.equal(applicationMenu.label, "T3 Code (Nightly)"); + assert.equal(applicationMenu.label, "Otter Code"); if (!Array.isArray(applicationMenu.submenu)) { throw new Error("Expected application menu submenu to be an array."); } assert.equal( applicationMenu.submenu.find((item) => item.role === "about")?.label, - "About T3 Code (Nightly)", + "About Otter Code", ); assert.equal( applicationMenu.submenu.find((item) => item.role === "hide")?.label, - "Hide T3 Code (Nightly)", + "Hide Otter Code", ); assert.equal( applicationMenu.submenu.find((item) => item.role === "quit")?.label, - "Quit T3 Code (Nightly)", + "Quit Otter Code", ); }), ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index d0363fb01a27..1a206f56d6cf 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -74,7 +74,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { yield* electronDialog.showMessageBox({ type: "info", title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + message: `Otter Code ${updateState.currentVersion} is currently the newest version available.`, buttons: ["OK"], }); } else if (updateState.status === "error") { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c9a49b1d735b..e574fc620d07 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -78,7 +78,7 @@ function makeFakeBrowserWindow() { copyImageAt: vi.fn(), focus: vi.fn(), isDestroyed: vi.fn(() => false), - getURL: vi.fn(() => "t3code-dev://app/"), + getURL: vi.fn(() => "ottercode-dev://app/"), getZoomLevel: vi.fn(() => zoomLevel), getZoomFactor: vi.fn(() => 1.2 ** zoomLevel), setZoomLevel: vi.fn((level: number) => { @@ -618,19 +618,19 @@ describe("DesktopWindow", () => { it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "t3code://app/settings/connections", + applicationUrl: "ottercode://app/", + navigationUrl: "ottercode://app/settings/connections", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", + applicationUrl: "ottercode://app/", navigationUrl: "https://accounts.microsoft.com/oauth", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", + applicationUrl: "ottercode://app/", navigationUrl: "not a url", }), ); @@ -663,7 +663,7 @@ describe("DesktopWindow", () => { assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["ottercode-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), @@ -851,10 +851,10 @@ describe("DesktopWindow", () => { yield* desktopWindow.zoomMain(direction); const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; assert.isDefined(position); - // The 14-point native buttons should share the zoomed 52px header's center. - const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); + // The 14-point native buttons should share the zoomed 43px header's center. + const headerCenter = 21.5 * fakeWindow.window.webContents.getZoomFactor(); assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); - assert.equal(position.x, 16); + assert.equal(position.x, 14); } fakeWindow.isFullScreen.mockReturnValue(true); @@ -864,7 +864,7 @@ describe("DesktopWindow", () => { fakeWindow.isFullScreen.mockReturnValue(false); fakeWindow.windowListeners.get("leave-full-screen")?.(); - assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]); + assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 14, y: 15 }]); }).pipe(Effect.provide(layer)); }), ); @@ -1371,17 +1371,17 @@ describe("DesktopWindow", () => { return yield* Effect.die("renderer load listeners were not registered"); } - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "ottercode-dev://app/", true); assert.equal(fakeWindow.loadURL.mock.calls.length, 1); yield* TestClock.adjust(100); assert.deepEqual(fakeWindow.loadURL.mock.calls, [ - ["t3code-dev://app/"], - ["t3code-dev://app/"], + ["ottercode-dev://app/"], + ["ottercode-dev://app/"], ]); assert.equal(fakeWindow.reload.mock.calls.length, 0); - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "ottercode-dev://app/", true); didFinishLoad(); yield* TestClock.adjust(250); assert.equal(fakeWindow.loadURL.mock.calls.length, 2); @@ -1393,23 +1393,23 @@ describe("DesktopWindow", () => { it("retries only transient failures for the development renderer", () => { assert.isTrue( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "ottercode-dev://app/", errorCode: -102, isMainFrame: true, - validatedUrl: "t3code-dev://app/", + validatedUrl: "ottercode-dev://app/", }), ); assert.isFalse( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "ottercode-dev://app/", errorCode: -3, isMainFrame: true, - validatedUrl: "t3code-dev://app/", + validatedUrl: "ottercode-dev://app/", }), ); assert.isFalse( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "ottercode-dev://app/", errorCode: -102, isMainFrame: true, validatedUrl: "https://example.com/", @@ -1574,7 +1574,7 @@ describe("DesktopWindow", () => { createCount, mainWindow, onReveal: () => { - foreground = "T3 Code"; + foreground = "Otter Code"; operations.push("reveal"); }, }); @@ -1583,7 +1583,7 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); yield* desktopWindow.dispatchSnapShotEvent({ type: "started", id: captureOne }); - assert.equal(foreground, "T3 Code"); + assert.equal(foreground, "Otter Code"); foreground = "Explorer"; yield* desktopWindow.dispatchSnapShotEvent({ type: "ready", id: captureOne }); yield* desktopWindow.dispatchSnapShotEvent({ type: "failed", id: captureTwo }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 75393d54a1b2..16a032f2af3a 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -28,6 +28,7 @@ import { import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; +import * as PreviewExtensions from "../preview/extensions/PreviewExtensions.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -36,15 +37,17 @@ import { makeQuitShortcutHandler } from "./QuitHold.ts"; import { copyContextMenuImage } from "./ContextMenuImage.ts"; const TITLEBAR_HEIGHT = 40; -// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS -// buttons are 14 points tall and do not scale with the renderer's zoom. -const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; +// Matches the Mac app's --workspace-topbar-height in apps/web/src/appFrame.css. +// Native macOS buttons are 14 points tall and do not scale with the renderer's zoom. +const MACOS_WORKSPACE_TOPBAR_HEIGHT = 43; const MACOS_WINDOW_BUTTON_RADIUS = 7; +// The buttons' left inset; the renderer leaves them 82px (preload.ts). +const MACOS_WINDOW_BUTTON_X = 14; function syncMacosWindowButtons(window: Electron.BrowserWindow): void { if (window.isDestroyed() || window.isFullScreen()) return; window.setWindowButtonPosition({ - x: 16, + x: MACOS_WINDOW_BUTTON_X, y: Math.round( (MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 - MACOS_WINDOW_BUTTON_RADIUS, @@ -269,8 +272,8 @@ function getWindowTitleBarOptions( return { titleBarStyle: "hiddenInset", trafficLightPosition: { - x: 16, - y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS, + x: MACOS_WINDOW_BUTTON_X, + y: Math.round(MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS), }, }; } @@ -606,6 +609,10 @@ export const make = Effect.gen(function* () { menuTemplate.push({ type: "separator" }); } + // Chrome extensions' own items, for the preview's pages. + const extensionItems = PreviewExtensions.extensionMenuItems(contents, params); + if (extensionItems.length > 0) menuTemplate.push(...extensionItems, { type: "separator" }); + menuTemplate.push( { role: "cut", enabled: params.editFlags.canCut }, { role: "copy", enabled: params.editFlags.canCopy }, diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index a3cb739be0f6..28186ad02ff1 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -182,12 +182,12 @@ describe("WSL runtime cache", () => { it("installs through a temporary directory and only reuses valid completed caches", () => { const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); + expect(script).toContain('runtime_parent="$HOME/.otter-code/wsl-runtime"'); expect(script).toContain(' [ -f "$ready_marker" ] &&'); expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); expect(script).toContain("if runtime_is_ready; then"); @@ -202,7 +202,7 @@ describe("WSL runtime cache", () => { // The release archive wraps everything in one `t3--linux-x64/` // directory; stripping it puts the executable at `$runtime_root/t3`. expect(script).toContain( - "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", + "tar -xzf '/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", ); expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); @@ -218,14 +218,14 @@ describe("WSL runtime cache", () => { it("verifies the archive digest before extracting, and only on a cache miss", () => { const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); const expected = "b".repeat(64); expect(script).toContain( - "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", + "archive_sha=$(sha256sum '/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", ); expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); @@ -247,7 +247,7 @@ describe("WSL runtime cache", () => { // the install path has to refuse too. it("moves an in-use runtime aside instead of deleting it under a live backend", () => { const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz", "sha256-" + "c".repeat(64), "b".repeat(64), ); @@ -276,7 +276,7 @@ describe("WSL runtime cache", () => { it("treats a runtime whose executable no longer runs as a cache miss", () => { const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); @@ -301,7 +301,7 @@ describe("WSL runtime cache", () => { // The digest the install records is what turns that into a miss. it("re-hashes the executable against the digest the install recorded", () => { const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); @@ -331,7 +331,7 @@ describe("WSL runtime cache", () => { it("refuses to mark an archive whose executable does not run as ready", () => { const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "/mnt/c/Program Files/Otter Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); @@ -349,8 +349,8 @@ describe("WSL runtime cache", () => { }); it("parses only absolute Linux runtime paths", () => { - expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe( - "/home/josh/.t3/wsl-runtime/1.2.3-x64", + expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.otter-code/wsl-runtime/1.2.3-x64\n")).toBe( + "/home/josh/.otter-code/wsl-runtime/1.2.3-x64", ); expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull(); expect(parseWslRuntimeRoot("noise\n")).toBeNull(); @@ -405,7 +405,9 @@ describe("WSL runtime cache", () => { // Readiness is a presence check, so a tree whose pty.node is present but // unloadable stays ready forever unless the probe can revoke the marker. - expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); + expect(script).toContain( + 'rm -f "$HOME/.otter-code/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"', + ); // Deleting the tree here would pull it out from under any backend still // running from it; the next install moves an unready root aside instead. expect(script).not.toContain("rm -rf"); @@ -461,9 +463,9 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed archivePath, archiveSha, runtimeId, - runtimeParent: `${work}/home/.t3/wsl-runtime`, - runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, - serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`, + runtimeParent: `${work}/home/.otter-code/wsl-runtime`, + runtimeRoot: `${work}/home/.otter-code/wsl-runtime/${runtimeId}`, + serverEntry: `${work}/home/.otter-code/wsl-runtime/${runtimeId}/t3`, installScript, install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), }; @@ -755,7 +757,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed "set -eu", "work=$(mktemp -d)", 'home="$work/home"', - 'runtime_parent="$home/.t3/wsl-runtime"', + 'runtime_parent="$home/.otter-code/wsl-runtime"', 'mkdir -p "$runtime_parent"', 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', "make_ready sha256-current", diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index dedf4a4a3427..f185aa49ce4a 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -297,7 +297,7 @@ export const buildWslRuntimeInstallScript = ( const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); return [ "set -eu", - 'runtime_parent="$HOME/.t3/wsl-runtime"', + 'runtime_parent="$HOME/.otter-code/wsl-runtime"', `runtime_root="$runtime_parent/${safeRuntimeId}"`, `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, // The runtime is a self-contained `t3` executable with Node inside, so the @@ -422,7 +422,7 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => { const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); return [ "set -eu", - 'runtime_parent="$HOME/.t3/wsl-runtime"', + 'runtime_parent="$HOME/.otter-code/wsl-runtime"', `current_runtime="$runtime_parent/${safeRuntimeId}"`, '[ -d "$runtime_parent" ] || exit 0', // Serialize the whole retention decision so two backends cannot select @@ -486,7 +486,7 @@ export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); return [ "set -eu", - `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`, + `rm -f "$HOME/.otter-code/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`, ].join("\n"); }; @@ -505,7 +505,7 @@ const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; const formatNodePtyProbeFailureReason = (exitCode: number): string | null => exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE - ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." + ? "WSL support is missing from this Otter Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." : null; // Captures the login-shell PATH as `resolvedPath:` so the launch can forward the diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 396ea7aa5489..1c31be5597cc 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -5,5 +5,10 @@ "types": ["node", "electron"], "lib": ["ESNext", "DOM", "esnext.disposable"] }, - "include": ["src", "vite.config.ts", "../../scripts/lib"] + "include": [ + "src", + "vite.config.ts", + "../../scripts/lib", + "../../packages/shared/src/otterAccounts.ts" + ] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 17ce805ffe16..775e87959e58 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -15,9 +15,8 @@ const isMainProcessExternal = (id: string) => id === "electron" || id.startsWith("electron/") || isDesktopRuntimeExternalDependency(id); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; const publicConfigDefine = { - __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( - repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", - ), + __T3CODE_BUILD_RELAY_URL__: JSON.stringify(repoEnv.T3CODE_RELAY_URL?.trim() ?? ""), + __T3CODE_BUILD_ACCOUNTS_URL__: JSON.stringify(repoEnv.T3CODE_ACCOUNTS_URL?.trim() ?? ""), }; export default defineConfig({ @@ -106,12 +105,6 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, entry: ["src/preload.ts"], - deps: { - // Sandboxed Electron preloads cannot reliably resolve package imports - // from inside the packaged ASAR. Bundle Clerk's preload bridge into the - // preload artifact instead of leaving a runtime require() behind. - alwaysBundle: (id) => id === "@clerk/electron" || id.startsWith("@clerk/electron/"), - }, }, { format: "cjs", @@ -132,6 +125,18 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), entry: ["src/preview-pip-preload.ts"], }, + { + // Chrome extensions in the preview: their workers' and pages' preload, + // and electron-chrome-web-store's for the Web Store, which the library + // looks for beside main.cjs when there are no node_modules. + format: "cjs", + outDir: "dist-electron", + dts: false, + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/preview-extensions-preload.ts"], + copy: { from: "node_modules/electron-chrome-web-store/dist/chrome-web-store.preload.js" }, + }, { // Sandboxed preloads must be self-contained, without shared runtime chunks. format: "cjs", diff --git a/apps/marketing/src/pages/privacy-policy.astro b/apps/marketing/src/pages/privacy-policy.astro index 52e97084d8c2..85081b469867 100644 --- a/apps/marketing/src/pages/privacy-policy.astro +++ b/apps/marketing/src/pages/privacy-policy.astro @@ -214,13 +214,6 @@ const sections = [ business. Depending on the features you use, these providers may include:

    -
  • - Clerk (Clerk, Inc.).
    - We use Clerk to provide account authentication.
    - You can view Clerk's privacy policy here:{" "}https://clerk.com/legal/privacy. -
  • Cloudflare (Cloudflare, Inc.).
    We use Cloudflare for networking, T3 Connect infrastructure, and content delivery.
    diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 20f98c6c9e34..51dc334153ea 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -125,8 +125,7 @@ Preview and production variants use Expo fingerprinting so OTA updates only reac The development variant uses `appVersion` to avoid recalculating the native fingerprint for each Metro launch manifest. `MOBILE_VERSION_POLICY` can override either default. If you distribute a custom Release build with the development identity and publish OTA updates to it, set `MOBILE_VERSION_POLICY=fingerprint` for both its build and updates. Changing the runtime policy requires a native rebuild for OTA matching; an existing dev client can still load local Metro bundles. -For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`, -`T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL` +For preview or production EAS environments, set `T3CODE_ACCOUNTS_URL`, as EAS environment variables. Expo config maps the canonical values into the mobile build. Create a PR preview dev-client build manually: diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 57717d2d8458..4da280c68ef3 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -5,6 +5,8 @@ import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; type AppVariant = "development" | "preview" | "production"; +const EAS_PROJECT_ID = "dc0a4a36-373e-4a93-afe2-0018939fad87"; + const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); @@ -18,9 +20,6 @@ const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID? const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; -// Android layers are rendered by scripts/export-android-icons.ts from the Icon Composer sources. -// The wordmark sits inside the adaptive safe zone; the variant artwork is a full-bleed background. -const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; if ( isIosPersonalTeamBuild && @@ -36,64 +35,61 @@ const DEVELOPMENT_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), - androidAdaptiveForeground, - androidAdaptiveBackgroundColor: "#347FF8", - androidAdaptiveBackgroundImage: "./assets/android-icon-background-dev.png", - androidSplashIcon: "./assets/android-splash-icon-dev.png", - androidMonochromeIcon: "./assets/android-icon-mark.png", - androidNotificationIcon: "./assets/android-notification-icon.png", - androidNotificationColor: "#00639B", + androidAdaptiveForeground: "./assets/otter/android-icon-foreground-dev.png", + androidAdaptiveBackgroundColor: "#1B4FD1", + androidAdaptiveBackgroundImage: "./assets/otter/android-icon-background-dev.png", + androidSplashIcon: "./assets/otter/android-splash-icon-dev.png", + androidMonochromeIcon: "./assets/otter/android-icon-mark.png", + androidNotificationIcon: "./assets/otter/android-notification-icon.png", + androidNotificationColor: "#FFFFFF", } as const; const PREVIEW_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), - androidAdaptiveForeground, - androidAdaptiveBackgroundColor: "#111533", - androidAdaptiveBackgroundImage: "./assets/android-icon-background-nightly.png", - androidSplashIcon: "./assets/android-splash-icon-nightly.png", - androidMonochromeIcon: "./assets/android-icon-mark.png", - androidNotificationIcon: "./assets/android-notification-icon.png", - androidNotificationColor: "#7565C7", + androidAdaptiveForeground: "./assets/otter/android-icon-foreground-nightly.png", + androidAdaptiveBackgroundColor: "#0D1033", + androidAdaptiveBackgroundImage: "./assets/otter/android-icon-background-nightly.png", + androidSplashIcon: "./assets/otter/android-splash-icon-nightly.png", + androidMonochromeIcon: "./assets/otter/android-icon-mark.png", + androidNotificationIcon: "./assets/otter/android-notification-icon.png", + androidNotificationColor: "#FFFFFF", } as const; const RELEASE_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), - androidAdaptiveForeground, - androidAdaptiveBackgroundColor: "#000000", + androidAdaptiveForeground: "./assets/otter/android-icon-foreground.png", + androidAdaptiveBackgroundColor: "#F8F5EC", androidAdaptiveBackgroundImage: undefined, - androidSplashIcon: "./assets/android-splash-icon-prod.png", - androidMonochromeIcon: "./assets/android-icon-mark.png", - androidNotificationIcon: "./assets/android-notification-icon.png", + androidSplashIcon: "./assets/otter/android-splash-icon.png", + androidMonochromeIcon: "./assets/otter/android-icon-mark.png", + androidNotificationIcon: "./assets/otter/android-notification-icon.png", androidNotificationColor: "#FFFFFF", } as const; const VARIANT_CONFIG = { development: { - appName: "T3 Code Dev", - scheme: "t3code-dev", - iosBundleIdentifier: "com.t3tools.t3code.dev", - androidPackage: "com.t3tools.t3code.dev", - relyingParty: "clerk.t3.codes", + appName: "Otter Code Dev", + scheme: "ottercode-dev", + iosBundleIdentifier: "dev.otterware.code.dev", + androidPackage: "dev.otterware.code.dev", assets: DEVELOPMENT_ASSETS, }, preview: { - appName: "T3 Code Preview", - scheme: "t3code-preview", - iosBundleIdentifier: "com.t3tools.t3code.preview", - androidPackage: "com.t3tools.t3code.preview", - relyingParty: "clerk.t3.codes", + appName: "Otter Code Preview", + scheme: "ottercode-preview", + iosBundleIdentifier: "dev.otterware.code.preview", + androidPackage: "dev.otterware.code.preview", assets: PREVIEW_ASSETS, }, production: { - appName: "T3 Code", - scheme: "t3code", - iosBundleIdentifier: "com.t3tools.t3code", - androidPackage: "com.t3tools.t3code", - relyingParty: "clerk.t3.codes", + appName: "Otter Code", + scheme: "ottercode", + iosBundleIdentifier: "dev.otterware.code", + androidPackage: "dev.otterware.code", assets: RELEASE_ASSETS, }, } as const; @@ -134,7 +130,7 @@ const widgetsPlugin: NonNullable[number] = [ { name: "SubscriptionUsage", displayName: "Subscription usage", - description: "Subscription quotas from your connected T3 Code environments.", + description: "Subscription quotas from your connected Otter Code environments.", ios: { configuration: { title: "Subscription usage", @@ -185,7 +181,7 @@ const widgetsPlugin: NonNullable[number] = [ { name: "AgentActivity", displayName: "Agent Activity", - description: "Shows the current state of active T3 Code agents.", + description: "Shows the current state of active Otter Code agents.", // Live Activity companion; there is no Android presentation for it. android: null, ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] }, @@ -226,7 +222,7 @@ const sharingPlugin: NonNullable[number] = [ const config: ExpoConfig = { name: variant.appName, - slug: "t3-code", + slug: "otter-code", platforms: ["ios", "android"], scheme: variant.scheme, version: "2.0.0", @@ -241,7 +237,7 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", updates: { enabled: repoEnv.T3CODE_MOBILE_UPDATES_ENABLED !== "0", - url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", + url: `https://u.expo.dev/${EAS_PROJECT_ID}`, checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, }, @@ -252,14 +248,10 @@ const config: ExpoConfig = { // showcase capture build requires full screen (see infoPlist below). requireFullScreen: process.env.T3_SHOWCASE_CAPTURE_BUILD === "1", bundleIdentifier: iosBundleIdentifier, - // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` + // Pin code signing to the Clary team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, // Sign in with Apple, or push notification entitlements). - appleTeamId: "ARK85ZXQ4Z", - associatedDomains: [ - `applinks:${variant.relyingParty}`, - `webcredentials:${variant.relyingParty}`, - ], + appleTeamId: "YNJ5WLH965", entitlements: { "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], }, @@ -273,8 +265,8 @@ const config: ExpoConfig = { NSAllowsArbitraryLoads: true, }, NSLocalNetworkUsageDescription: - "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", - NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.", + "Allow Otter Code to connect to Otter Code servers on your local network or tailnet.", + NSPhotoLibraryAddUsageDescription: "Allow Otter Code to save images to your photo library.", // "Audio, AirPlay, and Picture in Picture": the browser screen's system // picture in picture needs it to start and to stay up outside the app. UIBackgroundModes: ["audio"], @@ -360,8 +352,6 @@ const config: ExpoConfig = { }, ], // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before - // plugins earlier in this array, so it cannot strip the entitlement Clerk would add. - ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], "expo-web-browser", [ "expo-quick-actions", @@ -382,7 +372,7 @@ const config: ExpoConfig = { [ "expo-audio", { - microphonePermission: "Allow T3 Code to use your microphone for voice input.", + microphonePermission: "Allow Otter Code to use your microphone for voice input.", recordAudioAndroid: false, enableBackgroundPlayback: false, enableBackgroundRecording: false, @@ -391,7 +381,8 @@ const config: ExpoConfig = { [ "expo-camera", { - cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", + cameraPermission: + "Allow Otter Code to access your camera so you can scan pairing QR codes.", microphonePermission: false, barcodeScannerEnabled: true, recordAudioAndroid: false, @@ -474,29 +465,17 @@ const config: ExpoConfig = { relay: { url: repoEnv.T3CODE_RELAY_URL ?? null, }, - clerk: { - publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, - jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null, - }, - // Native Google sign-in credentials. @clerk/expo reads these from `extra` - // under their exact env-var names (not nested), and its config plugin reads - // the iOS URL scheme at prebuild to register it in Info.plist. - // Unset values must be omitted (not null): the public manifest serializes - // null to {}, which is truthy and would defeat Clerk's fallback checks. - EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID, - EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID, - EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID, - EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME, + accounts: { url: repoEnv.EXPO_PUBLIC_ACCOUNTS_URL ?? null }, observability: { tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? "https://api.axiom.co/v1/traces", tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null, tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + projectId: EAS_PROJECT_ID, }, }, - owner: "pingdotgg", + owner: "clary-so", }; export default config; diff --git a/apps/mobile/assets/otter/android-icon-background-dev.png b/apps/mobile/assets/otter/android-icon-background-dev.png new file mode 100644 index 000000000000..1811e19f30c9 Binary files /dev/null and b/apps/mobile/assets/otter/android-icon-background-dev.png differ diff --git a/apps/mobile/assets/otter/android-icon-background-nightly.png b/apps/mobile/assets/otter/android-icon-background-nightly.png new file mode 100644 index 000000000000..754588672542 Binary files /dev/null and b/apps/mobile/assets/otter/android-icon-background-nightly.png differ diff --git a/apps/mobile/assets/otter/android-icon-foreground-dev.png b/apps/mobile/assets/otter/android-icon-foreground-dev.png new file mode 100644 index 000000000000..03e03f12af41 Binary files /dev/null and b/apps/mobile/assets/otter/android-icon-foreground-dev.png differ diff --git a/apps/mobile/assets/otter/android-icon-foreground-nightly.png b/apps/mobile/assets/otter/android-icon-foreground-nightly.png new file mode 100644 index 000000000000..9019b7c105a3 Binary files /dev/null and b/apps/mobile/assets/otter/android-icon-foreground-nightly.png differ diff --git a/apps/mobile/assets/otter/android-icon-foreground.png b/apps/mobile/assets/otter/android-icon-foreground.png new file mode 100644 index 000000000000..da3032a8ec6e Binary files /dev/null and b/apps/mobile/assets/otter/android-icon-foreground.png differ diff --git a/apps/mobile/assets/otter/android-icon-mark.png b/apps/mobile/assets/otter/android-icon-mark.png new file mode 100644 index 000000000000..03e03f12af41 Binary files /dev/null and b/apps/mobile/assets/otter/android-icon-mark.png differ diff --git a/apps/mobile/assets/otter/android-notification-icon.png b/apps/mobile/assets/otter/android-notification-icon.png new file mode 100644 index 000000000000..30b3fb923ae9 Binary files /dev/null and b/apps/mobile/assets/otter/android-notification-icon.png differ diff --git a/apps/mobile/assets/otter/android-splash-icon-dev.png b/apps/mobile/assets/otter/android-splash-icon-dev.png new file mode 100644 index 000000000000..5509e927ebef Binary files /dev/null and b/apps/mobile/assets/otter/android-splash-icon-dev.png differ diff --git a/apps/mobile/assets/otter/android-splash-icon-nightly.png b/apps/mobile/assets/otter/android-splash-icon-nightly.png new file mode 100644 index 000000000000..c9841ddff039 Binary files /dev/null and b/apps/mobile/assets/otter/android-splash-icon-nightly.png differ diff --git a/apps/mobile/assets/otter/android-splash-icon.png b/apps/mobile/assets/otter/android-splash-icon.png new file mode 100644 index 000000000000..31dbeb3d0e34 Binary files /dev/null and b/apps/mobile/assets/otter/android-splash-icon.png differ diff --git a/apps/mobile/assets/widget/T3Mark.svg b/apps/mobile/assets/widget/T3Mark.svg index 48d1b09446f6..606c83605ae6 100644 --- a/apps/mobile/assets/widget/T3Mark.svg +++ b/apps/mobile/assets/widget/T3Mark.svg @@ -1,3 +1,3 @@ - - + + diff --git a/apps/mobile/clerk-theme.json b/apps/mobile/clerk-theme.json deleted file mode 100644 index f854f52a0d49..000000000000 --- a/apps/mobile/clerk-theme.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "colors": { - "primary": "#1b4ed8", - "background": "#fcfcfc", - "input": "#ffffff", - "danger": "#c10007", - "success": "#059669", - "warning": "#bb4d00", - "foreground": "#27272a", - "mutedForeground": "#71717b", - "primaryForeground": "#ffffff", - "inputForeground": "#27272a", - "neutral": "#fafafa", - "border": "#e4e4e7", - "ring": "#1b4ed8", - "muted": "#fafafa", - "shadow": "#000000" - }, - "darkColors": { - "primary": "#346bf1", - "background": "#0a0a0a", - "input": "#111111", - "danger": "#ff6467", - "success": "#34d399", - "warning": "#ffb900", - "foreground": "#f5f5f5", - "mutedForeground": "#818181", - "primaryForeground": "#ffffff", - "inputForeground": "#f5f5f5", - "neutral": "#111111", - "border": "#191919", - "ring": "#346bf1", - "muted": "#111111", - "shadow": "#000000" - }, - "design": { - "borderRadius": 18 - } -} diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 2947275d106e..86446f450a0b 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -81,7 +81,7 @@ }, "production": { "ios": { - "ascAppId": "6787819824" + "ascAppId": "6815697255" }, "android": { "track": "internal" diff --git a/apps/mobile/generated-uniwind-theme-names.json b/apps/mobile/generated-uniwind-theme-names.json index 4ec9f01f8ee7..35fe965883d2 100644 --- a/apps/mobile/generated-uniwind-theme-names.json +++ b/apps/mobile/generated-uniwind-theme-names.json @@ -1,4 +1,6 @@ [ + "chatgpt-light", + "chatgpt-dark", "t3-chat-light", "t3-chat-dark", "grove-light", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index caf91e40348b..0c2f944f97a2 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -155,11 +155,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #fcfcfc; - --color-clerk-foreground: #27272a; - --color-clerk-foreground-muted: #71717b; - --color-clerk-border: #e4e4e7; - --color-clerk-danger: #c10007; } @variant dark { @@ -316,11 +311,318 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0a0a0a; - --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #818181; - --color-clerk-border: #191919; - --color-clerk-danger: #ff6467; + } + + @variant chatgpt-light { + --color-screen: #ffffff; + --color-sheet: rgba(249, 249, 249, 0.98); + --color-sheet-solid: #f9f9f9; + --color-card: #ffffff; + --color-grouped-card: #ffffff; + --color-card-alt: #f4f4f4; + --color-card-translucent: rgba(255, 255, 255, 0.8); + --color-thread-canvas: #ffffff; + --color-thread-selected: #e8e8e8; + --color-thread-selected-foreground: #0d0d0d; + --color-thread-selected-foreground-muted: #686868; + --color-thread-hover: #efefef; + --color-row-hover: #f0f0f0; + --color-composer-panel: rgba(255, 255, 255, 0.88); + --color-composer-surface: rgba(255, 255, 255, 0.94); + --color-composer-border: rgba(232, 232, 232, 0.54); + --color-foreground: #0d0d0d; + --color-foreground-secondary: #5d5d5d; + --color-foreground-muted: #5d5d5d; + --color-foreground-tertiary: #5d5d5d; + --color-border: #e8e8e8; + --color-focus: #0285ff; + --color-border-subtle: rgba(232, 232, 232, 0.7); + --color-separator: rgba(232, 232, 232, 0.55); + --color-subtle: #f7f7f7; + --color-subtle-strong: #f4f4f4; + --color-inline-skill-background: #f0f0f0; + --color-inline-skill-border: rgba(2, 133, 255, 0.42); + --color-inline-skill-foreground: #0d0d0d; + --color-primary: #0d0d0d; + --color-prominent-action: #0d0d0d; + --color-prominent-action-foreground: #ffffff; + --color-primary-foreground: #ffffff; + --color-primary-text: #0d0d0d; + --color-primary-shadow: #000000; + --color-secondary: #f4f4f4; + --color-secondary-foreground: #0d0d0d; + --color-secondary-border: #e8e8e8; + --color-switch-active-track: #0d0d0d; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: #f4f4f4; + --color-switch-inactive-thumb: #5d5d5d; + --color-warning: #fdf4e6; + --color-warning-border: rgba(227, 140, 0, 0.32); + --color-warning-foreground: #af5800; + --color-danger: #fdecec; + --color-danger-border: rgba(224, 46, 42, 0.32); + --color-danger-foreground: #c4201c; + --color-update: #e6f2ff; + --color-update-foreground: #0169cc; + --color-input: #ffffff; + --color-input-border: #d9d9d9; + --color-sidebar-search: #ececec; + --color-placeholder: #707070; + --color-icon: #0d0d0d; + --color-icon-muted: #7a7a7a; + --color-icon-subtle: #5d5d5d; + --color-header: rgba(255, 255, 255, 0.97); + --color-header-foreground: #0d0d0d; + --color-header-border: #e8e8e8; + --color-glass-surface: rgba(255, 255, 255, 0.74); + --color-glass-fallback: rgba(255, 255, 255, 0.94); + --color-glass-tint: rgba(255, 255, 255, 0.22); + --color-status-bar: #ffffff; + --color-md-body: #0d0d0d; + --color-md-strong: #0d0d0d; + --color-md-link: #0d0d0d; + --color-md-blockquote-border: #e8e8e8; + --color-md-blockquote-bg: #f7f7f7; + --color-md-code-bg: #f4f4f4; + --color-md-code-text: #0d0d0d; + --color-md-user-code-bg: rgba(13, 13, 13, 0.18); + --color-md-user-code-text: #0d0d0d; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #0d0d0d; + --color-md-hr: #e8e8e8; + --color-user-bubble: #f4f4f4; + --color-user-bubble-foreground: #0d0d0d; + --color-user-bubble-foreground-muted: rgba(13, 13, 13, 0.78); + --color-user-bubble-skill-foreground: #026fd5; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: #f9f9f9; + --color-drawer-foreground: #0d0d0d; + --color-drawer-foreground-muted: #6c6c6c; + --color-drawer-border: #e8e8e8; + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(93, 93, 93, 0.35); + --color-wordmark: #0d0d0d; + --color-chevron: rgba(93, 93, 93, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%); + --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); + --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%); + --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%); + --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant chatgpt-dark { + --color-screen: #181818; + --color-sheet: rgba(32, 32, 32, 0.98); + --color-sheet-solid: #202020; + --color-card: #1c1c1c; + --color-grouped-card: #1c1c1c; + --color-card-alt: #303030; + --color-card-translucent: rgba(28, 28, 28, 0.8); + --color-thread-canvas: #181818; + --color-thread-selected: #333333; + --color-thread-selected-foreground: #ececec; + --color-thread-selected-foreground-muted: #9b9b9b; + --color-thread-hover: #292929; + --color-row-hover: #2e2e2e; + --color-composer-panel: rgba(24, 24, 24, 0.92); + --color-composer-surface: rgba(28, 28, 28, 0.9); + --color-composer-border: rgba(44, 44, 44, 0.46); + --color-foreground: #ececec; + --color-foreground-secondary: #a3a3a3; + --color-foreground-muted: #a3a3a3; + --color-foreground-tertiary: #a3a3a3; + --color-border: #2c2c2c; + --color-focus: #339cff; + --color-border-subtle: rgba(44, 44, 44, 0.7); + --color-separator: rgba(44, 44, 44, 0.55); + --color-subtle: #1f1f1f; + --color-subtle-strong: #232323; + --color-inline-skill-background: #262626; + --color-inline-skill-border: rgba(51, 156, 255, 0.42); + --color-inline-skill-foreground: #ececec; + --color-primary: #ffffff; + --color-prominent-action: #ffffff; + --color-prominent-action-foreground: #0d0d0d; + --color-primary-foreground: #0d0d0d; + --color-primary-text: #ffffff; + --color-primary-shadow: #000000; + --color-secondary: #232323; + --color-secondary-foreground: #ececec; + --color-secondary-border: #2c2c2c; + --color-switch-active-track: #ffffff; + --color-switch-active-thumb: #0d0d0d; + --color-switch-inactive-track: #232323; + --color-switch-inactive-thumb: #a3a3a3; + --color-warning: #35291a; + --color-warning-border: rgba(245, 165, 36, 0.32); + --color-warning-foreground: #ffc266; + --color-danger: #3a1f1f; + --color-danger-border: rgba(255, 107, 107, 0.32); + --color-danger-foreground: #ff8a8a; + --color-update: #1a2b3d; + --color-update-foreground: #66b5ff; + --color-input: #1c1c1c; + --color-input-border: #3a3a3a; + --color-sidebar-search: #2e2e2e; + --color-placeholder: #979797; + --color-icon: #ececec; + --color-icon-muted: #9a9a9a; + --color-icon-subtle: #a3a3a3; + --color-header: rgba(24, 24, 24, 0.97); + --color-header-foreground: #ececec; + --color-header-border: #2a2a2a; + --color-glass-surface: rgba(45, 45, 45, 0.74); + --color-glass-fallback: rgba(28, 28, 28, 0.9); + --color-glass-tint: rgba(45, 45, 45, 0.22); + --color-status-bar: #181818; + --color-md-body: #ececec; + --color-md-strong: #ececec; + --color-md-link: #ffffff; + --color-md-blockquote-border: #2c2c2c; + --color-md-blockquote-bg: #1f1f1f; + --color-md-code-bg: #232323; + --color-md-code-text: #ececec; + --color-md-user-code-bg: rgba(236, 236, 236, 0.18); + --color-md-user-code-text: #ececec; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #ececec; + --color-md-hr: #2c2c2c; + --color-user-bubble: #2f2f2f; + --color-user-bubble-foreground: #ececec; + --color-user-bubble-foreground-muted: rgba(236, 236, 236, 0.78); + --color-user-bubble-skill-foreground: #ffffff; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: #202020; + --color-drawer-foreground: #ececec; + --color-drawer-foreground-muted: #9a9a9a; + --color-drawer-border: #2a2a2a; + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(163, 163, 163, 0.35); + --color-wordmark: #ececec; + --color-chevron: rgba(163, 163, 163, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%); + --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); + --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%); + --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%); + --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); } @variant t3-chat-light { @@ -477,11 +779,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #fcfcfc; - --color-clerk-foreground: #27272a; - --color-clerk-foreground-muted: #71717b; - --color-clerk-border: #e4e4e7; - --color-clerk-danger: #c10007; } @variant t3-chat-dark { @@ -638,11 +935,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0a0a0a; - --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #818181; - --color-clerk-border: #191919; - --color-clerk-danger: #ff6467; } @variant grove-light { @@ -799,11 +1091,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #fcfcfc; - --color-clerk-foreground: #27272a; - --color-clerk-foreground-muted: #71717b; - --color-clerk-border: #e4e4e7; - --color-clerk-danger: #c10007; } @variant grove-dark { @@ -960,11 +1247,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0a0a0a; - --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #818181; - --color-clerk-border: #191919; - --color-clerk-danger: #ff6467; } @variant ocean-light { @@ -1121,11 +1403,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #fcfcfc; - --color-clerk-foreground: #27272a; - --color-clerk-foreground-muted: #71717b; - --color-clerk-border: #e4e4e7; - --color-clerk-danger: #c10007; } @variant ocean-dark { @@ -1282,11 +1559,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0a0a0a; - --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #818181; - --color-clerk-border: #191919; - --color-clerk-danger: #ff6467; } @variant ember-light { @@ -1443,11 +1715,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #fcfcfc; - --color-clerk-foreground: #27272a; - --color-clerk-foreground-muted: #71717b; - --color-clerk-border: #e4e4e7; - --color-clerk-danger: #c10007; } @variant ember-dark { @@ -1604,11 +1871,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0a0a0a; - --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #818181; - --color-clerk-border: #191919; - --color-clerk-danger: #ff6467; } @variant iris-light { @@ -1765,11 +2027,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #fcfcfc; - --color-clerk-foreground: #27272a; - --color-clerk-foreground-muted: #71717b; - --color-clerk-border: #e4e4e7; - --color-clerk-danger: #c10007; } @variant iris-dark { @@ -1926,11 +2183,6 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0a0a0a; - --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #818181; - --color-clerk-border: #191919; - --color-clerk-danger: #ff6467; } } } diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index 5c7cd2e43560..5f504f994fb1 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -127,7 +127,7 @@ object AgentNotifications { val fresh = System.currentTimeMillis() - updatedAt in -MAX_MESSAGE_AGE_MS..MAX_MESSAGE_AGE_MS if (registered && fresh && NotificationManagerCompat.from(context).areNotificationsEnabled()) { channels(context) - val scheme = prefs.getString("scheme", "t3code") ?: "t3code" + val scheme = prefs.getString("scheme", "ottercode") ?: "ottercode" showAlert(context, prefs, scheme, data) updateActivity(context, prefs, scheme, data, updatedAt) } diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml index 7aa01bac42c1..45de094216be 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml @@ -1,9 +1,9 @@ - + - - + android:viewportWidth="780" android:viewportHeight="780"> + + diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index 1703a7670202..b7e5178f5feb 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -18,7 +18,7 @@ internal object T3ComposerClipboard { } val records = payload?.optJSONArray("records") if (payload == null || records == null) { - clipboard.setPrimaryClip(ClipData.newPlainText("T3 Code", text)) + clipboard.setPrimaryClip(ClipData.newPlainText("Otter Code", text)) return } val all = (0 until records.length()).map { records.getJSONObject(it) } @@ -36,12 +36,12 @@ internal object T3ComposerClipboard { clipboard.setPrimaryClip( if (selected.isEmpty()) { ClipData.newPlainText( - "T3 Code", + "Otter Code", text ) } else { ClipData.newHtmlText( - "T3 Code", + "Otter Code", text, "
    $escaped
    " ) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 1cde09d65f61..8caa227af0df 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -16,7 +16,8 @@ final class T3LayoutMetricsView: ExpoView { } #if compiler(>=6.4) if #available(iOS 27.1, *) { - hasHinge = nil + // Publish as hinge-less right away: phones without a hinge may never get an update, + // and the workspace waits for metrics before attaching any screen. addInteraction(UIHingeInteraction { [weak self] _, update in guard let self, self.window != nil else { return } // Capability survives temporary detachment and does not follow the hinge angle. @@ -35,21 +36,9 @@ final class T3LayoutMetricsView: ExpoView { override func didMoveToWindow() { super.didMoveToWindow() lastMetrics = nil - assumeNoHingeIfSilent() publishMetrics() } - /// UIKit only sends hinge updates on devices that have a hinge. Without this, - /// an ordinary iPhone never publishes and the navigator never attaches. - private func assumeNoHingeIfSilent() { - guard window != nil, hasHinge == nil else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self, self.window != nil, self.hasHinge == nil else { return } - self.hasHinge = false - self.publishMetrics() - } - } - override func layoutSubviews() { super.layoutSubviews() publishMetrics() diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 16eccc711442..eadf59230a93 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,10 +5,10 @@ "main": "index.ts", "scripts": { "dev": "expo start", - "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --lan", - "dev:client:reset": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan", - "dev:client:preview-env": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=development expo start --dev-client --scheme t3code-dev --lan --port 0'", - "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --lan'", + "dev:client": "APP_VARIANT=development expo start --dev-client --scheme ottercode-dev --lan", + "dev:client:reset": "APP_VARIANT=development expo start --dev-client --scheme ottercode-dev --clear --lan", + "dev:client:preview-env": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=development expo start --dev-client --scheme ottercode-dev --lan --port 0'", + "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme ottercode-preview --lan'", "start": "expo start", "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", @@ -46,7 +46,6 @@ "typecheck": "tsc-rs --noEmit" }, "dependencies": { - "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/metro-runtime": "~58.0.10", diff --git a/apps/mobile/plugins/withWidgetLogoAsset.cjs b/apps/mobile/plugins/withWidgetLogoAsset.cjs index 5c976c2a0cba..b1f72d08dfc1 100644 --- a/apps/mobile/plugins/withWidgetLogoAsset.cjs +++ b/apps/mobile/plugins/withWidgetLogoAsset.cjs @@ -1,6 +1,6 @@ "use strict"; -// Ships the branded T3 mark to the Live Activity / widget extension. +// Ships the code-bracket mark to the Live Activity / widget extension. // // expo-widgets generates ExpoWidgetsTarget without a Resources build phase and // has no asset support, so this plugin (a) writes an SVG template image set into diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts index 35d5b5f5e3d0..bf9861b7cb56 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.mts +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -6,8 +6,6 @@ import tailwindColors from "tailwindcss/colors"; import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; import { - createMobileThemeVariables, - getMobileThemeColors, getMobileThemeVariables, DEFAULT_MOBILE_THEME_ID, type MobileThemeAppearance, @@ -23,7 +21,6 @@ const GENERATED_DEFAULT_VARIABLES_PATH = NodePath.resolve( import.meta.dirname, "../generated-uniwind-default-theme-variables.json", ); -const GENERATED_CLERK_THEME_PATH = NodePath.resolve(import.meta.dirname, "../clerk-theme.json"); type TailwindColorFamily = keyof typeof tailwindColors; type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; @@ -169,47 +166,8 @@ const adaptiveVariablesFor = (appearance: MobileThemeAppearance) => const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({ ...getMobileThemeVariables(themeId, appearance), ...adaptiveVariablesFor(appearance), - ...clerkVariablesFor(appearance), }); -// Clerk's native screens use one build-time palette per appearance. Custom -// profile pages must match it even when the rest of the app uses a named theme. -const clerkColorsFor = (appearance: MobileThemeAppearance) => { - // Native authentication uses plain cards, rather than tonal settings groups. - const variables = createMobileThemeVariables( - getMobileThemeColors(DEFAULT_MOBILE_THEME_ID, appearance), - appearance, - ); - return { - primary: variables["--color-primary"], - background: variables["--color-sheet-solid"], - input: variables["--color-input"], - danger: variables["--color-danger-foreground"], - success: appearance === "dark" ? "#34d399" : "#059669", - warning: variables["--color-warning-foreground"], - foreground: variables["--color-foreground"], - mutedForeground: variables["--color-foreground-muted"], - primaryForeground: variables["--color-primary-foreground"], - inputForeground: variables["--color-foreground"], - neutral: variables["--color-secondary"], - border: variables["--color-border"], - ring: variables["--color-focus"], - muted: variables["--color-subtle"], - shadow: variables["--color-primary-shadow"], - }; -}; - -const clerkVariablesFor = (appearance: MobileThemeAppearance) => { - const colors = clerkColorsFor(appearance); - return { - "--color-clerk-page": colors.background.toLowerCase(), - "--color-clerk-foreground": colors.foreground.toLowerCase(), - "--color-clerk-foreground-muted": colors.mutedForeground.toLowerCase(), - "--color-clerk-border": colors.border, - "--color-clerk-danger": colors.danger.toLowerCase(), - }; -}; - const renderVariant = (name: string, variables: Readonly>) => { const declarations = Object.entries(variables) .map(([variable, value]) => ` ${variable}: ${value};`) @@ -223,7 +181,6 @@ export const renderUniwindThemesCSS = () => { renderVariant(appearance, { ...getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, appearance), ...adaptiveVariablesFor(appearance), - ...clerkVariablesFor(appearance), }), ), ...BUILT_IN_THEME_IDS.flatMap((themeId) => @@ -261,10 +218,6 @@ export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray< [GENERATED_CSS_PATH, renderUniwindThemesCSS()], [GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`], [GENERATED_DEFAULT_VARIABLES_PATH, renderDefaultThemeVariablesJSON()], - [ - GENERATED_CLERK_THEME_PATH, - `${JSON.stringify({ colors: clerkColorsFor("light"), darkColors: clerkColorsFor("dark"), design: { borderRadius: 18 } }, null, 2)}\n`, - ], ]; const writeFileAtomically = (filename: string, contents: string) => { diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts index a225b7087c86..c93a2e61906b 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.test.ts +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -27,6 +27,8 @@ describe("generate mobile Uniwind themes", () => { it("registers every custom palette for both appearances", () => { expect(customThemeNames).toEqual([ + "chatgpt-light", + "chatgpt-dark", "t3-chat-light", "t3-chat-dark", "grove-light", @@ -57,7 +59,7 @@ describe("generate mobile Uniwind themes", () => { expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark)); }); - it("gives every theme the same variables and a fixed Clerk palette for its appearance", () => { + it("gives every theme the same variables", () => { const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8") + renderUniwindThemesCSS(); @@ -74,19 +76,6 @@ describe("generate mobile Uniwind themes", () => { const lightVariables = themes.get("light")!; for (const [name, variables] of themes) { expect([...variables.keys()].sort(), name).toEqual([...lightVariables.keys()].sort()); - const isDark = name === "dark" || name.endsWith("-dark"); - expect( - Object.fromEntries( - [...variables].filter(([variable]) => variable.startsWith("--color-clerk-")), - ), - name, - ).toEqual({ - "--color-clerk-page": isDark ? "#0a0a0a" : "#fcfcfc", - "--color-clerk-foreground": isDark ? "#f5f5f5" : "#27272a", - "--color-clerk-foreground-muted": isDark ? "#818181" : "#71717b", - "--color-clerk-border": isDark ? "#191919" : "#e4e4e7", - "--color-clerk-danger": isDark ? "#ff6467" : "#c10007", - }); } }); }); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 9be57907b358..f1237c1f3702 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -40,7 +40,7 @@ void SplashScreen.preventAutoHideAsync().catch(() => { }); const appLinking = { - prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], + prefixes: [Linking.createURL("/"), "ottercode://", "ottercode-dev://", "ottercode-preview://"], // Keep the compact thread list available beneath a directly opened thread. config: { initialRouteName: "Home" }, filter: shouldHandleAppLink, diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4dfa3f3a9856..a4ec4537f878 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -280,7 +280,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ SettingsAbout: createNativeStackScreen({ screen: SettingsAboutRouteScreen, linking: "about", - options: { title: "About T3 Code" }, + options: { title: "About Otter Code" }, }), SettingsEnvironmentNew: createNativeStackScreen({ screen: ConnectionsNewRouteScreen, @@ -430,7 +430,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ }); // The outer stack never owns visible chrome. Settings routes render inside a -// nested stack whose native header remains mounted, while Clerk owns auth chrome. +// nested stack whose native header remains mounted, while Otter Accounts owns auth chrome. // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. const SettingsSheetStack = createV5SheetStackNavigator({ initialRouteName: "SettingsContent", @@ -960,7 +960,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ // A root-level Android formSheet does not host the native stack bar; // the route renders an embedded AndroidSheetHeader instead. ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), - title: "Set up T3 Connect", + title: "Set up Otter Connect", gestureEnabled: true, ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.6, 0.95], diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 1643f3f6fdeb..776df0c8911e 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -27,7 +27,7 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab /> - T3 Code + Otter Code {stageLabel} diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index dc5503e13760..3eed5dd5828e 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -4,7 +4,6 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stac import { Platform, View } from "react-native"; import { AppText as Text } from "./AppText"; -import { T3Wordmark } from "./T3Wordmark"; import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../lib/mobileBranding"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; @@ -34,13 +33,19 @@ export function CompactBrandTitle( return ( - + + Otter + Option.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)), ), - clerkToken: Effect.gen(function* () { + accountToken: Effect.gen(function* () { const session = appAtomRegistry.get(managedRelaySessionAtom); if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "Sign in to T3 Connect to connect this environment.", + detail: "Sign in to Otter Connect to connect this environment.", }); } - const token = yield* session.readClerkToken().pipe( + const token = yield* session.readAccountToken().pipe( Effect.mapError( (error) => new ConnectionTransientError({ @@ -183,7 +183,7 @@ const layerCapabilities = Layer.effectContext( if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "The T3 Connect session is unavailable.", + detail: "The Otter Connect session is unavailable.", }); } return token; diff --git a/apps/mobile/src/features/accounts/AccountProvider.tsx b/apps/mobile/src/features/accounts/AccountProvider.tsx new file mode 100644 index 000000000000..f321acfcf667 --- /dev/null +++ b/apps/mobile/src/features/accounts/AccountProvider.tsx @@ -0,0 +1,142 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { AppState } from "react-native"; +import { AuthRequest, makeRedirectUri, ResponseType } from "expo-auth-session"; +import Constants from "expo-constants"; +import * as SecureStore from "expo-secure-store"; +import { createAccountSession } from "@t3tools/client-runtime/accounts"; +import { ACCOUNT_CLIENT_IDS, ACCOUNT_OAUTH_SCOPES } from "@t3tools/shared/otterAccounts"; +import type { OtterAccountUser } from "@t3tools/contracts/accounts"; +import { resolveCloudPublicConfig } from "../cloud/publicConfig"; + +const sessionKey = "otter-account-session-v1"; +type AccountContextValue = { + readonly isLoaded: boolean; + readonly isSignedIn: boolean; + readonly userId: string | null; + readonly user: OtterAccountUser | null; + readonly error: string | null; + readonly getToken: () => Promise; + readonly signIn: () => Promise; + readonly signOut: () => Promise; +}; +const AccountContext = createContext({ + isLoaded: true, + isSignedIn: false, + userId: null, + user: null, + error: null, + getToken: async () => null, + signIn: async () => {}, + signOut: async () => {}, +}); + +export function AccountProvider({ children }: { readonly children: ReactNode }) { + const [manager] = useState(() => { + const config = resolveCloudPublicConfig(); + if (!config.accounts.url || !config.relay.url) + throw new Error("Otter Accounts is not configured."); + return createAccountSession({ + accountsUrl: config.accounts.url, + resource: config.relay.url, + clientId: ACCOUNT_CLIENT_IDS.mobile, + fetch: globalThis.fetch, + storage: { + read: () => SecureStore.getItemAsync(sessionKey), + write: (value) => + value + ? SecureStore.setItemAsync(sessionKey, value, { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }) + : SecureStore.deleteItemAsync(sessionKey), + }, + }); + }); + const snapshot = useSyncExternalStore(manager.subscribe, manager.getSnapshot); + const [error, setError] = useState(null); + const [actions] = useState(() => ({ + signIn: async () => { + setError(null); + try { + const config = resolveCloudPublicConfig(); + const redirectUri = makeRedirectUri({ + native: `${Constants.expoConfig?.scheme ?? "ottercode"}://account/callback`, + }); + const request = new AuthRequest({ + clientId: ACCOUNT_CLIENT_IDS.mobile, + redirectUri, + responseType: ResponseType.Code, + scopes: [...ACCOUNT_OAUTH_SCOPES], + usePKCE: true, + extraParams: { resource: config.relay.url! }, + }); + const result = await request.promptAsync({ + authorizationEndpoint: `${config.accounts.url}/oauth2/authorize`, + }); + if (result.type === "cancel" || result.type === "dismiss") return; + if ( + result.type !== "success" || + !result.params.code || + !request.codeVerifier || + result.params.iss !== config.accounts.url + ) + throw new Error("Sign-in could not be completed. Please try again."); + await manager.signIn({ + code: result.params.code, + verifier: request.codeVerifier, + redirectUri, + }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Sign-in could not be completed."); + } + }, + signOut: async () => { + setError(null); + try { + await manager.signOut(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not finish signing out."); + } + }, + })); + useEffect(() => { + let active = true; + const report = (cause: unknown) => { + if (active) + setError(cause instanceof Error ? cause.message : "Could not restore your Otter account."); + }; + void manager + .load() + .then(() => manager.validate()) + .catch(report); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void manager.validate().catch(report); + }); + return () => { + active = false; + subscription.remove(); + }; + }, [manager]); + const value = useMemo( + () => ({ + isLoaded: snapshot.loaded, + isSignedIn: snapshot.user !== null, + userId: snapshot.user?.id ?? null, + user: snapshot.user, + error, + getToken: manager.readToken, + ...actions, + }), + [snapshot, error, manager, actions], + ); + return {children}; +} + +export const useAuth = () => useContext(AccountContext); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index 73f84434d89d..c7f7a9403503 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -21,7 +21,7 @@ export function supportsAndroidAgentNotifications(): boolean { function appScheme(): string { const scheme = Constants.expoConfig?.scheme; - return (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code"; + return (Array.isArray(scheme) ? scheme[0] : scheme) ?? "ottercode"; } export function configureAndroidAgentNotifications( diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index dd17b610bfad..34ffd804e566 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -83,7 +83,7 @@ describe("liveActivityPreferences", () => { yield* setLiveActivityUpdatesEnabled({ enabled: false, previousEnabled: true, - clerkToken: "clerk-token", + accountToken: "account-token", connections: [connection], canConfigureEnvironment: () => true, }); @@ -92,7 +92,7 @@ describe("liveActivityPreferences", () => { liveActivitiesEnabled: false, }); expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ - clerkToken: "clerk-token", + accountToken: "account-token", connection, liveActivitiesEnabled: false, }); @@ -104,7 +104,7 @@ describe("liveActivityPreferences", () => { yield* setLiveActivityUpdatesEnabled({ enabled: true, previousEnabled: false, - clerkToken: "clerk-token", + accountToken: "account-token", connections: [connection], canConfigureEnvironment: () => true, }); @@ -113,7 +113,7 @@ describe("liveActivityPreferences", () => { liveActivitiesEnabled: true, }); expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ - clerkToken: "clerk-token", + accountToken: "account-token", connection, liveActivitiesEnabled: true, }); @@ -125,7 +125,7 @@ describe("liveActivityPreferences", () => { yield* setLiveActivityUpdatesEnabled({ enabled: false, previousEnabled: true, - clerkToken: null, + accountToken: null, connections: [connection], canConfigureEnvironment: () => true, }); @@ -147,14 +147,14 @@ describe("liveActivityPreferences", () => { yield* setLiveActivityUpdatesEnabled({ enabled: true, previousEnabled: false, - clerkToken: "clerk-token", + accountToken: "account-token", connections: [connection, managedConnection], canConfigureEnvironment: () => true, }); expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ - clerkToken: "clerk-token", + accountToken: "account-token", connection, liveActivitiesEnabled: true, }); @@ -171,7 +171,7 @@ describe("liveActivityPreferences", () => { setLiveActivityUpdatesEnabled({ enabled: false, previousEnabled: true, - clerkToken: "clerk-token", + accountToken: "account-token", connections: [connection], canConfigureEnvironment: () => true, }), @@ -185,12 +185,12 @@ describe("liveActivityPreferences", () => { liveActivitiesEnabled: true, }); expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { - clerkToken: "clerk-token", + accountToken: "account-token", connection, liveActivitiesEnabled: false, }); expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { - clerkToken: "clerk-token", + accountToken: "account-token", connection, liveActivitiesEnabled: true, }); @@ -207,7 +207,7 @@ describe("liveActivityPreferences", () => { yield* setLiveActivityUpdatesEnabled({ enabled: true, previousEnabled: false, - clerkToken: "clerk-token", + accountToken: "clerk-token", connections: [readOnlyConnection, connection], canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId, }); @@ -216,7 +216,7 @@ describe("liveActivityPreferences", () => { liveActivitiesEnabled: true, }); expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledExactlyOnceWith({ - clerkToken: "clerk-token", + accountToken: "clerk-token", connection, liveActivitiesEnabled: true, }); @@ -229,7 +229,7 @@ describe("liveActivityPreferences", () => { yield* setLiveActivityUpdatesEnabled({ enabled, previousEnabled: !enabled, - clerkToken: "clerk-token", + accountToken: "clerk-token", connections: [connection], canConfigureEnvironment: () => false, }); @@ -257,7 +257,7 @@ describe("liveActivityPreferences", () => { yield* setLiveActivityUpdatesEnabled({ enabled: false, previousEnabled: true, - clerkToken: "clerk-token", + accountToken: "clerk-token", connections: [connection], canConfigureEnvironment: () => canConfigureEnvironment, }); @@ -284,7 +284,7 @@ describe("liveActivityPreferences", () => { setLiveActivityUpdatesEnabled({ enabled: false, previousEnabled: true, - clerkToken: "clerk-token", + accountToken: "clerk-token", connections: [connection], canConfigureEnvironment: () => canConfigureEnvironment, }), diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 826edaf839b9..acc5dd0bdc6a 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -9,7 +9,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn function* (input: { readonly enabled: boolean; readonly previousEnabled: boolean; - readonly clerkToken: string | null; + readonly accountToken: string | null; readonly connections: ReadonlyArray; readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean; }) { @@ -20,12 +20,12 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( connection: SavedRemoteConnection, enabled: boolean, - clerkToken: string, + accountToken: string, ) { if (!input.canConfigureEnvironment(connection.environmentId)) return; yield* linkEnvironmentToCloudWithPreference({ - clerkToken, + accountToken, connection, liveActivitiesEnabled: enabled, }); @@ -36,12 +36,12 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn liveActivitiesEnabled: enabled, }); - const clerkToken = input.clerkToken; - if (!clerkToken) return; + const accountToken = input.accountToken; + if (!accountToken) return; yield* Effect.forEach( linkedConnections, - (connection) => updateEnvironmentPreference(connection, enabled, clerkToken), + (connection) => updateEnvironmentPreference(connection, enabled, accountToken), { concurrency: "unbounded" }, ); }); @@ -55,13 +55,13 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn ), ); - const clerkToken = input.clerkToken; - if (!clerkToken) return; + const accountToken = input.accountToken; + if (!accountToken) return; yield* Effect.forEach( linkedConnections, (connection) => - updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe( + updateEnvironmentPreference(connection, input.previousEnabled, accountToken).pipe( Effect.catchCause((cause) => Effect.logWarning( `Could not restore Live Activity preference for environment ${connection.environmentId}.`, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 37725165ea47..32794f9e3432 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -456,7 +456,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { end: vi.fn(), }; widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* refreshActiveLiveActivityRemoteRegistration(); @@ -477,7 +477,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { addPushTokenListener: vi.fn(), }; widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* runBackgroundOperations(); @@ -507,7 +507,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { end, }; widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); expect(appStateMock.listeners).toHaveLength(1); setAgentAwarenessRelayTokenProvider(null); @@ -552,7 +552,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* runBackgroundOperations(); @@ -595,7 +595,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce( new Error("registration failed"), ); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { // Drive the registration directly so the assertion does not depend on the @@ -608,7 +608,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); it("clears registration status on cloud sign-out", () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); setAgentAwarenessRelayTokenProvider(null); expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled(); @@ -623,7 +623,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }; widgetMocks.getInstances.mockReturnValue([activity] as never); registrationRecordStore.current = { identity: "", signature: "sig" }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); expect(appStateMock.listeners).toHaveLength(1); releaseAgentAwarenessRelayTokenProvider(); @@ -637,7 +637,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { it.effect("resets a pending status to unknown when relay config is missing", () => { // No relay url configured: registration can neither run nor ever succeed, // so the status must not stick at "pending". - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* runBackgroundOperations(); @@ -651,7 +651,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { url: "https://relay.example.test/", }, }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* runBackgroundOperations(); @@ -673,7 +673,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { url: "https://relay.example.test/", }, }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* refreshAgentAwarenessRegistration(); @@ -722,7 +722,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { addPushTokenListener: vi.fn(), }; widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { // Drains the sign-in refresh, which registers the activity token. @@ -745,7 +745,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, }; registrationRecordStore.current = { identity: "someone-else", signature: "stale" }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* refreshAgentAwarenessRegistration(); @@ -778,7 +778,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }; vi.mocked(Notifications.getPermissionsAsync).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); registerAgentAwarenessConnection(savedConnection()); return Effect.gen(function* () { @@ -797,7 +797,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { const tokenProvider = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("auth unavailable")) - .mockResolvedValue("clerk-token-user-a"); + .mockResolvedValue("account-token-user-a"); setAgentAwarenessRelayTokenProvider(tokenProvider); const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; expect(tokenListener).toBeDefined(); @@ -843,7 +843,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }; vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; expect(tokenListener).toBeDefined(); @@ -882,7 +882,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }; registerAgentAwarenessConnection(savedConnection()); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); return Effect.gen(function* () { yield* runBackgroundOperations(); fetchMock.mockClear(); @@ -895,7 +895,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); it("skips the Live Activity seed when the environment reports publishing disabled", async () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); vi.mocked(loadPreferences).mockResolvedValueOnce({ liveActivitiesEnabled: true, } as Preferences); @@ -914,7 +914,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); it("seeds the Live Activity for publishing and pre-capability environments", async () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a")); environmentConfigsMock.configs.set("env-publishing", { environment: { capabilities: { agentActivityPublishing: true } }, }); @@ -969,7 +969,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Response.json({ ok: true }); }); Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account"), "user-a"); return Effect.gen(function* () { yield* runBackgroundOperations(); expect(registrations.at(-1)).toMatchObject({ @@ -1018,7 +1018,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Promise.resolve(response); }); Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a"), "user-a"); return Effect.gen(function* () { // Hermes' compiled error hashing reads the response's cookie getter. @@ -1070,7 +1070,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ); Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-a"), "user-a"); return Effect.gen(function* () { yield* refreshAgentAwarenessRegistration(); expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalled(); @@ -1080,7 +1080,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(widgetMocks.getInstances).not.toHaveBeenCalled(); expect(configureAndroidAgentNotifications).toHaveBeenCalledWith("device-1", "user-a", true); vi.mocked(clearAndroidAgentNotifications).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token-user-b"), "user-b"); expect(clearAndroidAgentNotifications).toHaveBeenCalled(); }).pipe(Effect.provide(layerRelayTest)); }); @@ -1096,7 +1096,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; const now = vi.spyOn(Date, "now").mockReturnValue(1000000); vi.mocked(clearAndroidAgentNotifications).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token"), "user-a"); return Effect.gen(function* () { yield* runBackgroundOperations(); expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); @@ -1104,7 +1104,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* refreshAgentAwarenessRegistration(); expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); releaseAgentAwarenessRelayTokenProvider(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token"), "user-a"); yield* runBackgroundOperations(); expect(clearAndroidAgentNotifications).not.toHaveBeenCalled(); expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2); @@ -1130,7 +1130,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.spyOn(Platform, "Version", "get").mockReturnValue(36); Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("account-token"), "user-a"); releaseAgentAwarenessRelayTokenProvider(); vi.mocked(configureAndroidAgentNotifications).mockClear(); return Effect.gen(function* () { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 7ed7ddc8bae1..a7406eb9cbf5 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -224,7 +224,7 @@ export function setAgentAwarenessRelayTokenProvider( "active live activity registration after cloud sign-in failed", ); if (isExistingIdentity) { - // Same account re-activating (e.g. Clerk token refresh) normally needs no + // Same account re-activating (e.g. Otter Accounts token refresh) normally needs no // re-registration — but if the previous attempt never succeeded, this is // the only trigger that will retry it before the next cold start. if (registrationStatus !== "registered") { @@ -425,7 +425,7 @@ function registerDeviceWithRelay( expectedGeneration, }); yield* client.registerDevice({ - clerkToken: token, + accountToken: token, payload, }); if (expectedGeneration !== deviceRegistrationGeneration) { @@ -475,7 +475,7 @@ function unregisterDeviceWithRelay(input: { const client = yield* ManagedRelay.ManagedRelayClient; yield* client.unregisterDevice({ - clerkToken: token, + accountToken: token, deviceId: input.deviceId, }); }); @@ -534,7 +534,7 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { const nowIso = new Date(Date.now()).toISOString(); const activity = startAgentLiveActivity( { - title: "T3 Code", + title: "Otter Code", subtitle: "Agent work in progress", activeCount: 1, updatedAt: nowIso, @@ -581,7 +581,7 @@ function readAgentActivitySnapshot(): Effect.Effect< return null; } const client = yield* ManagedRelay.ManagedRelayClient; - return yield* client.getAgentActivitySnapshot({ clerkToken: token }); + return yield* client.getAgentActivitySnapshot({ accountToken: token }); }).pipe( Effect.catch((error) => Effect.sync(() => { @@ -605,7 +605,7 @@ function registerLiveActivityWithRelay( const client = yield* ManagedRelay.ManagedRelayClient; yield* client.registerLiveActivity({ - clerkToken: token, + accountToken: token, payload: body, }); return true; diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 77e45963b831..d01d8af21b51 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -54,7 +54,7 @@ function makeSnapshot( describe("buildArchivedThreadGroups", () => { it("groups archived threads by project and sorts newest first", () => { - const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const project = makeProject({ id: ProjectId.make("project-1"), title: "Otter Code" }); const older = makeThread({ id: ThreadId.make("thread-older"), projectId: project.id, @@ -80,7 +80,7 @@ describe("buildArchivedThreadGroups", () => { it("filters by environment and matches project, thread, and branch text", () => { const secondEnvironmentId = EnvironmentId.make("environment-2"); - const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "Otter Code" }); const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Website" }); const firstThread = makeThread({ branch: "fix/archive-screen", @@ -115,7 +115,7 @@ describe("buildArchivedThreadGroups", () => { }); it("ignores non-archived entries returned in a snapshot", () => { - const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const project = makeProject({ id: ProjectId.make("project-1"), title: "Otter Code" }); const active = makeThread({ archivedAt: null, id: ThreadId.make("thread-active"), diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts index 5fe74f673141..9dfe46a5d54f 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts @@ -5,15 +5,11 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./CloudAuthProvider"; import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration"; -vi.mock("@clerk/expo", () => ({ - ClerkProvider: vi.fn(), +vi.mock("../accounts/AccountProvider", () => ({ + AccountProvider: vi.fn(), useAuth: vi.fn(), })); -vi.mock("@clerk/expo/token-cache", () => ({ - tokenCache: {}, -})); - vi.mock("../../lib/runtime", () => ({ runtime: { runPromiseExit: vi.fn(), @@ -34,10 +30,9 @@ vi.mock("../../state/use-composer-drafts", () => ({ vi.mock("./publicConfig", () => ({ resolveCloudPublicConfig: vi.fn(() => ({ - clerk: { publishableKey: null }, + accounts: { url: null }, relay: { url: null }, })), - resolveRelayClerkTokenOptions: vi.fn(), })); vi.mock("../agent-awareness/remoteRegistration", () => ({ diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index fffdd2343044..c1f4112eb837 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,5 +1,4 @@ -import { ClerkProvider, useAuth } from "@clerk/expo"; -import { tokenCache } from "@clerk/expo/token-cache"; +import { AccountProvider, useAuth } from "../accounts/AccountProvider"; import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import { reportAtomCommandResult, @@ -23,7 +22,7 @@ import { unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; -import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +import { resolveCloudPublicConfig } from "./publicConfig"; import { removeCloudEnvironments } from "./cloud-drafts"; function resetManagedRelayTokenCache() { @@ -46,12 +45,12 @@ export function activateCloudRelayAccount( setAgentAwarenessRelayTokenProvider(tokenProvider, accountId); setManagedRelaySession(appAtomRegistry, { accountId, - readClerkToken: tokenProvider, + readAccountToken: tokenProvider, }); } function CloudAuthBridge(props: { readonly children: ReactNode }) { - const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const { getToken, isLoaded, isSignedIn, userId } = useAuth(); const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, { reportFailure: false, reportDefect: false, @@ -133,7 +132,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { } const previous = previousTokenProviderRef.current; - const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); + const tokenProvider = () => getToken(); const activateSession = () => { if (cancelled) { return; @@ -197,22 +196,22 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { export function CloudAuthProvider(props: { readonly children: ReactNode }) { const config = resolveCloudPublicConfig(); - const publishableKey = config.clerk.publishableKey; + const accountsUrl = config.accounts.url; const relayUrl = config.relay.url; useEffect(() => { - if (!publishableKey || !relayUrl) { + if (!accountsUrl || !relayUrl) { deactivateCloudRelayAccount(); } - }, [publishableKey, relayUrl]); + }, [accountsUrl, relayUrl]); - if (!publishableKey || !relayUrl) { + if (!accountsUrl || !relayUrl) { return props.children; } return ( - + {props.children} - + ); } diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 7987e8ff6d66..a580024578e9 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -1,6 +1,6 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { NativeHeaderToolbar } from "../../native/StackHeader"; -import { useAuth } from "@clerk/expo"; +import { useAuth } from "../accounts/AccountProvider"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useState } from "react"; import { Platform, Pressable, RefreshControl, View } from "react-native"; @@ -45,7 +45,7 @@ export function ConnectOnboardingRouteScreen() { function ConfiguredConnectOnboardingRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const { isSignedIn, userId } = useAuth(); const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } = useRemoteConnections(); const { refreshRelayEnvironments } = useConnectionController(); @@ -86,7 +86,7 @@ function ConfiguredConnectOnboardingRouteScreen() { {Platform.OS === "android" ? ( - Sign in to your T3 account to set up T3 Connect. + Sign in to your Otter Code account to set up Otter Connect. )} diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index a49a80709fbb..7c148d7c9b38 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -37,7 +37,7 @@ function endpointLabel(environment: RelayClientEnvironmentRecord): string { function confirmDeregister(environment: RelayClientEnvironmentRecord, onConfirm: () => void) { const title = "Deregister server?"; - const message = `“${environment.label}” will be removed from this account. T3 Connect access will be revoked, any managed tunnel will be removed, and a host space will become available. Local connections on your devices are not changed.`; + const message = `“${environment.label}” will be removed from this account. Otter Connect access will be revoked, any managed tunnel will be removed, and a host space will become available. Local connections on your devices are not changed.`; if (process.env.EXPO_OS === "ios") { Alert.alert(title, message, [ { text: "Cancel", style: "cancel" }, @@ -49,7 +49,7 @@ function confirmDeregister(environment: RelayClientEnvironmentRecord, onConfirm: } /** - * The "T3 Connect" custom page inside Clerk's native user profile: every + * The "T3 Connect" custom page inside Account's native user profile: every * environment registered to the signed-in account, with account-level * deregistration. Mirrors the web UserButton page; connections on this device * are managed in Settings instead. @@ -131,7 +131,7 @@ export function T3ConnectProfilePage() { return ( } > - Registered servers + Registered servers {environmentsState.error ? ( <> - {errorTraceId ? ( - { copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); @@ -160,21 +160,18 @@ export function T3ConnectProfilePage() { ) : isInitialLoad ? ( - - Loading environments + + Loading environments ) : environments.length > 0 ? ( environments.map((environment) => ( - + ) : ( @@ -204,13 +201,13 @@ export function T3ConnectProfilePage() { /> )) ) : ( - )} - + Connections on this device are managed in Settings. @@ -221,19 +218,16 @@ const ENVIRONMENT_MENU_ACTIONS = [ { id: "deregister", title: "Deregister", image: "trash", attributes: { destructive: true } }, ] satisfies MenuAction[]; -// Layout primitives that mirror clerk-ios ClerkKitUI's profile rows so a custom -// page reads as one of Clerk's own screens. System font on purpose: Clerk's -// native views do not use the app's DM Sans. - -function ClerkSectionHeader(props: { readonly children: string }) { +// Account rows use the same adaptive palette as the settings screens. +function AccountSectionHeader(props: { readonly children: string }) { return ( - + {props.children} ); } -function ClerkRow(props: { +function AccountRow(props: { readonly title: string; readonly subtitle: string; readonly accessory?: ReactNode; @@ -241,13 +235,13 @@ function ClerkRow(props: { return ( - + {props.title} - + {props.subtitle} @@ -256,14 +250,14 @@ function ClerkRow(props: { ); } -function ClerkButtonRow(props: { readonly label: string; readonly onPress: () => void }) { +function AccountButtonRow(props: { readonly label: string; readonly onPress: () => void }) { return ( - {props.label} + {props.label} ); } diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index c609294549ab..e90456e502e5 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -101,7 +101,7 @@ describe("mobile DPoP", () => { const connect = yield* createDpopProof({ method: "POST", url: "https://relay.example.test/v1/environments/env-1/connect", - accessToken: "clerk-token", + accessToken: "account-token", proofKey, }); const bootstrap = yield* createDpopProof({ @@ -118,7 +118,7 @@ describe("mobile DPoP", () => { method: "POST", url: "https://relay.example.test/v1/environments/env-1/connect", expectedThumbprint: proofKey.thumbprint, - expectedAccessToken: "clerk-token", + expectedAccessToken: "account-token", nowEpochSeconds: proofIat(connect.proof), }), ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); @@ -140,7 +140,7 @@ describe("mobile DPoP", () => { const proof = yield* createDpopProof({ method: "POST", url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored", - accessToken: "clerk-token", + accessToken: "account-token", proofKey, }); @@ -153,7 +153,7 @@ describe("mobile DPoP", () => { method: "POST", url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored", expectedThumbprint: proofKey.thumbprint, - expectedAccessToken: "clerk-token", + expectedAccessToken: "account-token", nowEpochSeconds: proofIat(proof.proof), }), ).toMatchObject({ ok: true }); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 1e78f250ef4e..cbe47d60f569 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -166,7 +166,7 @@ describe("mobile cloud link environment client", () => { const error = yield* withCloudServices( linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", + accountToken: "account-token", connection: savedConnection, liveActivitiesEnabled: true, }), @@ -199,7 +199,7 @@ describe("mobile cloud link environment client", () => { const error = yield* withCloudServices( linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", + accountToken: "account-token", connection: savedConnection, liveActivitiesEnabled: true, }), @@ -237,7 +237,7 @@ describe("mobile cloud link environment client", () => { const error = yield* withCloudServices( linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", + accountToken: "account-token", connection: savedConnection, liveActivitiesEnabled: true, }), @@ -275,7 +275,7 @@ describe("mobile cloud link environment client", () => { const error = yield* withCloudServices( linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", + accountToken: "account-token", connection: savedConnection, liveActivitiesEnabled: true, }), @@ -312,7 +312,7 @@ describe("mobile cloud link environment client", () => { yield* withCloudServices( linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", + accountToken: "account-token", connection: savedConnection, liveActivitiesEnabled: false, }), @@ -366,7 +366,7 @@ describe("mobile cloud link environment client", () => { yield* withCloudServices( linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", + accountToken: "account-token", connection: savedConnection, liveActivitiesEnabled: true, }), diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 1f7d9bb14371..06d37b262207 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -146,7 +146,7 @@ function ensureLinkedEnvironmentMatches(input: { interface LinkEnvironmentToCloudInput { readonly connection: SavedRemoteConnection; - readonly clerkToken: string; + readonly accountToken: string; } type LinkEnvironmentToCloudRequirements = @@ -173,7 +173,7 @@ export function linkEnvironmentToCloudWithPreference( const liveActivitiesEnabled = input.liveActivitiesEnabled; const challenge = yield* relayClient .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, + accountToken: input.accountToken, payload: { notificationsEnabled: true, liveActivitiesEnabled, @@ -203,7 +203,7 @@ export function linkEnvironmentToCloudWithPreference( .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); const link = yield* relayClient .linkEnvironment({ - clerkToken: input.clerkToken, + accountToken: input.accountToken, payload: { deviceId, proof, diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 05bf1a8fbccd..182a875ee2d1 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,131 +1,18 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - CloudPublicConfigMissingError, - hasTracingPublicConfig, - resolveCloudPublicConfig, - resolveRelayClerkTokenOptions, -} from "./publicConfig"; - -vi.mock("expo-constants", () => ({ - default: { - expoConfig: { - extra: {}, - }, - }, -})); - -describe("resolveCloudPublicConfig", () => { - it("reports the missing Clerk JWT template as structured configuration", () => { - expect(() => resolveRelayClerkTokenOptions()).toThrowError( - new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), - ); - }); - - it("returns no cloud configuration for an unconfigured build", () => { - expect(resolveCloudPublicConfig({})).toEqual({ - clerk: { - publishableKey: null, - jwtTemplate: null, - }, - relay: { - url: null, - }, - observability: { - tracesUrl: null, - tracesDataset: null, - tracesToken: null, - }, - }); - }); - - it("normalizes statically injected cloud configuration", () => { - expect( - resolveCloudPublicConfig({ - clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, - relay: { url: " https://relay.example.test/// " }, - observability: { - tracesUrl: " https://api.axiom.co/v1/traces ", - tracesDataset: " mobile-traces ", - tracesToken: " public-ingest-token ", - }, - }), - ).toEqual({ - clerk: { - publishableKey: "pk_test_example", - jwtTemplate: "t3-relay", - }, - relay: { - url: "https://relay.example.test", - }, - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }); - }); - - it("rejects an insecure relay URL", () => { - expect( - resolveCloudPublicConfig({ - clerk: { publishableKey: "pk_test_example", jwtTemplate: "t3-relay" }, - relay: { url: "http://relay.example.test" }, - }), - ).toEqual({ - clerk: { - publishableKey: "pk_test_example", - jwtTemplate: "t3-relay", - }, - relay: { - url: null, - }, - observability: { - tracesUrl: null, - tracesDataset: null, - tracesToken: null, - }, - }); - }); - - it("rejects an insecure traces URL", () => { - expect( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "http://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }).observability, - ).toEqual({ - tracesUrl: null, - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }); - }); - - it("keeps tracing disabled unless every public tracing value is configured", () => { - expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); - expect( - hasTracingPublicConfig( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - }, - }), - ), - ).toBe(false); - expect( - hasTracingPublicConfig( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }), - ), - ).toBe(true); - }); +import { expect, it, vi } from "vite-plus/test"; +vi.mock("expo-constants", () => ({ default: { expoConfig: { extra: {} } } })); +import { resolveCloudPublicConfig, hasTracingPublicConfig } from "./publicConfig"; +it("normalizes the shared issuer and rejects insecure cloud endpoints", () => { + expect( + resolveCloudPublicConfig({ + accounts: { url: "https://accounts.otterware.app/v1/auth/" }, + relay: { url: "https://relay.code.otterware.app" }, + }).accounts.url, + ).toBe("https://accounts.otterware.app/v1/auth"); + expect( + resolveCloudPublicConfig({ + accounts: { url: "pk_test_example" }, + relay: { url: "http://relay.test" }, + }), + ).toMatchObject({ accounts: { url: null }, relay: { url: null } }); + expect(hasTracingPublicConfig(resolveCloudPublicConfig())).toBe(false); }); diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index a91a8024d485..3369b65cef51 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -1,23 +1,10 @@ +import { normalizeAccountsUrl } from "@t3tools/shared/otterAccounts"; import Constants from "expo-constants"; -import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; -import * as Schema from "effect/Schema"; - -export class CloudPublicConfigMissingError extends Schema.TaggedError()( - "CloudPublicConfigMissingError", - { - key: Schema.Literal("T3CODE_CLERK_JWT_TEMPLATE"), - }, -) { - override get message(): string { - return `${this.key} is not configured.`; - } -} export interface CloudPublicConfig { - readonly clerk: { - readonly publishableKey: string | null; - readonly jwtTemplate: string | null; + readonly accounts: { + readonly url: string | null; }; readonly relay: { readonly url: string | null; @@ -58,10 +45,7 @@ function normalizeSecureUrl(value: unknown): string | null { export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { return { - clerk: { - publishableKey: trimNonEmpty(extra?.clerk?.publishableKey), - jwtTemplate: trimNonEmpty(extra?.clerk?.jwtTemplate), - }, + accounts: { url: normalizeAccountsUrl(trimNonEmpty(extra?.accounts?.url) ?? "") }, relay: { url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""), }, @@ -75,7 +59,7 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); - return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url); + return Boolean(config.accounts.url && config.relay.url); } type Configured = { @@ -95,11 +79,3 @@ export function hasTracingPublicConfig( config.observability.tracesToken, ); } - -export function resolveRelayClerkTokenOptions() { - const { jwtTemplate } = resolveCloudPublicConfig().clerk; - if (!jwtTemplate) { - throw new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }); - } - return relayClerkTokenOptions(jwtTemplate); -} diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 86df2e915863..fd398675b31c 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -1,6 +1,6 @@ import { AppActivityIndicator } from "../../components/AppActivityIndicator"; import { ConnectionTraceId } from "./ConnectionTraceId"; -import { useAuth } from "@clerk/expo"; +import { useAuth } from "../accounts/AccountProvider"; import { SymbolView } from "../../components/AppSymbol"; import { connectionStatusText, @@ -54,13 +54,13 @@ interface CloudEnvironmentRowsProps { * its errors) requires a signed-in session. */ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { - // Showcase captures run without a Clerk publishable key, so `ClerkProvider` + // Showcase captures run without a Otter account configuration, so `AccountProvider` // is never mounted and any `useAuth` call throws — the fixture states whether - // the rows are signed in instead of asking Clerk. + // the rows are signed in instead of asking Otter Accounts. if (props.showcaseSignedIn !== undefined) { return props.showcaseSignedIn ? : null; } - // No cloud config means no `ClerkProvider` either, so `useAuth` would throw. + // No cloud config means no `AccountProvider` either, so `useAuth` would throw. if (!hasCloudPublicConfig()) { return ; } @@ -68,7 +68,7 @@ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { } function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { - const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { isSignedIn } = useAuth(); if (!isSignedIn) return ; return ; } @@ -115,7 +115,9 @@ function CloudEnvironmentRowsContent( {showHeader ? ( - T3 Connect + + Otter Connect + ) : null} @@ -172,7 +174,7 @@ function CloudEnvironmentRowsContent( !controller.relayDiscovery.isRefreshing ? ( - Could not load T3 Connect environments + Could not load Otter Connect environments {controller.relayDiscovery.error} {controller.relayDiscovery.errorTraceId ? ( diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index f4a682c6c14c..e5ed645788a9 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -156,7 +156,7 @@ export function ConnectionEnvironmentRow(props: { > {props.environment.isRelayManaged ? ( - Managed by T3 Connect. Tunnel details update automatically. + Managed by Otter Connect. Tunnel details update automatically. ) : ( <> diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 89580939d4c5..8e969a597ac0 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,5 +1,5 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; -import { useAuth } from "@clerk/expo"; +import { useAuth } from "../accounts/AccountProvider"; import { CameraView, useCameraPermissions } from "expo-camera"; import * as Haptics from "expo-haptics"; import { @@ -369,7 +369,7 @@ function PairingScanCard(props: { )} - Scan the code from t3 pair or desktop Connections settings + Scan the code from otter-code pair or desktop Connections settings @@ -379,10 +379,10 @@ function PairingScanCard(props: { /** * Managed-relay alternative to manual pairing: signed in, the account's * published environments connect with a switch; signed out, one row opens the - * T3 Account sheet. + * Otter account sheet. */ function T3ConnectSection() { - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { isLoaded, isSignedIn } = useAuth(); const navigation = useNavigation(); const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } = useRemoteConnections(); @@ -411,7 +411,7 @@ function T3ConnectSection() { className="min-h-13 flex-row items-center gap-3 rounded-[26px] border-continuous bg-grouped-card px-4 active:opacity-70" > - Sign in to T3 Connect + Sign in to Otter Connect Reach your machines from anywhere, no network setup. diff --git a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx b/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx index 3358fa1995d6..99e486d9a592 100644 --- a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx +++ b/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx @@ -61,7 +61,7 @@ export function GitHubRoutingSettings() { {entry.target.label} - {connectionCatalogDisplayUrl(entry) ?? "T3 Connect"} + {connectionCatalogDisplayUrl(entry) ?? "Otter Connect"} diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 1293e7893235..48f207616ef4 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -47,7 +47,7 @@ describe("relayManagedEnvironmentIds", () => { }); describe("mobile environment settings sections", () => { - it("keeps saved relay-managed connections under T3 Connect", () => { + it("keeps saved relay-managed connections under Otter Connect", () => { const local = connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false, @@ -158,7 +158,7 @@ describe("mobile environment settings sections", () => { expect(sections.availableCloudEnvironments).toEqual([]); }); - it("offers T3 Connect for a machine saved only over the LAN, as an added route", () => { + it("offers Otter Connect for a machine saved only over the LAN, as an added route", () => { const local = connectedEnvironment({ environmentId: "environment-desk", isRelayManaged: false, diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 193927684794..77cb2bd568e2 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -37,7 +37,7 @@ describe("extractPairingUrlFromQrPayload", () => { it("unwraps mobile deep links that carry an encoded pairing url", () => { expect( extractPairingUrlFromQrPayload( - "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "ottercode://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", ), ).toBe("https://remote.example.com/pair#token=pairing-token"); }); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index ee9e4b9ec8f3..16c3f2d43b98 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -78,7 +78,7 @@ export function extractPairingUrlFromQrPayload(payload: string): string { try { const url = new URL(trimmed); - if (url.protocol === "t3code:") { + if (url.protocol === "ottercode:") { const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? ""; if (pairingUrl.length > 0) { return pairingUrl; diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.test.ts b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts index 761d9a49ed96..eeaa5443701b 100644 --- a/apps/mobile/src/features/diagnostics/crash-log-model.test.ts +++ b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts @@ -61,14 +61,14 @@ describe("formatStartupCrashReport", () => { version: "1.1.1", build: "56", }); - expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true); + expect(report.startsWith("Otter Code 1.1.1 (56)\n")).toBe(true); expect(report).toContain("2026-09-13T05:35:52.000Z"); expect(report).toContain("at NewTaskFlowProvider"); }); it("says so when nothing was recorded", () => { expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe( - "T3 Code 1.1.1 (56)\nNo startup crashes recorded.", + "Otter Code 1.1.1 (56)\nNo startup crashes recorded.", ); }); }); diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.ts b/apps/mobile/src/features/diagnostics/crash-log-model.ts index 4c222a1f0be9..fc17d36d9a94 100644 --- a/apps/mobile/src/features/diagnostics/crash-log-model.ts +++ b/apps/mobile/src/features/diagnostics/crash-log-model.ts @@ -72,7 +72,7 @@ export function formatStartupCrashReport( records: ReadonlyArray, app: { readonly version: string; readonly build: string }, ): string { - const header = `T3 Code ${app.version} (${app.build})`; + const header = `Otter Code ${app.version} (${app.build})`; if (records.length === 0) return `${header}\nNo startup crashes recorded.`; return [ header, diff --git a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts index a457b5d8102d..7a8f26e3a32c 100644 --- a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts +++ b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts @@ -54,7 +54,7 @@ type LinkedDraftRequest = { }; /** - * Opens a `t3code://new?…` link. The link lands on the New Task picker with its query as route + * Opens a `ottercode://new?…` link. The link lands on the New Task picker with its query as route * params; this resolves it to a project, a thread, or a choice, then fills a draft. Nothing is * ever sent: the user reads the prompt and presses Send. Used by `NewTaskRouteScreen`. */ diff --git a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts b/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts index 87c0b46dd14c..1063e8bdbe7c 100644 --- a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts +++ b/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts @@ -13,7 +13,7 @@ import { const THREAD_LINK_KEYS = ["prompt"]; /** - * Applies the `prompt` of a `t3code://threads//?prompt=…` link to that + * Applies the `prompt` of a `ottercode://threads//?prompt=…` link to that * thread's composer draft, once per link. Used by `ThreadRouteScreen`. */ export function useThreadPromptLink( diff --git a/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx b/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx index b7a3de14bde4..4b193a1b7ffd 100644 --- a/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx +++ b/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx @@ -69,7 +69,7 @@ export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility brand={ } onPress={() => diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index d2e6c1254617..bd576eb7acf1 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,12 +1,14 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; -import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; +import type { ReviewDiffPreviewSource, ReviewDiffPreviewSourceKind } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; -export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; +// Mobile asks for the default working-tree and branch-range pair; the other source kinds only +// answer scoped requests, which the desktop and web diff panel make. +export type ReviewSectionKind = "turn" | ReviewDiffPreviewSourceKind; const CHANGES_SECTION_ID = "git:branch-range"; const CHANGES_TITLE = "Changes"; diff --git a/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx index bbb70b236bea..2923d0f47160 100644 --- a/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx @@ -20,7 +20,7 @@ export function SettingsAboutRouteScreen() { const insets = useSafeAreaInsets(); return ( - + , - }, -] satisfies UserProfileCustomPage[]; - export function SettingsAuthRouteScreen() { - const navigation = useNavigation(); - - useLayoutEffect(() => { - if (!hasCloudPublicConfig()) { - navigation.dispatch(StackActions.replace("SettingsContent")); - } - }, [navigation]); - - return hasCloudPublicConfig() ? : null; -} - -function ConfiguredSettingsAuthRouteScreen() { - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const navigation = useNavigation(); - const handleHostBack = useCallback( - () => navigation.dispatch(StackActions.popTo("SettingsContent")), - [navigation], - ); - const hasBeenSignedIn = useRef(isSignedIn); - if (isSignedIn) { - hasBeenSignedIn.current = true; - } - - useEffect(() => { - if (hasBeenSignedIn.current && isLoaded && isSignedIn === false) { - navigation.dispatch(StackActions.popTo("SettingsContent")); - } - }, [isLoaded, isSignedIn, navigation]); - + const { isLoaded, isSignedIn, user, error, signIn, signOut } = useAuth(); return ( - - {isLoaded ? ( - hasBeenSignedIn.current ? ( - - ) : ( - - ) + + + { + if (!isSignedIn) void signIn(); + }} + /> + {isSignedIn ? ( + <> + + void Linking.openURL(`${new URL(OTTER_ACCOUNTS_URL).origin}/otter/account`) + } + /> + void signOut()} + /> + + ) : null} + + {error ? ( + + {error} + + ) : null} + {isSignedIn ? ( + + + ) : null} - + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index 1065a860a5e4..6ad1f5df1162 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -113,7 +113,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm return; Alert.alert( `Update ${environment?.environmentLabel ?? "environment"}?`, - `Install T3 Code ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`, + `Install Otter Code ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`, [ { text: "Cancel", style: "cancel" }, { @@ -227,7 +227,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm {notice ? {notice} : null} {config ? ( <> - + Version {version} {running ? ( @@ -252,7 +252,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm {capabilities?.serverSelfUpdate === "desktop-managed" ? "Update the desktop app on this machine." - : "Update and restart T3 Code on this machine."} + : "Update and restart Otter Code on this machine."} ) : null} diff --git a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx index f4303a659cc7..bbd1cca51e6d 100644 --- a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx @@ -166,7 +166,7 @@ export function SettingsMicrophoneRouteScreen() { {permission === "ask" ? "Voice input needs microphone access before it can record or list your microphones." - : "Microphone access is off for T3 Code. Turn it on in Settings to use voice input."} + : "Microphone access is off for Otter Code. Turn it on in Settings to use voice input."} ) : null} diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index 25c2e03d46f7..12927ecd0a3c 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -1,7 +1,7 @@ import { AuthRelayWriteScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "../../state/session"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; -import { useAuth } from "@clerk/expo"; +import { useAuth } from "../accounts/AccountProvider"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Notifications from "expo-notifications"; import { useNavigation } from "@react-navigation/native"; @@ -32,7 +32,7 @@ import { subscribeAgentAwarenessRegistrationStatus, } from "../agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; -import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -67,7 +67,7 @@ export function SettingsNotificationsRouteScreen() { contentContainerClassName="px-5 pt-4" > - Notifications require T3 Connect in this app build. + Notifications require Otter Connect in this app build. @@ -88,7 +88,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { : agentAwarenessPlatform.subtitle; const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { getToken, isLoaded, isSignedIn } = useAuth(); const { savedConnectionsById } = useSavedRemoteConnections(); const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); @@ -178,7 +178,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } else { Alert.alert( "Couldn't finish enabling notifications", - "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", + "Notification access was granted, but this device could not be registered with Otter Connect. Notifications will start once registration succeeds.", ); } return; @@ -208,8 +208,8 @@ function ConfiguredSettingsNotificationsRouteScreen() { const promptSignIn = useCallback(() => { Alert.alert( - "Sign in to T3 Connect", - "Live Activity updates require T3 Connect so relay can deliver updates to this device.", + "Sign in to Otter Connect", + "Live Activity updates require Otter Connect so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, { @@ -254,7 +254,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } setNotificationStatus("enabled"); } - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + const tokenResult = await settlePromise(() => getToken()); if (tokenResult._tag === "Failure") { setLiveActivityStatus("disabled"); const error = squashAtomCommandFailure(tokenResult); @@ -277,7 +277,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { readEnvironmentScope(environmentId, AuthRelayWriteScope), enabled: true, previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: tokenResult.value, + accountToken: tokenResult.value, connections, }), ), @@ -312,7 +312,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } else { Alert.alert( "Couldn't finish enabling activity updates", - "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.", + "This device could not be registered with Otter Connect, so activity updates won't appear yet. They'll start once registration succeeds.", ); } }, [ @@ -338,7 +338,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { Alert.alert( "Disable notifications", - "Open system Settings to disable notifications for T3 Code.", + "Open system Settings to disable notifications for Otter Code.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, @@ -358,9 +358,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { try { let token: string | null = null; if (isSignedIn) { - const tokenResult = await settlePromise(() => - getToken(resolveRelayClerkTokenOptions()), - ); + const tokenResult = await settlePromise(() => getToken()); if (tokenResult._tag === "Failure") { setLiveActivityStatus("enabled"); reportAtomCommandResult(tokenResult, { @@ -378,7 +376,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { readEnvironmentScope(environmentId, AuthRelayWriteScope), enabled: false, previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: token, + accountToken: token, connections, }), ), @@ -492,7 +490,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { void openAndroidLiveUpdateSettings().catch(() => { Alert.alert( "Couldn't open Settings", - "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.", + "Open Android Settings, select Otter Code, then enable Live Updates in Notifications.", ); }); }} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index adb6551cf5fa..f81dd4340b30 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,5 +1,5 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; -import { useAuth, useUser } from "@clerk/expo"; +import { useAuth } from "../accounts/AccountProvider"; import { useNavigation } from "@react-navigation/native"; import { Platform, View } from "react-native"; import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; @@ -58,14 +58,14 @@ export function SettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { user } = useUser(); + const { isLoaded, isSignedIn } = useAuth(); + const { user } = useAuth(); const { savedConnectionsById } = useSavedRemoteConnections(); const accountLabel = !isLoaded ? "Checking" : !isSignedIn ? "Sign in" - : (user?.primaryEmailAddress?.emailAddress ?? "Signed in"); + : (user?.email ?? "Signed in"); return ( @@ -79,7 +79,7 @@ function ConfiguredSettingsRouteScreen() { navigation.navigate("SettingsSheet", { screen: "SettingsAuth" })} @@ -215,7 +215,7 @@ function SettingsIndexSections() { - + ); diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts index 94aaa2fc5e9f..08d44aa6b795 100644 --- a/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts @@ -29,7 +29,7 @@ const thread = (environmentId: string, id: string, projectId: string, title: str }) as EnvironmentThreadShell; const projects = [ - project("moonbase-terminal", "t3code", "T3 Code"), + project("moonbase-terminal", "t3code", "Otter Code"), project("suspense-station", "react", "React"), project("kernel-cabin", "linux", "Linux"), ]; @@ -60,10 +60,10 @@ it("stages relay-shaped rows against the seeded threads", () => { assert.deepStrictEqual( activity.activities.map((row) => [row.threadId, row.status, row.projectTitle, row.updatedAt]), [ - ["pocket-command-center", "Approval", "T3 Code", "2026-07-16T08:59:00.000Z"], + ["pocket-command-center", "Approval", "Otter Code", "2026-07-16T08:59:00.000Z"], ["beautiful-boot", "Input", "Linux", "2026-07-16T08:56:00.000Z"], ["buttery-suspense", "Working", "React", "2026-07-16T08:58:00.000Z"], - ["remote-command-center", "Done", "T3 Code", "2026-07-16T08:57:00.000Z"], + ["remote-command-center", "Done", "Otter Code", "2026-07-16T08:57:00.000Z"], ], ); assert.strictEqual( @@ -84,10 +84,10 @@ it("encodes the Android Live Update and its alert like a relay push", () => { assert.strictEqual(data.activity_title, "3 active agents · 2 need attention"); assert.strictEqual( data.activity_line_0, - "Approval\tPut the command center in your pocket\tT3 Code", + "Approval\tPut the command center in your pocket\tOtter Code", ); - assert.strictEqual(data.activity_line_3, "Done\tMake remote coding feel local\tT3 Code"); + assert.strictEqual(data.activity_line_3, "Done\tMake remote coding feel local\tOtter Code"); assert.strictEqual(data.alert_title, "Put the command center in your pocket"); - assert.strictEqual(data.alert_body, "Approval: T3 Code"); + assert.strictEqual(data.alert_body, "Approval: Otter Code"); assert.strictEqual(data.alert_path, "/threads/moonbase-terminal/pocket-command-center"); }); diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts index 736c672dff0c..c6c965952e23 100644 --- a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -74,7 +74,7 @@ export function buildShowcaseAgentActivity( }); } return { - title: "T3 Code", + title: "Otter Code", subtitle: "Agent work in progress", activeCount: rows.filter((row) => ACTIVE_PHASES.has(row.phase)).length, updatedAt: new Date(now).toISOString(), diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts index 8ab043d7cd8b..f4da7aaca9cc 100644 --- a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -11,7 +11,7 @@ const projects: ReadonlyArray = [ { environmentId: EnvironmentId.make("moonbase-terminal"), id: ProjectId.make("t3code"), - title: "T3 Code", + title: "Otter Code", workspaceRoot: "/workspace/t3code", repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, @@ -48,7 +48,7 @@ it("builds sendable-looking pending tasks against real showcase projects", () => { environmentId: "moonbase-terminal", projectId: "t3code", - title: "T3 Code", + title: "Otter Code", branch: "feat/offline-launchpad", createdAt: "2026-07-16T08:52:00.000Z", }, diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 53f14674183d..fdcb59d97f56 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -38,7 +38,7 @@ import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task- type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; - // A `t3code://new?…` prompt link. See usePromptLinkResolver. + // A `ottercode://new?…` prompt link. See usePromptLinkResolver. readonly prompt?: string | string[]; readonly repo?: string | string[]; readonly pr?: string | string[]; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f84ad7cbce01..b518d18972c9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -635,7 +635,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer armAgentAwarenessLiveActivityForLocalWork({ environmentId: props.environmentId, threadTitle: props.selectedThread.title, - projectTitle: props.environmentLabel ?? "T3 Code", + projectTitle: props.environmentLabel ?? "Otter Code", }); } finally { inFlightThreadIdsRef.current.delete(threadKey); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 61d6da783bf0..d74bdfa161fb 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1198,7 +1198,7 @@ function renderFeedEntry( ) : presentation.attribution === "t3code" ? ( - Sent by T3 Code + Sent by Otter Code ) : presentation.attribution === "agent" ? ( ) : null} + {linearIssues.length > 0 ? ( + + Linear issues + + {linearIssues.map((issue, index) => ( + + {index > 0 && Platform.OS !== "android" ? ( + + ) : null} + { + void tryOpenExternalUrl(issue.url, "linear-issue").then((opened) => { + if (!opened) + Alert.alert( + "Unable to open issue", + "The Linear issue could not be opened.", + ); + }); + }} + /> + + ))} + + + ) : null} + {currentWorktreePath ? : null} ); diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index c358466e22d2..ae946abfaeef 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -86,6 +86,8 @@ export function SheetListRow(props: { readonly icon: ComponentProps["name"]; readonly title: string; readonly subtitle?: string | null; + /** Small status dot leading the subtitle, e.g. a Linear workflow state color. */ + readonly subtitleDotColor?: string | null; readonly disabled?: boolean; readonly onPress: () => void; }) { @@ -107,7 +109,17 @@ export function SheetListRow(props: { {props.title} - {props.subtitle ? ( + {props.subtitle && props.subtitleDotColor ? ( + + + + {props.subtitle} + + + ) : props.subtitle ? ( {props.subtitle} ) : null} diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 12f669530aae..957ea1931217 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -44,7 +44,7 @@ function makeProject( function makeScope(projects: ReadonlyArray): HomeProjectScope { return { key: "github.com/t3tools/t3code", - title: "T3 Code", + title: "Otter Code", representative: projects[0]!, projects, projectRefs: projects.map((project) => ({ @@ -167,7 +167,7 @@ describe("filterProjectScopes", () => { }); it("matches logical names and workspace names or paths without case sensitivity", () => { - expect(filterProjectScopes(scopes, " T3 CODE ")).toEqual([code]); + expect(filterProjectScopes(scopes, " OTTER CODE ")).toEqual([code]); expect(filterProjectScopes(scopes, "DESKTOP")).toEqual([code]); expect(filterProjectScopes(scopes, "REMOTE-WORKSPACE")).toEqual([code]); expect(filterProjectScopes(scopes, "documentation")).toEqual([docs]); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d7596305cff2..bb77ff1dadef 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -36,6 +36,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useEnvironmentScope } from "../../state/session"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { presentThreadLinearIssues } from "../../state/thread-linear-issue-presentation"; import { useThreadPr } from "../../state/use-thread-pr"; import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; @@ -620,6 +621,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const providerInstance = props.providerInstance; const pr = useThreadPr(thread); + const linearIssues = useMemo( + () => presentThreadLinearIssues(thread.linearIssues), + [thread.linearIssues], + ); const theme = useUniwindTheme(); const sidebarPane = props.pane === "sidebar"; @@ -1156,6 +1161,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} + {linearIssues ? ( + + {linearIssues.stateColor ? ( + + ) : null} + + {linearIssues.label} + + + ) : null} {providerInstance ? ( // Earlier owners peek out behind the current provider so a // handed-off thread shows where it has been. The current owner diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index 81474586220b..09fb04fea61a 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -42,7 +42,7 @@ function WorkspaceRootTitle(props: { + } maxWidth={props.maxWidth} onPress={props.onOpenEnvironments} @@ -114,7 +114,7 @@ function WorkspaceRootContent() { /> ) : ( { - it.each(["t3code://", "t3code:///", "t3code-dev://", "t3code-preview://"])( + it.each(["ottercode://", "ottercode:///", "ottercode-dev://", "ottercode-preview://"])( "ignores scheme-only URL %s", (url) => { expect(shouldHandleAppLink(url)).toBe(false); @@ -11,14 +11,14 @@ describe("shouldHandleAppLink", () => { ); it.each([ - "t3code://threads/env-1/thread-1", - "t3code://pair?pairingUrl=x", - "t3code-dev://settings/usage?tab=limits", + "ottercode://threads/env-1/thread-1", + "ottercode://pair?pairingUrl=x", + "ottercode-dev://settings/usage?tab=limits", ])("handles path-bearing URL %s", (url) => { expect(shouldHandleAppLink(url)).toBe(true); }); - it.each(["t3code://expo-development-client/?url=x", "t3code://expo-sharing/anything"])( + it.each(["ottercode://expo-development-client/?url=x", "ottercode://expo-sharing/anything"])( "ignores lifecycle URL %s", (url) => { expect(shouldHandleAppLink(url)).toBe(false); diff --git a/apps/mobile/src/lib/appLinking.ts b/apps/mobile/src/lib/appLinking.ts index 16c746868802..d9e5af2f7a05 100644 --- a/apps/mobile/src/lib/appLinking.ts +++ b/apps/mobile/src/lib/appLinking.ts @@ -12,6 +12,6 @@ export function shouldHandleAppLink(url: string): boolean { return ( !url.includes("expo-development-client") && !url.includes("://expo-sharing") && - !/^t3code(-dev|-preview)?:\/*$/.test(url) + !/^ottercode(-dev|-preview)?:\/*$/.test(url) ); } diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index d706d00fbec4..d041e04a822b 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -7,7 +7,7 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM const deviceModel = Device.modelName?.trim(); return { - label: "T3 Code Mobile", + label: "Otter Code Mobile", deviceType: Device.deviceType === Device.DeviceType.TABLET ? "tablet" diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 6cc9ce8607d1..eec84fd643cf 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -40,7 +40,7 @@ describe("mobile remote connection records", () => { it("identifies mobile token exchanges for authorized-client presentation", () => { expect(authClientMetadata()).toEqual({ - label: "T3 Code Mobile", + label: "Otter Code Mobile", deviceType: "mobile", os: "iOS", osMajorVersion: 18, diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 2da47f91cd74..9abebf5a5b09 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -26,7 +26,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ readonly id: MobileThemeId; readonly label: string; }> = [ - { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" }, + { id: DEFAULT_MOBILE_THEME_ID, label: "Otter Code" }, { id: "material-you", label: "Material You" }, ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; @@ -260,6 +260,13 @@ export function createMobileThemeVariables( c.secondary, groupedCard, ]; + // Skills in a sent message wear the send color; a monochrome theme's send + // color is its message text (ChatGPT's), so there they take its accent. + const actionOnBubble = themeColorWithContrast(c.messageAction, c.messageSurface); + const skillOnBubble = + actionOnBubble === c.messageForeground + ? themeColorWithContrast(c.accent, c.messageSurface) + : actionOnBubble; return { "--color-screen": c.canvas, "--color-sheet": withAlpha(c.chrome, 0.98), @@ -349,10 +356,7 @@ export function createMobileThemeVariables( "--color-user-bubble": c.messageSurface, "--color-user-bubble-foreground": c.messageForeground, "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78), - "--color-user-bubble-skill-foreground": themeColorWithContrast( - c.messageAction, - c.messageSurface, - ), + "--color-user-bubble-skill-foreground": skillOnBubble, "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22), "--color-drawer": c.sidebar, "--color-drawer-foreground": c.sidebarForeground, @@ -400,7 +404,9 @@ export function getMobileThemeVariables( ? appearance === "light" ? colors.toolbarControl : colors.surface - : colors.muted; + : themeId === "chatgpt" + ? colors.surface + : colors.muted; const mobileColors = themeId === DEFAULT_MOBILE_THEME_ID ? { diff --git a/apps/mobile/src/lib/mobileThemeRuntime.test.ts b/apps/mobile/src/lib/mobileThemeRuntime.test.ts index ad678d1f8815..22b8ebefd5b6 100644 --- a/apps/mobile/src/lib/mobileThemeRuntime.test.ts +++ b/apps/mobile/src/lib/mobileThemeRuntime.test.ts @@ -28,7 +28,7 @@ describe("mobileThemeRuntime", () => { (operation) => operation.kind === "update-text-variables", ); - expect(variableOperations).toHaveLength(12); + expect(variableOperations).toHaveLength(14); expect(variableOperations.at(-1)?.themeName).toBe("iris-dark"); expect(operations.at(-1)).toEqual({ kind: "set-appearance-mode", @@ -68,7 +68,7 @@ describe("mobileThemeRuntime", () => { baseFontSize: 18, }); - expect(operations).toHaveLength(12); + expect(operations).toHaveLength(14); expect(operations.every((operation) => operation.kind === "update-text-variables")).toBe(true); expect(operations.at(-1)).toMatchObject({ kind: "update-text-variables", diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index f4feb41dcc66..e0e678b75ff3 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -5,6 +5,7 @@ const ExternalUrlTarget = Schema.Literals([ "file-preview", "markdown-link", "pull-request", + "linear-issue", "provider-auth", "html-render", "mcp-app", diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 95cf9fdd6ed6..81b894900020 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1445,9 +1445,9 @@ describe("buildThreadFeed", () => { const feed = buildThreadFeed([projected(toolItem, 0)]); const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; - expect(activity?.summary).toBe("Read a T3 thread"); + expect(activity?.summary).toBe("Read an Otter Code thread"); expect(activity?.logo).toBe("t3-code"); - expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread"); + expect(activity?.getCopyText().split("\n")[0]).toBe("Read an Otter Code thread"); }); it.each(["cua_repl.js", "node_repl.js", "js"])( diff --git a/apps/mobile/src/state/thread-linear-issue-presentation.test.ts b/apps/mobile/src/state/thread-linear-issue-presentation.test.ts new file mode 100644 index 000000000000..a97b54eca044 --- /dev/null +++ b/apps/mobile/src/state/thread-linear-issue-presentation.test.ts @@ -0,0 +1,82 @@ +import type { ThreadLinearIssueLink } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + linearIssueDetail, + linearIssueStateColor, + presentThreadLinearIssues, +} from "./thread-linear-issue-presentation"; + +function issue( + identifier: string, + overrides: Partial = {}, +): ThreadLinearIssueLink { + return { + identifier, + issueId: `id-${identifier}`, + url: `https://linear.app/acme/issue/${identifier}`, + source: "manual", + linkedAt: "2026-09-24T00:00:00.000Z", + snapshot: { + identifier, + title: `Fix ${identifier}`, + state: { name: "In Progress", type: "started", color: "#f2c94c" }, + assignee: "Alice", + updatedAt: null, + syncedAt: "2026-09-24T00:00:00.000Z", + }, + ...overrides, + }; +} + +describe("presentThreadLinearIssues", () => { + it("returns null without linked issues", () => { + expect(presentThreadLinearIssues(undefined)).toBeNull(); + expect(presentThreadLinearIssues([])).toBeNull(); + }); + + it("shows the lone identifier with its state color", () => { + expect(presentThreadLinearIssues([issue("ENG-1")])).toEqual({ + label: "ENG-1", + stateColor: "#f2c94c", + accessibilityLabel: "Linear issue ENG-1, In Progress", + }); + }); + + it("leads with the delegated issue and counts the rest", () => { + expect( + presentThreadLinearIssues([ + issue("ENG-1"), + issue("ENG-2", { source: "delegated", snapshot: null }), + issue("ENG-3"), + ]), + ).toEqual({ + label: "ENG-2 +2", + stateColor: null, + accessibilityLabel: "Linear issue ENG-2, status pending, and 2 more", + }); + }); +}); + +describe("linearIssueStateColor", () => { + it("drops non-hex colors", () => { + const link = issue("ENG-1"); + expect( + linearIssueStateColor({ + ...link, + snapshot: { ...link.snapshot!, state: { ...link.snapshot!.state, color: "red" } }, + }), + ).toBeNull(); + }); +}); + +describe("linearIssueDetail", () => { + it("describes state and assignee, or a pending status before sync", () => { + expect(linearIssueDetail(issue("ENG-1"))).toBe("In Progress · Alice"); + const link = issue("ENG-1"); + expect(linearIssueDetail({ ...link, snapshot: { ...link.snapshot!, assignee: null } })).toBe( + "In Progress · Unassigned", + ); + expect(linearIssueDetail(issue("ENG-1", { snapshot: null }))).toBe("Status pending"); + }); +}); diff --git a/apps/mobile/src/state/thread-linear-issue-presentation.ts b/apps/mobile/src/state/thread-linear-issue-presentation.ts new file mode 100644 index 000000000000..47db8a679d9e --- /dev/null +++ b/apps/mobile/src/state/thread-linear-issue-presentation.ts @@ -0,0 +1,50 @@ +import type { ThreadLinearIssueLink } from "@t3tools/contracts"; + +export interface ThreadLinearIssueBadge { + /** Compact row label, e.g. "ENG-123" or "ENG-123 +2". */ + readonly label: string; + /** Linear workflow state color of the lead issue; null until synced. */ + readonly stateColor: string | null; + readonly accessibilityLabel: string; +} + +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/iu; + +/** Linear sends hex colors; anything else is dropped rather than handed to native styles. */ +export function linearIssueStateColor(link: ThreadLinearIssueLink): string | null { + const color = link.snapshot?.state.color.trim(); + return color !== undefined && HEX_COLOR_PATTERN.test(color) ? color : null; +} + +/** The issue that started a delegated thread leads; otherwise link order wins. */ +function leadLinearIssue(links: ReadonlyArray) { + return links.find((link) => link.source === "delegated") ?? links[0]; +} + +/** Thread list badge; null when the thread has no linked issues. */ +export function presentThreadLinearIssues( + links: ReadonlyArray | undefined, +): ThreadLinearIssueBadge | null { + if (links === undefined) return null; + const lead = leadLinearIssue(links); + if (lead === undefined) return null; + const others = links.length - 1; + const state = lead.snapshot?.state.name ?? "status pending"; + return { + label: others > 0 ? `${lead.identifier} +${others}` : lead.identifier, + stateColor: linearIssueStateColor(lead), + accessibilityLabel: + others > 0 + ? `Linear issue ${lead.identifier}, ${state}, and ${others} more` + : `Linear issue ${lead.identifier}, ${state}`, + }; +} + +/** "In Progress · Alice", falling back to a pending label before the first sync. */ +export function linearIssueDetail(link: ThreadLinearIssueLink): string { + const snapshot = link.snapshot; + if (snapshot === null) return "Status pending"; + return snapshot.assignee === null + ? `${snapshot.state.name} · Unassigned` + : `${snapshot.state.name} · ${snapshot.assignee}`; +} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index d2d654b05f14..caffa38ff7c5 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -173,11 +173,11 @@ export function useRemoteConnections() { if (environment.isRelayManaged) { Alert.alert( "Remove from this device?", - `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, + `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your Otter Connect account and keeps its host space. Deregister it under Otter Code Account → Otter Connect to free it.`, [ { text: "Cancel", style: "cancel" }, { - text: "Open T3 Account", + text: "Open Otter Code Account", onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), }, remove, diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 3624caebb076..635c9b2c1571 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -85,6 +85,7 @@ function threadDetailToShell( linkedPullRequest: thread.linkedPullRequest ?? null, pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest ?? null, + linearIssues: thread.linearIssues, activeProviderThreadId: thread.activeProviderThreadId, lineage: thread.lineage, forkedFrom: thread.forkedFrom, diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index bf02bb138c2f..5fa255175ffb 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -47,7 +47,7 @@ function makeRow(overrides: Partial): AgentActivityRowPro } const props = { - title: "T3 Code", + title: "Otter Code", subtitle: "Agent work in progress", activeCount: 1, updatedAt: "2026-05-25T13:07:00.000Z", @@ -197,14 +197,14 @@ describe("AgentActivity widget layout", () => { environment as never, ); expect(JSON.stringify(layout.banner)).toContain( - '"widgetURL":"t3code://threads/env-1/thread-2"', + '"widgetURL":"ottercode://threads/env-1/thread-2"', ); }); it("deep links the banner to the first row when nothing needs attention", () => { const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); expect(JSON.stringify(layout.banner)).toContain( - '"widgetURL":"t3code://threads/env-1/thread-1"', + '"widgetURL":"ottercode://threads/env-1/thread-1"', ); }); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index bc27e8b88113..099cc91eaf63 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -172,7 +172,7 @@ export function AgentActivity( const deepLinkRow = attentionRow ?? row0; const deepLink = deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") - ? `t3code://${deepLinkRow.deepLink.slice(1)}` + ? `ottercode://${deepLinkRow.deepLink.slice(1)}` : null; // A scannable status glyph per phase — reads faster than colored words and @@ -244,17 +244,9 @@ export function AgentActivity( ); - // The branded T3 mark. `assetName` resolves the template image set bundled in - // the widget extension's asset catalog. Image views only honor `resizable` - // directly (frame/foregroundStyle are dropped), so we size it via a container - // frame the resizable image fills and tint it through the container's - // foreground style, which the template image inherits. The 3:2 frame matches - // the glyph's aspect ratio so it never distorts. - const renderLogo = (height: number, color: Foreground) => ( - - - - ); + // Fills the island slots that cannot stay empty with the lead agent's status. + const renderHeroGlyph = (size: number) => + renderGlyph(phaseSymbol(heroRow?.phase ?? "running"), size, tint); return { banner: ( @@ -268,14 +260,9 @@ export function AgentActivity( ...(deepLink ? [widgetURL(deepLink)] : []), ]} > - {/* Logo pinned to the leading edge; the status texts centered across the - full width (ZStack so the logo doesn't skew the centering). No footer — - overflow beyond the visible rows is inferable from the count. */} + {/* Status texts centered across the full width. No footer — overflow + beyond the visible rows is inferable from the count. */} - - {renderLogo(13, primaryForeground)} - - - {renderLogo(14, primaryForeground)} ), - compactLeading: renderLogo(14, tint), + compactLeading: renderHeroGlyph(14), compactTrailing: ( {attentionRow @@ -359,16 +345,15 @@ export function AgentActivity( : activeLabel} ), - // The shared/minimal form is a ~22pt circle — a single signal reads there, - // the wordmark does not. Show the blocking/outcome phase glyph, else the - // mark (all-done shows the hero row's checkmark/cross). + // The shared/minimal form is a ~22pt circle — a single signal reads there. + // Show the blocking/outcome phase glyph in its tint, else the running glyph + // (all-done shows the hero row's checkmark/cross). minimal: (attentionRow || failedRow || allDone) && heroRow ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase)) - : renderLogo(11, tint), + : renderHeroGlyph(13), expandedLeading: ( - {renderLogo(15, tint)} {allDone ? doneLabel : `${props.activeCount}`} diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts index db2efd5da17b..c3dae3e86bc6 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts @@ -68,25 +68,25 @@ describe("SubscriptionUsage Android layout", () => { ...snapshot, providers: [{ ...provider, expiresAt: now - 1 }], }); - expect(tree).toContain("Open T3 to refresh"); + expect(tree).toContain("Open Otter Code to refresh"); expect(tree).not.toContain("LinearProgressIndicator"); - expect(tree).not.toContain("more in T3"); + expect(tree).not.toContain("more in Otter Code"); }); it("counts the quotas that did not fit", () => { const tree = render({ ...snapshot, providers: [{ ...provider, totalWindows: 5 }] }); - expect(tree).toContain("3 more in T3"); + expect(tree).toContain("3 more in Otter Code"); }); it("keeps quotas without an expiry deadline visible", () => { const tree = render({ ...snapshot, providers: [{ ...provider, expiresAt: 0 }] }); expect(tree).toContain("5 hours · 60% left"); - expect(tree).not.toContain("Open T3 to refresh"); + expect(tree).not.toContain("Open Otter Code to refresh"); }); it("invites connecting when nothing has been checked", () => { const tree = render({ checkedAt: 0, providers: [] }, "light"); - expect(tree).toContain("Tap to connect in T3"); + expect(tree).toContain("Tap to connect in Otter Code"); expect(tree).not.toContain("As of "); expect(tree).toContain('"containerColor":"light-surface"'); }); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx index ac47edf8cdd1..b93afb80966c 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx @@ -29,8 +29,20 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi }); const muted = colors.onSurfaceVariant; const providers = props.providers ?? [ - { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, - { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, + { + name: "Codex", + detail: "Open Otter Code to connect", + windows: [], + expiresAt: 0, + totalWindows: 0, + }, + { + name: "Claude", + detail: "Open Otter Code to connect", + windows: [], + expiresAt: 0, + totalWindows: 0, + }, ]; return ( // The card is one Button so a tap reaches the app's interaction listener, @@ -60,7 +72,7 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi {shown.length === 0 ? ( - {stale ? "Open T3 to refresh" : provider.detail} + {stale ? "Open Otter Code to refresh" : provider.detail} ) : null} {shown.map((window) => { @@ -90,7 +102,7 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi })} {hidden > 0 ? ( - {`${hidden} more in T3`} + {`${hidden} more in Otter Code`} ) : null} @@ -104,7 +116,7 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi > {props.checkedAt ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}` - : "Tap to connect in T3"} + : "Tap to connect in Otter Code"} diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx index 78197cd2858e..c0f8826a79df 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -39,8 +39,8 @@ function SubscriptionUsage( const monochrome = environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; const providers = props.providers ?? [ - { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0 }, - { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0 }, + { name: "Codex", detail: "Open Otter Code to connect", windows: [], expiresAt: 0 }, + { name: "Claude", detail: "Open Otter Code to connect", windows: [], expiresAt: 0 }, ]; const columns = providers.map((provider) => { const stale = provider.windows.length > 0 && now >= provider.expiresAt; @@ -73,7 +73,7 @@ function SubscriptionUsage( ].slice(0, limit) : windows.slice(0, limit); const detail = stale - ? "Open T3 to refresh" + ? "Open Otter Code to refresh" : period !== "auto" && windows.length === 0 && provider.windows.length > 0 ? `No ${period} limit reported` : provider.detail; @@ -121,7 +121,7 @@ function SubscriptionUsage( ? `${tightest.remaining}% left` : period !== "auto" && !stale && provider.windows.length > 0 ? "N/A" - : "Open T3"} + : "Open Otter Code"} {tightest ? ( @@ -223,7 +223,7 @@ function SubscriptionUsage( > {(period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) - limit}{" "} - more in T3 + more in Otter Code ) : null} @@ -255,7 +255,7 @@ function SubscriptionUsage( > {props.checkedAt ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}` - : "Tap to connect in T3"} + : "Tap to connect in Otter Code"} ) : null} diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 44f2c6ef4bed..6b16207f10b3 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -94,7 +94,7 @@ function subscriptionUsageProps( return { name, detail: !fresh - ? "Open T3 to refresh" + ? "Open Otter Code to refresh" : pool.accounts.length > 1 ? `${pool.accounts.length} accounts · pooled` : "Subscription remaining", @@ -161,7 +161,7 @@ export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, n ...snapshot, providers: snapshot.providers.map((provider) => provider.windows.length > 0 && provider.expiresAt <= date - ? { ...provider, detail: "Open T3 to refresh", windows: [], totalWindows: 0 } + ? { ...provider, detail: "Open Otter Code to refresh", windows: [], totalWindows: 0 } : provider, ), }, diff --git a/apps/mobile/uniwind-types.d.ts b/apps/mobile/uniwind-types.d.ts index 22856ab57ffa..b915a4933d24 100644 --- a/apps/mobile/uniwind-types.d.ts +++ b/apps/mobile/uniwind-types.d.ts @@ -3,7 +3,7 @@ declare module 'uniwind' { export interface UniwindConfig { - themes: readonly ['light', 'dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark'] + themes: readonly ['light', 'dark', 'chatgpt-light', 'chatgpt-dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark'] } } diff --git a/apps/server/package.json b/apps/server/package.json index 715fb3097986..aa62872e0bea 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -4,7 +4,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/pingdotgg/t3code", + "url": "https://github.com/otterware-app/otter-code", "directory": "apps/server" }, "bin": { @@ -57,8 +57,13 @@ "node-pty": "^1.2.0-beta.15", "playwright-core": "1.60.0", "proper-lockfile": "4.1.2", + "pyright": "1.1.414", "stream-chain": "^4.2.5", "stream-json": "3.6.0", + "typescript-tsserver": "npm:typescript@6.0.3", + "vscode-json-languageservice": "5.6.3", + "vscode-jsonrpc": "8.2.1", + "vscode-languageserver-textdocument": "1.0.12", "yaml": "catalog:", "yauzl": "^3.4.0" }, @@ -77,6 +82,7 @@ "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", + "typescript": "catalog:", "vite-plus": "catalog:" }, "engines": { diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 60bc08b0d4bb..707236cac626 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -168,7 +168,7 @@ const buildExeCmd = Command.make( /** * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: - * every `@t3code/t3-.tgz` first, `t3.tgz` (the launcher) last, so + * every `@otterware/otter-code-.tgz` first, `otter-code.tgz` (the launcher) last, so * the launcher is never installable before the executables it depends on. * Tarballs rather than directories because `npm publish ` strips the * `node_modules/` the executable loads its native addons from. @@ -192,17 +192,17 @@ const publishCmd = Command.make( // npm runs with cwd set to the packages dir below, so tarball paths are // resolved once here rather than joined twice. const packagesDir = path.resolve(config.packagesDir); - const scopeDir = path.join(packagesDir, "@t3code"); - const launcherTarball = path.join(packagesDir, "t3.tgz"); + const scopeDir = path.join(packagesDir, "@otterware"); + const launcherTarball = path.join(packagesDir, "otter-code.tgz"); const platformTarballs = (yield* fs .readDirectory(scopeDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) - .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz")) + .filter((entry) => entry.startsWith("otter-code-") && entry.endsWith(".tgz")) .sort() .map((entry) => path.join(scopeDir, entry)); if (platformTarballs.length === 0) { return yield* new ServerCliBuildAssetMissingError({ - assetPath: path.join(scopeDir, "t3-.tgz"), + assetPath: path.join(scopeDir, "otter-code-.tgz"), }); } if (!(yield* fs.exists(launcherTarball))) { @@ -231,7 +231,7 @@ const publishCmd = Command.make( }), ).pipe( Command.withDescription( - "Publish the @t3code/t3- tarballs and then the t3 launcher to npm.", + "Publish the @otterware/otter-code- tarballs and then the otter-code launcher to npm.", ), ); diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index c25b35a61cc0..366da3065b19 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -644,7 +644,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { origin: "https://box.tail1234.ts.net", "x-forwarded-host": "box.tail1234.ts.net", }); - yield* upgrade({ origin: "t3code://app" }); + yield* upgrade({ origin: "ottercode://app" }); yield* upgrade({ origin: "http://localhost:5733" }); yield* upgrade({}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..2bfe87256365 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -129,6 +129,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.linearIssueDetail]: AuthOrchestrationReadScope, + [WS_METHODS.linearSubscribeIssueChanges]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, @@ -148,6 +150,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, + [WS_METHODS.projectsLanguage]: AuthFilesystemReadScope, + [WS_METHODS.projectsLanguageServers]: AuthFilesystemReadScope, [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope, @@ -178,6 +182,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope, [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, + [WS_METHODS.reviewListCommits]: AuthFilesystemReadScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, [WS_METHODS.terminalObserve]: AuthTerminalReadScope, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063093874503..0cecf7595b72 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -5,7 +5,8 @@ * terminal-fallback agents run `t3 acp-mcp-call` per tool call, so their * startup sits on first-message latency. Both dispatch here before the full * CLI module graph (seconds of evaluation) loads; everything else defers to - * the real CLI in ./binCli.ts. + * the real CLI in ./binCli.ts. `t3 __run-node-script` runs bundled language + * servers under the single-executable build (see ./nodeScript.ts). */ import { isEntrypoint } from "./entrypoint.ts"; @@ -20,6 +21,9 @@ if ( if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); + } else if (command === "__run-node-script") { + const { runNodeScript } = await import("./nodeScript.ts"); + runNodeScript(process.argv.slice(3)); } else { const { runCli } = await import("./binCli.ts"); runCli(); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index e785baa58f1c..618fbf89dc43 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -285,7 +285,7 @@ describe("t3 app", () => { it.effect("prefers the installed desktop app when a dev desktop is also running", () => withTempDirectory("t3-app-preferred-test-", (root) => Effect.gen(function* () { - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".otter-code"); const desktop = yield* fakeDesktop({ baseDir }); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -300,7 +300,7 @@ describe("t3 app", () => { it.effect("finds the dev desktop when the default desktop socket is absent", () => withTempDirectory("t3-app-dev-test-", (root) => Effect.gen(function* () { - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".otter-code"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); yield* runCli(["app"]); @@ -315,7 +315,7 @@ describe("t3 app", () => { it.effect("never searches a dev state directory for an explicit T3 home", () => withTempDirectory("t3-app-explicit-test-", (root) => Effect.gen(function* () { - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".otter-code"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); @@ -333,7 +333,7 @@ describe("t3 app", () => { (responseKind) => withTempDirectory("t3-app-response-test-", (root) => Effect.gen(function* () { - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".otter-code"); const desktop = yield* fakeDesktop({ baseDir, reply: (request) => diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 5cd89a1457b2..b3470fdfedae 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -13,25 +13,25 @@ import { it("formats package runner commands from their cache entry paths", () => { for (const [entryPath, expected] of [ - ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"], + ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx otter-code serve"], [ "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "npx t3 serve", + "npx otter-code serve", ], - ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"], + ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx otter-code serve"], [ "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", - "pnpm dlx t3 serve", + "pnpm dlx otter-code serve", ], [ "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "pnpm dlx t3 serve", + "pnpm dlx otter-code serve", ], - ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"], - ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"], + ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx otter-code serve"], + ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx otter-code serve"], [ "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - "bunx t3 serve", + "bunx otter-code serve", ], ] as const) { assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); @@ -47,17 +47,17 @@ it("treats stable installs as direct invocations", () => { ]) { assert.equal( formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), - "t3 serve", + "otter-code serve", ); } }); it("re-suggests the prerelease channel only for prerelease builds", () => { for (const [version, expected] of [ - ["0.0.31-nightly.20260729", "npx t3@nightly serve"], - ["0.0.31-preview.20260729.1", "npx t3@preview serve"], - ["0.0.31-foo-preview.20260729.1", "npx t3 serve"], - ["0.0.31", "npx t3 serve"], + ["0.0.31-nightly.20260729", "npx otter-code@nightly serve"], + ["0.0.31-preview.20260729.1", "npx otter-code@preview serve"], + ["0.0.31-foo-preview.20260729.1", "npx otter-code serve"], + ["0.0.31", "npx otter-code serve"], ] as const) { assert.equal( formatCliCommand({ @@ -77,7 +77,7 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs", version: "0.0.31-nightly.20260729", }), - "npx t3@nightly serve", + "npx otter-code@nightly serve", ); assert.equal( formatCliCommand({ @@ -85,7 +85,7 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", version: "0.0.31", }), - "bunx t3 serve", + "bunx otter-code serve", ); assert.equal( formatCliCommand({ @@ -93,12 +93,12 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/usr/local/lib/node_modules/t3/dist/bin.mjs", version: "0.0.31-nightly.20260729", }), - "t3 serve", + "otter-code serve", ); }); it.layer(NodeServices.layer)("root CLI commands", (it) => { - /** `sudo t3 browser setup` as this process would render it, with `t3` on PATH or not. */ + /** `sudo otter-code browser setup` as this process would render it, with `otter-code` on PATH or not. */ const rootCommand = (input: { readonly node: string; readonly entry: string; @@ -114,12 +114,12 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { Effect.provideService(HostProcess.Environment, { PATH: input.path ?? "", ...input.env }), ); - /** A directory holding an executable `t3`, to stand in for one on PATH. */ + /** A directory holding an executable `otter-code`, to stand in for one on PATH. */ const pathWithT3 = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const bin = yield* fs.makeTempDirectoryScoped(); - yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + yield* fs.writeFileString(path.join(bin, "otter-code"), "#!/bin/sh\n", { mode: 0o755 }); return bin; }); @@ -128,28 +128,28 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; // sudo's secure_path already has a system Node. expect(yield* rootCommand({ node: "/usr/bin/node", entry: npx })).toBe( - "sudo npx t3 browser setup", + "sudo npx otter-code browser setup", ); // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. expect( yield* rootCommand({ node: "/home/theo/.nvm/versions/node/v24/bin/node", entry: npx }), - ).toBe('sudo env "PATH=$PATH" npx t3 browser setup'); + ).toBe('sudo env "PATH=$PATH" npx otter-code browser setup'); expect( yield* rootCommand({ node: "/home/theo/.local/node/bin/node", entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", path: yield* pathWithT3, }), - ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + ).toBe('sudo env "PATH=$PATH" otter-code browser setup'); }).pipe(Effect.scoped), ); - it.effect("names this install's launcher when t3 is not on PATH", () => + it.effect("names this install's launcher when otter-code is not on PATH", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const shim = path.join(home, ".local bin", "t3"); + const shim = path.join(home, ".local bin", "otter-code"); yield* fs.makeDirectory(path.dirname(shim), { recursive: true }); yield* fs.writeFileString(shim, "#!/bin/sh\n", { mode: 0o755 }); const desktop = { @@ -159,9 +159,9 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { }; // The desktop app's shim, quoted for the shell and run as root as is. expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`); - // A `t3` the person put on PATH still wins. + // An `otter-code` the person put on PATH still wins. expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe( - 'sudo env "PATH=$PATH" t3 browser setup', + 'sudo env "PATH=$PATH" otter-code browser setup', ); // A standalone binary names itself. expect( @@ -171,10 +171,10 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { executable: true, }), ).toBe("sudo /opt/t3/t3 browser setup"); - // A stale shim path, then no launcher at all, fall back to plain `t3`. + // A stale shim path, then no launcher at all, fall back to plain `otter-code`. expect( yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), - ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + ).toBe('sudo env "PATH=$PATH" otter-code browser setup'); }).pipe(Effect.scoped), ); }); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index ff5cb9d8f9c8..36180b7b3768 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -112,7 +112,7 @@ export const resolveServerInstallation = Effect.gen(function* () { */ function suggestedPackageSpec(version: string): string { const channel = /^[^-+]+-(nightly|preview)\./.exec(version)?.[1]; - return channel === undefined ? "t3" : `t3@${channel}`; + return channel === undefined ? "otter-code" : `otter-code@${channel}`; } /** @@ -128,7 +128,7 @@ export function formatCliCommand(input: { }): string { const runner = detectCliRunner(input.entryPath); if (runner === null) { - return `t3 ${input.subcommand}`; + return `otter-code ${input.subcommand}`; } return `${runner} ${suggestedPackageSpec(input.version)} ${input.subcommand}`; } @@ -172,8 +172,8 @@ const resolveInstallLauncher = Effect.gen(function* () { const resolveHostCliCommand = (subcommand: string) => Effect.gen(function* () { const command = yield* resolveCliCommand(subcommand); - if (command !== `t3 ${subcommand}`) return { command, launcher: false }; - if (yield* isCommandAvailable("t3")) return { command, launcher: false }; + if (command !== `otter-code ${subcommand}`) return { command, launcher: false }; + if (yield* isCommandAvailable("otter-code")) return { command, launcher: false }; const launcher = yield* resolveInstallLauncher; return Option.isSome(launcher) ? { command: `${shellWord(launcher.value)} ${subcommand}`, launcher: true } diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 56a84bba7177..ceacab2e4f22 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -270,8 +270,8 @@ describe("t3 pair", () => { typeof error === "object" && error !== null && "cause" in error ? error.cause : error, ); assert.include(rendered, "No running T3 Code server found."); - assert.include(rendered, "npx t3 serve"); - assert.include(rendered, "npx t3 connect"); + assert.include(rendered, "npx otter-code serve"); + assert.include(rendered, "npx otter-code connect"); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index debac218a983..053a42885e7c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -78,7 +78,7 @@ export class NoRunningServerError extends Schema.TaggedError ` checked ${statePath}`), - "Start one with `npx t3 serve`, or connect this machine with T3 Connect: `npx t3 connect`.", + "Start one with `npx otter-code serve`, or connect this machine with T3 Connect: `npx otter-code connect`.", ].join("\n"); } } diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 073217d505cb..87fcfc834632 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -45,6 +45,7 @@ const ReleaseIndex = Schema.Array( Schema.Struct({ tag_name: Schema.String, draft: Schema.optional(Schema.Boolean), + name: Schema.optional(Schema.String), }), ); const decodeReleaseIndex = Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseIndex)); diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index c0fb59026a76..f648468cd684 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -17,10 +17,11 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as CliTokenManager from "./CliTokenManager.ts"; -// pk_test_ +// Fake Otter Accounts issuer used by the token transport tests. const TEST_ENV = { - T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test", + T3CODE_RELAY_URL: "https://relay.code.otterware.app", + T3CODE_ACCOUNTS_URL: "https://accounts.example.test/v1/auth", + T3CODE_ACCOUNTS_CLI_CLIENT_ID: "oauth_client_test", T3CODE_HOSTED_APP_URL: "https://hosted.example.test", }; @@ -79,7 +80,7 @@ it.effect("opens the browser on Enter and switches the active flow on H", () => const opened: Array = []; const result = yield* CliTokenManager.waitForLoopbackAuthorization({ - authorizationUrl: "https://clerk.example.test/authorize", + authorizationUrl: "https://accounts.example.test/authorize", callback: Effect.never, terminal: makeTestTerminal(queue), launchBrowser: (url) => @@ -88,7 +89,7 @@ it.effect("opens the browser on Enter and switches the active flow on H", () => }), }); - assert.deepEqual(opened, ["https://clerk.example.test/authorize"]); + assert.deepEqual(opened, ["https://accounts.example.test/authorize"]); assert.deepEqual(result, { _tag: "HeadlessRequested" }); }), ); @@ -97,16 +98,16 @@ it.effect("finishes normally when the browser callback wins", () => Effect.gen(function* () { const queue = yield* Queue.make(); const callback = yield* Deferred.make(); - yield* Deferred.succeed(callback, "clerk-code-123"); + yield* Deferred.succeed(callback, "account-code-123"); const result = yield* CliTokenManager.waitForLoopbackAuthorization({ - authorizationUrl: "https://clerk.example.test/authorize", + authorizationUrl: "https://accounts.example.test/authorize", callback: Deferred.await(callback), terminal: makeTestTerminal(queue), launchBrowser: () => Effect.die("browser launch should not run"), }); - assert.deepEqual(result, { _tag: "AuthorizationCode", code: "clerk-code-123" }); + assert.deepEqual(result, { _tag: "AuthorizationCode", code: "account-code-123" }); }), ); @@ -145,7 +146,7 @@ const layerDeviceFlow = (server: DeviceFlowServer) => const body = request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; server.requests.push({ url: request.url, params: new URLSearchParams(body) }); - const reply = request.url.endsWith("/oauth/device_authorization") + const reply = request.url.endsWith("/device/code") ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } : ((server.tokenReplies.length > 1 ? server.tokenReplies.shift() @@ -162,53 +163,58 @@ const layerDeviceFlow = (server: DeviceFlowServer) => ); const tokenRequests = (requests: ReadonlyArray) => - requests.filter((request) => request.url.endsWith("/oauth/token")); + requests.filter((request) => request.url.endsWith("/oauth2/token")); it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => { - it.effect("requests a device code, shows it, and polls until Clerk grants the token", () => - Effect.gen(function* () { - const server: DeviceFlowServer = { - requests: [], - tokenReplies: [oauthError("authorization_pending"), tokenGranted], - }; - const prompts: Array = []; - - const fiber = yield* CliTokenManager.deviceAuthorizationLogin((prompt) => - Effect.sync(() => { - prompts.push(prompt); - }), - ).pipe(Effect.provide(layerDeviceFlow(server)), provideTestEnv, Effect.forkChild); - - yield* TestClock.adjust(Duration.seconds(10)); - const { token, identity } = yield* Fiber.join(fiber); - - assert.deepEqual(prompts, [ - { - verificationUri: "https://accounts.example.test/device", - verificationUriComplete: "https://accounts.example.test/device?user_code=BCDF-GHJK", - userCode: "BCDF-GHJK", - expiresIn: Duration.seconds(600), - }, - ]); - assert.equal(token.accessToken, "access-token-1"); - assert.equal(token.refreshToken, "refresh-token-1"); - assert.equal(token.identity, "theo@example.test"); - assert.equal(identity, "theo@example.test"); - - const authorization = server.requests[0]!; - assert.equal(authorization.url, "https://clerk.example.test/oauth/device_authorization"); - assert.equal(authorization.params.get("client_id"), "oauth_client_test"); - assert.equal(authorization.params.get("scope"), "openid profile email offline_access"); - - const polls = tokenRequests(server.requests); - assert.lengthOf(polls, 2); - for (const poll of polls) { - assert.equal(poll.url, "https://clerk.example.test/oauth/token"); - assert.equal(poll.params.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code"); - assert.equal(poll.params.get("device_code"), "device-code-1"); - assert.equal(poll.params.get("client_id"), "oauth_client_test"); - } - }), + it.effect( + "requests a device code, shows it, and polls until Otter Accounts grants the token", + () => + Effect.gen(function* () { + const server: DeviceFlowServer = { + requests: [], + tokenReplies: [oauthError("authorization_pending"), tokenGranted], + }; + const prompts: Array = []; + + const fiber = yield* CliTokenManager.deviceAuthorizationLogin((prompt) => + Effect.sync(() => { + prompts.push(prompt); + }), + ).pipe(Effect.provide(layerDeviceFlow(server)), provideTestEnv, Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(10)); + const { token, identity } = yield* Fiber.join(fiber); + + assert.deepEqual(prompts, [ + { + verificationUri: "https://accounts.example.test/device", + verificationUriComplete: "https://accounts.example.test/device?user_code=BCDF-GHJK", + userCode: "BCDF-GHJK", + expiresIn: Duration.seconds(600), + }, + ]); + assert.equal(token.accessToken, "access-token-1"); + assert.equal(token.refreshToken, "refresh-token-1"); + assert.equal(token.identity, "theo@example.test"); + assert.equal(identity, "theo@example.test"); + + const authorization = server.requests[0]!; + assert.equal(authorization.url, "https://accounts.example.test/v1/auth/device/code"); + assert.equal(authorization.params.get("client_id"), "oauth_client_test"); + assert.equal(authorization.params.get("scope"), "openid profile email offline_access"); + + const polls = tokenRequests(server.requests); + assert.lengthOf(polls, 2); + for (const poll of polls) { + assert.equal(poll.url, "https://accounts.example.test/v1/auth/oauth2/token"); + assert.equal( + poll.params.get("grant_type"), + "urn:ietf:params:oauth:grant-type:device_code", + ); + assert.equal(poll.params.get("device_code"), "device-code-1"); + assert.equal(poll.params.get("client_id"), "oauth_client_test"); + } + }), ); it.effect("waits the advertised interval between polls and backs off on slow_down", () => diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 475f9aa9caee..6318704d942d 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -36,11 +36,11 @@ import { } from "./publicConfig.ts"; import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; -const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; +const CLOUD_CLI_OAUTH_TOKEN_SECRET = "otter-account-oauth-token"; const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; -// RFC 8628 defaults, used only when Clerk omits the field. +// RFC 8628 defaults, used only when Otter Accounts omits the field. const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5); // RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval". const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5); @@ -318,14 +318,14 @@ const isTransportError = (error: unknown) => HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError"; /** - * Polls Clerk's token endpoint until the user approves or denies the device + * Polls Otter Accounts's token endpoint until the user approves or denies the device * request in the browser (RFC 8628 §3.4/3.5). `authorization_pending` keeps * waiting, while `slow_down` and transient failures widen the interval before * the next tick; the caller bounds the whole loop with the device code's * lifetime. */ const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* ( - metadata: Pick, + metadata: Pick, deviceCode: string, initialInterval: Duration.Duration, ) { @@ -334,6 +334,7 @@ const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* grant_type: DEVICE_CODE_GRANT_TYPE, device_code: deviceCode, client_id: metadata.clientId, + resource: metadata.resource, }; let interval = initialInterval; while (true) { @@ -377,7 +378,7 @@ const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* /** * OAuth device authorization grant for machines without a local browser - * (SSH). Clerk issues a short user code; the user approves it on Clerk's + * (SSH). Otter Accounts issues a short user code; the user approves it on Otter Accounts's * hosted device page from any browser while this process polls the token * endpoint. Nothing is typed into the terminal and no redirect URI is * involved, so the hosted app plays no part in this flow. @@ -389,12 +390,13 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author const authorization = yield* HttpClientRequest.post(metadata.deviceAuthorizationEndpoint).pipe( HttpClientRequest.bodyUrlParams({ client_id: metadata.clientId, + resource: metadata.resource, scope: metadata.scopes.join(" "), }), httpClient.execute, Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), ); - // Clerk's advertised lifetime and interval are authoritative. + // Otter Accounts's advertised lifetime and interval are authoritative. const expiresIn = Duration.seconds(authorization.expires_in); const interval = authorization.interval === undefined @@ -450,6 +452,7 @@ export const make = Effect.gen(function* () { grant_type: "refresh_token", refresh_token: token.refreshToken, client_id: metadata.clientId, + resource: metadata.resource, }); return refreshed.identity === undefined && token.identity !== undefined ? { ...refreshed, identity: token.identity } @@ -468,7 +471,14 @@ export const make = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const url = new URL(request.originalUrl, metadata.redirectUri); const code = url.searchParams.get("code"); - if (url.searchParams.get("state") !== state || !code) { + if ( + request.method !== "GET" || + url.pathname !== "/callback" || + url.searchParams.get("state") !== state || + url.searchParams.get("iss") !== + new URL(metadata.tokenEndpoint).href.replace(/\/oauth2\/token$/, "") || + !code + ) { return HttpServerResponse.text("Invalid T3 Connect authorization callback.", { status: 400, }); @@ -490,10 +500,10 @@ export const make = Effect.gen(function* () { ), Layer.build, ); - // The hosted /connect page establishes a Clerk session before forwarding + // The hosted /connect page establishes a Otter Accounts session before forwarding // the request to /oauth/authorize with the loopback redirect URI. Sending // a signed-out browser to /oauth/authorize directly loses the authorize - // parameters across Clerk's sign-in redirect (#5051). + // parameters across Otter Accounts's sign-in redirect (#5051). const authorizationUrl = buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, @@ -520,6 +530,7 @@ export const make = Effect.gen(function* () { code: authorization.code, redirect_uri: metadata.redirectUri, client_id: metadata.clientId, + resource: metadata.resource, code_verifier: verifier, }); return { _tag: "Authorized", token } as const; diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 65bf0818a970..0914a5fa5b5b 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -25,7 +25,7 @@ const linuxPlan = { program: [linuxRuntime, "__service-launcher"], baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", + unitPath: "/home/theo/.config/systemd/user/otter-code.service", }; it("runs the pinned runtime's own executable as the systemd launcher", () => { @@ -41,7 +41,7 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], baseDir, logPath: `${baseDir}/userdata/logs/boot-service.log`, - unitPath: "/home/theo/.config/systemd/user/t3code.service", + unitPath: "/home/theo/.config/systemd/user/otter-code.service", }); expect( @@ -75,7 +75,7 @@ const macPlan = { program: [macRuntime, "__service-launcher"], baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", + unitPath: "/Users/theo/Library/LaunchAgents/dev.otterware.code.service.plist", }; const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; @@ -166,11 +166,13 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const failed = command === control.failCommand; if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; - if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; - if (!failed && command === "systemctl --user restart t3code.service") control.active = true; + if (!failed && command === "systemctl --user enable otter-code.service") + control.enabled = true; + if (!failed && command === "systemctl --user restart otter-code.service") + control.active = true; if ( control.stateAfterStop !== undefined && - (command === "systemctl --user stop t3code.service" || + (command === "systemctl --user stop otter-code.service" || command.startsWith("launchctl bootout --wait ")) ) { yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); @@ -298,7 +300,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ); expect(yield* fs.readFileString(statePath)).toBe(before); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); - expect(commands).not.toContain("systemctl --user stop t3code.service"); + expect(commands).not.toContain("systemctl --user stop otter-code.service"); }), ); @@ -372,7 +374,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).installed).toBe(false); // The stop can block up to systemd's 90s TimeoutStopSec; the runner's // 60s default would cancel it mid-shutdown. - expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( + expect(timeouts.get("systemctl --user disable --now otter-code.service")).toEqual( Duration.seconds(120), ); }), @@ -444,9 +446,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ), ).toEqual( platform === "linux" - ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"] + ? [ + "systemctl --user stop otter-code.service", + "systemctl --user restart otter-code.service", + ] : [ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl bootout --wait gui/501/dev.otterware.code.service", `launchctl bootstrap gui/501 ${plan.unitPath}`, ], ); @@ -573,10 +578,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), ), ).toEqual([ - "systemctl --user stop t3code.service", + "systemctl --user stop otter-code.service", "systemctl --user daemon-reload", - "systemctl --user enable t3code.service", - "systemctl --user restart t3code.service", + "systemctl --user enable otter-code.service", + "systemctl --user restart otter-code.service", ]); }), ); @@ -609,9 +614,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), ), ).toEqual([ - "systemctl --user stop t3code.service", + "systemctl --user stop otter-code.service", "systemctl --user daemon-reload", - "systemctl --user restart t3code.service", + "systemctl --user restart otter-code.service", ]); }), ); @@ -630,9 +635,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), ), ).toEqual([ - "systemctl --user stop t3code.service", + "systemctl --user stop otter-code.service", "systemctl --user daemon-reload", - "systemctl --user restart t3code.service", + "systemctl --user restart otter-code.service", ]); }), ); @@ -664,8 +669,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), ), ).toEqual([ - "systemctl --user stop t3code.service", - "systemctl --user restart t3code.service", + "systemctl --user stop otter-code.service", + "systemctl --user restart otter-code.service", ]); } }), @@ -687,7 +692,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect( plan.unitPath.endsWith( - path.join("Library", "LaunchAgents", "com.t3tools.t3code.service.plist"), + path.join("Library", "LaunchAgents", "dev.otterware.code.service.plist"), ), ).toBe(true); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -709,7 +714,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); // A bootout can block up to the plist's 90s ExitTimeOut; the runner's // 60s default would cancel it and let bootstrap race a loaded job. - expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual( + expect(timeouts.get("launchctl bootout --wait gui/501/dev.otterware.code.service")).toEqual( Duration.seconds(120), ); }), @@ -726,8 +731,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const error = yield* service.install().pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", - "launchctl enable gui/501/com.t3tools.t3code.service", + "launchctl bootout --wait gui/501/dev.otterware.code.service", + "launchctl enable gui/501/dev.otterware.code.service", `launchctl bootstrap gui/501 ${plistPath}`, `launchctl bootstrap gui/501 ${plistPath}`, ]); @@ -789,7 +794,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); yield* service.install(); - control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; + control.failCommand = "launchctl bootout --wait gui/501/dev.otterware.code.service"; yield* service.install(); expect((yield* service.status).current).toBe(true); @@ -820,7 +825,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ); expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl bootout --wait gui/501/dev.otterware.code.service", `launchctl bootstrap gui/501 ${plistPath}`, ]); } diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 1d7d50b13185..9da0d4048a3a 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -31,11 +31,11 @@ import { type ServiceState, } from "./serviceProtocol.ts"; -const BOOT_SERVICE_NAME = "t3code"; +const BOOT_SERVICE_NAME = "otter-code"; const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id -// (com.t3tools.t3code), so launchd and TCC records never collide. -const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; +// (dev.otterware.code), so launchd and TCC records never collide. +const BOOT_SERVICE_LAUNCHD_LABEL = "dev.otterware.code.service"; const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** File in the logs dir that receives the service's stdout and stderr. `t3 triage` points agents at it. */ diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index a49a88277eb0..c0e1e5e778a6 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -11,6 +11,16 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as AuthHttp from "../auth/http.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerConfig from "../config.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { LinearIssueSyncReactor } from "../linear/LinearIssueSyncReactor.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { ThreadLaunchService } from "../orchestration-v2/ThreadLaunchService.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import * as CloudLink from "./CloudLink.ts"; import * as ConnectHttp from "./http.ts"; @@ -33,6 +43,21 @@ const answerHealthWith = async (failure: HealthFailure) => { // The session-gated routes are declared too; this request never reaches them. Layer.provide(AuthHttp.layerAuthenticatedAuth), Layer.provide(Layer.mock(EnvironmentAuth.EnvironmentAuth)({})), + // Only the Linear routes read these. + Layer.provide(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provide(Layer.mock(ServerEnvironment.ServerEnvironment)({})), + Layer.provideMerge( + Layer.mergeAll( + Layer.mock(LinearIssueSyncReactor)({}), + Layer.mock(OrchestratorV2)({}), + Layer.mock(ThreadLaunchService)({}), + Layer.mock(ThreadManagementService)({}), + Layer.mock(ProjectService)({}), + Layer.mock(GitWorkflowService)({}), + Layer.mock(ServerSettingsService)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-connect-http-test-" }), + ), + ), Layer.provideMerge( HttpPlatform.layer.pipe( Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 51f0dda5f2e1..e2a52f38cda8 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -10,13 +10,35 @@ import { EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/http/HttpEffect"; import { HttpServerRequest, HttpServerResponse } from "effect/http"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { requireEnvironmentScope } from "../auth/http.ts"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { + deliverLinearAgentPrompt, + handleLinearAgentPromptRequest, +} from "../linear/LinearAgentPrompts.ts"; +import { + handleLinearAgentSessionRequest, + launchLinearAgentSession, + type LinearRelayProofChecks, +} from "../linear/LinearAgentSessionLauncher.ts"; +import { handleLinearIssueChangesRequest } from "../linear/LinearIssueChanges.ts"; +import * as LinearIssueSyncReactor from "../linear/LinearIssueSyncReactor.ts"; import * as CloudLink from "./CloudLink.ts"; +import { + CLOUD_LINKED_USER_ID, + CLOUD_MINT_PUBLIC_KEY, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; +import { hasBoundedCloudProofLifetime } from "./linkChecks.ts"; import type { RelayRequestError } from "./relayResponse.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; @@ -105,11 +127,81 @@ const toHttpErrorOrUnavailable = ( ), ); +const readCloudSecretString = ( + secrets: ServerSecretStore.ServerSecretStore["Service"], + name: string, + missing: () => EnvironmentAuth.ServerAuthInternalError, +) => + secrets.get(name).pipe( + Effect.mapError(() => missing()), + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(new TextDecoder().decode(bytes.value)) + : Effect.fail(missing()), + ), + ); + +/** The relay proof checks shared by the Linear session, prompt, and issue change requests. */ +const linearRelayProofChecks = ( + secrets: ServerSecretStore.ServerSecretStore["Service"], + environment: ServerEnvironment.ServerEnvironment["Service"], +): LinearRelayProofChecks => ({ + secrets, + environment, + cloudMintPublicKey: readCloudSecretString( + secrets, + CLOUD_MINT_PUBLIC_KEY, + () => new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}), + ), + relayIssuer: readCloudSecretString( + secrets, + RELAY_ISSUER_SECRET, + () => new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}), + ).pipe( + Effect.catch(() => + readCloudSecretString( + secrets, + RELAY_URL_SECRET, + () => new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}), + ), + ), + ), + linkedCloudUserId: readCloudSecretString( + secrets, + CLOUD_LINKED_USER_ID, + () => new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}), + ), + isValidProofWindow: hasBoundedCloudProofLifetime, + consumeReplayGuards: (names, value) => + CloudLink.consumeCloudReplayGuards({ secrets, names, value }), +}); + +const isLinearRequestError = Schema.is( + Schema.Union([EnvironmentHttpUnauthorizedError, EnvironmentHttpConflictError]), +); + +type LinearHttpError = + | EnvironmentHttpUnauthorizedError + | EnvironmentHttpConflictError + | EnvironmentHttpInternalServerError; + +/** Rejected proofs keep their status; anything else is logged and answers 500. */ +const toLinearHttpError = + (message: string) => + (effect: Effect.Effect): Effect.Effect => + Effect.catch(effect, (error): Effect.Effect => + isLinearRequestError(error) ? Effect.fail(error) : internalServerError({ message }, error), + ); + export const layer = HttpApiBuilder.group( EnvironmentHttpApi, "connect", Effect.fnUntraced(function* (handlers) { const cloudLink = yield* CloudLink.CloudLink; + const linearChecks = linearRelayProofChecks( + yield* ServerSecretStore.ServerSecretStore, + yield* ServerEnvironment.ServerEnvironment, + ); return handlers .handle("linkProof", ({ payload }) => Effect.gen(function* () { @@ -156,6 +248,46 @@ export const layer = HttpApiBuilder.group( Effect.tap(() => appendCloudCredentialResponseHeaders), ), ), + ) + .handle("linearAgentSession", ({ payload }) => + traceRelayRequest( + handleLinearAgentSessionRequest( + { ...linearChecks, launch: launchLinearAgentSession }, + payload, + ).pipe( + Effect.tap(() => appendCloudCredentialResponseHeaders), + toLinearHttpError("Could not start the delegated Linear issue."), + ), + ), + ) + .handle("linearAgentPrompt", ({ payload }) => + traceRelayRequest( + handleLinearAgentPromptRequest( + { ...linearChecks, deliver: deliverLinearAgentPrompt }, + payload, + ).pipe( + Effect.tap(() => appendCloudCredentialResponseHeaders), + toLinearHttpError("Could not deliver the Linear message."), + ), + ), + ) + .handle("linearIssueChanges", ({ payload }) => + traceRelayRequest( + handleLinearIssueChangesRequest( + { + ...linearChecks, + // A replayed "issue changed" only triggers one extra read of an issue this + // environment already follows, and these arrive for every Linear edit, so + // they skip the replay guards that would otherwise pile up in the secret store. + consumeReplayGuards: () => Effect.succeed(true), + deliver: (changes) => + LinearIssueSyncReactor.LinearIssueSyncReactor.pipe( + Effect.flatMap((reactor) => reactor.publishIssueChanges(changes)), + ), + }, + payload, + ).pipe(toLinearHttpError("Could not accept Linear issue changes.")), + ), ); }), ); diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 02276e87d6de..57d9b1021b3a 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -82,65 +82,34 @@ it.effect("rejects malformed or insecure hosted app URLs", () => }), ); -it.effect("derives direct Clerk OAuth endpoints from statically injected public config", () => +it.effect("derives account token and device endpoints with a relay audience", () => Effect.gen(function* () { const config = yield* makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", - clerkCliOAuthClientIdFallback: "oauth_client_embedded", - }).pipe(provideEnv({})); - + accountsUrlFallback: "https://accounts.otterware.app/v1/auth", + }).pipe(provideEnv({ T3CODE_RELAY_URL: "https://relay.code.otterware.app" })); assert.deepEqual(config, { - tokenEndpoint: "https://clerk.example.test/oauth/token", - deviceAuthorizationEndpoint: "https://clerk.example.test/oauth/device_authorization", - clientId: "oauth_client_embedded", + tokenEndpoint: "https://accounts.otterware.app/v1/auth/oauth2/token", + deviceAuthorizationEndpoint: "https://accounts.otterware.app/v1/auth/device/code", + resource: "https://relay.code.otterware.app", + clientId: "otter-code-cli", loopbackPort: 34338, redirectUri: "http://127.0.0.1:34338/callback", scopes: ["openid", "profile", "email", "offline_access"], }); }), ); - -it.effect("prefers runtime Clerk OAuth config overrides over statically injected values", () => +it.effect("rejects malformed or insecure account issuers as typed failures", () => Effect.gen(function* () { - const config = yield* makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback: "pk_test_ZW1iZWRkZWQuZXhhbXBsZS50ZXN0JA==", - clerkCliOAuthClientIdFallback: "oauth_client_embedded", - }).pipe( - provideEnv({ - T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_cnVudGltZS5leGFtcGxlLnRlc3Qk", - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_runtime", - }), - ); - - assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); - assert.equal(config.clientId, "oauth_client_runtime"); - }), -); - -it.effect("requires Clerk OAuth config when the server bundle has no injected values", () => - makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback: "", - clerkCliOAuthClientIdFallback: "", - }).pipe(provideEnv({}), Effect.flip), -); - -it.effect("reports malformed Clerk publishable keys as typed configuration failures", () => - Effect.gen(function* () { - const result = yield* makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback: "pk_test_not-base64!!", - clerkCliOAuthClientIdFallback: "oauth_client_embedded", - }).pipe(provideEnv({}), Effect.result); - - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) { - assert.equal(result.failure.cause._tag, "SourceError"); - if (result.failure.cause._tag === "SourceError") { - assert.equal( - result.failure.cause.message, - "Failed to derive Clerk Frontend API URL from the publishable key.", - ); - assert.instanceOf(result.failure.cause.cause, Error); - } + for (const issuer of [ + "pk_test_invalid", + "http://accounts.example/v1/auth", + "https://accounts.example/v1/auth?key=secret", + ]) { + const result = yield* makeCloudCliOAuthConfig({ accountsUrlFallback: issuer }).pipe( + provideEnv({ T3CODE_RELAY_URL: "https://relay.code.otterware.app" }), + Effect.result, + ); + assert.isTrue(Result.isFailure(result)); } }), ); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 903f4a88a1e0..9576f13902a2 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -3,17 +3,16 @@ import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL, } from "@t3tools/shared/connectAuth"; -import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; +import { ACCOUNT_CLIENT_IDS, normalizeAccountsUrl } from "@t3tools/shared/otterAccounts"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; -declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; -declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; +declare const __T3CODE_BUILD_ACCOUNTS_URL__: string | undefined; +declare const __T3CODE_BUILD_ACCOUNTS_CLI_CLIENT_ID__: string | undefined; declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; @@ -53,15 +52,13 @@ const buildTimeRelayUrl = typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" ? "" : (normalizeSecureRelayUrl(__T3CODE_BUILD_RELAY_URL__) ?? ""); -const buildTimeClerkPublishableKey = readBuildTimeValue( - typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" - ? undefined - : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, +const buildTimeAccountsUrl = readBuildTimeValue( + typeof __T3CODE_BUILD_ACCOUNTS_URL__ === "undefined" ? undefined : __T3CODE_BUILD_ACCOUNTS_URL__, ); -const buildTimeClerkCliOAuthClientId = readBuildTimeValue( - typeof __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__ === "undefined" +const buildTimeAccountsCliClientId = readBuildTimeValue( + typeof __T3CODE_BUILD_ACCOUNTS_CLI_CLIENT_ID__ === "undefined" ? undefined - : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, + : __T3CODE_BUILD_ACCOUNTS_CLI_CLIENT_ID__, ); const buildTimeRelayClientTracing = { tracesUrl: readBuildTimeValue( @@ -149,14 +146,9 @@ function makePublicValueConfig(name: string, fallback: string) { ); } -/** - * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes - * through the hosted /connect page, which builds the authorize URL after a - * Clerk session exists (see CliTokenManager.login). The token endpoint and, - * for headless hosts, the device authorization endpoint are contacted - * directly. - */ +/** Public OAuth endpoints used by browser and headless CLI login. */ export interface CloudCliOAuthConfig { + readonly resource: string; readonly tokenEndpoint: string; readonly deviceAuthorizationEndpoint: string; readonly clientId: string; @@ -166,46 +158,39 @@ export interface CloudCliOAuthConfig { } export function makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback = buildTimeClerkPublishableKey, - clerkCliOAuthClientIdFallback = buildTimeClerkCliOAuthClientId, + accountsUrlFallback = buildTimeAccountsUrl, + accountsCliClientIdFallback = buildTimeAccountsCliClientId || ACCOUNT_CLIENT_IDS.cli, }: { - readonly clerkPublishableKeyFallback?: string; - readonly clerkCliOAuthClientIdFallback?: string; + readonly accountsUrlFallback?: string; + readonly accountsCliClientIdFallback?: string; } = {}) { return Config.all({ - clerkPublishableKey: makePublicValueConfig( - "T3CODE_CLERK_PUBLISHABLE_KEY", - clerkPublishableKeyFallback, - ), - clientId: makePublicValueConfig( - "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", - clerkCliOAuthClientIdFallback, - ), + accountsUrl: makePublicValueConfig("T3CODE_ACCOUNTS_URL", accountsUrlFallback), + clientId: makePublicValueConfig("T3CODE_ACCOUNTS_CLI_CLIENT_ID", accountsCliClientIdFallback), + resource: relayUrlConfig, }).pipe( - Config.mapEffect(({ clerkPublishableKey, clientId }) => - Effect.try({ - try: () => clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey), - catch: (cause) => + Config.mapEffect(({ accountsUrl, clientId, resource }) => { + const issuer = normalizeAccountsUrl(accountsUrl); + if (!issuer) + return Effect.fail( new Config.ConfigError( - new ConfigProvider.SourceError({ - message: "Failed to derive Clerk Frontend API URL from the publishable key.", - cause, - }), + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "Accounts URL must be an HTTPS /v1/auth issuer.", + }), + ), ), - }).pipe( - Effect.map( - (clerkFrontendApiUrl) => - ({ - tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, - deviceAuthorizationEndpoint: `${clerkFrontendApiUrl}/oauth/device_authorization`, - clientId, - loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT, - redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT), - scopes: CLOUD_CLI_OAUTH_SCOPES, - }) satisfies CloudCliOAuthConfig, - ), - ), - ), + ); + return Effect.succeed({ + resource, + tokenEndpoint: `${issuer}/oauth2/token`, + deviceAuthorizationEndpoint: `${issuer}/device/code`, + clientId, + loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT, + redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT), + scopes: CLOUD_CLI_OAUTH_SCOPES, + } satisfies CloudCliOAuthConfig); + }), ); } @@ -213,6 +198,5 @@ export const cloudCliOAuthConfig = makeCloudCliOAuthConfig(); export const hasCloudPublicConfig = Boolean( (normalizeSecureRelayUrl(process.env.T3CODE_RELAY_URL ?? "") ?? buildTimeRelayUrl) && - (process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || buildTimeClerkPublishableKey) && - (process.env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || buildTimeClerkCliOAuthClientId), + normalizeAccountsUrl(process.env.T3CODE_ACCOUNTS_URL?.trim() || buildTimeAccountsUrl), ); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 97f124721bb0..fc54b553b97b 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -56,7 +56,7 @@ describe("remote helper lifecycle", () => { const bin = NodePath.join(home, "bin"); await NodeFSP.mkdir(bin); await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - const root = NodePath.join(home, ".t3/device"); + const root = NodePath.join(home, ".otter-code/device"); const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`); const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs"); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index de412b5873d5..0279b18d802e 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -36,7 +36,7 @@ const path = require('node:path'); const os = require('node:os'); const net = require('node:net'); const { spawn, spawnSync } = require('node:child_process'); -const root = path.join(os.homedir(), '.t3', 'device'); +const root = path.join(os.homedir(), '.otter-code', 'device'); const state = path.join(root, 'hosts', owner); const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a55b1098d5f0..53f6ba6a4ef7 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -248,6 +248,7 @@ export const make = Effect.gen(function* () { threadVisitedTracking: true, threadPullRequests: true, threadPullRequestWatch: true, + threadLinearIssues: true, pullRequestStackActions: true, threadPullRequestLinking: true, serverResolvedCommandContext: true, diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts index a6c702979efa..903cbfd19e4d 100644 --- a/apps/server/src/httpCors.ts +++ b/apps/server/src/httpCors.ts @@ -11,4 +11,4 @@ export const browserApiCorsAllowedHeaders = [ ] as const; /** Origins of the Electron renderer, packaged and dev. */ -export const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"] as const; +export const DESKTOP_RENDERER_ORIGINS = ["ottercode://app", "ottercode-dev://app"] as const; diff --git a/apps/server/src/linear/LinearAgentPrompts.test.ts b/apps/server/src/linear/LinearAgentPrompts.test.ts new file mode 100644 index 000000000000..6aae5f69f3d7 --- /dev/null +++ b/apps/server/src/linear/LinearAgentPrompts.test.ts @@ -0,0 +1,161 @@ +// @effect-diagnostics nodeBuiltinImport:off - signs relay JWTs with a real ed25519 key pair. +import * as NodeCrypto from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { RelayLinearAgentPromptProofPayload } from "@t3tools/contracts/relay"; +import { + RELAY_LINEAR_PROMPT_REQUEST_TYP, + RELAY_LINEAR_SESSION_REQUEST_TYP, + signRelayJwt, +} from "@t3tools/shared/relayJwt"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import { deliverLinearAgentPrompt, handleLinearAgentPromptRequest } from "./LinearAgentPrompts.ts"; + +const RELAY_ISSUER = "https://relay.example.test"; +const ENVIRONMENT_ID = EnvironmentId.make("env-linear"); +const LINKED_USER = "user_linked"; + +const relayKeys = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +function makeContext() { + const guards = new Set(); + const delivered: Array = []; + const context = { + secrets: { + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: (_name: string, bytes: number) => Effect.succeed(new Uint8Array(bytes)), + remove: () => Effect.void, + }, + environment: { getEnvironmentId: Effect.succeed(ENVIRONMENT_ID) }, + cloudMintPublicKey: Effect.succeed(relayKeys.publicKey), + relayIssuer: Effect.succeed(RELAY_ISSUER), + linkedCloudUserId: Effect.succeed(LINKED_USER), + isValidProofWindow: ({ iat, exp }: { iat: number; exp: number }) => exp > iat, + consumeReplayGuards: (names: ReadonlyArray) => + Effect.sync(() => { + if (names.some((name) => guards.has(name))) return false; + for (const name of names) guards.add(name); + return true; + }), + deliver: (proof: RelayLinearAgentPromptProofPayload) => + Effect.sync(() => { + delivered.push(proof); + return "delivered" as const; + }), + }; + return { context, delivered }; +} + +const signPrompt = (typ = RELAY_LINEAR_PROMPT_REQUEST_TYP) => + Effect.gen(function* () { + const now = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000); + return yield* signRelayJwt({ + privateKey: relayKeys.privateKey, + typ, + payload: { + iss: RELAY_ISSUER, + aud: `t3-env:${ENVIRONMENT_ID}`, + sub: LINKED_USER, + jti: NodeCrypto.randomUUID(), + iat: now, + exp: now + 120, + environmentId: ENVIRONMENT_ID, + nonce: NodeCrypto.randomUUID(), + scope: ["linear:prompt"], + agentSessionId: "session-1", + activityId: "activity-1", + kind: "message", + body: "Also add a test", + } satisfies RelayLinearAgentPromptProofPayload, + }); + }); + +describe("handleLinearAgentPromptRequest", () => { + // Live clock: the proofs carry real timestamps. + it.live("delivers a relay-signed reply once", () => + Effect.gen(function* () { + const { context, delivered } = makeContext(); + const proof = yield* signPrompt(); + const response = yield* handleLinearAgentPromptRequest(context, { proof }); + assert.equal(response.outcome, "delivered"); + assert.equal(delivered[0]?.body, "Also add a test"); + + const replay = yield* handleLinearAgentPromptRequest(context, { proof }).pipe(Effect.flip); + assert.equal(replay._tag, "EnvironmentHttpConflictError"); + assert.equal(delivered.length, 1); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects a proof signed for another request kind", () => + Effect.gen(function* () { + const { context, delivered } = makeContext(); + const proof = yield* signPrompt(RELAY_LINEAR_SESSION_REQUEST_TYP); + const error = yield* handleLinearAgentPromptRequest(context, { proof }).pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentHttpUnauthorizedError"); + assert.equal(delivered.length, 0); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("deliverLinearAgentPrompt", () => { + const stop = { + agentSessionId: "session-1", + activityId: "activity-2", + kind: "stop", + body: "", + } as RelayLinearAgentPromptProofPayload; + + const threadsLayer = (options: { + readonly exists: boolean; + readonly interrupted: ThreadManagement.ThreadManagementInterruptResult["type"]; + }) => { + const interrupts: Array = []; + const layer = Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed( + options.exists ? ({ projectId: ProjectId.make("project-1") } as never) : null, + ), + interruptThread: (input) => + Effect.sync(() => { + interrupts.push(input); + return { type: options.interrupted } as never; + }), + }); + return { layer, interrupts }; + }; + + it.effect("interrupts the session's thread on a stop", () => + Effect.gen(function* () { + const running = threadsLayer({ exists: true, interrupted: "interrupt_requested" }); + assert.equal( + yield* deliverLinearAgentPrompt(stop).pipe(Effect.provide(running.layer)), + "delivered", + ); + assert.equal(running.interrupts[0]?.threadId, "linear-session:session-1"); + + const idle = threadsLayer({ exists: true, interrupted: "no_active_run" }); + assert.equal( + yield* deliverLinearAgentPrompt(stop).pipe(Effect.provide(idle.layer)), + "not_running", + ); + + const deleted = threadsLayer({ exists: false, interrupted: "no_active_run" }); + assert.equal( + yield* deliverLinearAgentPrompt(stop).pipe(Effect.provide(deleted.layer)), + "thread_missing", + ); + }), + ); +}); diff --git a/apps/server/src/linear/LinearAgentPrompts.ts b/apps/server/src/linear/LinearAgentPrompts.ts new file mode 100644 index 000000000000..33260f9183f0 --- /dev/null +++ b/apps/server/src/linear/LinearAgentPrompts.ts @@ -0,0 +1,88 @@ +import { CommandId, MessageId } from "@t3tools/contracts"; +import { + RelayLinearAgentPromptProofPayload, + type RelayLinearAgentPromptOutcome, + type RelayLinearAgentPromptRequest, + type RelayLinearAgentPromptResponse, +} from "@t3tools/contracts/relay"; +import { RELAY_LINEAR_PROMPT_REQUEST_TYP } from "@t3tools/shared/relayJwt"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import { + type LinearRelayProofChecks, + linearSessionThreadId, + verifyLinearRelayRequest, +} from "./LinearAgentSessionLauncher.ts"; + +const LINEAR_PROMPT_REPLAY_PREFIX = "cloud-linear-prompt-"; +const decodeProof = Schema.decodeUnknownEffect(RelayLinearAgentPromptProofPayload); + +/** Messages Linear sent, so the mirror doesn't echo them back into Linear. */ +const LINEAR_PROMPT_MESSAGE_PREFIX = "linear-prompt:"; + +/** + * Delivers a reply sent in Linear to the session's thread, steering the + * running turn or starting a new one, or interrupts the thread on a stop. + */ +export const deliverLinearAgentPrompt = Effect.fn("LinearAgentPrompt.deliver")(function* ( + proof: RelayLinearAgentPromptProofPayload, +) { + const threads = yield* ThreadManagement.ThreadManagementService; + const threadId = linearSessionThreadId(proof.agentSessionId); + const thread = yield* threads.getThreadShell(threadId); + if (thread === null) return "thread_missing" as const; + if (proof.kind === "stop") { + const interrupted = yield* threads.interruptThread({ + projectId: thread.projectId, + commandId: CommandId.make(`linear-stop:${proof.activityId}`), + threadId, + reason: "Stopped from Linear.", + }); + return interrupted.type === "interrupt_requested" + ? ("delivered" as const) + : ("not_running" as const); + } + // Linear replies carry no attachments, so this skips the intake that claims uploads. + yield* threads.sendToThread({ + projectId: thread.projectId, + commandId: CommandId.make(`${LINEAR_PROMPT_MESSAGE_PREFIX}${proof.activityId}`), + threadId, + messageId: MessageId.make(`${LINEAR_PROMPT_MESSAGE_PREFIX}${proof.activityId}`), + text: proof.body, + attachments: [], + mode: "auto", + createdBy: "user", + creationSource: "server", + }); + return "delivered" as const; +}); + +export interface LinearPromptProofContext< + DeliverError, + DeliverRequirements, +> extends LinearRelayProofChecks { + /** `deliverLinearAgentPrompt` in production. */ + readonly deliver: ( + proof: RelayLinearAgentPromptProofPayload, + ) => Effect.Effect; +} + +/** Handles the relay's signed "someone replied or pressed stop in Linear" request. */ +export const handleLinearAgentPromptRequest = Effect.fn("environment.cloud.linearAgentPrompt")( + function* ( + context: LinearPromptProofContext, + request: RelayLinearAgentPromptRequest, + ) { + const { proof } = yield* verifyLinearRelayRequest(context, { + token: request.proof, + typ: RELAY_LINEAR_PROMPT_REQUEST_TYP, + scope: "linear:prompt", + replayPrefix: LINEAR_PROMPT_REPLAY_PREFIX, + decode: decodeProof, + }); + const outcome = yield* context.deliver(proof); + return { outcome } satisfies RelayLinearAgentPromptResponse; + }, +); diff --git a/apps/server/src/linear/LinearAgentSessionLauncher.test.ts b/apps/server/src/linear/LinearAgentSessionLauncher.test.ts new file mode 100644 index 000000000000..3a8c8fb5b774 --- /dev/null +++ b/apps/server/src/linear/LinearAgentSessionLauncher.test.ts @@ -0,0 +1,278 @@ +// @effect-diagnostics nodeBuiltinImport:off - signs relay JWTs with a real ed25519 key pair. +import * as NodeCrypto from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { RelayLinearAgentSessionProofPayload } from "@t3tools/contracts/relay"; +import { + RELAY_LINEAR_SESSION_REQUEST_TYP, + RELAY_LINEAR_SESSION_RESPONSE_TYP, + signRelayJwt, + verifyRelayJwt, +} from "@t3tools/shared/relayJwt"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { + handleLinearAgentSessionRequest, + linearSessionPrompt, + pickLinearProjectId, + pickLinearWorktreeBranch, + resolveLinearWorkspaceStrategy, +} from "./LinearAgentSessionLauncher.ts"; + +const RELAY_ISSUER = "https://relay.example.test"; +const ENVIRONMENT_ID = EnvironmentId.make("env-linear"); +const LINKED_USER = "user_linked"; + +function ed25519() { + return NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); +} + +const relayKeys = ed25519(); +const environmentKeys = ed25519(); + +function makeContext() { + const store = new Map([ + ["cloud-link-ed25519-key-pair", new TextEncoder().encode(JSON.stringify(environmentKeys))], + ]); + const launched: Array = []; + const context = { + secrets: { + get: (name: string) => Effect.succeed(Option.fromNullishOr(store.get(name))), + set: (name: string, value: Uint8Array) => Effect.sync(() => void store.set(name, value)), + create: (name: string, value: Uint8Array) => Effect.sync(() => void store.set(name, value)), + getOrCreateRandom: (_name: string, bytes: number) => Effect.succeed(new Uint8Array(bytes)), + remove: (name: string) => Effect.sync(() => void store.delete(name)), + }, + environment: { getEnvironmentId: Effect.succeed(ENVIRONMENT_ID) }, + cloudMintPublicKey: Effect.succeed(relayKeys.publicKey), + relayIssuer: Effect.succeed(RELAY_ISSUER), + linkedCloudUserId: Effect.succeed(LINKED_USER), + isValidProofWindow: ({ iat, exp }: { iat: number; exp: number }) => exp > iat, + consumeReplayGuards: (names: ReadonlyArray) => + Effect.sync(() => { + if (names.some((name) => store.has(name))) return false; + for (const name of names) store.set(name, new Uint8Array()); + return true; + }), + launch: (proof: RelayLinearAgentSessionProofPayload) => + Effect.sync(() => { + launched.push(proof); + return { + outcome: "launched" as const, + threadId: ThreadId.make(`linear-session:${proof.agentSessionId}`), + }; + }), + }; + return { context, launched }; +} + +const signProof = (overrides: Partial = {}) => + Effect.gen(function* () { + const now = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000); + const payload = { + iss: RELAY_ISSUER, + aud: `t3-env:${ENVIRONMENT_ID}`, + sub: LINKED_USER, + jti: NodeCrypto.randomUUID(), + iat: now, + exp: now + 120, + environmentId: ENVIRONMENT_ID, + nonce: NodeCrypto.randomUUID(), + scope: ["linear:session"], + agentSessionId: "session-1", + issue: { + id: "issue-uuid", + identifier: "ENG-7", + title: "Fix login", + url: "https://linear.app/acme/issue/ENG-7/fix-login", + teamKey: "ENG", + }, + prompt: '…', + creatorName: "Ada", + ...overrides, + } satisfies RelayLinearAgentSessionProofPayload; + const proof = yield* signRelayJwt({ + privateKey: relayKeys.privateKey, + typ: RELAY_LINEAR_SESSION_REQUEST_TYP, + payload, + }); + return { proof, nonce: payload.nonce }; + }); + +describe("handleLinearAgentSessionRequest", () => { + // Live clock: the proofs carry real timestamps. + it.live("launches once for a relay-signed request and signs the outcome back", () => + Effect.gen(function* () { + const { context, launched } = makeContext(); + const { proof, nonce } = yield* signProof(); + const response = yield* handleLinearAgentSessionRequest(context, { proof }); + assert.equal(response.outcome, "launched"); + assert.equal(response.threadId, "linear-session:session-1"); + assert.equal(launched.length, 1); + assert.equal(launched[0]?.issue.identifier, "ENG-7"); + + const signed = yield* verifyRelayJwt({ + publicKey: environmentKeys.publicKey, + token: response.proof, + typ: RELAY_LINEAR_SESSION_RESPONSE_TYP, + issuer: `t3-env:${ENVIRONMENT_ID}`, + audience: RELAY_ISSUER, + nowEpochSeconds: Math.floor((yield* DateTime.now).epochMilliseconds / 1_000), + }); + assert.equal(signed.requestNonce, nonce); + assert.equal(signed.threadId, response.threadId); + + const replay = yield* handleLinearAgentSessionRequest(context, { proof }).pipe(Effect.flip); + assert.equal(replay._tag, "EnvironmentHttpConflictError"); + assert.equal(launched.length, 1); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects requests for another user or scope without launching", () => + Effect.gen(function* () { + const { context, launched } = makeContext(); + for (const overrides of [ + { sub: "someone_else" }, + { scope: ["environment:connect" as never] }, + { aud: "t3-env:another-environment" }, + ]) { + const { proof } = yield* signProof(overrides); + const error = yield* handleLinearAgentSessionRequest(context, { proof }).pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentHttpUnauthorizedError"); + } + assert.equal(launched.length, 0); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("pickLinearProjectId", () => { + const linear = { + defaultProjectId: ProjectId.make("default"), + teamProjects: [{ teamKey: "ENG", projectId: ProjectId.make("engineering") }], + }; + + it("prefers the team's project, then the default", () => { + assert.equal(pickLinearProjectId(linear, "eng"), "engineering"); + assert.equal(pickLinearProjectId(linear, "OPS"), "default"); + assert.equal(pickLinearProjectId(linear, null), "default"); + assert.isNull(pickLinearProjectId({ ...linear, defaultProjectId: null }, "OPS")); + }); +}); + +describe("linearSessionPrompt", () => { + const issue = { + id: "issue-uuid", + identifier: "ENG-7", + title: "Fix login", + url: "https://linear.app/acme/issue/ENG-7", + teamKey: "ENG", + branchName: "eng-7-fix-login", + }; + + it("fills the environment's template, preferring the worktree's actual branch", () => { + const prompt = linearSessionPrompt({ + template: + "Work on {{issue.identifier}}:\nBranch: {{ issue.branchName }}\n\n{{context}}{{unknown}}", + issue, + branch: "eng-7-fix-login-2", + prompt: '', + }); + assert.equal( + prompt, + 'Work on ENG-7:\nBranch: eng-7-fix-login-2\n\n{{unknown}}', + ); + }); + + it("uses the default template and falls back when Linear sent no context", () => { + const prompt = linearSessionPrompt({ template: " ", issue, branch: undefined, prompt: "" }); + assert.include(prompt, "Work on Linear issue ENG-7 (https://linear.app/acme/issue/ENG-7)."); + assert.include(prompt, "\nLinear issue ENG-7.\n"); + }); +}); + +describe("pickLinearWorktreeBranch", () => { + it("uses Linear's branch, suffixed when a local branch already has it", () => { + const branch = "ada/eng-7-fix-login"; + assert.equal(pickLinearWorktreeBranch(branch, ["main"]), branch); + assert.equal(pickLinearWorktreeBranch(branch, ["main", "Ada/ENG-7-fix-login"]), `${branch}-2`); + assert.equal( + pickLinearWorktreeBranch(branch, [branch, `${branch}-2`, `${branch}-3`]), + `${branch}-4`, + ); + }); + + it("leaves naming to the server without a usable suggestion", () => { + assert.isUndefined(pickLinearWorktreeBranch(undefined, [])); + for (const invalid of ["-eng-7", "eng..7", "eng-7.lock", "eng 7", "eng/", ".eng-7"]) { + assert.isUndefined(pickLinearWorktreeBranch(invalid, []), invalid); + } + }); +}); + +describe("resolveLinearWorkspaceStrategy", () => { + const gitLayer = (localBranches: ReadonlyArray) => + Layer.mock(GitWorkflow.GitWorkflowService)({ + listRefs: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: localBranches.length, + refs: localBranches.map((name) => ({ + name, + current: name === "main", + isDefault: name === "main", + worktreePath: null, + })), + }), + listLocalBranchNames: () => Effect.succeed([...localBranches]), + }); + + it.effect("opens the worktree on Linear's branch off the default branch", () => + Effect.gen(function* () { + const strategy = yield* resolveLinearWorkspaceStrategy({ + workspaceRoot: "/repo", + startFromOrigin: true, + suggestedBranch: "eng-7-fix-login", + }).pipe(Effect.provide(gitLayer(["main", "feature/other"]))); + assert.deepStrictEqual(strategy, { + type: "worktree", + baseRef: "main", + startFromOrigin: true, + branch: "eng-7-fix-login", + }); + }), + ); + + it.effect("suffixes a branch that already exists, and omits it without a suggestion", () => + Effect.gen(function* () { + const layer = gitLayer(["main", "eng-7-fix-login"]); + const taken = yield* resolveLinearWorkspaceStrategy({ + workspaceRoot: "/repo", + startFromOrigin: false, + suggestedBranch: "eng-7-fix-login", + }).pipe(Effect.provide(layer)); + assert.equal(taken.type === "worktree" ? taken.branch : null, "eng-7-fix-login-2"); + + const unnamed = yield* resolveLinearWorkspaceStrategy({ + workspaceRoot: "/repo", + startFromOrigin: false, + suggestedBranch: undefined, + }).pipe(Effect.provide(layer)); + assert.deepStrictEqual(unnamed, { + type: "worktree", + baseRef: "main", + startFromOrigin: false, + }); + }), + ); +}); diff --git a/apps/server/src/linear/LinearAgentSessionLauncher.ts b/apps/server/src/linear/LinearAgentSessionLauncher.ts new file mode 100644 index 000000000000..246e046004a8 --- /dev/null +++ b/apps/server/src/linear/LinearAgentSessionLauncher.ts @@ -0,0 +1,366 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + type LinearSettings, + renderLinearPromptTemplate, + type ProjectId, + EnvironmentHttpConflictError, + EnvironmentHttpUnauthorizedError, + MessageId, + ThreadId, +} from "@t3tools/contracts"; +import { + RelayLinearAgentSessionProofPayload, + type RelayLinearAgentSessionOutcome, + type RelayLinearAgentSessionRequest, + type RelayLinearAgentSessionResponse, + type RelayLinearAgentSessionResponseProofPayload, +} from "@t3tools/contracts/relay"; +import { + normalizeRelayIssuer, + RELAY_LINEAR_SESSION_REQUEST_TYP, + RELAY_LINEAR_SESSION_RESPONSE_TYP, + signRelayJwt, + verifyRelayJwt, +} from "@t3tools/shared/relayJwt"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import type * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; +import type * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadMessageIntake from "../orchestration-v2/ThreadMessageIntake.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import { getAutoBootstrapThreadModelSelection } from "../serverRuntimeStartup.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + +const LINEAR_SESSION_REPLAY_PREFIX = "cloud-linear-session-"; + +const decodeProof = Schema.decodeUnknownEffect(RelayLinearAgentSessionProofPayload); + +/** What the relay proof checks need from the cloud HTTP layer; see `cloud/http.ts`. */ +export interface LinearRelayProofChecks { + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; + readonly environment: Pick; + readonly cloudMintPublicKey: Effect.Effect; + readonly relayIssuer: Effect.Effect; + readonly linkedCloudUserId: Effect.Effect; + readonly isValidProofWindow: (input: { + readonly iat: number; + readonly exp: number; + readonly nowSeconds: number; + }) => boolean; + readonly consumeReplayGuards: ( + names: ReadonlyArray, + value: Uint8Array, + ) => Effect.Effect; +} + +export interface LinearSessionProofContext< + LaunchError, + LaunchRequirements, +> extends LinearRelayProofChecks { + /** Starts the thread once the request is verified; `launchLinearAgentSession` in production. */ + readonly launch: (proof: RelayLinearAgentSessionProofPayload) => Effect.Effect< + { + readonly outcome: RelayLinearAgentSessionOutcome; + readonly threadId: ThreadId | null; + }, + LaunchError, + LaunchRequirements + >; +} + +/** + * Checks a relay-signed Linear request the same way as the mint and health + * handlers: relay-signed, addressed to this environment and its linked user, + * short lived, carrying exactly the expected scope, and single-use. + */ +export const verifyLinearRelayRequest = Effect.fn("LinearRelayRequest.verify")(function* < + A extends { + readonly environmentId: string; + readonly sub: string; + readonly iat: number; + readonly exp: number; + readonly jti: string; + readonly nonce: string; + readonly scope: ReadonlyArray; + }, + DecodeError, +>( + checks: LinearRelayProofChecks, + input: { + readonly token: string; + readonly typ: string; + readonly scope: string; + /** Names this request kind's replay guards, e.g. `cloud-linear-session-`. */ + readonly replayPrefix: string; + readonly decode: (payload: unknown) => Effect.Effect; + }, +) { + const cloudMintPublicKey = yield* checks.cloudMintPublicKey; + const relayIssuer = yield* checks.relayIssuer; + const environmentId = yield* checks.environment.getEnvironmentId; + const linkedCloudUserId = yield* checks.linkedCloudUserId; + const now = yield* DateTime.now; + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const proofOption = yield* verifyRelayJwt({ + publicKey: cloudMintPublicKey, + token: input.token, + typ: input.typ, + issuer: normalizeRelayIssuer(relayIssuer), + audience: `t3-env:${environmentId}`, + nowEpochSeconds: nowSeconds, + }).pipe(Effect.flatMap(input.decode), Effect.option); + if ( + Option.isNone(proofOption) || + proofOption.value.environmentId !== environmentId || + proofOption.value.sub !== linkedCloudUserId || + !checks.isValidProofWindow({ ...proofOption.value, nowSeconds }) || + proofOption.value.scope.length !== 1 || + proofOption.value.scope[0] !== input.scope + ) { + return yield* new EnvironmentHttpUnauthorizedError({ + message: "Invalid Linear request.", + }); + } + const proof = proofOption.value; + const consumed = yield* checks.consumeReplayGuards( + [`${input.replayPrefix}jti-${proof.jti}`, `${input.replayPrefix}nonce-${proof.nonce}`], + new TextEncoder().encode(DateTime.formatIso(now)), + ); + if (!consumed) { + return yield* new EnvironmentHttpConflictError({ + message: "Linear request was already consumed.", + }); + } + return { proof, environmentId, relayIssuer, nowSeconds }; +}); + +const LINEAR_SESSION_THREAD_PREFIX = "linear-session:"; + +/** The thread a delegated Linear session runs in, keyed by the session so retries land on it. */ +export const linearSessionThreadId = (agentSessionId: string) => + ThreadId.make(`${LINEAR_SESSION_THREAD_PREFIX}${agentSessionId}`); + +/** The Linear session a thread was delegated from, or null for any other thread. */ +export const linearAgentSessionIdOf = (threadId: string): string | null => + threadId.startsWith(LINEAR_SESSION_THREAD_PREFIX) + ? threadId.slice(LINEAR_SESSION_THREAD_PREFIX.length) || null + : null; + +/** The first message of a delegated thread, from the environment's Linear prompt template. */ +export function linearSessionPrompt(input: { + readonly template: string; + readonly issue: RelayLinearAgentSessionProofPayload["issue"]; + readonly branch: string | undefined; + readonly prompt: string; +}): string { + const context = input.prompt.trim(); + return renderLinearPromptTemplate(input.template, { + "issue.identifier": input.issue.identifier, + "issue.title": input.issue.title, + "issue.url": input.issue.url, + "issue.branchName": input.branch ?? input.issue.branchName ?? "", + context: context.length > 0 ? context : `Linear issue ${input.issue.identifier}.`, + }); +} + +/** The project a delegated issue runs in: the team's mapping, else the default. */ +export function pickLinearProjectId( + linear: Pick, + teamKey: string | null, +): ProjectId | null { + const mapped = teamKey + ? linear.teamProjects.find((entry) => entry.teamKey.toUpperCase() === teamKey.toUpperCase()) + : undefined; + return mapped?.projectId ?? linear.defaultProjectId; +} + +/** Git's ref-name rules, for the branch names Linear suggests; anything odd is left to the server's naming. */ +function isUsableBranchName(name: string): boolean { + return /^[A-Za-z0-9._/-]+$/u.test(name) && !/(^|\/)[.-]|\.\.|\/\/|[/.]$|\.lock(\/|$)/u.test(name); +} + +/** + * The worktree branch for a delegated issue: Linear's suggested name, suffixed + * when a local branch already has it (say, the issue was delegated before). + * `undefined` lets the server pick a temporary name and rename it later. + */ +export function pickLinearWorktreeBranch( + suggested: string | undefined, + existingBranchNames: ReadonlyArray, +): string | undefined { + if (suggested === undefined || !isUsableBranchName(suggested)) return undefined; + const existing = new Set(existingBranchNames.map((name) => name.toLowerCase())); + if (!existing.has(suggested.toLowerCase())) return suggested; + let suffix = 2; + while (existing.has(`${suggested}-${suffix}`.toLowerCase())) suffix += 1; + return `${suggested}-${suffix}`; +} + +const resolveLaunchTarget = Effect.fn("LinearAgentSession.resolveLaunchTarget")(function* ( + teamKey: string | null, +) { + const settings = yield* (yield* ServerSettingsService).getSettings; + const projects = yield* ProjectService.ProjectService; + const projectId = pickLinearProjectId(settings.linear, teamKey); + if (projectId === null) return null; + const project = yield* projects.getById(projectId).pipe(Effect.map(Option.getOrNull)); + if (project === null || project.deletedAt !== null) return null; + const resolved = resolveProjectSettings(settings, project.id, project).settings; + return { + project, + modelSelection: resolved.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(), + runtimeMode: resolved.defaultRuntimeMode, + startFromOrigin: settings.newWorktreesStartFromOrigin, + promptTemplate: settings.linear.promptTemplate, + }; +}); + +/** + * Delegated issues always get their own worktree off the default branch, so an + * agent working unattended never touches the user's checkout. The branch is + * Linear's suggested name when the relay sent one. + */ +export const resolveLinearWorkspaceStrategy = Effect.fn( + "LinearAgentSession.resolveWorkspaceStrategy", +)(function* (input: { + readonly workspaceRoot: string; + readonly startFromOrigin: boolean; + readonly suggestedBranch: string | undefined; +}) { + const git = yield* GitWorkflow.GitWorkflowService; + const refs = yield* git + .listRefs({ cwd: input.workspaceRoot, refKind: "local", limit: 100 }) + .pipe(Effect.option); + if (Option.isNone(refs) || !refs.value.isRepo) return { type: "root" as const }; + const base = + refs.value.refs.find((ref) => ref.isDefault) ?? refs.value.refs.find((ref) => ref.current); + if (!base) return { type: "root" as const }; + // Unknown local branches would risk a failing `git worktree add`; fall back to the server's naming. + const branch = + input.suggestedBranch === undefined + ? undefined + : yield* git.listLocalBranchNames(input.workspaceRoot).pipe( + Effect.map((names) => pickLinearWorktreeBranch(input.suggestedBranch, names)), + Effect.orElseSucceed(() => undefined), + ); + return { + type: "worktree" as const, + baseRef: base.name, + startFromOrigin: input.startFromOrigin, + ...(branch ? { branch } : {}), + }; +}); + +export const launchLinearAgentSession = Effect.fn("LinearAgentSession.launch")(function* ( + proof: RelayLinearAgentSessionProofPayload, +) { + const target = yield* resolveLaunchTarget(proof.issue.teamKey); + if (target === null) return { outcome: "no_project" as const, threadId: null }; + // Keyed by the Linear session, so a relay retry lands on the same thread. + const commandId = CommandId.make(`linear-session:${proof.agentSessionId}`); + const threadId = linearSessionThreadId(proof.agentSessionId); + const workspaceStrategy = yield* resolveLinearWorkspaceStrategy({ + workspaceRoot: target.project.workspaceRoot, + startFromOrigin: target.startFromOrigin, + suggestedBranch: proof.issue.branchName, + }); + const result = yield* ThreadMessageIntake.launchThread({ + commandId, + threadId, + projectId: target.project.id, + title: `${proof.issue.identifier} ${proof.issue.title}`.slice(0, 120), + modelSelection: target.modelSelection, + runtimeMode: target.runtimeMode, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + workspaceStrategy, + initialMessage: { + messageId: MessageId.make(`linear-session:${proof.agentSessionId}`), + text: linearSessionPrompt({ + template: target.promptTemplate, + issue: proof.issue, + branch: workspaceStrategy.type === "worktree" ? workspaceStrategy.branch : undefined, + prompt: proof.prompt, + }), + attachments: [], + }, + createdBy: "user", + creationSource: "server", + }); + const orchestrator = yield* OrchestratorV2; + yield* orchestrator + .dispatch({ + type: "thread.linear-issue.link", + commandId: CommandId.make(`linear-session-link:${proof.agentSessionId}`), + threadId: result.threadId, + identifier: proof.issue.identifier, + url: proof.issue.url, + source: "delegated", + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("could not link the delegated Linear issue", { cause }), + ), + ); + return { outcome: "launched" as const, threadId: result.threadId }; +}); + +/** + * Handles the relay's signed "start a thread for this delegated issue" + * request. The response is signed back bound to the request nonce. + */ +export const handleLinearAgentSessionRequest = Effect.fn("environment.cloud.linearAgentSession")( + function* ( + context: LinearSessionProofContext, + request: RelayLinearAgentSessionRequest, + ) { + const { proof, environmentId, relayIssuer, nowSeconds } = yield* verifyLinearRelayRequest( + context, + { + token: request.proof, + typ: RELAY_LINEAR_SESSION_REQUEST_TYP, + scope: "linear:session", + replayPrefix: LINEAR_SESSION_REPLAY_PREFIX, + decode: decodeProof, + }, + ); + + const launched: { + readonly outcome: RelayLinearAgentSessionOutcome; + readonly threadId: ThreadId | null; + } = yield* context.launch(proof); + + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(context.secrets); + const responsePayload = { + iss: `t3-env:${environmentId}`, + aud: normalizeRelayIssuer(relayIssuer), + sub: environmentId, + jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), + iat: nowSeconds, + exp: nowSeconds + 5 * 60, + environmentId, + requestNonce: proof.nonce, + outcome: launched.outcome, + threadId: launched.threadId, + } satisfies RelayLinearAgentSessionResponseProofPayload; + const responseProof = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_LINEAR_SESSION_RESPONSE_TYP, + payload: responsePayload, + }); + return { + outcome: launched.outcome, + threadId: launched.threadId, + proof: responseProof, + } satisfies RelayLinearAgentSessionResponse; + }, +); diff --git a/apps/server/src/linear/LinearApi.test.ts b/apps/server/src/linear/LinearApi.test.ts new file mode 100644 index 000000000000..05a3644caa1b --- /dev/null +++ b/apps/server/src/linear/LinearApi.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import { makeLinearApi, makeSignedUploadCache, reviewUrlFromMcpResponse } from "./LinearApi.ts"; +import { issueOfDetail } from "./LinearIssueSyncReactor.ts"; + +function issueNode(identifier: string, stateType: string) { + return { + id: `uuid-${identifier}`, + identifier, + title: `Title ${identifier}`, + url: `https://linear.app/acme/issue/${identifier}`, + updatedAt: "2026-09-24T10:00:00.000Z", + state: { name: "State", type: stateType, color: "#123456" }, + assignee: null, + }; +} + +const decodeBody = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ query: Schema.String })), +); + +function fixture(respond: (query: string) => unknown) { + const requests: Array<{ authorization: string | undefined; query: string }> = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" + ? decodeBody(new TextDecoder().decode(request.body.body)) + : { query: "" }; + requests.push({ authorization: request.headers.authorization, query: body.query }); + return HttpClientResponse.fromWeb(request, Response.json(respond(body.query))); + }), + ); + return { requests, api: makeLinearApi.pipe(Effect.provideService(HttpClient.HttpClient, http)) }; +} + +describe("LinearApi.readIssues", () => { + it.effect("reads issues in aliased batches and skips ones Linear did not return", () => + Effect.gen(function* () { + const references = Array.from({ length: 51 }, (_, index) => `ENG-${index + 1}`); + const test = fixture((query) => { + const data: Record = {}; + for (const match of query.matchAll(/(i\d+): issue\(id: "([^"]+)"\)/gu)) { + // ENG-2 was deleted; Linear answers its alias with null. + data[match[1]!] = match[2] === "ENG-2" ? null : issueNode(match[2]!, "started"); + } + return { data }; + }); + const api = yield* test.api; + const issues = yield* api.readIssues("lin_api_key", references); + expect(test.requests).toHaveLength(2); + expect(test.requests[0]?.authorization).toBe("lin_api_key"); + expect(issues.size).toBe(50); + expect(issues.has("ENG-2")).toBe(false); + expect(issues.get("ENG-51")?.state.type).toBe("started"); + }), + ); + + it.effect("maps unknown state types and fails when Linear returns no data", () => + Effect.gen(function* () { + const ok = fixture(() => ({ data: { i0: issueNode("ENG-1", "paused") } })); + const issues = yield* (yield* ok.api).readIssues("lin_oauth_token", ["ENG-1"]); + expect(issues.get("ENG-1")?.state.type).toBe("unknown"); + expect(ok.requests[0]?.authorization).toBe("Bearer lin_oauth_token"); + + const denied = fixture(() => ({ errors: [{ message: "Authentication required" }] })); + const error = yield* (yield* denied.api).readIssues("bad", ["ENG-1"]).pipe(Effect.flip); + expect(error.detail).toBe("Authentication required"); + }), + ); +}); + +describe("LinearApi.readIssueDetail", () => { + const person = (displayName: string) => ({ displayName, avatarUrl: null }); + const history = (fields: Record) => ({ + actor: person("ada"), + botActor: null, + fromState: null, + toState: null, + fromAssignee: null, + toAssignee: null, + addedLabels: null, + removedLabels: null, + fromPriority: null, + toPriority: null, + fromTitle: null, + toTitle: null, + attachment: null, + ...fields, + }); + const detailNode = { + ...issueNode("ENG-7", "started"), + description: null, + priority: 2, + priorityLabel: "High", + createdAt: "2026-09-01T10:00:00.000Z", + branchName: "eng-7-fix-login", + assignee: person("ada"), + creator: person("karl"), + team: { + key: "ENG", + name: "Engineering", + states: { nodes: [{ position: 2 }, { position: 1 }, { position: 3 }] }, + }, + project: null, + labels: { nodes: [{ name: "Bug", color: "#ff0000" }] }, + attachments: { nodes: [] }, + comments: { + nodes: [ + { + id: "c1", + body: "This comment thread is synced to Slack.", + createdAt: "2026-09-03T10:00:00.000Z", + parent: null, + user: person("karl"), + botActor: null, + externalUser: null, + externalThread: { name: "Slack", displayName: "#design", url: "https://slack/1" }, + syncedWith: [{ service: "slack", metadata: { isFromSlack: false } }], + }, + { + id: "c2", + body: "Looks good", + createdAt: "2026-09-03T11:00:00.000Z", + parent: { id: "c1" }, + user: null, + botActor: { name: "Slack" }, + externalUser: { name: "Elad", avatarUrl: null }, + externalThread: null, + syncedWith: [{ service: "slack", metadata: { isFromSlack: true } }], + }, + ], + }, + history: { + nodes: [ + history({ + id: "h1", + createdAt: "2026-09-02T10:00:00.000Z", + fromState: { name: "Todo", color: "#e2e2e2", type: "unstarted", position: 0 }, + toState: { name: "In Progress", color: "#f2994a", type: "started", position: 2 }, + }), + history({ + id: "h2", + createdAt: "2026-09-04T10:00:00.000Z", + fromPriority: 3, + toPriority: 2, + }), + // A description edit: nothing the page can show. + history({ id: "h3", createdAt: "2026-09-05T10:00:00.000Z" }), + ], + }, + }; + + it.effect("merges comments and recorded changes, oldest first", () => + Effect.gen(function* () { + const test = fixture(() => ({ data: { issue: detailNode } })); + const detail = yield* (yield* test.api).readIssueDetail("lin_api_key", "ENG-7"); + expect(detail?.description).toBe(""); + expect(detail?.activity.map((entry) => [entry.id, entry.kind])).toEqual([ + ["created:uuid-ENG-7", "created"], + ["h1", "state"], + ["c1", "comment"], + ["c2", "comment"], + ["h2", "priority"], + ]); + expect(detail?.activity[0]?.actor?.name).toBe("karl"); + // The middle of three started states: its icon's pie is half full. + expect(detail?.activity[1]).toMatchObject({ to: { type: "started", progress: 0.5 } }); + expect(detail?.activity[2]).toMatchObject({ + via: null, + syncedThread: { source: "Slack", displayName: "#design" }, + }); + expect(detail?.activity[3]).toMatchObject({ actor: { name: "Elad" }, via: "Slack" }); + expect(detail?.activity[4]).toMatchObject({ from: "Medium", to: "High" }); + }), + ); + + it.effect("reads the same sidebar status from the issue page as from a sync", () => + Effect.gen(function* () { + const test = fixture((query) => + query.includes("i0:") ? { data: { i0: detailNode } } : { data: { issue: detailNode } }, + ); + const api = yield* test.api; + const synced = (yield* api.readIssues("lin_api_key", ["ENG-7"])).get("ENG-7"); + const detail = yield* api.readIssueDetail("lin_api_key", "ENG-7"); + expect(detail && issueOfDetail(detail)).toEqual(synced); + }), + ); + + it.effect("reads a missing issue as null", () => + Effect.gen(function* () { + const test = fixture(() => ({ errors: [{ message: "Entity not found: Issue" }] })); + expect(yield* (yield* test.api).readIssueDetail("lin_api_key", "ENG-404")).toBeNull(); + }), + ); +}); + +describe("makeSignedUploadCache", () => { + const upload = (signature: string) => + `![shot](https://uploads.linear.app/org/file-1?signature=${signature})`; + + it("keeps the first signed URL for a file until it nears expiry", () => { + const cache = makeSignedUploadCache(); + const hour = 60 * 60 * 1_000; + expect(cache.stabilize(upload("first"), 0)).toBe(upload("first")); + // A re-read signs the same file again; the page keeps its URL, so the image doesn't reload. + expect(cache.stabilize(upload("second"), hour)).toBe(upload("first")); + // Within the last hour of the 12-hour signature, the new one takes over. + expect(cache.stabilize(upload("third"), 11.5 * hour)).toBe(upload("third")); + }); +}); + +describe("reviewUrlFromMcpResponse", () => { + const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const answer = (text: string, isError = false) => + `event: message\ndata: ${encode({ jsonrpc: "2.0", id: 1, result: { isError, content: [{ type: "text", text }] } })}\n`; + + it("reads Linear's review page from a get_diff answer", () => { + expect( + reviewUrlFromMcpResponse( + answer(encode({ appUrl: "https://linear.app/acme/review/fix-login-d003c35c482c" })), + ), + ).toBe("https://linear.app/acme/review/fix-login-d003c35c482c"); + }); + + it("reads a missing review, an error, or anything unexpected as none", () => { + expect(reviewUrlFromMcpResponse(answer("Error: Diff not found", true))).toBeNull(); + expect(reviewUrlFromMcpResponse(answer(encode({ appUrl: "https://evil.example" })))).toBeNull(); + expect(reviewUrlFromMcpResponse("")).toBeNull(); + }); +}); diff --git a/apps/server/src/linear/LinearApi.ts b/apps/server/src/linear/LinearApi.ts new file mode 100644 index 000000000000..51280e9aee4b --- /dev/null +++ b/apps/server/src/linear/LinearApi.ts @@ -0,0 +1,601 @@ +import type { + LinearIssueActivity, + LinearIssueDetail, + LinearIssueDetailPerson, + LinearIssueState, + LinearIssueStateType, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; +/** Aliased lookups per request; Linear caps a single query's complexity. */ +const MAX_ISSUES_PER_REQUEST = 50; + +export class LinearApiError extends Schema.TaggedError()("LinearApiError", { + detail: Schema.String, +}) {} + +/** The team's started states, to place an issue's state among them. */ +const StartedStates = Schema.Struct({ + nodes: Schema.Array(Schema.Struct({ position: Schema.Number })), +}); + +const IssueNode = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + title: Schema.String, + url: Schema.String, + updatedAt: Schema.NullOr(Schema.String), + state: Schema.Struct({ + name: Schema.String, + type: Schema.String, + color: Schema.String, + position: Schema.optional(Schema.Number), + }), + assignee: Schema.NullOr(Schema.Struct({ displayName: Schema.String })), + team: Schema.optional(Schema.Struct({ states: StartedStates })), +}); + +const IssuesResponse = Schema.Struct({ + data: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.NullOr(IssueNode)))), + errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))), +}); +const decodeIssuesResponse = Schema.decodeUnknownEffect(IssuesResponse); + +export interface LinearIssue { + readonly id: string; + readonly identifier: string; + readonly title: string; + readonly url: string; + readonly updatedAt: string | null; + readonly state: LinearIssueState; + readonly assignee: string | null; +} + +const KNOWN_STATE_TYPES = new Set([ + "triage", + "backlog", + "unstarted", + "started", + "completed", + "canceled", +]); + +const ISSUE_FIELDS = `id identifier title url updatedAt state { name type color position } + assignee { displayName } team { states(filter: { type: { eq: "started" } }) { nodes { position } } }`; + +/** + * The issue's state, with how far along a started state is among the team's + * started states: Linear fills its state icon's pie by that much. + */ +function toLinearIssueState( + state: { + readonly name: string; + readonly type: string; + readonly color: string; + readonly position?: number | undefined; + }, + startedPositions: ReadonlyArray<{ readonly position: number }> | undefined, +): LinearIssueState { + const type = (KNOWN_STATE_TYPES.has(state.type) ? state.type : "unknown") as LinearIssueStateType; + const positions = (startedPositions ?? []) + .map((entry) => entry.position) + .toSorted((a, b) => a - b); + const index = state.position === undefined ? -1 : positions.indexOf(state.position); + return { + name: state.name, + type, + color: state.color, + ...(type === "started" && index >= 0 ? { progress: (index + 1) / (positions.length + 1) } : {}), + }; +} + +const Person = Schema.NullOr( + Schema.Struct({ + displayName: Schema.String, + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), + }), +); +const Bot = Schema.NullOr(Schema.Struct({ name: Schema.NullOr(Schema.String) })); +const StateChip = Schema.NullOr( + Schema.Struct({ + name: Schema.String, + color: Schema.String, + type: Schema.String, + position: Schema.optional(Schema.Number), + }), +); +const Named = Schema.NullOr(Schema.Array(Schema.Struct({ name: Schema.String }))); + +const IssueDetailNode = Schema.Struct({ + ...IssueNode.fields, + description: Schema.NullOr(Schema.String), + priority: Schema.Number, + priorityLabel: Schema.String, + createdAt: Schema.String, + branchName: Schema.String, + assignee: Person, + creator: Person, + team: Schema.Struct({ key: Schema.String, name: Schema.String, states: StartedStates }), + project: Schema.NullOr(Schema.Struct({ name: Schema.String })), + labels: Schema.Struct({ + nodes: Schema.Array(Schema.Struct({ name: Schema.String, color: Schema.String })), + }), + attachments: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + title: Schema.String, + subtitle: Schema.NullOr(Schema.String), + url: Schema.String, + sourceType: Schema.NullOr(Schema.String), + }), + ), + }), + comments: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + id: Schema.String, + body: Schema.String, + createdAt: Schema.String, + parent: Schema.NullOr(Schema.Struct({ id: Schema.String })), + user: Person, + botActor: Bot, + externalUser: Schema.NullOr( + Schema.Struct({ name: Schema.String, avatarUrl: Schema.NullOr(Schema.String) }), + ), + externalThread: Schema.NullOr( + Schema.Struct({ + name: Schema.NullOr(Schema.String), + displayName: Schema.NullOr(Schema.String), + url: Schema.NullOr(Schema.String), + }), + ), + syncedWith: Schema.NullOr( + Schema.Array( + Schema.Struct({ + service: Schema.String, + metadata: Schema.NullOr( + Schema.Struct({ isFromSlack: Schema.optional(Schema.Boolean) }), + ), + }), + ), + ), + }), + ), + }), + history: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + id: Schema.String, + createdAt: Schema.String, + actor: Person, + botActor: Bot, + fromState: StateChip, + toState: StateChip, + fromAssignee: Person, + toAssignee: Person, + addedLabels: Named, + removedLabels: Named, + fromPriority: Schema.NullOr(Schema.Number), + toPriority: Schema.NullOr(Schema.Number), + fromTitle: Schema.NullOr(Schema.String), + toTitle: Schema.NullOr(Schema.String), + attachment: Schema.NullOr(Schema.Struct({ title: Schema.String, url: Schema.String })), + }), + ), + }), +}); +type IssueDetailNode = typeof IssueDetailNode.Type; + +const IssueDetailResponse = Schema.Struct({ + data: Schema.optional(Schema.NullOr(Schema.Struct({ issue: Schema.NullOr(IssueDetailNode) }))), + errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))), +}); +const decodeIssueDetailResponse = Schema.decodeUnknownEffect(IssueDetailResponse); + +const PERSON_FIELDS = "displayName avatarUrl"; +/** Linear's largest page; an issue with more shows its latest and says so. */ +const ACTIVITY_PAGE_SIZE = 250; + +/** The page's sections, with the most recent comments and changes. */ +const ISSUE_DETAIL_QUERY = `query($id: String!) { issue(id: $id) { + ${ISSUE_FIELDS} description priority priorityLabel createdAt branchName + assignee { ${PERSON_FIELDS} } creator { ${PERSON_FIELDS} } + team { key name } project { name } labels { nodes { name color } } + attachments(first: 50) { nodes { title subtitle url sourceType } } + comments(last: ${ACTIVITY_PAGE_SIZE}) { nodes { id body createdAt parent { id } user { ${PERSON_FIELDS} } botActor { name } + externalUser { name avatarUrl } externalThread { name displayName url } + syncedWith { service metadata { ... on ExternalEntitySlackMetadata { isFromSlack } } } } } + history(last: ${ACTIVITY_PAGE_SIZE}) { nodes { id createdAt actor { ${PERSON_FIELDS} } botActor { name } + fromState { name color type position } toState { name color type position } fromAssignee { ${PERSON_FIELDS} } toAssignee { ${PERSON_FIELDS} } + addedLabels { name } removedLabels { name } fromPriority toPriority fromTitle toTitle attachment { title url } } } +} }`; + +const PRIORITY_LABELS = ["No priority", "Urgent", "High", "Medium", "Low"]; +const priorityLabel = (priority: number | null) => + priority === null ? null : (PRIORITY_LABELS[priority] ?? null); + +function person( + node: { readonly displayName: string; readonly avatarUrl?: string | null | undefined } | null, +): LinearIssueDetailPerson | null { + const name = node?.displayName.trim(); + return name ? { name, avatarUrl: node?.avatarUrl ?? null } : null; +} + +function actorOf( + user: IssueDetailNode["comments"]["nodes"][number]["user"], + bot: IssueDetailNode["comments"]["nodes"][number]["botActor"], +): LinearIssueDetailPerson | null { + return person(user) ?? (bot?.name?.trim() ? { name: bot.name.trim(), avatarUrl: null } : null); +} + +/** One history entry as the change it records; entries the page can't show are dropped. */ +function historyActivity( + entry: IssueDetailNode["history"]["nodes"][number], + startedPositions: IssueDetailNode["team"]["states"]["nodes"], +): LinearIssueActivity | null { + const base = { + id: entry.id, + createdAt: entry.createdAt, + actor: actorOf(entry.actor, entry.botActor), + }; + if (entry.fromState || entry.toState) { + return { + ...base, + kind: "state", + from: entry.fromState && toLinearIssueState(entry.fromState, startedPositions), + to: entry.toState && toLinearIssueState(entry.toState, startedPositions), + }; + } + if (entry.fromAssignee || entry.toAssignee) { + return { + ...base, + kind: "assignee", + from: person(entry.fromAssignee)?.name ?? null, + to: person(entry.toAssignee)?.name ?? null, + }; + } + if ((entry.addedLabels?.length ?? 0) > 0 || (entry.removedLabels?.length ?? 0) > 0) { + return { + ...base, + kind: "labels", + added: (entry.addedLabels ?? []).map((label) => label.name), + removed: (entry.removedLabels ?? []).map((label) => label.name), + }; + } + if (entry.fromPriority !== null || entry.toPriority !== null) { + return { + ...base, + kind: "priority", + from: priorityLabel(entry.fromPriority), + to: priorityLabel(entry.toPriority), + }; + } + if (entry.fromTitle !== null || entry.toTitle !== null) { + return { ...base, kind: "title", from: entry.fromTitle, to: entry.toTitle }; + } + if (entry.attachment) { + return { + ...base, + kind: "attachment", + title: entry.attachment.title, + url: entry.attachment.url, + }; + } + return null; +} + +const SYNC_SERVICE_NAMES: Record = { + slack: "Slack", + github: "GitHub", + jira: "Jira", + intercom: "Intercom", + zendesk: "Zendesk", +}; + +/** "Slack" for a comment written in Slack; a Linear comment merely mirrored there stays unmarked. */ +function syncedVia( + syncedWith: IssueDetailNode["comments"]["nodes"][number]["syncedWith"], +): string | null { + const origin = (syncedWith ?? []).find( + (entry) => entry.service !== "slack" || entry.metadata?.isFromSlack === true, + ); + return origin ? (SYNC_SERVICE_NAMES[origin.service] ?? origin.service) : null; +} + +/** Maps Linear's issue into the page's shape: comments and changes merged, oldest first. */ +function toLinearIssueDetail(node: IssueDetailNode): LinearIssueDetail { + const startedPositions = node.team.states.nodes; + const created: LinearIssueActivity = { + id: `created:${node.id}`, + createdAt: node.createdAt, + actor: person(node.creator), + kind: "created", + }; + const comments: Array = node.comments.nodes.map((comment) => ({ + id: comment.id, + createdAt: comment.createdAt, + actor: comment.externalUser + ? { name: comment.externalUser.name, avatarUrl: comment.externalUser.avatarUrl } + : actorOf(comment.user, comment.botActor), + kind: "comment", + body: comment.body, + parentId: comment.parent?.id ?? null, + via: syncedVia(comment.syncedWith), + syncedThread: comment.externalThread + ? { + source: comment.externalThread.name ?? "Slack", + displayName: comment.externalThread.displayName, + url: comment.externalThread.url, + } + : null, + })); + const changes = node.history.nodes.flatMap((entry) => { + const activity = historyActivity(entry, startedPositions); + return activity ? [activity] : []; + }); + return { + id: node.id, + identifier: node.identifier, + title: node.title, + url: node.url, + description: node.description ?? "", + state: toLinearIssueState(node.state, startedPositions), + priority: node.priority, + priorityLabel: node.priorityLabel, + assignee: person(node.assignee), + creator: person(node.creator), + team: node.team, + project: node.project?.name ?? null, + labels: node.labels.nodes, + branchName: node.branchName, + attachments: node.attachments.nodes, + createdAt: node.createdAt, + updatedAt: node.updatedAt ?? node.createdAt, + activityTruncated: + node.comments.nodes.length >= ACTIVITY_PAGE_SIZE || + node.history.nodes.length >= ACTIVITY_PAGE_SIZE, + // Linear leads with the creation even when older comments moved over with the issue. + activity: [ + created, + ...[...comments, ...changes].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + ], + }; +} + +const LINEAR_MCP_URL = "https://mcp.linear.app/mcp"; +const REVIEW_URL_TTL_MS = 10 * 60 * 1_000; +const GITHUB_PULL_REQUEST_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/u; + +const McpMessage = Schema.Struct({ + result: Schema.optional( + Schema.Struct({ + isError: Schema.optional(Schema.Boolean), + content: Schema.Array(Schema.Struct({ text: Schema.optional(Schema.String) })), + }), + ), +}); +const decodeMcpMessage = Schema.decodeUnknownOption(Schema.fromJsonString(McpMessage)); +const decodeDiff = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ appUrl: Schema.optional(Schema.String) })), +); + +/** The review page from an MCP `get_diff` answer, sent as JSON or as a server-sent event. */ +export function reviewUrlFromMcpResponse(body: string): string | null { + const data = body.split("\n").find((line) => line.startsWith("data: ")); + const message = Option.getOrNull(decodeMcpMessage(data ? data.slice(6) : body)); + const text = message?.result?.isError ? undefined : message?.result?.content[0]?.text; + const appUrl = text ? Option.getOrNull(decodeDiff(text))?.appUrl : undefined; + return appUrl?.startsWith("https://linear.app/") ? appUrl : null; +} + +const SIGNED_UPLOAD_TTL_SECONDS = 12 * 60 * 60; +/** Reused until this close to expiry; the page is re-read far more often than that. */ +const SIGNED_UPLOAD_RENEW_MS = 60 * 60 * 1_000; +const LINEAR_UPLOAD_URL = /https:\/\/uploads\.linear\.app\/[^\s)"'<>]+/gu; + +/** + * Linear signs upload URLs afresh on every read. Handing the page a new + * signature every 15 seconds would reload each image, so the first signed + * URL for a file is kept until it nears expiry. + */ +export function makeSignedUploadCache() { + const byPath = new Map(); + return { + stabilize(text: string, nowMs: number): string { + for (const [path, entry] of byPath) { + if (entry.expiresAt - nowMs < SIGNED_UPLOAD_RENEW_MS) byPath.delete(path); + } + return text.replace(LINEAR_UPLOAD_URL, (url) => { + const path = url.split("?")[0] ?? url; + const cached = byPath.get(path); + if (cached) return cached.url; + if (!url.includes("signature=")) return url; + byPath.set(path, { url, expiresAt: nowMs + SIGNED_UPLOAD_TTL_SECONDS * 1_000 }); + return url; + }); + }, + }; +} + +/** + * Reads issues by identifier ("ENG-123") or UUID; Linear's `issue(id:)` + * accepts both. Issues Linear does not return (deleted, no access) are + * absent from the result map. + */ +export const makeLinearApi = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const signedUploads = makeSignedUploadCache(); + + const request = ( + apiKey: string, + body: Record, + headers: Record = {}, + ) => + client + .execute( + HttpClientRequest.post(LINEAR_GRAPHQL_URL).pipe( + // Personal API keys go bare; OAuth access tokens need the Bearer scheme. + HttpClientRequest.setHeaders({ + ...headers, + Authorization: apiKey.startsWith("lin_oauth_") ? `Bearer ${apiKey}` : apiKey, + }), + HttpClientRequest.bodyJsonUnsafe(body), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout("15 seconds"), + Effect.mapError(() => new LinearApiError({ detail: "The Linear API request failed." })), + ); + + const reviewUrls = new Map(); + + /** + * Linear's review page for a pull request, through its MCP server: the only + * place it's exposed, and only to a person's sign-in (not API keys or apps). + */ + const readReviewUrl = (token: string, pullRequestUrl: string, nowMs: number) => { + const cached = reviewUrls.get(pullRequestUrl); + if (cached && cached.until > nowMs) return Effect.succeed(cached.url); + return client + .execute( + HttpClientRequest.post(LINEAR_MCP_URL).pipe( + HttpClientRequest.setHeaders({ + Authorization: `Bearer ${token}`, + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-03-26", + }), + HttpClientRequest.bodyJsonUnsafe({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "get_diff", arguments: { urlOrId: pullRequestUrl } }, + }), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + Effect.map(reviewUrlFromMcpResponse), + Effect.timeout("8 seconds"), + Effect.orElseSucceed(() => null), + Effect.tap((url) => + Effect.sync(() => + reviewUrls.set(pullRequestUrl, { url, until: nowMs + REVIEW_URL_TTL_MS }), + ), + ), + ); + }; + + /** One issue with its description, attachments, and activity; null if Linear doesn't return it. */ + const readIssueDetail = Effect.fn("LinearApi.readIssueDetail")(function* ( + apiKey: string, + reference: string, + options: { readonly reviews: boolean } = { reviews: false }, + ) { + const body = yield* request( + apiKey, + { query: ISSUE_DETAIL_QUERY, variables: { id: reference } }, + // Uploads need Linear sign-in; signed URLs let the page show images without it. + { "public-file-urls-expire-in": String(SIGNED_UPLOAD_TTL_SECONDS) }, + ); + const decoded = yield* decodeIssueDetailResponse(body).pipe( + Effect.mapError( + () => new LinearApiError({ detail: "Linear returned an unexpected response." }), + ), + ); + if (decoded.data === undefined || decoded.data === null) { + const message = decoded.errors?.[0]?.message ?? "Linear returned no data."; + // Linear answers a missing or inaccessible issue with an error and no data. + if (/not found|entity not found/iu.test(message)) return null; + return yield* new LinearApiError({ detail: message }); + } + if (decoded.data.issue === null) return null; + const detail = toLinearIssueDetail(decoded.data.issue); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const stable = (text: string) => signedUploads.stabilize(text, now); + const attachments = options.reviews + ? yield* Effect.forEach( + detail.attachments, + (attachment) => + GITHUB_PULL_REQUEST_URL.test(attachment.url) + ? readReviewUrl(apiKey, attachment.url, now).pipe( + Effect.map((reviewUrl) => ({ ...attachment, reviewUrl })), + ) + : Effect.succeed(attachment), + { concurrency: 4 }, + ) + : detail.attachments; + return { + ...detail, + attachments, + description: stable(detail.description), + activity: detail.activity.map((entry) => + entry.kind === "comment" ? { ...entry, body: stable(entry.body) } : entry, + ), + }; + }); + + const readChunk = Effect.fn("LinearApi.readChunk")(function* ( + apiKey: string, + references: ReadonlyArray, + ) { + const query = `query {${references + .map( + (reference, index) => + ` i${index}: issue(id: ${JSON.stringify(reference)}) { ${ISSUE_FIELDS} }`, + ) + .join("")} }`; + const body = yield* request(apiKey, { query }); + const decoded = yield* decodeIssuesResponse(body).pipe( + Effect.mapError( + () => new LinearApiError({ detail: "Linear returned an unexpected response." }), + ), + ); + // One missing issue fails only its alias; a response without data is an auth or query error. + if (!decoded.data) { + return yield* new LinearApiError({ + detail: decoded.errors?.[0]?.message ?? "Linear returned no data.", + }); + } + const issues = new Map(); + references.forEach((reference, index) => { + const node = decoded.data?.[`i${index}`]; + if (!node) return; + issues.set(reference, { + id: node.id, + identifier: node.identifier, + title: node.title, + url: node.url, + updatedAt: node.updatedAt, + state: toLinearIssueState(node.state, node.team?.states.nodes), + assignee: node.assignee?.displayName.trim() || null, + }); + }); + return issues; + }); + + const readIssues = Effect.fn("LinearApi.readIssues")(function* ( + apiKey: string, + references: ReadonlyArray, + ) { + const chunks: Array> = []; + for (let index = 0; index < references.length; index += MAX_ISSUES_PER_REQUEST) { + chunks.push(references.slice(index, index + MAX_ISSUES_PER_REQUEST)); + } + const results = yield* Effect.forEach(chunks, (chunk) => readChunk(apiKey, chunk), { + concurrency: 2, + }); + return new Map(results.flatMap((result) => [...result])); + }); + + return { readIssues, readIssueDetail }; +}); diff --git a/apps/server/src/linear/LinearCredentials.ts b/apps/server/src/linear/LinearCredentials.ts new file mode 100644 index 000000000000..dca15c9a30b6 --- /dev/null +++ b/apps/server/src/linear/LinearCredentials.ts @@ -0,0 +1,103 @@ +import { RelayApi } from "@t3tools/contracts/relay"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpApiClient from "effect/http-api/HttpApiClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_URL_SECRET } from "../cloud/config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + +/** Ask the relay again this long before the sign-in expires. */ +const RENEW_BEFORE_MS = 5 * 60 * 1_000; +/** How long a relay answer (including "no sign-in") is trusted. */ +const RECHECK_MS = 5 * 60 * 1_000; + +export interface LinearCredential { + /** + * `user`: the person's Linear sign-in, kept by the relay when they linked + * their account; it can also read reviews. `apiKey`: the key in settings. + */ + readonly kind: "user" | "apiKey"; + readonly token: string; +} + +/** + * How this environment reads Linear: the signed-in account of whoever linked + * it through Otter Connect when the relay holds one, else the API key. + */ +export const makeLinearCredentials = Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + let cached: { readonly token: string | null; readonly until: number } | null = null; + + const readSecretString = (name: string) => + secrets + .get(name) + .pipe( + Effect.map((bytes) => + Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, + ), + ); + + const fetchUserToken = Effect.gen(function* () { + const [url, environmentCredential] = yield* Effect.all([ + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if (!url || !environmentCredential) return null; + const environmentId = yield* serverEnvironment.getEnvironmentId; + const relay = yield* HttpApiClient.make(RelayApi, { + baseUrl: url, + transformClient: HttpClient.mapRequest( + HttpClientRequest.setHeader("authorization", `Bearer ${environmentCredential}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + const response = yield* relay.linearServer.getLinearUserToken({ params: { environmentId } }); + return response.token; + }); + + const userToken: Effect.Effect = Effect.gen(function* () { + const now = DateTime.toEpochMillis(yield* DateTime.now); + if (cached === null || cached.until <= now) { + // An older relay, no link, or a network error all mean: use the API key for now. + const user = yield* fetchUserToken.pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => null), + ); + const expiresAt = user?.expiresAt ? Date.parse(user.expiresAt) : Number.NaN; + cached = { + token: user?.accessToken ?? null, + until: Number.isFinite(expiresAt) + ? Math.min(expiresAt - RENEW_BEFORE_MS, now + RECHECK_MS) + : now + RECHECK_MS, + }; + } + return cached.token; + }); + + const apiKey: Effect.Effect = settings.getSettings.pipe( + Effect.map((value) => value.linear.apiKey || null), + Effect.orElseSucceed(() => null), + ); + + const current: Effect.Effect = Effect.gen(function* () { + const user = yield* userToken; + if (user !== null) return { kind: "user", token: user }; + const key = yield* apiKey; + return key === null ? null : { kind: "apiKey", token: key }; + }); + + return { + current, + /** Forget the cached sign-in, e.g. after Linear rejected it. */ + invalidate: Effect.sync(() => { + cached = null; + }), + }; +}); diff --git a/apps/server/src/linear/LinearIssueChanges.test.ts b/apps/server/src/linear/LinearIssueChanges.test.ts new file mode 100644 index 000000000000..07061da6f050 --- /dev/null +++ b/apps/server/src/linear/LinearIssueChanges.test.ts @@ -0,0 +1,99 @@ +// @effect-diagnostics nodeBuiltinImport:off - signs relay JWTs with a real ed25519 key pair. +import * as NodeCrypto from "node:crypto"; + +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, type LinearIssueChange } from "@t3tools/contracts"; +import type { RelayLinearIssueChangesProofPayload } from "@t3tools/contracts/relay"; +import { + RELAY_LINEAR_CHANGES_REQUEST_TYP, + RELAY_LINEAR_PROMPT_REQUEST_TYP, + signRelayJwt, +} from "@t3tools/shared/relayJwt"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { handleLinearIssueChangesRequest } from "./LinearIssueChanges.ts"; + +const RELAY_ISSUER = "https://relay.example.test"; +const ENVIRONMENT_ID = EnvironmentId.make("env-linear"); +const LINKED_USER = "user_linked"; + +const relayKeys = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +function makeContext() { + const delivered: Array> = []; + const context = { + secrets: { + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: (_name: string, bytes: number) => Effect.succeed(new Uint8Array(bytes)), + remove: () => Effect.void, + }, + environment: { getEnvironmentId: Effect.succeed(ENVIRONMENT_ID) }, + cloudMintPublicKey: Effect.succeed(relayKeys.publicKey), + relayIssuer: Effect.succeed(RELAY_ISSUER), + linkedCloudUserId: Effect.succeed(LINKED_USER), + isValidProofWindow: ({ iat, exp }: { iat: number; exp: number }) => exp > iat, + consumeReplayGuards: () => Effect.succeed(true), + deliver: (changes: ReadonlyArray) => + Effect.sync(() => void delivered.push(changes)), + }; + return { context, delivered }; +} + +const signChanges = ( + overrides: Partial = {}, + typ = RELAY_LINEAR_CHANGES_REQUEST_TYP, +) => + Effect.gen(function* () { + const now = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000); + return yield* signRelayJwt({ + privateKey: relayKeys.privateKey, + typ, + payload: { + iss: RELAY_ISSUER, + aud: `t3-env:${ENVIRONMENT_ID}`, + sub: LINKED_USER, + jti: NodeCrypto.randomUUID(), + iat: now, + exp: now + 120, + environmentId: ENVIRONMENT_ID, + nonce: NodeCrypto.randomUUID(), + scope: ["linear:changes"], + changes: [{ issueId: "issue-1", identifier: "ENG-1" }], + ...overrides, + } satisfies RelayLinearIssueChangesProofPayload, + }); + }); + +describe("handleLinearIssueChangesRequest", () => { + // Live clock: the proofs carry real timestamps. + it.live("hands relay-signed issue changes to the sync", () => + Effect.gen(function* () { + const { context, delivered } = makeContext(); + const proof = yield* signChanges(); + assert.deepEqual(yield* handleLinearIssueChangesRequest(context, { proof }), { ok: true }); + assert.deepEqual(delivered, [[{ issueId: "issue-1", identifier: "ENG-1" }]]); + }), + ); + + it.live("rejects another user's, another kind's, or another scope's request", () => + Effect.gen(function* () { + const { context, delivered } = makeContext(); + for (const proof of [ + yield* signChanges({ sub: "someone_else" }), + yield* signChanges({ scope: ["linear:prompt" as never] }), + yield* signChanges({}, RELAY_LINEAR_PROMPT_REQUEST_TYP), + ]) { + const error = yield* handleLinearIssueChangesRequest(context, { proof }).pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentHttpUnauthorizedError"); + } + assert.equal(delivered.length, 0); + }), + ); +}); diff --git a/apps/server/src/linear/LinearIssueChanges.ts b/apps/server/src/linear/LinearIssueChanges.ts new file mode 100644 index 000000000000..f13633fbc7c4 --- /dev/null +++ b/apps/server/src/linear/LinearIssueChanges.ts @@ -0,0 +1,48 @@ +import type { LinearIssueChange } from "@t3tools/contracts"; +import { + RelayLinearIssueChangesProofPayload, + type RelayLinearIssueChangesRequest, +} from "@t3tools/contracts/relay"; +import { RELAY_LINEAR_CHANGES_REQUEST_TYP } from "@t3tools/shared/relayJwt"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + type LinearRelayProofChecks, + verifyLinearRelayRequest, +} from "./LinearAgentSessionLauncher.ts"; + +const LINEAR_CHANGES_REPLAY_PREFIX = "cloud-linear-changes-"; +const decodeProof = Schema.decodeUnknownEffect(RelayLinearIssueChangesProofPayload); + +export interface LinearIssueChangesProofContext< + DeliverError, + DeliverRequirements, +> extends LinearRelayProofChecks { + /** `LinearIssueSyncReactor.publishIssueChanges` in production. */ + readonly deliver: ( + changes: ReadonlyArray, + ) => Effect.Effect; +} + +/** + * Handles the relay's signed "these issues changed in Linear" request, sent + * from the Otter app's webhook, so linked issues refresh without waiting for + * the next poll. + */ +export const handleLinearIssueChangesRequest = Effect.fn("environment.cloud.linearIssueChanges")( + function* ( + context: LinearIssueChangesProofContext, + request: RelayLinearIssueChangesRequest, + ) { + const { proof } = yield* verifyLinearRelayRequest(context, { + token: request.proof, + typ: RELAY_LINEAR_CHANGES_REQUEST_TYP, + scope: "linear:changes", + replayPrefix: LINEAR_CHANGES_REPLAY_PREFIX, + decode: decodeProof, + }); + yield* context.deliver(proof.changes); + return { ok: true }; + }, +); diff --git a/apps/server/src/linear/LinearIssueSyncReactor.ts b/apps/server/src/linear/LinearIssueSyncReactor.ts new file mode 100644 index 000000000000..40d6702c5b87 --- /dev/null +++ b/apps/server/src/linear/LinearIssueSyncReactor.ts @@ -0,0 +1,291 @@ +import { + CommandId, + type LinearIssueChange, + type LinearIssueDetail, + LinearIssueDetailError, + type ThreadLinearIssueLink, + type ThreadLinearIssueSnapshot, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { + ProjectionStoreV2, + type ProjectionThreadLinearIssues, +} from "../orchestration-v2/ProjectionStore.ts"; +import { forkParked } from "../serverActivation.ts"; +import { makeLinearApi, type LinearIssue } from "./LinearApi.ts"; +import { makeLinearCredentials } from "./LinearCredentials.ts"; + +/** + * Every linked issue is re-read on this cadence, in batches of 50 per request. + * Changes Linear's webhook reports through the relay trigger a sweep at once; + * the poll catches what they miss (no app install, no tunnel, a lost delivery). + */ +const SYNC_INTERVAL = "30 seconds"; + +interface LinkEntry { + readonly thread: ProjectionThreadLinearIssues; + readonly link: ThreadLinearIssueLink; +} + +/** Stable across identifier changes once the first sync has recorded the issue UUID. */ +function linkKey(link: ThreadLinearIssueLink): string { + return link.issueId ?? link.identifier; +} + +type SnapshotFields = Omit; + +function snapshotFieldsOf(issue: LinearIssue): SnapshotFields { + return { + identifier: issue.identifier, + title: issue.title, + state: issue.state, + assignee: issue.assignee, + updatedAt: issue.updatedAt, + }; +} + +export function issueOfDetail(detail: LinearIssueDetail): LinearIssue { + return { + id: detail.id, + identifier: detail.identifier, + title: detail.title, + url: detail.url, + updatedAt: detail.updatedAt, + state: detail.state, + assignee: detail.assignee?.name ?? null, + }; +} + +function snapshotChanged(link: ThreadLinearIssueLink, issue: LinearIssue): boolean { + const current = link.snapshot; + return ( + current === null || + link.issueId !== issue.id || + link.url !== issue.url || + current.identifier !== issue.identifier || + current.title !== issue.title || + current.state.name !== issue.state.name || + current.state.type !== issue.state.type || + current.state.color !== issue.state.color || + current.state.progress !== issue.state.progress || + current.assignee !== issue.assignee || + current.updatedAt !== issue.updatedAt + ); +} + +/** + * Keeps every thread ↔ Linear issue link's status current, the way + * PullRequestSyncReactor does for pull requests. Each sweep reads only the + * active threads that have links, reads their issues in batched GraphQL + * requests, and writes back only what changed. Does nothing until a Linear + * API key is set. + */ +export class LinearIssueSyncReactor extends Context.Service< + LinearIssueSyncReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + /** Sync now instead of waiting for the next sweep, e.g. after a new link. */ + readonly requestSync: Effect.Effect; + /** Issues changing in Linear, as the relay reports them; any issue in the workspace. */ + readonly issueChanges: Stream.Stream; + /** Takes the changes the relay pushed from Linear's webhook. */ + readonly publishIssueChanges: ( + changes: ReadonlyArray, + ) => Effect.Effect; + /** One issue for the Linear issue page, read live with this environment's API key. */ + readonly readIssueDetail: ( + reference: string, + ) => Effect.Effect; + } +>()("t3/linear/LinearIssueSyncReactor") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const engine = yield* OrchestratorV2; + const projections = yield* ProjectionStoreV2; + const api = yield* makeLinearApi; + const crypto = yield* Crypto.Crypto; + /** Every linked issue's UUID and identifier, so changes to other issues are ignored. */ + let linkedReferences = new Set(); + const credentials = yield* makeLinearCredentials; + const changes = yield* PubSub.unbounded(); + + const logSkipped = + (message: string, fields: Record) => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); + + // Untraced until there is something to sync, so idle polls write no spans + // and never ask the relay for a token. + const sweep = Effect.gen(function* () { + const threads = yield* projections.getThreadsWithLinearIssues(); + const groups = new Map>(); + for (const thread of threads) { + for (const link of thread.linearIssues ?? []) { + const key = linkKey(link); + const entries = groups.get(key) ?? []; + entries.push({ thread, link }); + groups.set(key, entries); + } + } + linkedReferences = new Set( + [...groups.values()].flatMap((entries) => + entries.flatMap(({ link }) => [link.identifier, ...(link.issueId ? [link.issueId] : [])]), + ), + ); + + if (groups.size > 0) yield* syncGroups(groups); + }); + + const syncGroups = Effect.fn("LinearIssueSyncReactor.sweep")(function* ( + groups: ReadonlyMap>, + ) { + const credential = yield* credentials.current; + if (credential === null) return; + const issues = yield* api.readIssues(credential.token, [...groups.keys()]); + const now = yield* DateTime.now; + for (const [key, entries] of groups) { + const issue = issues.get(key); + if (issue) yield* writeSnapshots(entries, issue, now); + } + }); + + /** Records a freshly read issue on every link to it that shows something else. */ + const writeSnapshots = Effect.fn("LinearIssueSyncReactor.writeSnapshots")(function* ( + entries: ReadonlyArray, + issue: LinearIssue, + now: DateTime.Utc, + ) { + const syncedAt = DateTime.formatIso(now); + for (const entry of entries) { + if (!snapshotChanged(entry.link, issue)) continue; + const uuid = yield* crypto.randomUUIDv4; + yield* engine + .dispatch({ + type: "thread.linear-issue-link.sync", + commandId: CommandId.make(`server:linear-sync:${entry.thread.id}:${uuid}`), + threadId: entry.thread.id, + identifier: entry.link.identifier, + issueId: issue.id, + url: issue.url, + snapshot: { ...snapshotFieldsOf(issue), syncedAt }, + }) + .pipe( + Effect.catchCause( + logSkipped("linear issue sync skipped", { + threadId: entry.thread.id, + key: linkKey(entry.link), + }), + ), + ); + } + }); + + const worker = yield* makeDrainableWorker(() => + sweep.pipe(Effect.catchCause(logSkipped("linear issue sync sweep failed", {}))), + ); + + const requestSync = worker.enqueue(undefined); + + const start: LinearIssueSyncReactor["Service"]["start"] = Effect.fn( + "LinearIssueSyncReactor.start", + )(function* () { + yield* forkParked( + Stream.runForEach(engine.streamDomainEvents, (event) => + event.type === "thread.pull-request-synced" && + (event.payload.linearIssues ?? []).some((link) => link.snapshot === null) + ? requestSync + : Effect.void, + ).pipe(Effect.catchCause(logSkipped("linear issue sync event stream failed", {}))), + ); + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced(SYNC_INTERVAL)), Effect.asVoid), + ); + yield* forkParked( + Stream.runForEach(Stream.fromPubSub(changes), (change) => + linkedReferences.has(change.issueId) || + (change.identifier !== null && linkedReferences.has(change.identifier)) + ? requestSync + : Effect.void, + ), + ); + }); + + /** Writes an issue read for its page onto the threads linked to it, like a sweep would. */ + const recordDetail = Effect.fn("LinearIssueSyncReactor.recordDetail")(function* ( + detail: LinearIssueDetail, + ) { + const threads = yield* projections.getThreadsWithLinearIssues(); + const entries = threads.flatMap((thread) => + (thread.linearIssues ?? []) + .filter((link) => link.issueId === detail.id || link.identifier === detail.identifier) + .map((link) => ({ thread, link })), + ); + yield* writeSnapshots(entries, issueOfDetail(detail), yield* DateTime.now); + }); + + const readIssueDetail: LinearIssueSyncReactor["Service"]["readIssueDetail"] = Effect.fn( + "LinearIssueSyncReactor.readIssueDetail", + )(function* (reference) { + const credential = yield* credentials.current; + if (credential === null) { + return yield* new LinearIssueDetailError({ + reason: "not_configured", + detail: + "Connect Linear or add a Linear API key in Settings → Connections → Linear to see issue details.", + }); + } + const detail = yield* api + .readIssueDetail(credential.token, reference, { reviews: credential.kind === "user" }) + .pipe( + Effect.mapError( + (error) => new LinearIssueDetailError({ reason: "unavailable", detail: error.detail }), + ), + ); + if (detail === null) { + return yield* new LinearIssueDetailError({ + reason: "not_found", + detail: `Linear has no issue ${reference}, or this API key can't see it.`, + }); + } + // The page and the sidebar show one read: this one also updates every linked thread. + if (linkedReferences.has(detail.id) || linkedReferences.has(detail.identifier)) { + yield* recordDetail(detail).pipe( + Effect.catchCause((cause) => + Effect.logWarning("linear issue sync skipped", { + key: detail.identifier, + cause: Cause.pretty(cause), + }), + ), + ); + } + return detail; + }); + + return { + start, + drain: worker.drain, + requestSync, + readIssueDetail, + issueChanges: Stream.fromPubSub(changes), + publishIssueChanges: (published) => PubSub.publishAll(changes, published).pipe(Effect.asVoid), + } satisfies LinearIssueSyncReactor["Service"]; +}); + +export const layer = Layer.effect(LinearIssueSyncReactor, make); diff --git a/apps/server/src/linear/LinearSessionMirror.ts b/apps/server/src/linear/LinearSessionMirror.ts new file mode 100644 index 000000000000..83d3b1b1616b --- /dev/null +++ b/apps/server/src/linear/LinearSessionMirror.ts @@ -0,0 +1,128 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { RelayApi, type RelayLinearActivity } from "@t3tools/contracts/relay"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpApiClient from "effect/http-api/HttpApiClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_URL_SECRET } from "../cloud/config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { forkParked } from "../serverActivation.ts"; +import { linearAgentSessionIdOf } from "./LinearAgentSessionLauncher.ts"; +import { LinearSessionActivityMirror } from "./linearSessionActivities.ts"; + +/** The relay accepts this many activities per request. */ +const ACTIVITIES_PER_REQUEST = 20; + +interface PendingActivities { + readonly threadId: ThreadId; + readonly agentSessionId: string; + readonly activities: ReadonlyArray; +} + +/** + * Mirrors threads started from a Linear agent session back into that + * session, through the relay that holds the workspace's Linear token. + * Live only: steps that happen while the server is down aren't replayed. + */ +export class LinearSessionMirror extends Context.Service< + LinearSessionMirror, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/linear/LinearSessionMirror") {} + +const make = Effect.gen(function* () { + const engine = yield* OrchestratorV2; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const mirrors = new Map(); + + const readSecretString = (name: string) => + secrets + .get(name) + .pipe( + Effect.map((bytes) => + Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, + ), + ); + + const post = Effect.fn("LinearSessionMirror.post")(function* (pending: PendingActivities) { + const [url, environmentCredential] = yield* Effect.all([ + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if (!url || !environmentCredential) { + return yield* Effect.logDebug("linear activity skipped; not linked to a relay"); + } + const environmentId = yield* serverEnvironment.getEnvironmentId; + const relay = yield* HttpApiClient.make(RelayApi, { + baseUrl: url, + transformClient: HttpClient.mapRequest( + HttpClientRequest.setHeader("authorization", `Bearer ${environmentCredential}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + for (let start = 0; start < pending.activities.length; start += ACTIVITIES_PER_REQUEST) { + yield* relay.linearServer.postLinearActivities({ + params: { environmentId, agentSessionId: pending.agentSessionId }, + payload: { + threadId: pending.threadId, + activities: pending.activities.slice(start, start + ACTIVITIES_PER_REQUEST), + }, + }); + } + }); + + // One queue, so a session's activities reach Linear in the order they happened. + const worker = yield* makeDrainableWorker((pending: PendingActivities) => + post(pending).pipe( + Effect.catchCause((cause) => + Effect.logWarning("linear activity mirror failed", { + threadId: pending.threadId, + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const start: LinearSessionMirror["Service"]["start"] = Effect.fn("LinearSessionMirror.start")( + function* () { + yield* forkParked( + Stream.runForEach(engine.streamDomainEvents, (event) => { + const agentSessionId = linearAgentSessionIdOf(event.threadId); + if (agentSessionId === null) return Effect.void; + if (event.type === "thread.deleted") { + mirrors.delete(event.threadId); + return Effect.void; + } + let mirror = mirrors.get(event.threadId); + if (mirror === undefined) { + mirror = new LinearSessionActivityMirror(); + mirrors.set(event.threadId, mirror); + } + const activities = mirror.apply(event); + return activities.length === 0 + ? Effect.void + : worker + .enqueue({ threadId: event.threadId, agentSessionId, activities }) + .pipe(Effect.asVoid); + }), + ); + }, + ); + + return { start, drain: worker.drain } satisfies LinearSessionMirror["Service"]; +}); + +export const layer = Layer.effect(LinearSessionMirror, make); diff --git a/apps/server/src/linear/linearSessionActivities.test.ts b/apps/server/src/linear/linearSessionActivities.test.ts new file mode 100644 index 000000000000..8fec2b395fd5 --- /dev/null +++ b/apps/server/src/linear/linearSessionActivities.test.ts @@ -0,0 +1,84 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; + +import { LinearSessionActivityMirror } from "./linearSessionActivities.ts"; + +// Only the fields the mirror reads; the rest of a domain event is irrelevant here. +const item = (fields: Record) => + ({ type: "turn-item.updated", payload: fields }) as unknown as OrchestrationV2DomainEvent; +const run = (id: string, status: string) => + ({ type: "run.updated", payload: { id, status } }) as unknown as OrchestrationV2DomainEvent; + +describe("LinearSessionActivityMirror", () => { + it("posts steps once they finish and ends a run with its last message", () => { + const mirror = new LinearSessionActivityMirror(); + const posted = [ + item({ id: "a1", type: "assistant_message", status: "running", text: "Look" }), + item({ id: "a1", type: "assistant_message", status: "completed", text: "Looking around." }), + item({ id: "c1", type: "command_execution", status: "running", input: "vp test" }), + item({ + id: "c1", + type: "command_execution", + status: "failed", + input: "vp test", + exitCode: 1, + }), + item({ + id: "c1", + type: "command_execution", + status: "failed", + input: "vp test", + exitCode: 1, + }), + item({ id: "f1", type: "file_change", status: "completed", fileName: "a.ts", additions: 3 }), + item({ id: "a2", type: "assistant_message", status: "completed", text: "Fixed it." }), + run("run-1", "completed"), + run("run-1", "completed"), + ].flatMap((event) => mirror.apply(event)); + + assert.deepStrictEqual(posted, [ + { type: "thought", body: "Looking around." }, + { type: "action", action: "Ran", parameter: "vp test", result: "Exit code 1" }, + { type: "action", action: "Edited", parameter: "a.ts", result: "+3" }, + { type: "response", body: "Fixed it." }, + ]); + }); + + it("asks once per question and never echoes messages that came from Linear", () => { + const mirror = new LinearSessionActivityMirror(); + const question = item({ + id: "q1", + type: "user_input_request", + status: "waiting", + questions: [{ question: "Which database?" }], + }); + const posted = [ + item({ id: "u0", type: "user_message", messageId: "linear-session:s1", text: "Issue" }), + item({ id: "u1", type: "user_message", messageId: "linear-prompt:act-1", text: "Use pg" }), + item({ id: "u2", type: "user_message", messageId: "message-9", text: "Also add tests" }), + question, + question, + run("run-1", "failed"), + ].flatMap((event) => mirror.apply(event)); + + assert.deepStrictEqual(posted, [ + { type: "thought", body: "Message sent in Otter Code:\n\nAlso add tests" }, + { type: "elicitation", body: "Which database?\n\nAnswer in Otter Code to continue." }, + { type: "error", body: "The run failed. Open the thread in Otter Code for details." }, + ]); + }); + + it("keeps what an interrupted run said without calling it finished", () => { + const mirror = new LinearSessionActivityMirror(); + const posted = [ + item({ id: "a1", type: "assistant_message", status: "completed", text: "Halfway." }), + run("run-1", "interrupted"), + run("run-2", "completed"), + ].flatMap((event) => mirror.apply(event)); + + assert.deepStrictEqual(posted, [ + { type: "thought", body: "Halfway." }, + { type: "response", body: "Done." }, + ]); + }); +}); diff --git a/apps/server/src/linear/linearSessionActivities.ts b/apps/server/src/linear/linearSessionActivities.ts new file mode 100644 index 000000000000..1c5836782c39 --- /dev/null +++ b/apps/server/src/linear/linearSessionActivities.ts @@ -0,0 +1,167 @@ +import type { OrchestrationV2DomainEvent, OrchestrationV2TurnItem } from "@t3tools/contracts"; +import type { RelayLinearActivity } from "@t3tools/contracts/relay"; + +const MAX_BODY_CHARS = 8_000; +const MAX_PARAMETER_CHARS = 2_000; + +/** Message ids the mirror never echoes: the launch prompt and replies that came from Linear. */ +const LINEAR_ORIGIN_MESSAGE_PREFIXES = ["linear-session:", "linear-prompt:"]; + +const truncate = (text: string, max: number) => + text.length <= max ? text : `${text.slice(0, max - 1)}…`; + +const FINISHED_ITEM_STATUSES = new Set(["completed", "failed", "cancelled", "interrupted"]); + +type TurnItem = Extract< + OrchestrationV2TurnItem, + { readonly type: Type } +>; + +function actionFor(item: OrchestrationV2TurnItem): RelayLinearActivity | null { + switch (item.type) { + case "command_execution": + return { + type: "action", + action: "Ran", + parameter: truncate(item.input, MAX_PARAMETER_CHARS), + ...(item.exitCode !== undefined && item.exitCode !== 0 + ? { result: `Exit code ${item.exitCode}` } + : {}), + }; + case "file_change": { + const stats = [ + item.additions === undefined ? null : `+${item.additions}`, + item.deletions === undefined ? null : `-${item.deletions}`, + ].filter((part) => part !== null); + return { + type: "action", + action: "Edited", + parameter: truncate(item.fileName, MAX_PARAMETER_CHARS), + ...(stats.length > 0 ? { result: stats.join(" ") } : {}), + }; + } + case "web_search": + return { + type: "action", + action: "Searched the web", + parameter: truncate((item.patterns ?? []).join(", "), MAX_PARAMETER_CHARS), + }; + case "file_search": + return { + type: "action", + action: "Searched files", + parameter: truncate(item.pattern ?? "", MAX_PARAMETER_CHARS), + }; + case "dynamic_tool": + return { + type: "action", + action: truncate(item.toolName ?? item.title ?? "Used a tool", 200), + parameter: item.toolName && item.title ? truncate(item.title, MAX_PARAMETER_CHARS) : "", + }; + default: + return null; + } +} + +function questionFor(item: TurnItem<"approval_request"> | TurnItem<"user_input_request">) { + const asked = + item.type === "approval_request" + ? `Otter Code needs approval: ${item.prompt ?? item.title ?? item.requestKind}` + : item.questions.map((question) => question.question).join("\n\n"); + return truncate(`${asked}\n\nAnswer in Otter Code to continue.`, MAX_BODY_CHARS); +} + +/** + * Turns one delegated thread's events into Linear agent activities. Linear + * can't edit an activity once posted and shows finished steps, so this posts + * each step once it's done: tool calls as actions, questions as elicitations, + * assistant messages as thoughts. The latest assistant message is held back + * so the one that ends a run becomes its response. + */ +export class LinearSessionActivityMirror { + private readonly seen = new Set(); + private held: string | null = null; + + apply(event: OrchestrationV2DomainEvent): ReadonlyArray { + if (event.type === "run.updated") return this.applyRun(event.payload); + if (event.type === "turn-item.updated") return this.applyItem(event.payload); + return []; + } + + private once(key: string): boolean { + if (this.seen.has(key)) return false; + this.seen.add(key); + return true; + } + + private flushHeld(): Array { + const held = this.held; + this.held = null; + return held === null ? [] : [{ type: "thought", body: held }]; + } + + private applyItem(item: OrchestrationV2TurnItem): ReadonlyArray { + switch (item.type) { + case "assistant_message": { + const text = item.text.trim(); + if (item.status !== "completed" || text.length === 0 || !this.once(item.id)) return []; + const flushed = this.flushHeld(); + this.held = truncate(text, MAX_BODY_CHARS); + return flushed; + } + case "user_message": { + const text = item.text.trim(); + if ( + text.length === 0 || + LINEAR_ORIGIN_MESSAGE_PREFIXES.some((prefix) => item.messageId.startsWith(prefix)) || + !this.once(item.id) + ) { + return []; + } + return [ + ...this.flushHeld(), + { + type: "thought", + body: truncate(`Message sent in Otter Code:\n\n${text}`, MAX_BODY_CHARS), + }, + ]; + } + case "approval_request": + case "user_input_request": + if (FINISHED_ITEM_STATUSES.has(item.status) || !this.once(item.id)) return []; + return [...this.flushHeld(), { type: "elicitation", body: questionFor(item) }]; + default: { + const action = actionFor(item); + if (action === null || !FINISHED_ITEM_STATUSES.has(item.status) || !this.once(item.id)) { + return []; + } + return [...this.flushHeld(), action]; + } + } + } + + private applyRun( + run: Extract["payload"], + ): ReadonlyArray { + switch (run.status) { + case "completed": { + if (!this.once(`run:${run.id}`)) return []; + const response = this.held ?? "Done."; + this.held = null; + return [{ type: "response", body: response }]; + } + case "failed": + if (!this.once(`run:${run.id}`)) return []; + return [ + ...this.flushHeld(), + { type: "error", body: "The run failed. Open the thread in Otter Code for details." }, + ]; + // A steering restart also interrupts a run, so only keep what it said. + case "interrupted": + case "cancelled": + return this.flushHeld(); + default: + return []; + } + } +} diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0e2b52696712..b6868bd94ffe 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -51,6 +51,8 @@ import * as WorktreeMcpService from "./WorktreeMcpService.ts"; import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; import * as DeviceHandlers from "./toolkits/device/handlers.ts"; +import * as LinearIssuesHandlers from "./toolkits/linearIssues/handlers.ts"; +import { LinearIssuesToolkit } from "./toolkits/linearIssues/tools.ts"; import { DeviceScreenshotTool, DeviceScreenshotToolkit, @@ -848,6 +850,11 @@ const layerDeviceStandardToolkitRegistration = toolkitRegistration( DeviceHandlers.layerStandard, ); +const layerLinearIssuesToolkitRegistration = toolkitRegistration( + LinearIssuesToolkit, + LinearIssuesHandlers.layer, +); + const layerDeviceScreenshotRegistration = imageToolRegistration( registerDeviceScreenshot(), DeviceHandlers.layerScreenshot, @@ -876,6 +883,7 @@ export const layer = Layer.mergeAll( layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, + layerLinearIssuesToolkitRegistration, layerDeviceToolkit, layerHtmlToolkit, ).pipe(Layer.provideMerge(layerMcpTransport)); diff --git a/apps/server/src/mcp/toolkits/linearIssues/handlers.ts b/apps/server/src/mcp/toolkits/linearIssues/handlers.ts new file mode 100644 index 000000000000..2010cbd0656a --- /dev/null +++ b/apps/server/src/mcp/toolkits/linearIssues/handlers.ts @@ -0,0 +1,113 @@ +import { + CommandId, + linearIssueUrl, + parseLinearIssueReference, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; + +import { OrchestratorV2 } from "../../../orchestration-v2/Orchestrator.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { + LinearIssueLinkFailedError, + LinearIssueNoCallingThreadError, + LinearIssueReferenceInvalidError, + LinearIssueThreadNotFoundError, + LinearIssuesToolkit, + type LinearIssueTargetInput, +} from "./tools.ts"; + +const make = Effect.gen(function* () { + const engine = yield* OrchestratorV2; + const crypto = yield* Crypto.Crypto; + + const commandId = (tag: string, threadId: ThreadId) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)), + ); + + const requireThread = Effect.fn("LinearIssuesToolkit.requireThread")(function* () { + const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); + if (scope.thread === undefined) return yield* new LinearIssueNoCallingThreadError({}); + const threadId = scope.thread.threadId; + const thread = yield* engine + .getThreadShell(threadId) + .pipe(Effect.mapError((cause) => new LinearIssueLinkFailedError({ cause }))); + if (!thread) return yield* new LinearIssueThreadNotFoundError({ threadId }); + return thread; + }); + + const resolve = (input: LinearIssueTargetInput) => { + const parsed = parseLinearIssueReference(input.issue); + return parsed === null + ? Effect.fail(new LinearIssueReferenceInvalidError({})) + : Effect.succeed(parsed); + }; + + const dispatchFailure = (cause: Cause.Cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail(new LinearIssueLinkFailedError({ cause })); + + return { + link_linear_issue: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const thread = yield* requireThread(); + const target = yield* resolve(input); + if ((thread.linearIssues ?? []).some((link) => link.identifier === target.identifier)) { + return { identifier: target.identifier, alreadyLinked: true }; + } + yield* engine + .dispatch({ + type: "thread.linear-issue.link", + commandId: yield* commandId("mcp-linear-link", thread.id), + threadId: thread.id, + identifier: target.identifier, + url: target.url ?? linearIssueUrl(target.identifier), + source: "agent", + }) + .pipe(Effect.catchCause(dispatchFailure)); + return { identifier: target.identifier, alreadyLinked: false }; + }), + ), + unlink_linear_issue: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const thread = yield* requireThread(); + const target = yield* resolve(input); + if (!(thread.linearIssues ?? []).some((link) => link.identifier === target.identifier)) { + return { identifier: target.identifier, wasLinked: false }; + } + yield* engine + .dispatch({ + type: "thread.linear-issue.unlink", + commandId: yield* commandId("mcp-linear-unlink", thread.id), + threadId: thread.id, + identifier: target.identifier, + }) + .pipe(Effect.catchCause(dispatchFailure)); + return { identifier: target.identifier, wasLinked: true }; + }), + ), + list_thread_linear_issues: McpToolAccess.readsAsCaller(() => + Effect.gen(function* () { + const thread = yield* requireThread(); + return { + issues: (thread.linearIssues ?? []).map((link) => ({ + identifier: link.identifier, + url: link.url, + source: link.source, + title: link.snapshot?.title ?? null, + state: link.snapshot?.state.name ?? null, + stateType: link.snapshot?.state.type ?? null, + })), + }; + }), + ), + } satisfies McpToolAccess.Handlers; +}); + +export const layer = McpToolAccess.toLayer(LinearIssuesToolkit, make); diff --git a/apps/server/src/mcp/toolkits/linearIssues/tools.ts b/apps/server/src/mcp/toolkits/linearIssues/tools.ts new file mode 100644 index 000000000000..0ae31729497a --- /dev/null +++ b/apps/server/src/mcp/toolkits/linearIssues/tools.ts @@ -0,0 +1,145 @@ +import { + LinearIssueStateType, + McpCapabilityUnavailableError, + OrchestratorMcpFailure, + ThreadLinearIssueLinkSource, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/ai/Tool"; +import * as Toolkit from "effect/ai/Toolkit"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { OrchestratorV2 } from "../../../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + OrchestratorV2, + ThreadManagementService.ThreadManagementService, +]; + +export const LinearIssueTargetInput = Schema.Struct({ + issue: TrimmedNonEmptyString.annotate({ + description: + "The issue identifier, for example ENG-123, or its linear.app URL, for example https://linear.app/acme/issue/ENG-123/title.", + }), +}); +export type LinearIssueTargetInput = typeof LinearIssueTargetInput.Type; + +export class LinearIssueReferenceInvalidError extends Schema.TaggedError()( + "LinearIssueReferenceInvalidError", + {}, +) { + override get message(): string { + return "Pass a Linear issue identifier such as ENG-123 or a linear.app issue URL."; + } +} + +export class LinearIssueThreadNotFoundError extends Schema.TaggedError()( + "LinearIssueThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +/** These tools act on the calling thread, which an agent signed in through MCP OAuth lacks. */ +export class LinearIssueNoCallingThreadError extends Schema.TaggedError()( + "LinearIssueNoCallingThreadError", + {}, +) { + override get message(): string { + return "Linear issue tools only work from inside an Otter Code thread."; + } +} + +export class LinearIssueLinkFailedError extends Schema.TaggedError()( + "LinearIssueLinkFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not update the thread's Linear issues."; + } +} + +export const LinearIssueToolError = Schema.Union([ + OrchestratorMcpFailure, + McpCapabilityUnavailableError, + LinearIssueReferenceInvalidError, + LinearIssueThreadNotFoundError, + LinearIssueNoCallingThreadError, + LinearIssueLinkFailedError, +]); + +export const LinkLinearIssueResult = Schema.Struct({ + identifier: Schema.String, + alreadyLinked: Schema.Boolean, +}); + +export const UnlinkLinearIssueResult = Schema.Struct({ + identifier: Schema.String, + wasLinked: Schema.Boolean, +}); + +export const ThreadLinearIssueEntry = Schema.Struct({ + identifier: Schema.String, + url: Schema.String, + source: ThreadLinearIssueLinkSource, + title: Schema.NullOr(Schema.String), + state: Schema.NullOr(Schema.String), + stateType: Schema.NullOr(LinearIssueStateType), +}); +export type ThreadLinearIssueEntry = typeof ThreadLinearIssueEntry.Type; + +export const ListThreadLinearIssuesResult = Schema.Struct({ + issues: Schema.Array(ThreadLinearIssueEntry), +}); + +const LinkLinearIssueTool = Tool.make("link_linear_issue", { + description: + "Link a Linear issue to this thread so Otter Code shows its status beside the thread. Link the issue you are working on when the user names one. Linking an already-linked issue succeeds with alreadyLinked=true.", + parameters: LinearIssueTargetInput, + success: LinkLinearIssueResult, + failure: LinearIssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Link Linear issue to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnlinkLinearIssueTool = Tool.make("unlink_linear_issue", { + description: + "Remove a Linear issue link from this thread. Unlinking an issue that is not linked succeeds with wasLinked=false.", + parameters: LinearIssueTargetInput, + success: UnlinkLinearIssueResult, + failure: LinearIssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Unlink Linear issue from thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListThreadLinearIssuesTool = Tool.make("list_thread_linear_issues", { + description: + "List the Linear issues linked to this thread with their last known status. Status is null until Otter Code has synced the issue.", + success: ListThreadLinearIssuesResult, + failure: LinearIssueToolError, + dependencies, +}) + .annotate(Tool.Title, "List thread Linear issues") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const LinearIssuesToolkit = Toolkit.make( + LinkLinearIssueTool, + UnlinkLinearIssueTool, + ListThreadLinearIssuesTool, +); diff --git a/apps/server/src/nodeScript.test.ts b/apps/server/src/nodeScript.test.ts new file mode 100644 index 000000000000..b5b49b82dd00 --- /dev/null +++ b/apps/server/src/nodeScript.test.ts @@ -0,0 +1,35 @@ +// @effect-diagnostics nodeBuiltinImport:off - runs a CommonJS script from the filesystem. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { runNodeScript } from "./nodeScript.ts"; + +describe("runNodeScript", () => { + const originalArgv = process.argv; + afterEach(() => { + process.argv = originalArgv; + }); + + it("runs the script with the argv Node would give it", () => { + const directory = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-node-script-")), + ); + const script = NodePath.join(directory, "server.cjs"); + const output = NodePath.join(directory, "argv.json"); + NodeFS.writeFileSync( + script, + `require("node:fs").writeFileSync(${JSON.stringify(output)}, JSON.stringify({ argv: process.argv.slice(1), filename: __filename }));`, + ); + + runNodeScript([script, "--stdio"]); + + expect(JSON.parse(NodeFS.readFileSync(output, "utf8"))).toEqual({ + argv: [script, "--stdio"], + filename: script, + }); + NodeFS.rmSync(directory, { recursive: true, force: true }); + }); +}); diff --git a/apps/server/src/nodeScript.ts b/apps/server/src/nodeScript.ts new file mode 100644 index 000000000000..2ab8564dead7 --- /dev/null +++ b/apps/server/src/nodeScript.ts @@ -0,0 +1,23 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Runs before any Effect runtime is built, so it stays on Node built-ins. +import * as NodeModule from "node:module"; +import * as NodeSea from "node:sea"; + +/** + * How to run a JavaScript file shipped with T3 (tsserver, Pyright) on T3's own runtime. + * The single-executable build always starts its embedded entry and ignores a script + * argument, so it goes through `t3 __run-node-script`, which loads the script itself. + */ +export function nodeScriptCommand(script: string, args: ReadonlyArray) { + return { + command: process.execPath, + args: NodeSea.isSea() ? ["__run-node-script", script, ...args] : [script, ...args], + }; +} + +/** Entry for `t3 __run-node-script