diff --git a/.agents/skills/fork-repair/SKILL.md b/.agents/skills/fork-repair/SKILL.md index 3f81f795bd8c8..73156b2909a72 100644 --- a/.agents/skills/fork-repair/SKILL.md +++ b/.agents/skills/fork-repair/SKILL.md @@ -24,7 +24,7 @@ options. | `uv run .agents/skills/fork-repair/scripts/worktree.py create sync` | Create the worktree `fork-sync-` on branch `fork-sync/` from `origin/main`, and install. | | `uv run .agents/skills/fork-repair/scripts/worktree.py create fix ` | Create the worktree `fork-fix-` on branch `fork-fix/-` from `origin/main`, and install. | | `uv run .agents/skills/fork-repair/scripts/worktree.py remove ` | Remove a repair worktree and its local branch. It prints the uncommitted files and unpushed commits that it discards. | -| `uv run .agents/skills/fork-repair/scripts/lockfile.py` | In a merge with a `pnpm-lock.yaml` conflict: rebuild the fork lockfile, and stage it when its integrity changes match the merged side. | +| `uv run .agents/skills/fork-repair/scripts/lockfile.py` | In a merge with a `pnpm-lock.yaml` conflict: rebuild the upstream lockfile, and stage it when it holds no package version that neither side locked. | | `uv run .agents/skills/fork-repair/scripts/lockfile.py --rebuild` | The same, when git merged `pnpm-lock.yaml` without a conflict but `vp i --frozen-lockfile` fails with `ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY`. | | `uv run .agents/skills/fork-repair/scripts/route_tree.py` | In a merge that conflicts in `apps/web/src/routeTree.gen.ts` or moves route files: regenerate the route tree as the Vite router plugin does, and stage it. | | `uv run .agents/skills/fork-repair/scripts/shared_files.py` | List the files that upstream and the fork both changed since the merge base, and write the upstream diff of each file to its own file. | diff --git a/.agents/skills/fork-repair/scripts/lockfile.py b/.agents/skills/fork-repair/scripts/lockfile.py index 7f110cde3cb57..8770fad330af7 100644 --- a/.agents/skills/fork-repair/scripts/lockfile.py +++ b/.agents/skills/fork-repair/scripts/lockfile.py @@ -4,10 +4,16 @@ # /// """Resolves a pnpm-lock.yaml merge conflict. -It takes the fork lockfile, rebuilds it from the merged package.json files with -`CI=1 vp i --lockfile-only`, and compares the package integrity lines that changed with the ones -that the merged side changed. It stages the lockfile only when both sets are equal. The other -lockfile lines differ between the two sides on purpose: the fork resolves other peer versions. +It takes the upstream lockfile and rebuilds it from the merged package.json files with +`CI=1 vp i --lockfile-only`. pnpm keeps each upstream resolution that still fits and resolves only +the fork dependencies. It stages the lockfile only when each package integrity in it comes from the +upstream or the fork lockfile, so the rebuild picked no new package version. A start from the fork +lockfile instead resolves each range that upstream changed to the newest version of today, not to +the version that upstream locked. + +Limits: MERGE_HEAD must be the upstream side (run it in the sync worktree, not in a merge of fork +main into the sync branch). A fork-only dependency resolves fresh, so a new release of one stops +the script for a manual check. With --rebuild, it also runs when git merged the lockfile without a conflict, but the text merge broke it (`vp i --frozen-lockfile` fails with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY). @@ -23,20 +29,19 @@ INTEGRITY = "resolution: {integrity:" -def integrity_changes(diff: str) -> tuple[frozenset[str], frozenset[str]]: - """The removed and the added integrity lines of a unified diff, without the +/- mark.""" - removed = {line[1:].strip() for line in diff.splitlines() if line.startswith("-") and INTEGRITY in line} - added = {line[1:].strip() for line in diff.splitlines() if line.startswith("+") and INTEGRITY in line} - return frozenset(removed), frozenset(added) - - -Changes = tuple[frozenset[str], frozenset[str]] +def integrities(lockfile: str) -> frozenset[str]: + """The integrity hashes of a lockfile, one for each resolved package version.""" + return frozenset( + line.split(INTEGRITY, 1)[1].split(",", 1)[0].strip().rstrip("}").strip() + for line in lockfile.splitlines() + if INTEGRITY in line + ) -def matches_merged_side(ours: Changes, theirs: Changes) -> bool: - """The same additions, and removals that upstream also made. A package that upstream dropped - stays when a fork dependency still uses it, at its old integrity.""" - return ours[1] == theirs[1] and ours[0] <= theirs[0] +def new_integrities(rebuilt: str, *sides: str) -> frozenset[str]: + """The integrities of the rebuilt lockfile that no side has: package versions the rebuild picked.""" + known = frozenset().union(*(integrities(side) for side in sides)) + return integrities(rebuilt) - known def main() -> None: @@ -50,27 +55,25 @@ def main() -> None: if LOCKFILE not in unmerged and not args.rebuild: common.fail(f"{LOCKFILE} has no conflict.") - # A lockfile without a conflict has no "ours" stage; HEAD holds the fork's copy. - common.git("checkout", *(["--ours"] if LOCKFILE in unmerged else ["HEAD", "--"]), LOCKFILE, cwd=root) + common.git("checkout", "MERGE_HEAD", "--", LOCKFILE, cwd=root) log = common.log_dir("lockfile") / "install.log" code = common.run_logged([common.vp(root), "i", "--lockfile-only"], log, root, {**os.environ, "CI": "1"}) if code != 0: common.fail(f"`vp i --lockfile-only` failed (exit {code}). See {log}") - ours = integrity_changes(common.git("diff", "HEAD", "--", LOCKFILE, cwd=root)) - base = common.git("merge-base", "HEAD", "MERGE_HEAD", cwd=root) - theirs = integrity_changes(common.git("diff", base, "MERGE_HEAD", "--", LOCKFILE, cwd=root)) - print(f"Rebuilt {LOCKFILE}: {len(ours[0])} integrity lines removed, {len(ours[1])} added.") - if not matches_merged_side(ours, theirs): - print("The integrity changes differ from the merged side. Compare them before you stage:") - print(f" git diff HEAD -- {LOCKFILE}") - print(f" git diff {base[:12]} MERGE_HEAD -- {LOCKFILE}") + rebuilt = (root / LOCKFILE).read_text() + upstream = common.git("show", f"MERGE_HEAD:{LOCKFILE}", cwd=root) + fork = common.git("show", f"HEAD:{LOCKFILE}", cwd=root) + added = new_integrities(rebuilt, upstream, fork) + if added: + print(f"The rebuild picked {len(added)} package versions that neither side locked:") + for line in rebuilt.splitlines(): + if INTEGRITY in line and integrities(line) <= added: + print(f" {line.strip()}") + print(f"Check them before you stage: git diff MERGE_HEAD -- {LOCKFILE}") sys.exit(1) common.git("add", LOCKFILE, cwd=root) - kept = len(theirs[0] - ours[0]) - if kept: - print(f"{kept} packages that upstream removed stay, because fork dependencies still use them.") - print(f"The integrity changes match the merged side. Staged {LOCKFILE}.") + print(f"Rebuilt {LOCKFILE} from the upstream side with no new package versions. Staged it.") if __name__ == "__main__": diff --git a/.agents/skills/fork-repair/scripts/test_scripts.py b/.agents/skills/fork-repair/scripts/test_scripts.py index 481e426234015..74e65a073ccb9 100644 --- a/.agents/skills/fork-repair/scripts/test_scripts.py +++ b/.agents/skills/fork-repair/scripts/test_scripts.py @@ -112,27 +112,13 @@ def test_shared_files_and_diff_names(): assert shared_files.diff_file_name("apps/web/x.ts") == "apps__web__x.ts.diff" -def test_integrity_changes_ignore_peer_key_lines(): - fork = "\n".join( - [ - "- a@file:x-1.tgz(react@19.3.0(supports-color@10.2.2)):", - "+ a@file:x-2.tgz(react@19.3.0(supports-color@10.2.2)):", - "- resolution: {integrity: sha512-old, tarball: file:x-1.tgz}", - "+ resolution: {integrity: sha512-new, tarball: file:x-2.tgz}", - ] - ) - upstream = fork.replace("supports-color@10.2.2", "supports-color@8.1.1").replace(" resolution", " resolution") - assert lockfile.integrity_changes(fork) == lockfile.integrity_changes(upstream) - assert lockfile.integrity_changes(fork) != lockfile.integrity_changes(fork.replace("sha512-new", "sha512-other")) - - -def test_lockfile_keeps_packages_that_fork_dependencies_still_use(): - upstream = (frozenset({"old-a", "old-b"}), frozenset({"new"})) - assert lockfile.matches_merged_side(upstream, upstream) - # A fork dependency still uses old-b: only old-a goes. - assert lockfile.matches_merged_side((frozenset({"old-a"}), frozenset({"new"})), upstream) - assert not lockfile.matches_merged_side((frozenset({"old-c"}), frozenset({"new"})), upstream) - assert not lockfile.matches_merged_side((frozenset(), frozenset({"other"})), upstream) +def test_lockfile_rebuild_finds_only_versions_that_no_side_locked(): + upstream = " a@1.0.0:\n resolution: {integrity: sha512-a1}\n b@2.0.0:\n resolution: {integrity: sha512-b2, tarball: file:b.tgz}" + fork = " c@3.0.0:\n resolution: {integrity: sha512-c3}" + assert lockfile.integrities(upstream) == {"sha512-a1", "sha512-b2"} + assert lockfile.new_integrities(upstream + "\n" + fork, upstream, fork) == frozenset() + rebuilt = upstream.replace("sha512-a1", "sha512-a2") + assert lockfile.new_integrities(rebuilt, upstream, fork) == {"sha512-a2"} def test_sync_branches_lint_fork_files_and_fix_branches_run_fork_check(): diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 69dc53dc4393f..56e3f03525e01 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,7 +1,7 @@ # Security policy Report security vulnerabilities affecting T3 Code or T3 Tools-operated infrastructure to -[security@ping.gg](mailto:security@ping.gg). Please do not disclose them publicly until we have had +[security@t3.codes](mailto:security@t3.codes). Please do not disclose them publicly until we have had a reasonable opportunity to investigate and remediate them. See the [full security policy](https://t3.codes/security-policy) for reporting details, scope, diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e9b7ae70a36ad..e27a8c90fdff8 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -13,6 +13,10 @@ inputs: description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. required: false default: "" + save-pnpm-store: + description: Set to "false" when a concurrent job with the same install args saves the pnpm store + required: false + default: "true" runs: using: composite steps: @@ -123,10 +127,14 @@ runs: key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} # A filtered install that started from the current full store adds nothing - # worth a second copy of it. + # worth a second copy of it. A stacked PR, based on a branch other than the + # default, saves a ~1 GB store only its own reruns can read, so it only restores. + # Concurrent jobs with the same install args reserve one key, so the losers + # compress the store for nothing; all but one pass save-pnpm-store: false. - name: Save pnpm store if: >- - inputs.install == 'true' && + inputs.install == 'true' && inputs.save-pnpm-store == 'true' && + (github.event_name != 'pull_request' || github.base_ref == github.event.repository.default_branch) && steps.pnpm_cache.outputs.cache-hit != 'true' && (steps.pnpm_store.outputs.namespace == 'full' || steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc12d15d17860..417f0a95e8552 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,41 @@ jobs: if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: node apps/desktop/scripts/verify-preload-bundle.mjs + # Browser tests run source modules; this boots the production chunk graph. The + # desktop build above already produced apps/web/dist, and the client waits about + # 15s for a session that never comes, so this runs here instead of in Test Web, + # which gates the whole run. Chromium comes from the cache Test Web shares. + - name: Resolve Playwright version + id: playwright_version + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + + - name: Install Chromium for the built web client boot check + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' }} + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi + + - name: Boot the built web client + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + timeout-minutes: 3 + run: node apps/web/scripts/verify-built-boot.ts + - name: Typecheck if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }} timeout-minutes: 5 @@ -340,12 +375,6 @@ jobs: - name: Test run: vp run --filter @t3tools/web test - # Browser tests run source modules; this boots the production chunk graph. - - name: Boot the built web client - run: | - vp run --filter @t3tools/web build - node apps/web/scripts/verify-built-boot.ts - # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 6dcdd702c2da1..0213f3fce6a99 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -73,16 +73,27 @@ jobs: # Public T3 Connect identifiers (Clerk publishable key, relay URL). cp .env.example .env - - uses: ./.github/actions/setup-apt-mirrors + # PR heads from before build:desktop:bundle use the full build, which + # compiles the Linux browser secret helper and needs libsecret. + - name: Check bundle-only build support + id: bundle_build + run: | + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - if: steps.bundle_build.outputs.script == 'build:desktop' + uses: ./.github/actions/setup-apt-mirrors - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # desktop-macos-preview-publish.yml packages this bundle as the preview channel. + # desktop-macos-preview-publish.yml packages this bundle as the preview + # channel. The Linux browser secret helper is not part of a macOS app. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && 'preview' || 'dev' }} - run: vp run build:desktop + run: vp run ${{ steps.bundle_build.outputs.script }} # Same layout as release.yml's js-bundle so release-desktop.yml can # package it unchanged. diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3af4e6abeafe9..8056d08f4385d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -77,6 +77,12 @@ on: required: false default: "" type: string + # Whether this job saves the workspace pnpm store. Release's two Macs + # install the same store on the same runner type, so only one saves it. + save_pnpm_store: + required: false + default: true + type: boolean version: required: true type: string @@ -160,6 +166,7 @@ jobs: uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + save-pnpm-store: ${{ inputs.save_pnpm_store }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -408,6 +415,7 @@ jobs: run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } - name: Build CLI archive + id: build_cli if: inputs.cli_archive shell: bash env: @@ -425,30 +433,45 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} run: | set -euo pipefail - if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" + extra_args=() + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + # Older release refs lack deferred notarization and still wait here. + cli_help="$(node scripts/build-cli-archive.ts --help)" + if [[ "$cli_help" == *--notary-submission-file* ]]; then + extra_args=(--notary-submission-file "$RUNNER_TEMP/t3-cli-notary-submission") + echo "deferred_notarization=true" >> "$GITHUB_OUTPUT" + fi fi node scripts/build-cli-archive.ts \ --platform "${{ inputs.platform }}" \ --arch "${{ inputs.arch }}" \ --version "${{ inputs.version }}" \ --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli + --output-dir release-cli \ + ${extra_args[@]+"${extra_args[@]}"} - name: Smoke-test CLI archive if: inputs.cli_archive && inputs.verify_runner == '' shell: bash run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + # macOS uploads its archive after "Await CLI notarization" instead. - name: Upload CLI archive - if: inputs.cli_archive + if: inputs.cli_archive && inputs.platform != 'mac' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 - name: Install Linux desktop build libraries if: inputs.platform == 'linux' @@ -618,6 +641,39 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # The CLI was submitted without waiting so Apple reviews it while the + # desktop builds. Notarization does not change the archive bytes, but + # nothing uploads them, so nothing publishes them, until Apple accepts. + # With signing configured, a missing submission id fails this step. + - name: Await CLI notarization + if: inputs.cli_archive && inputs.platform == 'mac' && steps.build_cli.outputs.deferred_notarization == 'true' + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts await-notarization \ + --submission-file "$RUNNER_TEMP/t3-cli-notary-submission" + + - name: Upload notarized CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 + # Warm installs only touch the store index, so new content means a fetch. - name: Check staged install cache for new content id: stage_pnpm_changed @@ -761,6 +817,8 @@ jobs: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error + # Installers, zips and blockmaps are already compressed; deflating them again saves under 0.3%. + compression-level: 0 - name: Upload resource monitor uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82f39f8034cc4..9df801c108b7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -517,6 +517,8 @@ jobs: if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 + outputs: + npm_launcher_artifact_id: ${{ steps.npm_launcher.outputs.artifact-id }} 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 }} @@ -565,22 +567,32 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - uses: ./.github/actions/setup-apt-mirrors + # Older release refs lack build:desktop:bundle and use the full build, + # which compiles the Linux browser secret helper and needs libsecret. + - name: Check bundle-only build support + id: bundle_build + run: | + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - if: steps.bundle_build.outputs.script == 'build:desktop' + uses: ./.github/actions/setup-apt-mirrors - # @t3tools/desktop#build compiles the Linux browser secret helper on a - # Linux host before packing, and that needs libsecret headers. - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # Runs t3#build (which depends on @t3tools/web#build) and - # @t3tools/desktop#build, so apps/server/dist holds the server bundle - # plus the web client and apps/desktop/dist-electron the Electron main. + # Runs t3#build (which depends on @t3tools/web#build) and the desktop + # bundle, so apps/server/dist holds the server bundle plus the web client + # and apps/desktop/dist-electron the Electron main. build:desktop:bundle + # skips the Linux browser secret helper, which this artifact does not + # carry; each Linux package job compiles its own per arch. # The release channel is already stable, nightly, or preview. Builds in # other repositories report "dev" telemetry. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && needs.preflight.outputs.release_channel || 'dev' }} - run: vp run build:desktop + run: vp run ${{ steps.bundle_build.outputs.script }} # Two paths under apps/ so the artifact root is apps/; consumers download # into `apps` to restore both at their original locations. @@ -594,6 +606,22 @@ jobs: if-no-files-found: error retention-days: 1 + # publish_cli publishes this exact tarball, so the trusted publisher + # needs no workspace checkout or install. It is version-matched here. + - name: Build the npm launcher + run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + + # GitHub allows a failed-jobs rerun for 30 days. publish_cli downloads + # this upload by its ID, which a publish_cli-only rerun keeps. + - name: Upload the npm launcher + id: npm_launcher + uses: actions/upload-artifact@v7 + with: + name: npm-launcher + path: npm-packages/t3.tgz + if-no-files-found: error + retention-days: 30 + # One job per platform and architecture (see release-desktop.yml), each # gated only on the JS bundle. Windows targets build on Linux and run their # native checks on a Windows runner of their own architecture. @@ -625,6 +653,8 @@ jobs: resource_key: darwin-arm64 cli_archive: true launch_check: true + # desktop_mac_x64 saves the shared pnpm store; this job finishes last. + save_pnpm_store: false desktop_mac_x64: name: Desktop macOS x64 @@ -796,6 +826,24 @@ jobs: with: install-args: --filter=@t3tools/scripts... + # Overlaps the artifact downloads. The wait below ends it before any + # workspace script runs, so the Play key lives only in this process. + # `exec` lets the runner's stop signals reach node directly. + - id: android_apk + name: Download nightly Android APK + if: needs.preflight.outputs.release_channel == 'nightly' + background: true + continue-on-error: true + timeout-minutes: 5 + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + PACKAGE_NAME: com.t3tools.t3code + APK_OUTPUT_DIR: ${{ runner.temp }}/android-apk + run: | + if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then + exec node .github/scripts/mirror-play-apk.cjs + fi + - name: Download all desktop artifacts uses: actions/download-artifact@v8 with: @@ -810,18 +858,6 @@ jobs: merge-multiple: true path: release-assets - - name: Download internal Android APK for Nightly - if: needs.preflight.outputs.release_channel == 'nightly' - continue-on-error: true - env: - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - PACKAGE_NAME: com.t3tools.t3code - APK_OUTPUT_DIR: release-assets - run: | - if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then - node .github/scripts/mirror-play-apk.cjs - fi - # Installers verify archives against this file, so it is written from the # signed bytes that get uploaded, never from an earlier stage. - name: Write CLI archive checksums @@ -838,6 +874,10 @@ jobs: sha256sum "${archives[@]}" > SHA256SUMS cat SHA256SUMS + # Always runs, even after a failed step, and keeps that failure. + - name: Wait for nightly Android APK + wait: android_apk + # The desktop build omits the publish config for preview versions, so # electron-builder emits no updater manifests for them. Refuse to publish # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview @@ -903,10 +943,13 @@ jobs: echo 'release-assets/*.yml' fi echo 'release-assets/*.tar.gz' - # Only list APKs when Play has a processed internal build available. - for apk in release-assets/*.apk; do - [[ -f "$apk" ]] && echo "$apk" - done + # A failed or timed-out fetch can leave partial bytes, so list the APK + # only when this attempt's fetch succeeded and Play had one ready. + if [[ "${{ steps.android_apk.outcome }}" == "success" ]]; then + for apk in "$RUNNER_TEMP"/android-apk/*.apk; do + [[ -f "$apk" ]] && echo "$apk" + done + fi echo 'release-assets/SHA256SUMS' echo 'EOF' } >> "$GITHUB_OUTPUT" @@ -1000,7 +1043,7 @@ jobs: # nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm - needs: [preflight, release] + needs: [preflight, build_bundle, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 @@ -1008,26 +1051,57 @@ jobs: contents: read id-token: write steps: + # Only what setup-ci-deps reads to install the project's Node and npm. - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | - /* - !/.repos/ + /.github/actions/setup-ci-deps/ + /package.json + /pnpm-workspace.yaml sparse-checkout-cone-mode: false - - name: Setup dependencies + - name: Setup Node.js uses: ./.github/actions/setup-ci-deps with: - install-args: --filter=t3... --filter=@t3tools/scripts... + install: "false" - - name: Build the npm launcher - run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + # Bind to build_bundle's exact upload: a name lookup takes the newest + # same-name artifact, and an empty ID downloads every artifact in the + # run. A later same-name upload hides this ID, so the download fails. + - name: Check the npm launcher artifact ID + env: + ARTIFACT_ID: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + run: | + if [[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::build_bundle gave no valid npm-launcher artifact ID: '$ARTIFACT_ID'." >&2 + exit 1 + fi + + - name: Download the npm launcher + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + path: npm-packages + digest-mismatch: error + + - name: Check the npm launcher version + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + built="$(tar -xzOf npm-packages/t3.tgz package/package.json | jq -r .version)" + if [[ "$built" != "$RELEASE_VERSION" ]]; then + echo "::error::npm-launcher holds t3@$built, expected t3@$RELEASE_VERSION." >&2 + exit 1 + fi - name: Publish the npm launcher + env: + DIST_TAG: ${{ needs.preflight.outputs.cli_dist_tag }} run: | - if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose; then + # The ./ keeps npm from reading the path as a GitHub owner/repo spec. + if ! npm publish --access public --tag "$DIST_TAG" --provenance ./npm-packages/t3.tgz; then echo "::error::npm publish failed. Make sure package t3 has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md index 66e5434c5ed13..7e67d1d059427 100644 --- a/.macroscope/ignore.md +++ b/.macroscope/ignore.md @@ -279,3 +279,7 @@ native/libghostty-vt/include/** # it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review # that script and cpp/t3_highlights.c instead. apps/mobile/modules/t3-highlights/Vendor/** + +# Nitro bindings for T3Highlights. Regenerated by `vp run generate:nitro` in +# apps/mobile from modules/t3-highlights/src/T3Highlights.nitro.ts. +apps/mobile/modules/t3-highlights/nitrogen/generated/** diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index a7b10f64404a6..9ad611688fc91 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -2,15 +2,6 @@ // Keep other platforms native; release scripts set their own architectures. module.exports = { hooks: { - // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside - // vite-plus-core's typescript peer range, so pnpm gave the vite-plus that @effect/vitest - // imports apps/marketing's typescript@6.0.3 instead, and tests loaded a second vitest. - readPackage(pkg) { - if (pkg.name === "@voidzero-dev/vite-plus-core" && pkg.peerDependencies?.typescript) { - pkg.peerDependencies.typescript = "*"; - } - return pkg; - }, updateConfig(config) { // oxlint-disable-next-line t3code/no-global-process-runtime -- pnpm loads this file before any Effect runtime exists. if (process.platform !== "win32") return config; diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 99e2f7ddf76db..b7b4c7aec81f3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["oxc.oxc-vscode"] + "recommendations": ["oxc.oxc-vscode", "TypeScriptTeam.native-preview"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 564120b4aaff3..2dba907475af6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,8 @@ "source.fixAll.oxc": "always" }, "oxc.unusedDisableDirectives": "warn", - "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": ".vscode/tsdk", "typescript.preferences.autoImportFileExcludePatterns": [".repos/**"], "javascript.preferences.autoImportFileExcludePatterns": [".repos/**"], "files.watcherExclude": { diff --git a/.vscode/tsdk/tsgo b/.vscode/tsdk/tsgo new file mode 100755 index 0000000000000..aed04f69fd431 --- /dev/null +++ b/.vscode/tsdk/tsgo @@ -0,0 +1,11 @@ +#!/bin/sh +# VS Code's TypeScript 7 extension starts `/tsgo --lsp --stdio`. Exec Effect's patched tsrs +# directly: through the effect-tsgo Node launcher, the LSP's stdin read intermittently fails (EAGAIN). +exec "$(cd "$(dirname "$0")/../.." && node -e ' +const { createRequire } = require("node:module"); +const fs = require("node:fs"); +const path = require("node:path"); +const platform = `@effect/tsgo-${process.platform}-${process.arch}/package.json`; +const dir = path.join(path.dirname(createRequire(require.resolve("@effect/tsgo/package.json")).resolve(platform)), "artifacts/tsrs"); +process.stdout.write(path.join(dir, fs.readdirSync(dir).sort().at(-1), "tsrs")); +')" "$@" diff --git a/AGENTS.md b/AGENTS.md index 69af9798143bf..89ec53c3a5232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, tests beside changes, and the cross-file checks those miss. CI still runs the full test suites. - While you iterate, run `vp test run `. For big files, add `-t ""`. -- Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). +- Use `vp run typecheck`; it caches per package. Each package runs `effect-tsgo tsrs --noEmit`, the Rust TypeScript 7 checker with Effect's diagnostics built in. There is no `typescript` package to run. - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. - Test behavior that can break, such as orchestration and event-sourcing, provider protocol translation, auth and permission guards, persistence and migrations, external-format parsing, race fixes, and the logic a bug fix changed. Leave out tests for what types already guarantee, trivial logic, copy text, smoke runs, mock-call mirrors, callback wiring, and props rendered to static markup. - Extend before you add. When an existing test or table covers the behavior, add a row or an assertion there. Write a new `it` only when no case fits. diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 4901724afe812..10f04646398f4 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -71,14 +72,19 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as MacPermissions from "../permissions/MacPermissions.ts"; import * as PreviewManager from "../preview/Manager.ts"; -it.effect( - "starts the backend and window while Linux registration is stalled and cancels it on shutdown", - () => +it.effect.each([false, true])( + "stops backends together before flushing history and cancels Linux registration, stop defect=%s", + (stopDefect) => Effect.gen(function* () { const registrationStarted = yield* Deferred.make(); const releaseRegistration = yield* Deferred.make(); const registrationStopped = yield* Deferred.make(); const windowStarted = yield* Deferred.make(); + const firstStopStarted = yield* Deferred.make(); + const secondStopStarted = yield* Deferred.make(); + const releaseStops = yield* Deferred.make(); + const historyStarted = yield* Deferred.make(); + const releaseHistory = yield* Deferred.make(); const quitting = yield* Ref.make(false); const events: string[] = []; const record = (event: string) => @@ -179,7 +185,21 @@ it.effect( start: record("backend"), currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }), }), - list: Effect.succeed([]), + list: Effect.succeed([ + { + stop: () => + Deferred.succeed(firstStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + Effect.andThen(stopDefect ? Effect.die("stop defect") : Effect.void), + ), + }, + { + stop: () => + Deferred.succeed(secondStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + ), + }, + ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]), Effect.provideService(DesktopServerExposure.DesktopServerExposure, { configureFromSettings: () => Effect.succeed({ mode: "local-only" }), @@ -192,7 +212,10 @@ it.effect( start: Effect.void, } as unknown as DesktopAppActivation.DesktopAppActivation["Service"]), Effect.provideService(DesktopRendererHistory.DesktopRendererHistory, { - shutdown: Effect.void, + shutdown: Deferred.succeed(historyStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseHistory)), + Effect.asVoid, + ), } as DesktopRendererHistory.DesktopRendererHistory["Service"]), ); const app = yield* runApp.pipe( @@ -236,8 +259,19 @@ it.effect( yield* Deferred.await(windowStarted); assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); assert.isFalse(yield* Deferred.isDone(registrationStopped)); - yield* (yield* DesktopShutdown.DesktopShutdown).request; - yield* Fiber.join(app); + const shutdown = yield* DesktopShutdown.DesktopShutdown; + yield* shutdown.request; + yield* Deferred.await(firstStopStarted); + yield* Deferred.await(secondStopStarted); + assert.isFalse(yield* Deferred.isDone(historyStarted)); + assert.isFalse(yield* shutdown.isComplete); + yield* Deferred.succeed(releaseStops, undefined); + yield* Deferred.await(historyStarted); + assert.isFalse(yield* shutdown.isComplete); + yield* Deferred.succeed(releaseHistory, undefined); + const result = yield* Fiber.await(app); + assert.equal(Exit.isFailure(result), stopDefect); + assert.isTrue(yield* shutdown.isComplete); assert.isTrue(yield* Deferred.isDone(registrationStopped)); }).pipe(Effect.provide(DesktopShutdown.layer)), ); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7403c2b9d2ff9..ed437c931b1b6 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,5 +1,5 @@ import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -18,6 +18,7 @@ import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; @@ -43,6 +44,11 @@ const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; +const LinuxDesktopSessionConfig = Config.all({ + current: Config.String("XDG_CURRENT_DESKTOP").pipe(Config.option), + session: Config.String("XDG_SESSION_DESKTOP").pipe(Config.option), +}); + const makeDesktopRunId = SecureRandom.SecureRandom.pipe( Effect.flatMap((random) => random.uuidV4()), Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), @@ -155,7 +161,7 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( const instances = yield* pool.list; yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), + (instance) => instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), { concurrency: "unbounded" }, ); }, @@ -310,12 +316,13 @@ const startup = Effect.gen(function* () { yield* desktopSettings.load; if (linuxElectronOptions !== null) { + const xdgDesktop = yield* LinuxDesktopSessionConfig; yield* logStartupInfo("linux password store configured", { passwordStore: hasCommandLinePasswordStore ? "command-line" : (linuxElectronOptions.passwordStore ?? "electron-default"), - xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, - xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, + xdgCurrentDesktop: Option.getOrNull(xdgDesktop.current), + xdgSessionDesktop: Option.getOrNull(xdgDesktop.session), }); } @@ -359,6 +366,7 @@ const scopedProgram = Effect.scoped( stopAllPoolInstances().pipe( Effect.ensuring(rendererHistory.shutdown), Effect.ensuring(shutdown.markComplete), + Effect.withSpan("desktop.app.shutdown"), ), ); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 32e7b3b3118d9..74d134c0b1001 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -42,6 +42,7 @@ const layerElectronApp = (calls: ElectronAppCalls) => metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 8566613dd6069..ebb32bda43ced 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { afterEach, beforeEach, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -29,7 +29,13 @@ const environmentFor = ( } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); const commandIn = ( - input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, + input: { + home: string; + baseDir?: string; + platform?: NodeJS.Platform; + isPackaged?: boolean; + env?: NodeJS.ProcessEnv; + }, spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], ) => Effect.gen(function* () { @@ -42,6 +48,7 @@ const commandIn = ( DesktopEnvironment.DesktopEnvironment, environmentFor(path, { ...input, baseDir }), ), + Effect.provideService(HostProcess.Environment, input.env ?? { PATH: "" }), ); return yield* spawner ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) @@ -84,14 +91,6 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; -const machinePath = process.env.PATH; -beforeEach(() => { - process.env.PATH = ""; -}); -afterEach(() => { - process.env.PATH = machinePath; -}); - it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("links the launcher onto PATH and removes only that link", () => Effect.gen(function* () { @@ -124,7 +123,8 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const command = yield* commandIn({ home }); + const env: NodeJS.ProcessEnv = { PATH: "" }; + const command = yield* commandIn({ home, env }); const theirs = path.join(home, ".local", "bin", "t3"); yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); yield* fs.writeFileString(theirs, "npm's t3\n"); @@ -139,7 +139,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const launcher = path.join(home, ".t3", "bin", "t3"); yield* fs.writeFileString(launcher, "npm's t3\n"); - process.env.PATH = path.dirname(launcher); + env.PATH = path.dirname(launcher); expect(yield* command.state).toMatchObject({ onPath: false, shadowedBy: launcher }); expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); yield* command.uninstall; @@ -173,9 +173,9 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const shadow = path.join(home, "shadow"); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); + const env = { PATH: [shadow, path.join(home, ".local", "bin")].join(":") }; - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); const theirs = path.join(shadow, "t3"); expect((yield* command.state).shadowedBy).toBe(theirs); const error = yield* Effect.flip(command.install); @@ -209,9 +209,9 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - process.env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":"); + const env = { PATH: [folder, plain, shadow, path.join(home, ".local", "bin")].join(":") }; - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3")); const error = yield* Effect.flip(command.install); @@ -231,9 +231,9 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const launcher = path.join(home, ".t3", "bin", "t3"); - process.env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":"); + const env = { PATH: [path.dirname(launcher), path.join(home, ".local", "bin")].join(":") }; - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); const installed = yield* command.install; expect(yield* fs.exists(launcher)).toBe(true); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index f53895a60a570..de48328850794 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -2,6 +2,7 @@ import * as NodeFSP from "node:fs/promises"; import type { DesktopCliCommandState } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -61,11 +62,13 @@ export class DesktopCliCommand extends Context.Service< } >()("@t3tools/desktop/app/DesktopCliCommand") {} -/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // Read the live env: shell discovery patches PATH after startup, and Windows variable + // names are case-insensitive only on process.env, not on Config's copy of it. + const env = yield* HostProcess.Environment; const path = environment.path; const windows = environment.platform === "win32"; const launcher = DesktopCliShim.launcherPath(environment); @@ -105,15 +108,15 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - const powershell = (env: Record) => + const powershell = (overrides: Record) => Effect.scoped( Effect.gen(function* () { - const root = process.env.SystemRoot ?? process.env.WINDIR; + const root = env.SystemRoot ?? env.WINDIR; const handle = yield* spawner.spawn( ChildProcess.make( root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], - { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, + { stdin: "ignore", stderr: "ignore", env: overrides, extendEnv: true }, ), ); const [stdout, exitCode] = yield* Effect.all( @@ -137,14 +140,17 @@ export const make = Effect.gen(function* () { const isExecutableFile = Effect.fn("DesktopCliCommand.isExecutableFile")( function* (target: string) { if ((yield* fs.stat(target)).type !== "File") return false; - yield* Effect.tryPromise(() => NodeFSP.access(target, NodeFSP.constants.X_OK)); + yield* Effect.tryPromise({ + try: () => NodeFSP.access(target, NodeFSP.constants.X_OK), + catch: () => fail(`${target} is not executable.`), + }); return true; }, Effect.orElseSucceed(() => false), ); const firstOnPath = Effect.gen(function* () { - for (const directory of pathEntries(process.env.PATH, ":")) { + for (const directory of pathEntries(env.PATH, ":")) { const candidate = path.join(directory, "t3"); if (yield* isExecutableFile(candidate)) return Option.some(candidate); } @@ -239,7 +245,7 @@ export const make = Effect.gen(function* () { `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, ); } - const onPath = pathEntries(process.env.PATH, ":"); + const onPath = pathEntries(env.PATH, ":"); const candidates = unixCandidates(environment.homeDirectory, environment.platform); for (const directory of [ ...candidates.filter((candidate) => onPath.includes(candidate)), diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 11f975a6b86af..ef2909045bb4f 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -8,7 +8,7 @@ const trimNonEmptyOption = (value: string): Option.Option => { return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); }; -const trimmedString = (name: string) => +export const trimmedString = (name: string) => Config.String(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption))); const optionalBoolean = (name: string) => diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 82e4b4e891aec..f5d1341a1880f 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -39,6 +39,7 @@ function layerElectronApp( metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit, diff --git a/apps/desktop/src/app/DesktopManagedPolicy.ts b/apps/desktop/src/app/DesktopManagedPolicy.ts index 6dc9e8c21b2ae..38d39a75424ff 100644 --- a/apps/desktop/src/app/DesktopManagedPolicy.ts +++ b/apps/desktop/src/app/DesktopManagedPolicy.ts @@ -56,8 +56,7 @@ export const layerTest = (policy?: ManagedPolicyDocument | ManagedPolicyFileErro DesktopManagedPolicy.of({ read: policy === undefined - ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - No policy is undefined, not void. - Effect.succeed(undefined) + ? Effect.undefined : isManagedPolicyFileError(policy) ? Effect.fail(policy) : Effect.succeed({ source: "/test/policy.json", document: policy }), diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index e8635673b9829..50f857fb89e12 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -57,6 +57,7 @@ vi.mock("node:child_process", () => ({ execFileSync: execFileSyncMock, })); +import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; describe("DesktopPreReadyPlatform", () => { @@ -124,8 +125,6 @@ describe("DesktopPreReadyPlatform", () => { { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => { vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); getSwitchValueMock.mockReturnValue(""); let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; @@ -150,6 +149,12 @@ describe("DesktopPreReadyPlatform", () => { yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + Layer.provide( + DesktopConfig.layerTest({ + XDG_DATA_HOME: "/xdg", + APPIMAGE: "/Applications/current.AppImage", + }), + ), ), ); const identity = yield* Effect.promise(() => portalIdentity); @@ -171,9 +176,6 @@ describe("DesktopPreReadyPlatform", () => { it.effect("points the early portal entry at a configured distribution launcher", () => { vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); - vi.stubEnv("T3CODE_DESKTOP_LAUNCHER_PATH", " /opt/T3 Code/t3code "); getSwitchValueMock.mockReturnValue(""); return Effect.scoped( @@ -181,6 +183,13 @@ describe("DesktopPreReadyPlatform", () => { yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + Layer.provide( + DesktopConfig.layerTest({ + XDG_DATA_HOME: "/xdg", + APPIMAGE: "/Applications/current.AppImage", + T3CODE_DESKTOP_LAUNCHER_PATH: " /opt/T3 Code/t3code ", + }), + ), ), ); const entry = writeFileSyncMock.mock.calls.find( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 0a678341b25b0..2be5dfbec22f7 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -2,14 +2,17 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import * as Config from "effect/Config"; 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 Electron from "electron"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; +import { trimmedString } from "./DesktopConfig.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -49,10 +52,17 @@ export class DesktopPreReadyElectronOptions extends Context.Service< } >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} +const LinuxDesktopEntryConfig = Config.all({ + xdgDataHome: trimmedString("XDG_DATA_HOME"), + launcherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"), + appImagePath: trimmedString("APPIMAGE"), +}); + /** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; + const linuxDesktopEntryConfig = yield* LinuxDesktopEntryConfig; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { const linuxPasswordStoreCommandLine = platform === "linux" @@ -77,8 +87,9 @@ export const make = Effect.gen(function* () { // have removed the executable referenced by the previous launch's entry. try { const applicationsDir = NodePath.posix.join( - process.env.XDG_DATA_HOME?.trim() || + Option.getOrElse(linuxDesktopEntryConfig.xdgDataHome, () => NodePath.posix.join(homeDirectory, ".local", "share"), + ), "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); @@ -110,9 +121,12 @@ export const make = Effect.gen(function* () { appVersion: Electron.app.getVersion(), }).displayName, execTarget: - process.env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || - process.env.APPIMAGE?.trim() || - process.execPath, + Option.getOrUndefined( + Option.orElse( + linuxDesktopEntryConfig.launcherPath, + () => linuxDesktopEntryConfig.appImagePath, + ), + ) ?? process.execPath, schemes: ElectronProtocol.getDesktopLinkSchemes(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 3cb34c5f22609..440c352320782 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -96,14 +97,6 @@ function layerEnvironment( ); } -const restoreEnv = (name: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } -}; - const withHarness = ( effect: Effect.Effect< A, @@ -265,10 +258,10 @@ describe("DesktopBackendConfiguration", () => { ); assert.equal( first.bootstrap.desktopBootstrapToken, - currentDesktopBootstrapToken( + yield* currentDesktopBootstrapToken( first.bootstrap.desktopBootstrapSecret ?? "", yield* Clock.currentTimeMillis, - ), + ).pipe(Effect.provide(NodeCrypto.layerMac)), ); assert.equal( yield* configuration.currentBootstrapToken, @@ -695,49 +688,38 @@ describe("DesktopBackendConfiguration", () => { prefix: "t3-desktop-backend-config-test-", }); - const previousWslEnv = process.env.WSLENV; - const previousDisabled = process.env.OTEL_SDK_DISABLED; - const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; - const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; - try { - delete process.env.WSLENV; - process.env.OTEL_SDK_DISABLED = "true"; - process.env.T3CODE_TELEMETRY_ENABLED = "false"; - process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.env.OTEL_SDK_DISABLED, "true"); - assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); - assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); - assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); - assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); - assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.env.OTEL_SDK_DISABLED, "true"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); + assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); + assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), ), - ); - } finally { - restoreEnv("WSLENV", previousWslEnv); - restoreEnv("OTEL_SDK_DISABLED", previousDisabled); - restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); - restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); - } + ), + Effect.provideService(HostProcess.Environment, { + OTEL_SDK_DISABLED: "true", + T3CODE_TELEMETRY_ENABLED: "false", + OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=staging", + }), + ); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); @@ -748,63 +730,48 @@ describe("DesktopBackendConfiguration", () => { prefix: "t3-desktop-backend-config-test-", }); - const previousWslEnv = process.env.WSLENV; - const previousOpenAiKey = process.env.OPENAI_API_KEY; - const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; - const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; - const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; - const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); - try { - for (const [name] of ambientOtel) delete process.env[name]; - process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; - process.env.OPENAI_API_KEY = "openai-key"; - process.env.ANTHROPIC_API_KEY = "anthropic-key"; - process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; - process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf"; - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.executablePath, "wsl.exe"); - assert.equal(config.bootstrap.port, 5050); - assert.equal(config.bootstrap.host, "0.0.0.0"); - assert.equal(config.bootstrap.tailscaleServeEnabled, false); - assert.notProperty(config.bootstrap, "desktopTelemetryFd"); - assert.notProperty(config.bootstrap, "resourceMonitorPath"); - assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); - assert.equal(config.env.OPENAI_API_KEY, "openai-key"); - assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); - assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); - assert.equal( - config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", - ); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.executablePath, "wsl.exe"); + assert.equal(config.bootstrap.port, 5050); + assert.equal(config.bootstrap.host, "0.0.0.0"); + assert.equal(config.bootstrap.tailscaleServeEnabled, false); + assert.notProperty(config.bootstrap, "desktopTelemetryFd"); + assert.notProperty(config.bootstrap, "resourceMonitorPath"); + assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); + assert.equal(config.env.OPENAI_API_KEY, "openai-key"); + assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); + assert.equal( + config.env.WSLENV, + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", + ); + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), ), - ); - } finally { - restoreEnv("WSLENV", previousWslEnv); - restoreEnv("OPENAI_API_KEY", previousOpenAiKey); - restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); - restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); - restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); - for (const [name, value] of ambientOtel) restoreEnv(name, value); - } + ), + Effect.provideService(HostProcess.Environment, { + WSLENV: "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u", + OPENAI_API_KEY: "openai-key", + ANTHROPIC_API_KEY: "anthropic-key", + T3CODE_OTLP_HEADERS: 'authorization="Bearer%20my-token"', + T3CODE_OTLP_PROTOCOL: "http/protobuf", + }), + ); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index af9510e5beef2..1ede4abfea679 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -2,9 +2,11 @@ import * as NodeOS from "node:os"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Mac from "effect/crypto/Mac"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -571,6 +573,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const env = yield* HostProcess.Environment; // wslhost loopback forwarding is unreliable on some Windows hosts; advertise the WSL IP instead. const wslBindHost = "0.0.0.0"; @@ -642,7 +645,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const forwardedEnv: Record = {}; const forwardedEnvNames: string[] = []; for (const name of WSL_FORWARDED_ENV_NAMES) { - const value = process.env[name]; + const value = env[name]; if (value !== undefined && value.length > 0) { forwardedEnv[name] = value; forwardedEnvNames.push(name); @@ -650,7 +653,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } const parentEnvWithoutT3Home: Record = {}; - for (const [key, value] of Object.entries(process.env)) { + for (const [key, value] of Object.entries(env)) { if (key === "T3CODE_HOME") continue; parentEnvWithoutT3Home[key] = value; } @@ -667,7 +670,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...forwardedEnv, ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), }, - // env already excludes T3CODE_HOME; merging process.env would restore it. + // env already excludes T3CODE_HOME; merging the host env would restore it. extendEnv: false, bootstrap, bootstrapDelivery: "stdin" as const, @@ -733,6 +736,7 @@ export const make = Effect.gen(function* () { const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; + const mac = yield* Mac.Mac; const random = yield* SecureRandom.SecureRandom; // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); @@ -751,10 +755,10 @@ export const make = Effect.gen(function* () { const sharedInputs = Effect.gen(function* () { const bootstrapSecret = yield* getOrCreateBootstrapSecret; - const bootstrapToken = currentDesktopBootstrapToken( + const bootstrapToken = yield* currentDesktopBootstrapToken( bootstrapSecret, yield* Clock.currentTimeMillis, - ); + ).pipe(Effect.provideService(Mac.Mac, mac)); const observabilitySettings = yield* readBackendObservabilitySettings.pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), @@ -816,7 +820,9 @@ export const make = Effect.gen(function* () { const currentBootstrapToken = Effect.gen(function* () { const secret = yield* getOrCreateBootstrapSecret; - return currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis); + return yield* currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis).pipe( + Effect.provideService(Mac.Mac, mac), + ); }); return DesktopBackendConfiguration.of({ diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e4714673777d9..2741daa1ddd07 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -720,4 +720,58 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("the quit stop deadline waits for a backend that only exits at the force kill", () => + Effect.scoped( + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const teardownFinished = yield* Deferred.make(); + // The real spawner sends SIGTERM, waits the grace, then SIGKILLs. A backend that + // ignores SIGTERM therefore finishes tearing down just after the grace. + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Deferred.succeed(spawned, undefined); + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Effect.sleep(Duration.seconds(6))), + Effect.andThen(Deferred.succeed(teardownFinished, undefined)), + Effect.asVoid, + ), + ); + return makeProcess({ + exitCode: Deferred.await(teardownFinished).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }); + }), + ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + }); + yield* instance.start; + yield* Deferred.await(spawned); + + // App quit and the updater stop every backend with this deadline. + const quitDone = yield* Deferred.make(); + const quitFiber = yield* instance + .stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }) + .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); + + yield* Deferred.await(teardownStarted); + // Past the SIGTERM grace but before the force kill lands: quit must still be waiting. + yield* TestClock.adjust(Duration.millis(5_500)); + assert.isFalse(yield* Deferred.isDone(quitDone)); + + yield* TestClock.adjust(Duration.millis(500)); + yield* Fiber.join(quitFiber); + assert.isTrue(yield* Deferred.isDone(teardownFinished)); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 2f5b57c4e3c95..98de41e247155 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -41,6 +41,12 @@ const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); // Match the service launcher's grace period so backends can stop provider sessions. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); +// Allow SIGKILL and the spawner exit wait after the SIGTERM grace so quit cannot +// orphan a backend whose shutdown hangs. +export const BACKEND_QUIT_STOP_TIMEOUT = Duration.sum( + DEFAULT_BACKEND_TERMINATE_GRACE, + Duration.seconds(2), +); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; // Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. @@ -306,7 +312,10 @@ const closeRun = ( onNone: () => Effect.void, onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), }); - const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); + const close = Scope.close(run.scope, Exit.void).pipe( + Effect.withSpan("desktop.backendInstance.closeScope"), + Effect.andThen(waitForFiber.pipe(Effect.withSpan("desktop.backendInstance.awaitRun"))), + ); const timeout = options?.timeout; if (!timeout) { @@ -567,6 +576,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); const exit = yield* handle.exitCode.pipe( + Effect.withSpan("desktop.backendProcess.awaitExit"), Effect.map((exitCode) => ({ code: Option.some(exitCode), reason: `code=${exitCode}`, @@ -602,6 +612,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( }).pipe( Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT), Effect.ignore, + Effect.withSpan("desktop.backendProcess.drainOutput"), ); if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.fork.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.fork.test.ts index d9c4f30ffa104..cbac075a9db73 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.fork.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.fork.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, describe, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -74,7 +75,7 @@ describe("DesktopLocalEnvironmentAuth", () => { const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( Effect.provide( DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(layerPool, layerHttpClient)), + Layer.provide(Layer.mergeAll(layerPool, layerHttpClient, NodeCrypto.layerMac)), ), ), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index be16c4ecc43ee..e82e3afea7303 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -80,7 +81,7 @@ describe("DesktopLocalEnvironmentAuth", () => { ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); const layerTest = DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(layerPool, layerHttpClient)), + Layer.provide(Layer.mergeAll(layerPool, layerHttpClient, NodeCrypto.layerMac)), ); const [first, second] = yield* Effect.gen(function* () { @@ -151,14 +152,17 @@ describe("DesktopLocalEnvironmentAuth", () => { }).pipe( Effect.provide( DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), + Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer, NodeCrypto.layerMac)), ), ), ); assert.strictEqual( yield* Ref.get(presented), - currentDesktopBootstrapToken("desktop-secret", DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1), + yield* currentDesktopBootstrapToken( + "desktop-secret", + DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1, + ).pipe(Effect.provide(NodeCrypto.layerMac)), ); }).pipe(Effect.provide(TestClock.layer())), ); @@ -205,7 +209,7 @@ describe("DesktopLocalEnvironmentAuth", () => { const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( Effect.provide( DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(layerPool, layerHttpClient)), + Layer.provide(Layer.mergeAll(layerPool, layerHttpClient, NodeCrypto.layerMac)), ), ), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 809266ef7903d..f3612cf734cdc 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -5,6 +5,7 @@ import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapTo import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; +import * as Mac from "effect/crypto/Mac"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -69,6 +70,7 @@ export const make = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const httpClient = yield* HttpClient.HttpClient; const tokenRef = yield* Ref.make(Option.none<{ token: string; expiresAt: number }>()); + const mac = yield* Mac.Mac; const mutex = yield* Semaphore.make(1); const getBearerToken = mutex @@ -95,7 +97,9 @@ export const make = Effect.gen(function* () { const credential = secret === undefined ? config.bootstrap.desktopBootstrapToken - : currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis); + : yield* currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis).pipe( + Effect.provideService(Mac.Mac, mac), + ); if (!credential) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index d677baa56dce3..188f53c4be8d7 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -46,6 +46,7 @@ export class ElectronApp extends Context.Service< readonly name: Effect.Effect; /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; + readonly preferredSystemLanguages: Effect.Effect>; readonly whenReady: Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; readonly quit: Effect.Effect; @@ -155,6 +156,7 @@ export const make = ElectronApp.of({ } return toSystemLocaleTag(identifier, hourCycle); }), + preferredSystemLanguages: Effect.sync(() => Electron.app.getPreferredSystemLanguages()), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 3e3c691ca228e..f18b9e3c94ad8 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -228,9 +228,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( if (request.method !== "GET" && request.method !== "HEAD") { return new Response(null, { status: 405 }); } - const pathname = yield* Effect.try(() => decodeURIComponent(url.pathname)).pipe( - Effect.orElseSucceed(() => null), - ); + const pathname = Option.getOrNull(Option.liftThrowable(decodeURIComponent)(url.pathname)); if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); const root = path.resolve(assetDirectory); const assetPath = path.resolve(root, `.${pathname}`); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 4b61678ef06c5..327f9f49bf461 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -15,6 +15,7 @@ import { setConnectionCatalog, } from "./methods/connectionCatalog.ts"; import { + getHostEnvironmentId, getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, } from "./methods/localEnvironment.ts"; @@ -50,6 +51,7 @@ import { getLocalEnvironmentBearerToken, installRelayTelemetryEnabled, getSystemLocale, + getPreferredSystemLanguages, getWindowFullscreenState, openExternal, readMedia, @@ -105,10 +107,12 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* installRelayTelemetryEnabled(); yield* ipc.handleSync(getSystemLocale); + yield* ipc.handleSync(getPreferredSystemLanguages); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); + yield* ipc.handle(getHostEnvironmentId); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index fe84dcb858be4..ccb8a3af83767 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -34,9 +34,11 @@ export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; +export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-system-languages"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; +export const GET_HOST_ENVIRONMENT_ID_CHANNEL = "desktop:get-host-environment-id"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; @@ -93,6 +95,7 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; @@ -123,6 +126,9 @@ export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission" export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; +export const PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL = "desktop:preview-annotation-voice-event"; +export const PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL = "desktop:preview-annotation-voice-state"; + export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index 94bbf085a9921..36e89eb880c15 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -1,4 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,7 +13,11 @@ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; -import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; +import { + getHostEnvironmentId, + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./localEnvironment.ts"; const layerUnusedLifecycleRuntime = Layer.mergeAll( DesktopShutdown.layer, @@ -27,6 +34,26 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("local environment IPC", () => { + it.effect("reads the shared home identity without creating one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const environment = DesktopEnvironment.DesktopEnvironment.of({ + path, + stateDir, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const read = getHostEnvironmentId + .handler(undefined) + .pipe(Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment)); + assert.strictEqual(yield* read, null); + assert.isFalse(yield* fs.exists(path.join(stateDir, "environment-id"))); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), " service-id\n"); + assert.strictEqual(yield* read, "service-id"); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), "\n"); + assert.strictEqual(yield* read, null); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.effect("relaunches only when the setting changes and keeps other settings", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts index 74cccd04a0c58..3c5c2c63d77da 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as IpcChannels from "../channels.ts"; @@ -28,3 +30,18 @@ export const setLocalEnvironmentEnabled = makeIpcMethod({ } }), }); + +// A background service sharing this app's T3 home records its environment ID in the same state dir. +export const getHostEnvironmentId = makeIpcMethod({ + channel: IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.localEnvironment.getHostEnvironmentId")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe(Effect.orElseSucceed(() => "")); + return contents.trim() || null; + }), +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 20b7bb4877d59..98a0aab831645 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,8 @@ import { DesktopPreviewAnnotationSendEnabledInputSchema, DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewAnnotationVoiceStateSchema, + DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewConfigInputSchema, DesktopPreviewNavigateInputSchema, @@ -9,6 +11,7 @@ import { DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, + DesktopPreviewSetShownInputSchema, DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewSetZoomFactorInputSchema, BrowserImportResult, @@ -193,6 +196,15 @@ const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +const setShown = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, + payload: DesktopPreviewSetShownInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setShown")(function* ({ tabId, shown }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setShown(tabId, shown); + }), +}); const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -353,11 +365,21 @@ const setAnnotationTheme = DesktopIpc.makeIpcMethod({ const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, - payload: DesktopPreviewTabInputSchema, + payload: DesktopPreviewPickElementInputSchema, result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), - handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId, voice }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.pickElement(tabId, voice); + }), +}); + +const updateAnnotationVoice = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL, + payload: DesktopPreviewAnnotationVoiceStateSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.updateAnnotationVoice")(function* (state) { const manager = yield* PreviewManager.PreviewManager; - return yield* manager.pickElement(tabId); + yield* manager.updateAnnotationVoice(state); }), }); @@ -418,6 +440,7 @@ const saveRecording = DesktopIpc.makeIpcMethod({ export const methods = [ setForwardedShortcuts, + updateAnnotationVoice, createTab, closeTab, registerWebview, @@ -432,6 +455,7 @@ export const methods = [ setColorScheme, setZoomFactor, setAudioMuted, + setShown, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 15e04f71282b1..b368b5581d06d 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -26,7 +26,6 @@ import { } from "@t3tools/contracts"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -35,10 +34,12 @@ import * as DesktopIpc from "../DesktopIpc.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; -type DesktopSshEnvironmentRequestOperation = - | "fetch-environment-descriptor" - | "bootstrap-bearer-session" - | "fetch-session-state"; +const DesktopSshEnvironmentRequestOperation = Schema.Literals([ + "fetch-environment-descriptor", + "bootstrap-bearer-session", + "fetch-session-state", +]); +type DesktopSshEnvironmentRequestOperation = typeof DesktopSshEnvironmentRequestOperation.Type; type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; @@ -47,12 +48,13 @@ const isEnvironmentInternalError = Schema.is(EnvironmentInternalError); const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbiddenError); const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError); +const isRemoteEnvironmentAuthUndeclaredStatusError = Schema.is( + RemoteEnvironmentAuthUndeclaredStatusError, +); +const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null { - if ( - cause instanceof RemoteEnvironmentAuthUndeclaredStatusError || - cause instanceof SshHttpBridgeError - ) { + if (isRemoteEnvironmentAuthUndeclaredStatusError(cause) || isSshHttpBridgeError(cause)) { return cause.status ?? null; } if (isEnvironmentRequestInvalidError(cause)) { @@ -73,13 +75,14 @@ function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | n return null; } -export class DesktopSshEnvironmentRequestError extends Data.TaggedError( +export class DesktopSshEnvironmentRequestError extends Schema.TaggedError()( "DesktopSshEnvironmentRequestError", -)<{ - readonly operation: DesktopSshEnvironmentRequestOperation; - readonly cause: DesktopSshEnvironmentRequestCause; - readonly sshHttpStatus: number | null; -}> { + { + operation: DesktopSshEnvironmentRequestOperation, + cause: Schema.Defect(), + sshHttpStatus: Schema.NullOr(Schema.Number), + }, +) { // The web client matches "[ssh_http:500]" to explain version-skewed SSH pairing. override get message() { const prefix = this.sshHttpStatus === null ? "" : `[ssh_http:${this.sshHttpStatus}] `; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 480af7c6f82f2..6cd51b2126d1b 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -94,6 +94,15 @@ export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getPreferredSystemLanguages = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL, + result: Schema.Array(Schema.String), + handler: Effect.fn("desktop.ipc.window.getPreferredSystemLanguages")(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + return yield* electronApp.preferredSystemLanguages; + }), +}); + export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, result: Schema.Boolean, @@ -282,6 +291,12 @@ export const openExternal = DesktopIpc.makeIpcMethod({ const READ_MEDIA_MAX_BYTES = 256 * 1024 * 1024; +// No URL field: media URLs can carry signed query tokens. +class MediaReadError extends Schema.TaggedError()("MediaReadError", { + message: Schema.String, + cause: Schema.Defect(), +}) {} + // The renderer can display media it cannot fetch(); reading here keeps Save and Copy working for it. export const readMedia = DesktopIpc.makeIpcMethod({ channel: IpcChannels.READ_MEDIA_CHANNEL, @@ -298,25 +313,28 @@ export const readMedia = DesktopIpc.makeIpcMethod({ return null; } const session = window.value.webContents.session; - return yield* Effect.tryPromise(async () => { - // Like an , send the session's cookies. - const response = await session.fetch(url, { credentials: "include" }); - const mimeType = response.headers.get("content-type") ?? ""; - if (!response.ok || !response.body) { - await response.body?.cancel(); - return { status: response.status, mimeType, data: new Uint8Array() }; - } - // The whole body crosses IPC, so stop before a huge file can exhaust main-process memory. - const chunks: Uint8Array[] = []; - let size = 0; - for await (const chunk of response.body) { - size += chunk.byteLength; - if (size > READ_MEDIA_MAX_BYTES) { - return { status: 413, mimeType, data: new Uint8Array() }; + return yield* Effect.tryPromise({ + try: async () => { + // Like an , send the session's cookies. + const response = await session.fetch(url, { credentials: "include" }); + const mimeType = response.headers.get("content-type") ?? ""; + if (!response.ok || !response.body) { + await response.body?.cancel(); + return { status: response.status, mimeType, data: new Uint8Array() }; } - chunks.push(chunk); - } - return { status: response.status, mimeType, data: Buffer.concat(chunks) }; + // The whole body crosses IPC, so stop before a huge file can exhaust main-process memory. + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.byteLength; + if (size > READ_MEDIA_MAX_BYTES) { + return { status: 413, mimeType, data: new Uint8Array() }; + } + chunks.push(chunk); + } + return { status: response.status, mimeType, data: Buffer.concat(chunks) }; + }, + catch: (cause) => new MediaReadError({ message: "Could not read media.", cause }), }).pipe(Effect.orElseSucceed(() => null)); }), }); diff --git a/apps/desktop/src/permissions/MacPermissions.ts b/apps/desktop/src/permissions/MacPermissions.ts index 0a7bb3a2c4475..2052f378743c6 100644 --- a/apps/desktop/src/permissions/MacPermissions.ts +++ b/apps/desktop/src/permissions/MacPermissions.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { MacPermissionHelper } from "./MacPermissionHelper.ts"; @@ -18,6 +19,15 @@ export class MacPermissions extends Context.Service< } >()("@t3tools/desktop/permissions/MacPermissions") {} +class MacPermissionHelperError extends Schema.TaggedError()( + "MacPermissionHelperError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not show the permission helper."; + } +} + export const layer = Layer.effect( MacPermissions, Effect.gen(function* () { @@ -28,15 +38,17 @@ export const layer = Layer.effect( return MacPermissions.of({ showHelper: Effect.fn("MacPermissions.showHelper")(function* (permission, owner, isGranted) { if (environment.platform !== "darwin" || !environment.isPackaged) return; - yield* Effect.tryPromise(() => - helper.show( - permission, - path.join(environment.dirname, "mac-permission-preload.cjs"), - owner, - environment.resolveResourcePathCandidates("icon.png"), - isGranted, - ), - ).pipe( + yield* Effect.tryPromise({ + try: () => + helper.show( + permission, + path.join(environment.dirname, "mac-permission-preload.cjs"), + owner, + environment.resolveResourcePathCandidates("icon.png"), + isGranted, + ), + catch: (cause) => new MacPermissionHelperError({ cause }), + }).pipe( Effect.catch((cause) => Effect.logWarning("Could not show permission helper", cause)), ); }), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9b3a755e4d5fc..7cef446592719 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,6 +1,7 @@ import { forkCapture } from "./forkBrowserCapturePreload.ts"; import type { DesktopBridge, + DesktopPreviewAnnotationVoiceEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, @@ -107,6 +108,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, + getPreferredSystemLanguages: () => { + const result: unknown = ipcRenderer.sendSync( + IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL, + ); + return Array.isArray(result) ? result.filter((tag) => typeof tag === "string") : []; + }, getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { @@ -120,6 +127,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), + getHostEnvironmentId: () => ipcRenderer.invoke(IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), configureGlobalScriptShortcuts: (scripts) => ipcRenderer.invoke(IpcChannels.CONFIGURE_GLOBAL_SCRIPT_SHORTCUTS_CHANNEL, scripts), @@ -354,6 +362,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + setShown: (tabId, shown) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), @@ -372,7 +382,20 @@ contextBridge.exposeInMainWorld("desktopBridge", { tabId, enabled, }), - pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + annotationVoice: { + onEvent: (listener) => { + const onEvent = (_event: Electron.IpcRendererEvent, tabId: string, value: unknown) => { + if (typeof tabId !== "string" || typeof value !== "object" || value === null) return; + listener(tabId, value as DesktopPreviewAnnotationVoiceEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, onEvent); + return () => ipcRenderer.off(IpcChannels.PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, onEvent); + }, + update: (state) => + ipcRenderer.invoke(IpcChannels.PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL, state), + }, + pickElement: (tabId, voice) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId, voice }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), captureScreenshot: (tabId) => diff --git a/apps/desktop/src/preview/Annotation.css b/apps/desktop/src/preview/Annotation.css index 0897aad4f86d5..3c5604e1b8cf3 100644 --- a/apps/desktop/src/preview/Annotation.css +++ b/apps/desktop/src/preview/Annotation.css @@ -36,6 +36,7 @@ --t3-popover-foreground: oklch(0.269 0 0); --t3-primary: oklch(0.488 0.217 264); --t3-primary-foreground: white; + --t3-destructive: oklch(0.577 0.245 27.325); --t3-muted: rgb(0 0 0 / 4%); --t3-muted-foreground: oklch(0.556 0 0); --t3-accent: rgb(0 0 0 / 4%); @@ -66,3 +67,8 @@ textarea:focus-visible { outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); outline-offset: -2px; } + +/* Utility display classes must not override the native hidden state. */ +[hidden] { + display: none !important; +} diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index d02abc8ac6123..3b8e2b7000896 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border-width: 0;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-5 {\n height: calc(var(--spacing) * 5);\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.h-full {\n height: 100%;\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-0\\.5 {\n width: calc(var(--spacing) * 0.5);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-7 {\n width: calc(var(--spacing) * 7);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.origin-center {\n transform-origin: center;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-between {\n justify-content: space-between;\n}\n.justify-center {\n justify-content: center;\n}\n.justify-end {\n justify-content: flex-end;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.gap-px {\n gap: 1px;\n}\n.truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-full {\n border-radius: calc(infinity * 1px);\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.pb-2 {\n padding-bottom: calc(var(--spacing) * 2);\n}\n.text-right {\n text-align: right;\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.tabular-nums {\n --tw-numeric-spacing: tabular-nums;\n font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n}\n.shadow {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-destructive: oklch(0.577 0.245 27.325);\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n[hidden] {\n display: none !important;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ordinal {\n syntax: "*";\n inherits: false;\n}\n@property --tw-slashed-zero {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-figure {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-spacing {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-fraction {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-ordinal: initial;\n --tw-slashed-zero: initial;\n --tw-numeric-figure: initial;\n --tw-numeric-spacing: initial;\n --tw-numeric-fraction: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 0bf95cd641c5b..e5f8665d11358 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -92,16 +92,25 @@ export const checkBrowserImportPolicy = Effect.fn("BrowserImport.checkPolicy")(f } }); +class BrowserCookieFlushError extends Schema.TaggedError()( + "BrowserCookieFlushError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Imported cookies could not be flushed to disk."; + } +} + export class BrowserCookieWriteError extends Schema.TaggedError()( "BrowserCookieWriteError", { url: Schema.String, - name: Schema.String, + cookieName: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return `Could not write imported cookie ${this.name} for ${this.url}.`; + return `Could not write imported cookie ${this.cookieName} for ${this.url}.`; } } @@ -167,7 +176,8 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( sameSite: cookie.sameSite, ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }), }), - catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), + catch: (cause) => + new BrowserCookieWriteError({ url: cookie.url, cookieName: cookie.name, cause }), }).pipe( Effect.as(true), Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), @@ -182,7 +192,10 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( } // Cookie writes settle in memory; flush to disk before reporting a durable import. if (imported > 0) { - yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( + yield* Effect.tryPromise({ + try: () => session.cookies.flushStore(), + catch: (cause) => new BrowserCookieFlushError({ cause }), + }).pipe( Effect.tapError((error) => Effect.logWarning("Imported cookies could not be flushed to disk", { cause: error.cause }), ), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 7a339102f19a6..e68e52961fa60 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -16,6 +16,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly setPermissionRequestHandler: ReturnType; readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; + readonly webRequest: { readonly onBeforeSendHeaders: ReturnType }; } >(), })); @@ -62,6 +63,7 @@ describe("BrowserSession", () => { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -115,10 +117,12 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => + it.effect("keeps the native User-Agent except for the Electron token sent to Teams", () => Effect.gen(function* () { const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; + const teamsUserAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Safari/537.36"; fromPartition.mockReset(); fromPartition.mockImplementation((partition: string) => { let userAgent = nativeUserAgent; @@ -131,6 +135,7 @@ describe("BrowserSession", () => { setUserAgent: vi.fn((next: string) => { userAgent = next; }), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -143,6 +148,29 @@ describe("BrowserSession", () => { const browserSession = sessions.get(partition); assert.isDefined(browserSession); assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent); + + // Teams redirects Electron to an error page before sign-in; every other host sees the native one. + const calls = browserSession.webRequest.onBeforeSendHeaders.mock.calls; + assert.strictEqual(calls.length, 1); + const [filter, listener] = calls[0] ?? []; + assert.deepStrictEqual(filter, { + urls: ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"], + }); + const send = (requestHeaders: Record) => { + let response: { requestHeaders?: Record } | undefined; + listener({ requestHeaders }, (next: typeof response) => { + response = next; + }); + return response; + }; + for (const key of ["User-Agent", "user-agent"]) { + const requestHeaders = { [key]: nativeUserAgent, Accept: "*/*" }; + assert.deepStrictEqual(send(requestHeaders), { + requestHeaders: { [key]: teamsUserAgent, Accept: "*/*" }, + }); + assert.strictEqual(requestHeaders[key], nativeUserAgent); + } + assert.deepStrictEqual(send({ Accept: "*/*" }), {}); }).pipe(Effect.provide(layer)), ); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index a780cacf11ea9..b2523ba62b973 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -55,6 +55,9 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => } }; +// Teams sends Electron User-Agents to an error page before sign-in. Electron allows one listener per session. +const TEAMS_URL_PATTERNS = ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"]; + const MAX_PROMPT_URL_LENGTH = 300; const truncateForPrompt = (text: string): string => @@ -342,6 +345,22 @@ export const make = Effect.gen(function* BrowserSessionMake() { ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); + browserSession.webRequest.onBeforeSendHeaders( + { urls: TEAMS_URL_PATTERNS }, + ({ requestHeaders }, callback) => { + const userAgent = Object.entries(requestHeaders).find( + ([name]) => name.toLowerCase() === "user-agent", + ); + if (userAgent === undefined) { + callback({}); + return; + } + const [name, value] = userAgent; + callback({ + requestHeaders: { ...requestHeaders, [name]: value.replace(/ Electron\/\S+/, "") }, + }); + }, + ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 2e6e54a5293a7..b2a3886f87668 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -144,6 +144,44 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + host.attach(key, makeDebuggee().tab); + host.setShown(key, true); + const reader = yield* takeEvents(host, 5).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.detach(key); + host.attach(key, makeDebuggee().tab); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + { type: "detached", ...key }, + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + ]); + }), + ); + + it.effect("replays an explicit hidden report before announcing another tab", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const other = { ...key, tabId: "tab-2" }; + host.setShown(key, false); + host.attach(key, makeDebuggee().tab); + host.setShown(other, true); + host.attach(other, makeDebuggee().tab); + + expect(yield* takeEvents(host, 3)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: false }, + { type: "attached", ...other }, + ]); + }), + ); + it.effect("drops replies from a relay the server released", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 6b6276ba74a5f..16b3f2674e1bd 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -89,6 +89,7 @@ export class DesktopBrowserHost extends Context.Service< readonly handleCommandLine: (line: string) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; + readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ @@ -121,6 +122,9 @@ export const make = Effect.gen(function* () { const tabs = new Map(); /** Threads whose agent turn runs, by the server's account. */ const agentTurns = new Set(); + // Kept apart from attachment: the renderer can show a webview before its debugger attaches, + // and the server forgets `shown` on detach, for example while DevTools holds the debugger. + const shownTabs = new Map(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const forkCapture = makeForkBrowserCapture(emit, (key) => tabs.get(keyOf(key))?.debuggee); // Fork: native rendering leases. @@ -460,6 +464,19 @@ export const make = Effect.gen(function* () { debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); emit({ type: "attached", ...key }); + const shown = shownTabs.get(id); + if (shown !== undefined) emit({ type: "shown", ...key, shown }); + }; + + const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => { + const id = keyOf(key); + if (shown === undefined) { + shownTabs.delete(id); + return; + } + if (shownTabs.get(id) === shown) return; + shownTabs.set(id, shown); + if (tabs.has(id)) emit({ type: "shown", ...key, shown }); }; const handleCommandLine = (line: string) => @@ -500,7 +517,14 @@ export const make = Effect.gen(function* () { (tab) => { dropRelay(tab); forkCapture.announce(tab.key); // Fork: replay native presentation after a server restart. - return PubSub.publish(outbox, { type: "attached", ...tab.key }); + const shown = shownTabs.get(keyOf(tab.key)); + return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe( + Effect.andThen( + shown !== undefined + ? PubSub.publish(outbox, { type: "shown", ...tab.key, shown }) + : Effect.void, + ), + ); }, { discard: true }, ); @@ -521,6 +545,7 @@ export const make = Effect.gen(function* () { handleCommandLine, attach, detach, + setShown, placeDownload, humanStartedDownload, }); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 29011a3db7e01..a4fd88c475bca 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -14,3 +14,5 @@ export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; +export const ANNOTATION_VOICE_EVENT_CHANNEL = "preview:annotation-voice-event"; +export const ANNOTATION_VOICE_STATE_CHANNEL = "preview:annotation-voice-state"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 32b9ad23f9c93..26a6386a9abd2 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2443,6 +2443,70 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("delivers annotation voice state only to the pick session that owns it", () => + withManager((manager) => + Effect.gen(function* () { + const firstReady = yield* Deferred.make(); + const secondReady = yield* Deferred.make(); + let registrations = 0; + const guest = { + ...makeTestPreviewWebContents(async () => ({ + toPNG: () => Buffer.from("unused"), + toJPEG: () => Buffer.from("unused"), + getSize: () => ({ width: 1, height: 1 }), + })), + isFocused: () => true, + focus: vi.fn(), + send: (channel: string, ...args: unknown[]) => { + webviewSend(channel, ...args); + if (channel === "preview:start-pick") + Deferred.doneUnsafe(registrations++ === 0 ? firstReady : secondReady, Effect.void); + }, + once: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, + }; + fromId.mockReturnValue(guest as never); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const first = yield* manager + .pickElement("tab_1", { sessionId: "first", shortcuts: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstReady); + const state = { + tabId: "tab_1", + sessionId: "first", + available: true, + phase: "recording" as const, + status: "Recording", + errorAction: null, + preview: null, + freezesEditor: true, + blocksSubmission: true, + level: 0, + shortcutLabel: "Ctrl+D", + draft: { text: "Tighten spacing", cursor: 15 }, + }; + yield* manager.updateAnnotationVoice(state); + expect(webviewSend).toHaveBeenCalledWith("preview:annotation-voice-state", state); + const second = yield* manager + .pickElement("tab_1", { sessionId: "second", shortcuts: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondReady); + expect(yield* Fiber.join(first)).toBeNull(); + webviewSend.mockClear(); + yield* manager.updateAnnotationVoice(state); + expect(webviewSend).not.toHaveBeenCalled(); + yield* manager.updateAnnotationVoice({ ...state, sessionId: "second" }); + expect(webviewSend).toHaveBeenCalledOnce(); + yield* manager.cancelPickElement("tab_1"); + expect(yield* Fiber.join(second)).toBeNull(); + webviewSend.mockClear(); + yield* manager.updateAnnotationVoice({ ...state, sessionId: "second" }); + expect(webviewSend).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("keeps annotation send grants isolated and updates an active pick", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 18eb4b2b75968..8c0904199921d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,10 +1,13 @@ import * as NodeZlib from "node:zlib"; import { + DesktopPreviewAnnotationVoiceEventSchema, DesktopPreviewRecordingInputSchema, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAnnotationVoiceConfig, + DesktopPreviewAnnotationVoiceState, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewOpenLinkEvent, @@ -56,13 +59,19 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; -import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, + ANNOTATION_VOICE_EVENT_CHANNEL, + ANNOTATION_VOICE_STATE_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, @@ -396,9 +405,14 @@ interface PendingRecording { } interface PickSession { + readonly voiceSessionId?: string | undefined; readonly cancel: Effect.Effect; } +const decodeAnnotationVoiceEvent = Schema.decodeUnknownOption( + DesktopPreviewAnnotationVoiceEventSchema, +); + interface BrowserControlSession { readonly webContentsId: number; // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. @@ -1685,6 +1699,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (Option.isNone(tab)) return; const closedTab = tab.value; + if (closedTab.serverTab) browserHost.setShown(closedTab.serverTab, undefined); if (closedTab.webContentsId != null) { yield* Effect.all( [ @@ -1893,6 +1908,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; wc.once("destroyed", () => { + runFork(hideDestroyedGuest(webContentsId)); runFork(detachControlSession(webContentsId)); }); yield* ensureControlSession(wc).pipe( @@ -2072,16 +2088,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }); - const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) { + // Voice state reaches a guest only while its pick session, by id, still owns the tab. + const updateAnnotationVoice = Effect.fn("PreviewManager.updateAnnotationVoice")(function* ( + state: DesktopPreviewAnnotationVoiceState, + ) { + const sessions = yield* Ref.get(pickSessionsRef); + if (sessions.get(state.tabId)?.voiceSessionId !== state.sessionId) return; + const wc = yield* requireWebContents(state.tabId); + yield* attempt({ operation: "annotationVoice.update", tabId: state.tabId }, () => + wc.send(ANNOTATION_VOICE_STATE_CHANNEL, state), + ); + }); + + const pickElement = Effect.fn("PreviewManager.pickElement")(function* ( + tabId: string, + voice?: DesktopPreviewAnnotationVoiceConfig, + ) { const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { - const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; + const session: PickSession = { + voiceSessionId: voice?.sessionId, + cancel: Effect.suspend(() => cancelPickSession()), + }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.ipc.removeListener(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); @@ -2189,6 +2224,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); }; + // Only this guest's listener sees these; the renderer still checks the session id. + const onVoiceEvent = (_event: Electron.IpcMainEvent, value: unknown) => { + const decoded = decodeAnnotationVoiceEvent(value); + if (settled || !voice || Option.isNone(decoded)) return; + if (decoded.value.sessionId !== voice.sessionId) return; + runFork( + Effect.gen(function* () { + const window = yield* Ref.get(mainWindowRef); + if (Option.isNone(window) || window.value.isDestroyed()) return; + yield* attempt({ operation: "annotationVoice.forward", tabId }, () => + window.value.webContents.send( + PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + tabId, + decoded.value, + ), + ).pipe(Effect.ignore); + }), + ); + }; const onDestroyed = () => settle(null); const onNavigated = ( event: Electron.Event, @@ -2208,10 +2262,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + if (voice) wc.ipc.on(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.once("destroyed", onDestroyed); wc.on("did-start-navigation", onNavigated); if (!wc.isFocused()) wc.focus(); - wc.send(START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(tabId) === true); + wc.send( + START_PICK_CHANNEL, + annotationTheme, + annotationSendEnabled.get(tabId) === true, + voice, + ); }); }); runFork( @@ -2346,6 +2406,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* applyColorScheme(tabId, wc, colorScheme); }); + const setShown = Effect.fn("PreviewManager.setShown")(function* (tabId: string, shown: boolean) { + const serverTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.serverTab; + if (serverTab) browserHost.setShown(serverTab, shown); + }); + + // A window reload or guest crash destroys the guest without the renderer reporting it hidden. + // The renderer reports again once a new guest registers. + const hideDestroyedGuest = Effect.fnUntraced(function* (webContentsId: number) { + for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { + if (tab.webContentsId === webContentsId && tab.serverTab) { + browserHost.setShown(tab.serverTab, false); + } + } + }); + const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( tabId: string, audioMuted: boolean, @@ -3295,6 +3370,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + updateAnnotationVoice, prepareWebview, reapplyZoom, refresh, @@ -3307,6 +3383,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAnnotationSendEnabled, setAudioMuted, + setShown, setColorScheme, setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => @@ -3512,6 +3589,7 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setShown: (tabId: string, shown: boolean) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: ( partitions?: ReadonlyArray, @@ -3533,7 +3611,11 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly pickElement: ( tabId: string, + voice?: DesktopPreviewAnnotationVoiceConfig, ) => Effect.Effect; + readonly updateAnnotationVoice: ( + state: DesktopPreviewAnnotationVoiceState, + ) => Effect.Effect; readonly cancelPickElement: (tabId: string) => Effect.Effect; readonly captureScreenshot: ( tabId: string, @@ -3652,6 +3734,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, + setShown: operations.setShown, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { yield* browserSession @@ -3683,6 +3766,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setAnnotationTheme: operations.setAnnotationTheme, setAnnotationSendEnabled: operations.setAnnotationSendEnabled, pickElement: operations.pickElement, + updateAnnotationVoice: operations.updateAnnotationVoice, cancelPickElement: operations.cancelPickElement, captureScreenshot: operations.captureScreenshot, revealArtifact: operations.revealArtifact, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 4cb5987cdeb85..f54963d10916f 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,8 +1,12 @@ // @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; +import { matchesKeybindingShortcut } from "@t3tools/shared/keybindingMatching"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAnnotationVoiceConfig, + DesktopPreviewAnnotationVoiceEvent, + DesktopPreviewAnnotationVoiceState, PickedElementPayload, PickedElementStackFrame, PreviewAnnotationPayload, @@ -23,6 +27,8 @@ import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, ANNOTATION_THEME_CHANNEL, + ANNOTATION_VOICE_EVENT_CHANNEL, + ANNOTATION_VOICE_STATE_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, @@ -524,7 +530,10 @@ function strokeBounds( return { x: left, y: top, width: right - left, height: bottom - top }; } -function startAnnotation(sendEnabled: boolean): void { +function startAnnotation( + sendEnabled: boolean, + voice: DesktopPreviewAnnotationVoiceConfig | undefined, +): void { activeSession?.teardown(false); let finished = false; const host = document.createElement("div"); @@ -595,6 +604,225 @@ function startAnnotation(sendEnabled: boolean): void { "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; composerRow.appendChild(comment); + // Recording happens in the application renderer, which owns the microphone consent. + // This guest only draws the controls, forwards trusted gestures, and mirrors state. + let voicePhase: DesktopPreviewAnnotationVoiceState["phase"] = "idle"; + let voiceSettings = false; + let voiceBusy = false; + let voiceAvailable = false; + let voiceKeyCode: string | null = null; + let pendingVoiceSubmission: PreviewAnnotationSubmission | null = null; + let voiceSubmissionReady = false; + const voiceButton = createButton("", "Start voice input"); + voiceButton.innerHTML = + ''; + voiceButton.className += " h-8 w-8 shrink-0 p-0 text-muted-foreground"; + voiceButton.hidden = true; + voiceButton.setAttribute("aria-label", "Start voice input"); + composerRow.appendChild(voiceButton); + const voiceRow = document.createElement("div"); + voiceRow.className = "flex min-w-0 items-center justify-end gap-1 px-2 pb-2"; + voiceRow.hidden = true; + const voicePill = document.createElement("div"); + const voiceCancel = createButton("", "Cancel voice input"); + voiceCancel.innerHTML = + ''; + voiceCancel.className += " h-7 w-7 shrink-0 rounded-full p-0"; + const voiceFinish = createButton("", "Finish voice input"); + voiceFinish.className += + " h-7 w-7 shrink-0 rounded-full border-primary bg-primary p-0 text-primary-foreground hover:bg-primary/90"; + const voiceSkip = createButton("Skip", "Skip transcript cleanup"); + voiceSkip.className += " h-7 shrink-0 rounded-full px-2.5"; + // createButton's rounded-md sorts after rounded-full in the generated CSS, so it would win. + for (const button of [voiceCancel, voiceFinish, voiceSkip]) button.classList.remove("rounded-md"); + const voiceStatus = document.createElement("div"); + voiceStatus.className = "flex h-7 min-w-0 flex-1 items-center gap-2"; + voiceStatus.setAttribute("role", "status"); + const voiceStatusText = document.createElement("span"); + voiceStatusText.className = "min-w-0 flex-1 truncate text-right text-sm text-muted-foreground"; + const waveform = document.createElement("div"); + waveform.className = + "flex h-5 min-w-0 flex-1 items-center justify-between gap-px overflow-hidden"; + waveform.setAttribute("aria-hidden", "true"); + const waveformLevels = Array(24).fill(0); + const waveformBars = waveformLevels.map(() => { + const bar = document.createElement("span"); + bar.className = "h-full w-0.5 shrink-0 origin-center rounded-full bg-primary"; + bar.style.transform = "scaleY(0.08)"; + bar.style.opacity = "0.25"; + waveform.appendChild(bar); + return bar; + }); + const voiceElapsed = document.createElement("span"); + voiceElapsed.className = "shrink-0 text-xs tabular-nums text-muted-foreground"; + let recordingStartedAt = 0; + let elapsedTimer: ReturnType | null = null; + const updateElapsed = () => { + const seconds = Math.floor((Date.now() - recordingStartedAt) / 1_000); + voiceElapsed.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; + }; + voiceStatus.append(voiceStatusText, waveform, voiceElapsed); + voicePill.append(voiceCancel, voiceStatus, voiceSkip, voiceFinish); + voiceRow.appendChild(voicePill); + const voicePreview = document.createElement("p"); + voicePreview.className = "px-2 pb-2 text-sm text-muted-foreground"; + voicePreview.setAttribute("role", "status"); + voicePreview.hidden = true; + const sendVoice = ( + action: DesktopPreviewAnnotationVoiceEvent["action"], + keyboard?: KeyboardEvent, + ) => { + if (!voice || finished) return; + const event: DesktopPreviewAnnotationVoiceEvent = { + sessionId: voice.sessionId, + text: comment.value, + cursor: comment.selectionStart, + selectionEnd: comment.selectionEnd, + action, + ...(keyboard + ? { + keyboard: { + type: keyboard.type === "keyup" ? ("keyup" as const) : ("keydown" as const), + key: keyboard.key, + code: keyboard.code, + metaKey: keyboard.metaKey, + ctrlKey: keyboard.ctrlKey, + shiftKey: keyboard.shiftKey, + altKey: keyboard.altKey, + repeat: keyboard.repeat, + }, + } + : {}), + }; + ipcRenderer.send(ANNOTATION_VOICE_EVENT_CHANNEL, event); + }; + const isDictationChord = (event: KeyboardEvent) => + voice !== undefined && + voice.shortcuts.some((shortcut) => + matchesKeybindingShortcut(event, shortcut, navigator.platform), + ); + for (const button of [voiceButton, voiceFinish, voiceCancel, voiceSkip]) { + button.addEventListener("pointerdown", (event) => event.preventDefault()); + } + // Page scripts cannot reach the closed shadow root, and a synthetic click never counts. + voiceButton.addEventListener("click", (event) => { + if (!event.isTrusted) return; + if (voiceSettings) { + sendVoice("settings"); + return; + } + if (!voiceAvailable || (voiceBusy && voicePhase !== "recording")) return; + sendVoice(voicePhase === "recording" ? "stop" : "start"); + }); + voiceFinish.addEventListener("click", (event) => { + if (event.isTrusted) sendVoice("stop"); + }); + voiceCancel.addEventListener("click", (event) => { + if (!event.isTrusted) return; + pendingVoiceSubmission = null; + sendVoice("cancel"); + }); + voiceSkip.addEventListener("click", (event) => { + if (event.isTrusted) sendVoice("skip"); + }); + const onVoiceState = ( + _event: Electron.IpcRendererEvent, + state: DesktopPreviewAnnotationVoiceState, + ) => { + if (finished || state.sessionId !== voice?.sessionId) return; + const phaseChanged = voicePhase !== state.phase; + const submissionChanged = voiceBusy !== state.blocksSubmission; + const previewChanged = voicePreview.textContent !== (state.preview ?? ""); + voicePhase = state.phase; + voiceSettings = state.errorAction === "settings"; + voiceBusy = state.blocksSubmission; + voiceAvailable = state.available; + comment.readOnly = state.freezesEditor; + if (state.draft) { + voiceSubmissionReady = pendingVoiceSubmission !== null; + comment.value = state.draft.text; + comment.setSelectionRange(state.draft.cursor, state.draft.cursor); + resizeComment(); + } + const recording = state.phase === "recording"; + const active = state.phase !== "idle" && state.phase !== "error"; + const wasActive = voicePill.parentElement === composerRow; + voiceButton.hidden = !state.available || active; + voiceButton.disabled = voiceBusy; + const label = voiceSettings ? "Open voice settings" : "Start voice input"; + voiceButton.title = + !voiceSettings && state.shortcutLabel ? `${label} (${state.shortcutLabel})` : label; + voiceButton.setAttribute("aria-label", label); + // An active dictation takes the textarea's place; it takes focus once (sr-only) so Enter still attaches. + voiceRow.hidden = state.phase === "idle" || active; + comment.classList.toggle("sr-only", active); + if (active && !wasActive) comment.focus({ preventScroll: true }); + submit.hidden = active; + // Re-inserting on every level update would blur a focused Finish or Cancel. + if (active && voicePill.parentElement !== composerRow) comment.after(voicePill); + else if (!active && voicePill.parentElement !== voiceRow) voiceRow.appendChild(voicePill); + voicePill.className = active + ? "flex h-8 min-w-0 flex-1 items-center gap-2" + : "flex min-w-0 items-center gap-1"; + // A draft that arrived while the textarea was sr-only was measured at 1px wide. + if (wasActive && !active) resizeComment(); + else if (phaseChanged) queueEditorLayout(); + voiceCancel.title = + state.phase === "error" ? "Dismiss voice input error" : "Cancel voice input"; + voiceCancel.setAttribute("aria-label", voiceCancel.title); + voiceStatus.setAttribute("aria-label", state.status ?? "Voice input"); + voiceStatus.setAttribute("aria-live", recording ? "off" : "polite"); + voiceStatusText.textContent = state.status; + voiceStatusText.style.color = state.phase === "error" ? "var(--t3-destructive)" : ""; + voiceStatusText.hidden = recording; + waveform.hidden = !recording; + voiceElapsed.hidden = !recording; + if (recording && elapsedTimer === null) { + recordingStartedAt = Date.now(); + waveformLevels.fill(0); + updateElapsed(); + elapsedTimer = setInterval(updateElapsed, 250); + } else if (!recording && elapsedTimer !== null) { + clearInterval(elapsedTimer); + elapsedTimer = null; + } + if (recording) { + waveformLevels.copyWithin(0, 1); + waveformLevels[waveformLevels.length - 1] = state.level; + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + waveformBars.forEach((bar, index) => { + const level = waveformLevels[index] ?? 0; + bar.style.opacity = String(0.25 + level * 0.75); + bar.style.transform = `scaleY(${reducedMotion ? 0.35 : Math.max(0.08, level)})`; + }); + } + voiceSkip.hidden = state.phase !== "post-processing"; + voiceFinish.hidden = !active || state.phase === "post-processing"; + voiceFinish.disabled = !recording; + voiceFinish.title = recording ? "Finish voice input" : (state.status ?? "Voice input is busy"); + if (recording && state.shortcutLabel) voiceFinish.title += ` (${state.shortcutLabel})`; + voiceFinish.setAttribute("aria-label", voiceFinish.title); + if (phaseChanged) + voiceFinish.innerHTML = recording + ? '' + : ''; + if (!active) voicePill.append(voiceStatus, voiceCancel); + else if (phaseChanged) voicePill.append(voiceCancel, voiceStatus, voiceSkip, voiceFinish); + voicePreview.textContent = state.preview; + voicePreview.hidden = !state.preview; + if (phaseChanged || submissionChanged || previewChanged || state.draft) updateStatus(); + if (pendingVoiceSubmission !== null) { + if (state.phase === "recording") sendVoice("stop"); + if (state.phase === "error") pendingVoiceSubmission = null; + if (state.phase === "idle") { + const submission = pendingVoiceSubmission; + pendingVoiceSubmission = null; + if (voiceSubmissionReady && submission !== null) submitAnnotation(submission); + voiceSubmissionReady = false; + } + } + }; + const dragHandle = document.createElement("button"); dragHandle.type = "button"; dragHandle.textContent = "⠿"; @@ -613,7 +841,7 @@ function startAnnotation(sendEnabled: boolean): void { submit.className += " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; composerRow.appendChild(submit); - editor.appendChild(composerRow); + editor.append(composerRow, voicePreview, voiceRow); const stylePanel = document.createElement("div"); stylePanel.className = @@ -654,12 +882,19 @@ function startAnnotation(sendEnabled: boolean): void { comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; queueEditorLayout(); }; - comment.addEventListener("input", resizeComment); + comment.addEventListener("input", () => { + resizeComment(); + sendVoice("sync"); + }); const updateStatus = (): void => { const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; + if (!hasTargets && voiceBusy) { + pendingVoiceSubmission = null; + sendVoice("cancel"); + } editor.style.display = hasTargets ? "flex" : "none"; - submit.disabled = !hasTargets; + submit.disabled = !hasTargets || voicePhase === "preparing" || pendingCapture; submit.style.opacity = hasTargets ? "1" : "0.45"; adjust.disabled = !hasTargets; stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; @@ -695,7 +930,6 @@ function startAnnotation(sendEnabled: boolean): void { for (const [key, change] of styleChanges) { if (change.targetId === target.id) styleChanges.delete(key); } - updateStatus(); }; const addSelected = (element: Element): void => { @@ -710,7 +944,6 @@ function startAnnotation(sendEnabled: boolean): void { selected.set(element, target); root.append(target.outline, target.label); updateSelectedVisual(target); - updateStatus(); if (editorExpanded) { stylePanel.style.display = "grid"; syncStyleControls(); @@ -721,12 +954,13 @@ function startAnnotation(sendEnabled: boolean): void { const existing = selected.get(element); if (existing) { removeSelected(existing); - return; - } - if (!additive) { - for (const target of Array.from(selected.values())) removeSelected(target); + } else { + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); } - addSelected(element); + updateStatus(); }; const setStyleForSelected = (property: string, value: string): void => { @@ -1140,6 +1374,7 @@ function startAnnotation(sendEnabled: boolean): void { const rect = target.element.getBoundingClientRect(); if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { removeSelected(target); + updateStatus(); return true; } } @@ -1318,7 +1553,11 @@ function startAnnotation(sendEnabled: boolean): void { if (event.relatedTarget === null) clearHoverOutline(); }; + const onWindowFocus = (): void => { + if (editor.style.display !== "none") sendVoice("focus"); + }; const onWindowBlur = (): void => { + sendVoice("blur"); clearHoverOutline(); }; @@ -1344,7 +1583,9 @@ function startAnnotation(sendEnabled: boolean): void { window.removeEventListener("pointerout", onPointerOut, true); window.removeEventListener("click", onClick, true); window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("focus", onWindowFocus); window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("keyup", onVoiceKeyUp, true); window.removeEventListener("scroll", repaint, true); window.removeEventListener("resize", repaint); dragHandle.removeEventListener("pointerdown", onEditorPointerDown); @@ -1354,6 +1595,8 @@ function startAnnotation(sendEnabled: boolean): void { if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + ipcRenderer.off(ANNOTATION_VOICE_STATE_CHANNEL, onVoiceState); + if (elapsedTimer !== null) clearInterval(elapsedTimer); document.documentElement.removeAttribute("data-t3code-annotation-tool"); cursorStyle.remove(); inputLayer?.remove(); @@ -1364,7 +1607,32 @@ function startAnnotation(sendEnabled: boolean): void { const onCancel = (): void => teardown(false); const onCaptured = (): void => teardown(false); + const onVoiceKeyUp = (event: KeyboardEvent): void => { + if (!event.isTrusted || (event.code || event.key) !== voiceKeyCode) return; + voiceKeyCode = null; + event.preventDefault(); + event.stopImmediatePropagation(); + sendVoice("key", event); + }; const onKeyDown = (event: KeyboardEvent): void => { + // Only the keyboard drives dictation; a page cannot synthesize the chord or Escape. + if (voice && event.isTrusted && !event.isComposing && event.keyCode !== 229) { + if (event.key === "Escape" && voiceBusy) { + event.preventDefault(); + event.stopImmediatePropagation(); + voiceKeyCode = null; + pendingVoiceSubmission = null; + sendVoice("key", event); + return; + } + if (voiceAvailable && editor.style.display !== "none" && isDictationChord(event)) { + event.preventDefault(); + event.stopImmediatePropagation(); + voiceKeyCode = event.code || event.key; + sendVoice("key", event); + return; + } + } if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; if (event.key === "Escape") { event.preventDefault(); @@ -1382,6 +1650,16 @@ function startAnnotation(sendEnabled: boolean): void { const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { if (submission === "send" && !sendEnabled) return; + if (voicePhase === "preparing") return; + // Submitting while dictating finishes the recording and submits once the text lands. + if (voiceBusy) { + if (pendingVoiceSubmission === null) { + pendingVoiceSubmission = submission; + voiceSubmissionReady = false; + if (voicePhase === "recording") sendVoice("stop"); + } + return; + } if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) return; pendingCapture = true; @@ -1460,11 +1738,14 @@ function startAnnotation(sendEnabled: boolean): void { window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); window.addEventListener("click", onClick, { capture: true, passive: false }); window.addEventListener("blur", onWindowBlur); + window.addEventListener("focus", onWindowFocus); window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("keyup", onVoiceKeyUp, { capture: true }); window.addEventListener("scroll", repaint, { capture: true, passive: true }); window.addEventListener("resize", repaint, { passive: true }); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + if (voice) ipcRenderer.on(ANNOTATION_VOICE_STATE_CHANNEL, onVoiceState); if (inputLayer) { document.documentElement.appendChild(inputLayer); inputLayer.focus({ preventScroll: true }); @@ -1473,6 +1754,7 @@ function startAnnotation(sendEnabled: boolean): void { rootResizeObserver.observe(document.documentElement); refreshToolButtons(); updateStatus(); + sendVoice(document.hasFocus() ? "focus" : "sync"); activeSession = { teardown, applyTheme: (theme) => applyAnnotationTheme(host, theme), @@ -1485,9 +1767,14 @@ function startAnnotation(sendEnabled: boolean): void { ipcRenderer.on( START_PICK_CHANNEL, - (_event, theme: DesktopPreviewAnnotationTheme | undefined, sendEnabled?: boolean) => { + ( + _event, + theme: DesktopPreviewAnnotationTheme | undefined, + sendEnabled?: boolean, + voice?: DesktopPreviewAnnotationVoiceConfig, + ) => { if (theme) annotationTheme = theme; - startAnnotation(sendEnabled === true); + startAnnotation(sendEnabled === true, voice); }, ); ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => { diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index a8a743a72d832..9910001b13f2f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -203,16 +203,22 @@ describe("DesktopSettings", () => { withSettings( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, - mainWindowMaximized: true, - serverExposureMode: "network-accessible", - }); - - const loaded = yield* settings.load; - assert.isNull(loaded.mainWindowBounds); - assert.isFalse(loaded.mainWindowMaximized); - assert.equal(loaded.serverExposureMode, "network-accessible"); + for (const bounds of [ + { x: 10.5, y: 20, width: 400, height: 360 }, + { x: 10, y: 20, width: 399, height: 360 }, + { x: 10, y: 20, width: 400, height: 359 }, + ]) { + yield* writeSettingsPatch({ + mainWindowBounds: bounds, + mainWindowMaximized: true, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); + assert.equal(loaded.serverExposureMode, "network-accessible"); + } }), ), ); @@ -224,17 +230,24 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 400, height: 360 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { - mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowBounds: { x: -1200, y: 40, width: 400, height: 360 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); + + const restored = yield* Effect.gen(function* () { + const freshSettings = yield* DesktopAppSettings.DesktopAppSettings; + return yield* freshSettings.load; + }).pipe(Effect.provide(DesktopAppSettings.layer)); + assert.deepEqual(restored.mainWindowBounds, persisted.mainWindowBounds); + assert.isTrue(restored.mainWindowMaximized); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 60e7ddb681f33..9298665057e7e 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -54,9 +54,12 @@ export interface DesktopSettingsChange { } const DEFAULT_TAILSCALE_SERVE_PORT = 443; -const MIN_MAIN_WINDOW_SIZE = { - width: 840, - height: 620, +// Fits half and third tiles of common scaled displays (down to 480x432 DIP) on +// Wayland, where Electron's client-side frame adds 32x42 DIP to the enforced +// minimum. The web layout already handles phone widths. +export const MIN_MAIN_WINDOW_SIZE = { + width: 400, + height: 360, } as const; export const DesktopWindowBoundsSchema = Schema.Struct({ x: Schema.Int, diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 721f130ef5e56..59d658e1eedaf 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -1872,7 +1872,9 @@ it.effect("capture fails closed while snapshots are disabled", () => { it.effect("points capture setup failures at SnapShot settings", () => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - linuxCaptureMock.mockRejectedValueOnce(new CaptureSetupRequiredError("Install the helper.")); + linuxCaptureMock.mockRejectedValueOnce( + new CaptureSetupRequiredError({ state: "Install the helper." }), + ); return Effect.scoped( Effect.gen(function* () { const service = yield* DesktopSnapShot.make; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index a3d4db0bb4dc2..53799e1577939 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -94,6 +94,8 @@ import { import * as HostProcess from "@t3tools/shared/HostProcess"; import { readImageDimensions } from "@t3tools/shared/imageDimensions"; +const isCaptureSetupRequiredError = Schema.is(CaptureSetupRequiredError); + const MAX_CAPTURE_WIDTH = 2_560; const MAX_CAPTURE_HEIGHT = 1_600; const SHORTCUT_COOLDOWN_NS = 200_000_000n; @@ -167,9 +169,20 @@ export class DesktopSnapShotError extends Schema.TaggedError()( + "SnapShotShortcutError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not register the SnapShot shortcut."; + } +} + +const shortcutFailure = (cause: unknown) => new SnapShotShortcutError({ cause }); + function captureFailure(cause: unknown, captureId?: string): DesktopSnapShotError { if (isDesktopSnapShotError(cause)) return cause; - const operation = cause instanceof CaptureSetupRequiredError ? "setup-required" : "capture"; + const operation = isCaptureSetupRequiredError(cause) ? "setup-required" : "capture"; return new DesktopSnapShotError({ operation, captureId, cause }); } @@ -768,6 +781,8 @@ export const make = Effect.gen(function* () { const captureDirectory = path.join(environment.stateDir, "snap-shots"); const linuxAppId = environment.linuxDesktopEntryName.replace(/\.desktop$/, ""); const home = yield* HostProcess.HomeDirectory; + // Shell discovery can replace XDG_CURRENT_DESKTOP after startup, so read the live env per state. + const hostEnv = yield* HostProcess.Environment; let shortcutVerified = false; const gnomeSetupPaths = { bundle: environment.isPackaged @@ -1066,13 +1081,15 @@ export const make = Effect.gen(function* () { if (mode === "portal") { return { available: false, message: WAYLAND_MODIFIER_PAIR_UNAVAILABLE_MESSAGE }; } - const available = yield* Effect.tryPromise(() => - startPairShortcutProcess( - snapShotShortcutModifierPair(shortcut), - () => undefined, - () => undefined, - ), - ).pipe( + const available = yield* Effect.tryPromise({ + try: () => + startPairShortcutProcess( + snapShotShortcutModifierPair(shortcut), + () => undefined, + () => undefined, + ), + catch: shortcutFailure, + }).pipe( Effect.tap((stop) => Effect.sync(stop)), Effect.as(true), Effect.orElseSucceed(() => false), @@ -1087,7 +1104,10 @@ export const make = Effect.gen(function* () { const systemConflict = snapShotShortcutSystemConflict(shortcut); if (systemConflict) return { available: false, message: systemConflict }; if (mode === "portal") { - return yield* Effect.try(() => portalShortcutTrigger(shortcut)).pipe( + return yield* Effect.try({ + try: () => portalShortcutTrigger(shortcut), + catch: shortcutFailure, + }).pipe( Effect.match({ onSuccess: () => ({ available: true, @@ -1197,13 +1217,16 @@ export const make = Effect.gen(function* () { return; } if (mode === "portal" && niriSocketPath()) { - const registered = yield* Effect.tryPromise(async () => { - const { startNiriCaptureShortcut } = await import("./NiriCaptureShortcut.ts"); - return startNiriCaptureShortcut(linuxAppId, onCurrentShortcut, () => { - void runPromise( - setShortcutFailure("The Niri capture endpoint disconnected. Restart T3 Code."), - ).catch(() => undefined); - }); + const registered = yield* Effect.tryPromise({ + try: async () => { + const { startNiriCaptureShortcut } = await import("./NiriCaptureShortcut.ts"); + return startNiriCaptureShortcut(linuxAppId, onCurrentShortcut, () => { + void runPromise( + setShortcutFailure("The Niri capture endpoint disconnected. Restart T3 Code."), + ).catch(() => undefined); + }); + }, + catch: shortcutFailure, }).pipe( Effect.tap((stop) => Effect.sync(() => { @@ -1247,29 +1270,32 @@ export const make = Effect.gen(function* () { shortcutMessage: null, message: null, }); - yield* Effect.tryPromise(async () => { - const { PortalCaptureShortcut } = await import("./PortalCaptureShortcut.ts"); - return new PortalCaptureShortcut( - linuxAppId, - isModifierPairShortcut(shortcut) - ? { - key: "2", - ctrlKey: true, - modKey: false, - altKey: false, - shiftKey: true, - metaKey: false, - } - : shortcut, - onCurrentShortcut, - () => { - if (generation !== shortcutGeneration) return; - shortcutVerified = false; - void runPromise(emit({ type: "shortcut-changed" })).catch(() => undefined); - }, - undefined, - hyprland, - ); + yield* Effect.tryPromise({ + try: async () => { + const { PortalCaptureShortcut } = await import("./PortalCaptureShortcut.ts"); + return new PortalCaptureShortcut( + linuxAppId, + isModifierPairShortcut(shortcut) + ? { + key: "2", + ctrlKey: true, + modKey: false, + altKey: false, + shiftKey: true, + metaKey: false, + } + : shortcut, + onCurrentShortcut, + () => { + if (generation !== shortcutGeneration) return; + shortcutVerified = false; + void runPromise(emit({ type: "shortcut-changed" })).catch(() => undefined); + }, + undefined, + hyprland, + ); + }, + catch: shortcutFailure, }).pipe( Effect.tap((registration) => Effect.sync(() => { @@ -1291,15 +1317,21 @@ export const make = Effect.gen(function* () { let registered = false; if (isModifierPairShortcut(shortcut)) { - registered = yield* Effect.tryPromise(() => - startPairShortcutProcess(snapShotShortcutModifierPair(shortcut), onCurrentShortcut, () => { - void pipe( - snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), - setShortcutFailure, - runPromise, - ).catch(() => undefined); - }), - ).pipe( + registered = yield* Effect.tryPromise({ + try: () => + startPairShortcutProcess( + snapShotShortcutModifierPair(shortcut), + onCurrentShortcut, + () => { + void pipe( + snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), + setShortcutFailure, + runPromise, + ).catch(() => undefined); + }, + ), + catch: shortcutFailure, + }).pipe( Effect.tap((stop) => Effect.sync(() => { stopShiftShortcut = stop; @@ -1487,7 +1519,10 @@ export const make = Effect.gen(function* () { if (action === "retry-shortcut") { const currentPortal = portalShortcut; if (currentPortal?.hasSession && !currentPortal.state.shortcutPending) { - yield* Effect.tryPromise(() => currentPortal.configure()).pipe( + yield* Effect.tryPromise({ + try: () => currentPortal.configure(), + catch: shortcutFailure, + }).pipe( Effect.mapError( (error) => new DesktopSnapShotSetupError({ @@ -1581,9 +1616,12 @@ export const make = Effect.gen(function* () { state: Ref.get(stateRef).pipe( Effect.flatMap((state) => state.mode === "portal" - ? Effect.tryPromise(async () => { - const { getLinuxCaptureSupport } = await import("./LinuxSnapShot.ts"); - return getLinuxCaptureSupport(linuxAppId); + ? Effect.tryPromise({ + try: async () => { + const { getLinuxCaptureSupport } = await import("./LinuxSnapShot.ts"); + return getLinuxCaptureSupport(linuxAppId); + }, + catch: (cause) => new DesktopSnapShotError({ operation: "unsupported", cause }), }).pipe( Effect.map((support) => ({ ...state, ...support })), Effect.catch((error) => @@ -1621,7 +1659,8 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const linuxDesktop = state.mode === "portal" - ? process.env.XDG_CURRENT_DESKTOP?.toLowerCase() + ? (hostEnv.XDG_CURRENT_DESKTOP ?? "") + .toLowerCase() .split(":") .find( (name) => diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.ts index a0b8b4181cd2a..c40f397809d26 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.ts @@ -171,11 +171,11 @@ export async function captureHyprlandWindow( let state = await setup.state(); if (state.status === "update-required") { await setup.perform("install-hyprland-helper").catch((cause: unknown) => { - throw new CaptureSetupRequiredError(state.message, { cause }); + throw new CaptureSetupRequiredError({ state: state.message, cause }); }); state = await setup.state(); } - if (state.status !== "ready") throw new CaptureSetupRequiredError(state.message); + if (state.status !== "ready") throw new CaptureSetupRequiredError({ state: state.message }); const executable = hyprlandCaptureExecutable(paths); const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-hyprland-capture-")); const cleanup = () => NodeFSP.rm(directory, { recursive: true, force: true }); diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 2e5723f51aae0..f141bd485d027 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -215,7 +215,7 @@ export async function captureKdeWindow( options?: { readonly flash: boolean; readonly animate: boolean }, ): Promise { const state = await new KdeCaptureSetup(paths).state(); - if (state.status !== "ready") throw new CaptureSetupRequiredError(state.message); + if (state.status !== "ready") throw new CaptureSetupRequiredError({ state: state.message }); const { executable } = kdeCapturePaths(paths); const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-kde-capture-")); let retained = false; diff --git a/apps/desktop/src/snapShot/linuxCaptureSession.ts b/apps/desktop/src/snapShot/linuxCaptureSession.ts index 11bf3984223df..e1f7c3601efc0 100644 --- a/apps/desktop/src/snapShot/linuxCaptureSession.ts +++ b/apps/desktop/src/snapShot/linuxCaptureSession.ts @@ -2,15 +2,19 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeURL from "node:url"; import type { SnapShotKeyChord } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { nativeImage } from "electron"; // Keep dbus-next out of desktop detection and PNG reads in the main process. export const HYPRLAND_CAPTURE_ACTION = "capture-window"; -export class CaptureSetupRequiredError extends Error { - constructor(state: string, options?: ErrorOptions) { - super(`${state} Open Settings → SnapShots to continue setup.`, options); +export class CaptureSetupRequiredError extends Schema.TaggedError()( + "CaptureSetupRequiredError", + { state: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `${this.state} Open Settings → SnapShots to continue setup.`; } } export const NIRI_CAPTURE_PATH = "/com/t3tools/SnapShot"; diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 3a3e84e03a9b7..7b480b36fbd12 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -23,6 +23,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -74,11 +75,13 @@ function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } +const isSshPasswordPromptError = Schema.is(SshPasswordPromptError); + export function isDesktopSshPasswordPromptCancellation( error: unknown, ): error is SshPasswordPromptError { return ( - error instanceof SshPasswordPromptError && + isSshPasswordPromptError(error) && DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) ); } diff --git a/apps/desktop/src/telemetry/DesktopRendererHistory.ts b/apps/desktop/src/telemetry/DesktopRendererHistory.ts index ed1590f63b19b..dc3e157f95351 100644 --- a/apps/desktop/src/telemetry/DesktopRendererHistory.ts +++ b/apps/desktop/src/telemetry/DesktopRendererHistory.ts @@ -273,7 +273,11 @@ const make = Effect.fn("DesktopRendererHistory.make")(function* () { accepting = false; for (const renderer of active.values()) renderer.removeListeners(); active.clear(); - }).pipe(Effect.andThen(Fiber.interrupt(writerFiber)), Effect.asVoid); + }).pipe( + Effect.andThen(Fiber.interrupt(writerFiber)), + Effect.asVoid, + Effect.withSpan("desktop.rendererHistory.shutdown"), + ); yield* Effect.addFinalizer(() => shutdown); return DesktopRendererHistory.of({ register, recordMetrics, shutdown }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 1180c0fa70915..9201bc01110e8 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -37,6 +37,7 @@ function layerElectronApp( metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index ca2f8ef1eadbe..7998d15c63359 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -26,6 +26,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -828,7 +829,8 @@ export const make = Effect.gen(function* () { yield* Ref.set(installRunningBackendsRef, runningInstances); yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), + (instance) => + instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), { concurrency: "unbounded" }, ); yield* electronUpdater.quitAndInstall({ diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 93ba091e00c25..cbbe35b956159 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -31,6 +31,7 @@ const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 5e02f4cfd8bbe..292a3540d18bf 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Electron from "electron"; @@ -39,6 +40,15 @@ import { makeQuitShortcutHandler } from "./QuitHold.ts"; import { copyContextMenuImage } from "./ContextMenuImage.ts"; const TITLEBAR_HEIGHT = 40; + +class ContextMenuImageCopyError extends Schema.TaggedError()( + "ContextMenuImageCopyError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not copy the context-menu image."; + } +} // Match --workspace-topbar-height in apps/web/src/index.css; native macOS buttons stay 14 points at any zoom. const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; const MACOS_WINDOW_BUTTON_RADIUS = 7; @@ -389,8 +399,8 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, - minWidth: 840, - minHeight: 620, + minWidth: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.width, + minHeight: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.height, show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), @@ -580,9 +590,10 @@ export const make = Effect.gen(function* () { label: "Copy Image", click: () => { void runPromise( - Effect.tryPromise(() => copyContextMenuImage(contents, params)).pipe( - Effect.catch(() => logWindowWarning("failed to copy context-menu image")), - ), + Effect.tryPromise({ + try: () => copyContextMenuImage(contents, params), + catch: (cause) => new ContextMenuImageCopyError({ cause }), + }).pipe(Effect.catch(() => logWindowWarning("failed to copy context-menu image"))), ); }, }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index f65fc11f59a75..57d6c61f7c43a 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; @@ -1141,7 +1142,8 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; - const windir = process.env.WINDIR ?? "C:\\Windows"; + // Not Config: Windows variable names are case-insensitive only on the live process.env. + const windir = (yield* HostProcess.Environment).WINDIR ?? "C:\\Windows"; const provideSpawner = ( effect: Effect.Effect, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 3e5675b3f9d21..52ddba48e03b6 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -27,6 +27,12 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, + // CI bundle jobs skip the Linux browser secret helper; packaging stages one per arch. + "build:bundle": { + command: "node scripts/build-preview-annotation-css.mjs && vp pack", + dependsOn: ["t3#build"], + cache: false, + }, dev: { command: "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", @@ -43,9 +49,9 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, - // tsc (tsc-rs) rewrites the tsbuildinfo it reads, which keeps a task out of the cache. + // A local tsbuildinfo is leftover build state, not a typecheck input. typecheck: { - command: "tsc --noEmit", + command: "effect-tsgo tsrs --noEmit", cache: { input: [{ auto: true }, "!*.tsbuildinfo"] }, }, }, @@ -122,7 +128,10 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), entry: ["src/preview-pick-preload.ts"], deps: { - alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), + alwaysBundle: (id) => + id === "react-grab" || + id.startsWith("react-grab/") || + id === "@t3tools/shared/keybindingMatching", }, }, { diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index b9724347fbbd2..9435c6f30bc1c 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -10,6 +10,8 @@ delete process.env.VP_RUN_NODE_CLIENT_PATH; export default defineConfig({ vite: { cacheDir: forkViteCacheDir(import.meta.url) }, site: "https://t3.codes", + // Vercel 308s `/path/` to `/path` (see vercel.ts), so every internal link and canonical uses one form. + trailingSlash: "never", server: { port: Number(process.env.PORT ?? 4173), }, diff --git a/apps/marketing/public/robots.txt b/apps/marketing/public/robots.txt new file mode 100644 index 0000000000000..495a72e25e4b3 --- /dev/null +++ b/apps/marketing/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://t3.codes/sitemap.xml diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index d1aea12f09bc9..c21c60b14527f 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -9,18 +9,25 @@ import { IOS_APP_STORE_URL, MARKETING_STATS, } from "../lib/site"; +import { type JsonLd, jsonLdGraph, organizationJsonLd, websiteJsonLd } from "../lib/seo"; interface Props { title?: string; description?: string; pageClass?: string; + /** Page-specific schema.org nodes. Organization and WebSite are always included. */ + jsonLd?: ReadonlyArray; + noindex?: boolean; } const { title = "T3 Code", description = "T3 Code. The open-source control plane for coding agents.", pageClass, + jsonLd = [], + noindex = false, } = Astro.props; +const structuredData = jsonLdGraph([organizationJsonLd(), websiteJsonLd(), ...jsonLd]); // Link unfurlers require a 1200x630 JPG/PNG at an absolute URL. const socialImage = await getImage({ @@ -41,7 +48,7 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); - + {noindex ? : } @@ -61,7 +68,9 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); + {title} +