diff --git a/.github/fork/boundary-exceptions b/.github/fork/boundary-exceptions new file mode 100644 index 0000000000000..51a82e0e97a81 --- /dev/null +++ b/.github/fork/boundary-exceptions @@ -0,0 +1 @@ +apps/server/src/environmentTheme.ts diff --git a/.github/fork/check-boundaries.sh b/.github/fork/check-boundaries.sh new file mode 100755 index 0000000000000..98169dab10a48 --- /dev/null +++ b/.github/fork/check-boundaries.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Fails when this fork's changes reach code the phone app or the servers run. +# Usage: check-boundaries.sh (default: origin/upstream-mirror) +# Exceptions the owner approved go in .github/fork/boundary-exceptions, one path per line. +set -euo pipefail +base="${1:-origin/upstream-mirror}" +exceptions=.github/fork/boundary-exceptions + +forbidden='^(apps/server/|apps/mobile/|packages/client-runtime/|packages/contracts/|packages/provider-|packages/source-control-|packages/effect-)' +allowed='^packages/contracts/src/ipc\.ts$' +careful='^packages/shared/' + +status=0 +while read -r path; do + [[ -z "$path" ]] && continue + if [[ -f "$exceptions" ]] && grep -qxF "$path" "$exceptions"; then + echo "approved exception: $path" + elif [[ "$path" =~ $allowed ]]; then + : + elif [[ "$path" =~ $forbidden ]]; then + echo "::error file=$path::Fork change reaches code the phone or servers run. See FORK.md, Boundaries." + status=1 + elif [[ "$path" =~ $careful ]]; then + echo "::warning file=$path::Shared helper changed: confirm the server and phone keep their behavior." + fi +done < <(git diff --name-only "$base" HEAD) +exit "$status" diff --git a/.github/fork/dependency-consumers.test.cjs b/.github/fork/dependency-consumers.test.cjs new file mode 100644 index 0000000000000..c1f36faa247be --- /dev/null +++ b/.github/fork/dependency-consumers.test.cjs @@ -0,0 +1,22 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); +const { dirname, join } = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const mobileRequire = createRequire(join(__dirname, '../../apps/mobile/package.json')); +const nativeRequire = createRequire(mobileRequire.resolve('@react-navigation/native')); +const navigationDir = dirname(nativeRequire.resolve('@react-navigation/core')); +const screens = { Chat: 'chat' }; + +test('navigation decodes query parameters through the upgraded decoder', async () => { + const { getStateFromPath } = await import(pathToFileURL(join(navigationDir, 'getStateFromPath.js'))); + const state = getStateFromPath('/chat?message=hello%20world', { screens }); + assert.equal(state.routes[0].params.message, 'hello world'); +}); + +test('navigation encodes route parameters after dependency upgrades', async () => { + const { getPathFromState } = await import(pathToFileURL(join(navigationDir, 'getPathFromState.js'))); + const state = { routes: [{ name: 'Chat', params: { message: 'hello world' } }] }; + assert.equal(getPathFromState(state, { screens }), '/chat?message=hello%20world'); +}); diff --git a/.github/fork/fork-pipeline.test.cjs b/.github/fork/fork-pipeline.test.cjs new file mode 100644 index 0000000000000..79acb8fd39360 --- /dev/null +++ b/.github/fork/fork-pipeline.test.cjs @@ -0,0 +1,214 @@ +// What the fork's nightly pipeline promises, tested from the outside: +// it ships only upstream nightlies, never ships a phone/server protocol change +// by itself, refuses fork changes that the stock servers or the phone would +// see, and keeps its push credentials away from jobs that run repository code. +"use strict"; + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { execFileSync, spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { selectNightly } = require("./select-nightly.cjs"); + +const here = __dirname; +const workflow = fs.readFileSync(path.join(here, "../workflows/fork-nightly.yml"), "utf8"); + +function repo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-pipeline-")); + const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8" }).trim(); + git("init", "-q", "-b", "main"); + git("config", "user.email", "test@example.invalid"); + git("config", "user.name", "test"); + const write = (file, text) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text); + }; + const commit = (message) => { + git("add", "-A"); + git("commit", "-q", "--allow-empty", "-m", message); + return git("rev-parse", "HEAD"); + }; + const run = (script, ...args) => + spawnSync("bash", [path.join(here, script), ...args], { cwd: dir, encoding: "utf8" }); + return { dir, git, write, commit, run }; +} + +// --- Which upstream build the fork follows ------------------------------- + +test("ships the newest upstream nightly", () => { + const releases = [ + { tag_name: "v0.0.46-nightly.20261011.2994", draft: false }, + { tag_name: "v0.0.46-nightly.20261011.2980", draft: false }, + ]; + assert.equal(selectNightly(releases), "v0.0.46-nightly.20261011.2994"); +}); + +test("never follows a draft, preview, PR build or stable release", () => { + const releases = [ + { tag_name: "v0.0.46-nightly.20261012.3001", draft: true }, + { tag_name: "v0.0.46-preview.20261012.3000", draft: false }, + { tag_name: "v0.0.46-pr.18200.1", draft: false }, + { tag_name: "v0.0.45", draft: false }, + { tag_name: "v0.0.46-nightly.20261011.2994", draft: false }, + ]; + assert.equal(selectNightly(releases), "v0.0.46-nightly.20261011.2994"); +}); + +test("finds nothing rather than guessing when upstream has no nightly", () => { + assert.equal(selectNightly([{ tag_name: "v0.0.45", draft: false }]), null); + const cli = spawnSync("node", [path.join(here, "select-nightly.cjs")], { + input: JSON.stringify([{ tag_name: "v0.0.45", draft: false }]), + encoding: "utf8", + }); + assert.equal(cli.status, 1); +}); + +test("a release carries the upstream version unchanged, so servers and the app agree", () => { + // The skew banner compares nightly versions in full; any suffix would make + // the desktop app newer than every server. + assert.match(workflow, /version="\$\{tag#v\}"/); + assert.match(workflow, /--build-version "\$\{\{ needs\.rebase\.outputs\.version \}\}"/); + assert.match(workflow, /gh release create "\$TAG"/); +}); + +// --- The phone's protocol ------------------------------------------------ + +const protocolFile = "packages/contracts/src/environment.ts"; +const protocol = (n) => `export const ORCHESTRATION_PROTOCOL_VERSION = ${n};\n`; + +test("an unchanged protocol ships", () => { + const r = repo(); + r.write(protocolFile, protocol(2)); + const base = r.commit("base"); + r.write("apps/web/src/a.ts", "x\n"); + const target = r.commit("target"); + const result = r.run("protocol-guard.sh", base, target); + assert.equal(result.status, 0, result.stdout + result.stderr); +}); + +test("a protocol change holds instead of shipping", () => { + const r = repo(); + r.write(protocolFile, protocol(2)); + const base = r.commit("base"); + r.write(protocolFile, protocol(3)); + const target = r.commit("target"); + const result = r.run("protocol-guard.sh", base, target); + assert.equal(result.status, 1); + assert.match(result.stdout, /2 -> 3/); +}); + +test("an unreadable protocol holds rather than shipping blind", () => { + const r = repo(); + r.write(protocolFile, protocol(2)); + const base = r.commit("base"); + fs.rmSync(path.join(r.dir, protocolFile)); + const target = r.commit("moved"); + assert.equal(r.run("protocol-guard.sh", base, target).status, 1); +}); + +test("the protocol guard can only be skipped by an explicit manual input", () => { + assert.match(workflow, /if: steps\.plan\.outputs\.skip == 'false' && !inputs\.allow_protocol_change/); + assert.match(workflow, /allow_protocol_change:[\s\S]*?default: false/); +}); + +// --- What the fork may change --------------------------------------------- + +function boundary(changes, exceptions) { + const r = repo(); + r.write("README.md", "base\n"); + const base = r.commit("base"); + for (const file of changes) r.write(file, "changed\n"); + if (exceptions) r.write(".github/fork/boundary-exceptions", exceptions.join("\n") + "\n"); + r.commit("change"); + return r.run("check-boundaries.sh", base); +} + +test("desktop and web changes pass", () => { + const result = boundary(["apps/desktop/src/x.ts", "apps/web/src/y.tsx", "packages/contracts/src/ipc.ts"]); + assert.equal(result.status, 0, result.stdout); +}); + +for (const file of [ + "apps/server/src/ws.ts", + "apps/mobile/src/App.tsx", + "packages/client-runtime/src/connection/compatibility.ts", + "packages/contracts/src/environment.ts", + "packages/provider-grok/src/index.ts", +]) { + test(`a change the servers or phone would see is refused: ${file}`, () => { + const result = boundary([file]); + assert.equal(result.status, 1); + assert.match(result.stdout, new RegExp(file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + }); +} + +test("an owner-approved exception passes, and only that path", () => { + const ok = boundary(["apps/server/src/environmentTheme.ts"], ["apps/server/src/environmentTheme.ts"]); + assert.equal(ok.status, 0, ok.stdout); + const other = boundary( + ["apps/server/src/environmentTheme.ts", "apps/server/src/ws.ts"], + ["apps/server/src/environmentTheme.ts"], + ); + assert.equal(other.status, 1); +}); + +test("shared helpers pass with a warning to check servers and the phone", () => { + const result = boundary(["packages/shared/src/themeFile.ts"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /::warning/); +}); + +// --- Credentials and rewrites --------------------------------------------- + +function job(name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `job ${name} exists`); + const rest = workflow.slice(start + 1); + const next = rest.slice(3).search(/\n {2}[a-z_]+:\n/); + return next === -1 ? rest : rest.slice(0, next + 3); +} + +test("the push key never reaches jobs that run repository code", () => { + for (const name of ["check", "build"]) { + assert.doesNotMatch(job(name), /FORK_DEPLOY_KEY/, `${name} sees the deploy key`); + assert.doesNotMatch(job(name), /contents: write/, `${name} can write`); + } + assert.match(workflow, /^permissions:\n {2}contents: read\n/m); +}); + +test("the push key lives only in the two push steps, which run no repository code", () => { + const steps = workflow.split(/\n {6}- /).slice(1); + const holding = steps.filter((step) => step.includes("FORK_DEPLOY_KEY")); + assert.deepEqual( + holding.map((step) => step.match(/name: (.+)/)[1]), + ["Rebase", "Move branches"], + ); + for (const step of holding) { + assert.doesNotMatch(step, /\.github\/fork|\bnode |\bvp |\bbash \S/, "a key-holding step runs repository code"); + assert.doesNotMatch(step, /GIT_SSH_COMMAND[^\n]*GITHUB_ENV/, "the key's ssh command must not outlive its step"); + assert.match(step, /trap 'rm -f ~\/\.ssh\/fork_deploy' EXIT/); + } +}); + +test("custom is only ever replaced under a lease on the tip that was rebased", () => { + const publish = job("publish"); + assert.match(publish, /--force-with-lease="custom:\$OLD_CUSTOM"/); + assert.doesNotMatch(publish.replace(/--force-with-lease="custom:\$OLD_CUSTOM"[^\n]*/, ""), /:refs\/heads\/custom/); +}); + +test("nothing ships unless the rebase, the checks and the build all passed", () => { + const publish = job("publish"); + assert.match(publish, /needs: \[rebase, check, build\]/); + assert.match(publish, /needs\.check\.result == 'success' && needs\.build\.result == 'success'/); +}); + +test("the published files are exactly what the updater reads", () => { + const uploaded = job("build") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("release/")); + assert.deepEqual(uploaded, ["release/*.AppImage", "release/nightly-linux.yml"]); +}); diff --git a/.github/fork/protocol-guard.sh b/.github/fork/protocol-guard.sh new file mode 100755 index 0000000000000..5e24abb8a764e --- /dev/null +++ b/.github/fork/protocol-guard.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Refuses a target whose phone/server protocol number differs from the base +# custom sits on: the phone app connects only to a server with the same +# ORCHESTRATION_PROTOCOL_VERSION, so such a release is a fleet-wide cutover. +# Usage: protocol-guard.sh +set -euo pipefail +file=packages/contracts/src/environment.ts +pattern='ORCHESTRATION_PROTOCOL_VERSION = [0-9]+' +read_version() { + git show "$1:$file" 2>/dev/null | grep -oE "$pattern" | grep -oE '[0-9]+$' || true +} +old="$(read_version "$1")" +new="$(read_version "$2")" +if [[ -z "$old" || -z "$new" ]]; then + echo "::error::Cannot read ORCHESTRATION_PROTOCOL_VERSION from $file at $1 or $2; refusing to ship blind." + exit 1 +fi +if [[ "$old" != "$new" ]]; then + echo "::error::Upstream changed the phone/server protocol ($old -> $new). Ship by hand with allow_protocol_change after updating the phone app." + exit 1 +fi +echo "protocol unchanged ($old)" diff --git a/.github/fork/select-nightly.cjs b/.github/fork/select-nightly.cjs new file mode 100755 index 0000000000000..e189a0e6954b9 --- /dev/null +++ b/.github/fork/select-nightly.cjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Picks the upstream nightly the fork rebases onto: the newest non-draft +// release whose tag is a plain nightly version. Reads the GitHub releases +// API response (newest first) on stdin, prints the tag, exits 1 when none. +"use strict"; + +const NIGHTLY_TAG = /^v\d+\.\d+\.\d+-nightly\.\d{8}\.\d+$/; + +function selectNightly(releases) { + const release = releases.find((r) => !r.draft && NIGHTLY_TAG.test(r.tag_name)); + return release ? release.tag_name : null; +} + +module.exports = { selectNightly, NIGHTLY_TAG }; + +if (require.main === module) { + let input = ""; + process.stdin.on("data", (chunk) => (input += chunk)); + process.stdin.on("end", () => { + const tag = selectNightly(JSON.parse(input)); + if (!tag) { + console.error("no upstream nightly release found"); + process.exit(1); + } + console.log(tag); + }); +} diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml new file mode 100644 index 0000000000000..46e850df0d9d1 --- /dev/null +++ b/.github/workflows/fork-nightly.yml @@ -0,0 +1,349 @@ +# Rolling rebase of this fork's `custom` branch onto the newest upstream +# nightly, then a Linux desktop build published as a release of this fork. +# Contract and rules: FORK.md. Release tags reuse the upstream version so +# installed apps, servers and the version-skew check all see one number. +name: Fork nightly + +# No schedule: the owner's server dispatches this once a night, or by day after +# the owner approves. GitHub's cron dropped runs and fired hours late (#12). +on: + workflow_dispatch: + inputs: + target_tag: + description: "Upstream nightly tag to rebase onto (default: newest)" + required: false + allow_protocol_change: + description: "Ship even though the orchestration protocol number changed" + type: boolean + default: false + pull_request: + branches: [custom] + +# Read-only by default; jobs that run repository code (check, build) keep it. +permissions: + contents: read + +concurrency: + group: fork-nightly-${{ github.event_name == 'pull_request' && github.ref || 'main' }} + cancel-in-progress: false + +env: + UPSTREAM_URL: https://github.com/pingdotgg/t3code.git + UPSTREAM_REPO: pingdotgg/t3code + # Public values stock builds embed (repository variables, as upstream keeps them). + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + T3CODE_RELAY_URL: ${{ vars.RELAY_URL }} + VITE_T3CODE_RELAY_URL: ${{ vars.RELAY_URL }} + +jobs: + rebase: + name: Rebase custom onto upstream nightly + permissions: + contents: read + actions: write + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + outputs: + skip: ${{ steps.plan.outputs.skip }} + tag: ${{ steps.plan.outputs.tag }} + version: ${{ steps.plan.outputs.version }} + old_custom: ${{ steps.plan.outputs.old_custom }} + candidate: ${{ steps.rebase.outputs.candidate }} + steps: + - name: Keep upstream workflows off + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/$GITHUB_REPOSITORY/actions/workflows" --paginate \ + --jq '.workflows[] | select(.state == "active") | .path' | + while read -r path; do + case "$path" in + .github/workflows/fork-*.yml) ;; + *) gh workflow disable "$(basename "$path")" -R "$GITHUB_REPOSITORY" || true ;; + esac + done + + # Upstream's vendored .repos tree holds orphaned gitlinks that break + # actions/checkout's cleanup, so check out by hand like upstream does. + - name: Checkout custom + run: | + git init -q . + git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git remote set-url --push origin "git@github.com:${GITHUB_REPOSITORY}.git" + git remote add upstream "$UPSTREAM_URL" + git fetch -q --filter=blob:none origin custom upstream-mirror + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout -q -B custom origin/custom + git config user.name "t3code fork bot" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Choose target + id: plan + env: + GH_TOKEN: ${{ github.token }} + TARGET_TAG: ${{ inputs.target_tag }} + run: | + tag="${TARGET_TAG:-}" + if [[ -z "$tag" ]]; then + tag="$(gh api "repos/$UPSTREAM_REPO/releases?per_page=30" | node .github/fork/select-nightly.cjs)" + fi + [[ -n "$tag" ]] || { echo "::error::no upstream nightly found"; exit 1; } + version="${tag#v}" + git fetch -q --filter=blob:none upstream "refs/tags/$tag:refs/tags/$tag" + old_custom="$(git rev-parse origin/custom)" + skip=false + if gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $tag already published; nothing to ship until upstream moves." + skip=true + fi + { + echo "tag=$tag"; echo "version=$version" + echo "old_custom=$old_custom"; echo "skip=$skip" + } >> "$GITHUB_OUTPUT" + + - name: Protocol guard + if: steps.plan.outputs.skip == 'false' && !inputs.allow_protocol_change + env: + TAG: ${{ steps.plan.outputs.tag }} + run: | + if ! bash .github/fork/protocol-guard.sh origin/upstream-mirror "$TAG"; then + echo "HOLD_REASON=protocol" >> "$GITHUB_ENV" + exit 1 + fi + + - name: Rebase + id: rebase + if: steps.plan.outputs.skip == 'false' + env: + TAG: ${{ steps.plan.outputs.tag }} + FORK_DEPLOY_KEY: ${{ secrets.FORK_DEPLOY_KEY }} + run: | + if ! git rebase --onto "$TAG" origin/upstream-mirror custom; then + git status --short | sed 's/^/conflict: /' + git rebase --abort + echo "HOLD_REASON=conflict" >> "$GITHUB_ENV" + echo "::error::custom does not replay cleanly onto $TAG" + exit 1 + fi + # Pushes go over the fork's deploy key (GITHUB_TOKEN may not push + # workflow changes, and upstream's often carry some). The key lives + # only for this step, which runs no repository code. + install -d -m 700 ~/.ssh + printf '%s\n' "$FORK_DEPLOY_KEY" > ~/.ssh/fork_deploy && chmod 600 ~/.ssh/fork_deploy + trap 'rm -f ~/.ssh/fork_deploy' EXIT + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + GIT_SSH_COMMAND="ssh -i ~/.ssh/fork_deploy -o IdentitiesOnly=yes" \ + git push -q --force origin "custom:refs/heads/fork/candidate" + echo "candidate=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Record hold reason + if: failure() + run: | + mkdir -p hold + echo "${HOLD_REASON:-rebase-job}" > hold/reason + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: hold-reason + path: hold/reason + + check: + name: Check + needs: rebase + if: always() && (github.event_name == 'pull_request' || (needs.rebase.result == 'success' && needs.rebase.outputs.skip == 'false')) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + env: + REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || needs.rebase.outputs.candidate }} + REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + run: | + git init -q . + git remote add origin "https://github.com/${REPO}.git" + git fetch -q --no-tags --depth=1 origin "$REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout -q --detach FETCH_HEAD + # Same system packages as upstream's CI: the desktop tests compile the + # libsecret helper and the process-tree fixtures need cc. + - name: Install test build libraries + run: | + sudo apt-get update -q + sudo apt-get install -y -q --no-install-recommends build-essential libsecret-1-dev pkg-config + - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: false + - run: vp install + - run: vp run --filter @t3tools/desktop ensure:electron + - name: Typecheck + run: vp run typecheck + - name: Fork pipeline tests + run: node --test .github/fork/*.test.cjs + - name: Fork boundary check + env: + TAG: ${{ needs.rebase.outputs.tag }} + run: | + if [[ -n "$TAG" ]]; then + git fetch -q --depth=1 "$UPSTREAM_URL" "refs/tags/$TAG:refs/tags/$TAG" + base="$TAG" + else + git fetch -q --depth=1 "https://github.com/${GITHUB_REPOSITORY}.git" upstream-mirror:refs/remotes/origin/upstream-mirror + base=origin/upstream-mirror + fi + bash .github/fork/check-boundaries.sh "$base" + - name: Test the client side + run: vp run --parallel --concurrency-limit 4 --filter @t3tools/desktop --filter @t3tools/shared --filter @t3tools/contracts --filter @t3tools/client-runtime test + - name: Test the web app + if: success() || failure() + run: vp run --filter @t3tools/web test + + build: + name: Build Linux desktop app + needs: rebase + if: needs.rebase.result == 'success' && needs.rebase.outputs.skip == 'false' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + env: + REF: ${{ needs.rebase.outputs.candidate }} + run: | + git init -q . + git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git fetch -q --no-tags --depth=1 origin "$REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout -q --detach FETCH_HEAD + - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: false + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + - name: Install Linux build libraries + run: | + sudo apt-get update -q + sudo apt-get install -y -q libsecret-1-dev pkg-config imagemagick + - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + - name: Align package versions + run: node scripts/update-release-package-versions.ts "${{ needs.rebase.outputs.version }}" + - name: Build + run: vp run build:desktop + - name: Package AppImage + # GITHUB_REPOSITORY points the app's updater at this fork's releases. + run: | + vp run dist:desktop:artifact --platform linux --target AppImage --arch x64 \ + --build-version "${{ needs.rebase.outputs.version }}" --skip-build --verbose + ls -la release + - uses: actions/upload-artifact@v4 + with: + name: desktop-linux + # The updater reads nightly-linux.yml; builder-debug.yml stays private. + path: | + release/*.AppImage + release/nightly-linux.yml + if-no-files-found: error + + publish: + name: Publish + permissions: + contents: write + issues: write + actions: write + needs: [rebase, check, build] + if: needs.rebase.outputs.skip == 'false' && needs.check.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.rebase.outputs.tag }} + CANDIDATE: ${{ needs.rebase.outputs.candidate }} + OLD_CUSTOM: ${{ needs.rebase.outputs.old_custom }} + steps: + - uses: actions/download-artifact@v4 + with: + name: desktop-linux + path: dist + - name: Move branches + env: + FORK_DEPLOY_KEY: ${{ secrets.FORK_DEPLOY_KEY }} + run: | + # Same deploy key as the rebase push, alive only for this step. + install -d -m 700 ~/.ssh + printf '%s\n' "$FORK_DEPLOY_KEY" > ~/.ssh/fork_deploy && chmod 600 ~/.ssh/fork_deploy + trap 'rm -f ~/.ssh/fork_deploy' EXIT + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/fork_deploy -o IdentitiesOnly=yes" + git init -q . + git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git remote set-url --push origin "git@github.com:${GITHUB_REPOSITORY}.git" + git fetch -q --filter=blob:none origin fork/candidate + git fetch -q --filter=blob:none "$UPSTREAM_URL" "refs/tags/$TAG:refs/tags/$TAG" + # Lease on the tip this run rebased, so a change merged meanwhile is never lost. + git push --force-with-lease="custom:$OLD_CUSTOM" origin "$CANDIDATE:refs/heads/custom" + git push --force origin "refs/tags/$TAG^{commit}:refs/heads/upstream-mirror" + - name: Release + run: | + gh release create "$TAG" dist/* -R "$GITHUB_REPOSITORY" --prerelease \ + --target "$CANDIDATE" --title "$TAG (fork)" \ + --notes "Upstream ${TAG} with this fork's custom branch rebased on top ($CANDIDATE). See FORK.md." + # Moving custom registers any workflow upstream added; switch it off now + # rather than at the next run, before it can fire with this repo's secrets. + - name: Keep upstream workflows off + run: | + sleep 15 + gh api "repos/$GITHUB_REPOSITORY/actions/workflows" --paginate \ + --jq '.workflows[] | select(.state == "active") | .path' | + while read -r path; do + case "$path" in + .github/workflows/fork-*.yml) ;; + *) gh workflow disable "$(basename "$path")" -R "$GITHUB_REPOSITORY" || true ;; + esac + done + - name: Close holds + run: | + gh issue list -R "$GITHUB_REPOSITORY" --label rebase-hold --state open --json number --jq '.[].number' | + while read -r n; do + gh issue close "$n" -R "$GITHUB_REPOSITORY" --comment "Shipped $TAG; hold cleared." + done + + hold: + name: Hold + permissions: + issues: write + needs: [rebase, check, build, publish] + if: always() && github.event_name != 'pull_request' && (needs.rebase.result == 'failure' || needs.check.result == 'failure' || needs.build.result == 'failure' || needs.publish.result == 'failure') + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.rebase.outputs.tag }} + steps: + - uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: hold-reason + path: hold + - name: Open or update the hold issue + run: | + reason="$(cat hold/reason 2>/dev/null || echo "")" + if [[ -z "$reason" ]]; then + if [[ "${{ needs.check.result }}" == failure ]]; then reason=check + elif [[ "${{ needs.build.result }}" == failure ]]; then reason=build + else reason=publish; fi + fi + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + body="Nightly held at ${TAG:-unknown target}: **${reason}**. Nothing shipped; every machine stays on the last release. + + Run: $run_url + + Fix by following FORK.md (Holds). The next successful run closes this issue." + gh label create rebase-hold -R "$GITHUB_REPOSITORY" --color B60205 --description "Nightly rebase did not ship" 2>/dev/null || true + existing="$(gh issue list -R "$GITHUB_REPOSITORY" --label rebase-hold --state open --json number --jq '.[0].number')" + if [[ -n "$existing" ]]; then + gh issue comment "$existing" -R "$GITHUB_REPOSITORY" --body "$body" + else + gh issue create -R "$GITHUB_REPOSITORY" --label rebase-hold --title "Nightly rebase held" --body "$body" + fi diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000000000..c7791f02a63e3 --- /dev/null +++ b/FORK.md @@ -0,0 +1,200 @@ +# Fork contract + +This fork carries desktop-app changes on top of upstream T3 Code nightlies. +Servers and the phone app stay stock; only the Linux desktop app is built from +here. Every change lives on `custom` and is replayed onto each new upstream +nightly. + +## Branches + +| Branch | What it is | +|---|---| +| `custom` | Default branch. The upstream nightly in `upstream-mirror` plus this fork's commits on top. | +| `upstream-mirror` | The exact upstream nightly commit `custom` sits on. Never commit to it. | +| `fork/candidate` | Scratch output of the nightly run. Never base work on it. | +| `/` | A change to this fork, based on `custom`, merged by PR. | +| `upstream/` | An upstream pull request branch, based on upstream `main`. `feat/desktop-local-theme` (pingdotgg/t3code#17883) predates the naming. | + +## Rolling rebase + +- `custom` is a patch stack: upstream history, then one commit per customization. + Upstream never gets merged into it. Replay the stack with + `git rebase --onto upstream-mirror custom`. +- A change lands as a pull request into `custom`, rebased onto `custom` and + merged by squash (or rebase, for a few self-contained commits). The + repository refuses merge commits, which would break the next replay. +- When upstream ships a customization, drop its commit at the next rebase and + remove its entry below. +- Resolve a conflict by reading both sides and keeping the intent of each + customization below. The `custom` force-push uses + `--force-with-lease=custom:`, so a change merged in the + meantime is never lost. + +## Nightly pipeline + +`.github/workflows/fork-nightly.yml` runs daily at 07:35 UTC and on demand. + +1. It picks the newest upstream nightly release tag. +2. It refuses to ship by itself if `ORCHESTRATION_PROTOCOL_VERSION` changed, + because the phone app refuses a server whose protocol number differs. That + run ships only by hand (`allow_protocol_change`), timed with the phone update. +3. It rebases `custom`, typechecks, runs the client-side tests and the + boundary check, and builds the Linux AppImage. The version is the upstream + version unchanged, and the update feed is this repository's releases. +4. On success it moves `custom` and `upstream-mirror` and publishes the release + `v`. Desktop apps update from it. +5. Any failure ships nothing and opens or updates a `rebase-hold` issue. The + next successful run closes it. + +The same check job runs on every pull request into `custom`. The workflow +also keeps upstream's own workflows disabled, since they need runners this +fork does not have. + +## Holds + +- **Conflict:** replay the stack by hand on a `rebase/` branch, resolving + each conflict by the intent in Customizations. Show + `git range-diff upstream-mirror..custom ..rebase/` to the owner. + After approval, push it to `custom` and `` to `upstream-mirror`, both + with `--force-with-lease`. Then run the workflow with `target_tag=`. +- **Check or build:** fix the customization in a pull request into `custom`, + then run the workflow. If upstream fails on its own, the next nightly + retries. +- **Protocol:** the owner decides; see Nightly pipeline, step 2. + +## Boundaries + +The desktop app must keep speaking the stock servers' protocol. +`.github/fork/check-boundaries.sh` enforces this: + +- **Free:** `apps/desktop`, `apps/web`, desktop IPC in `packages/contracts/src/ipc.ts`. +- **Careful (warning):** `packages/shared`. Additions are fine. Before + changing existing behavior, confirm that servers and the phone keep it. +- **Refused:** `apps/server`, `apps/mobile`, `packages/client-runtime`, the + rest of `packages/contracts`, `packages/provider-*`, `packages/source-control-*`, + `packages/effect-*`. Server code changed here never runs on a stock server, + and wire changes break the link to it. Changing the version scheme or the + update channel is also off limits. + +An owner-approved exception is listed in `.github/fork/boundary-exceptions` +with its reason in that customization's entry below. + +## Customizations + +One entry per customization. "Must still be true" lists the behaviour the +customization exists for, in terms a reviewer can check against the code, +tests and app. A pull request that adds or changes a customization updates +its entry in the same PR and adds itself under Detail. Reviewing a pull +request, resolving a conflict or fixing a hold means confirming every entry +the change touches still holds; when an entry and the code disagree, the +entry is the intent and the code is the bug, unless the owner rules +otherwise. + +### Local desktop theme + +- **Commits:** `feat(desktop): follow a local palette file independently of servers`, + `fix(desktop): preserve local theme ownership during startup`, + `feat(desktop): match system colors and configure local palettes` +- **Must still be true:** + - Choosing **System theme** in Settings → Appearance makes the desktop app + follow the computer's light/dark appearance and accent, with local + environments disabled and when connected to remote servers only. + - With no accent exposed, both appearances use T3's standard colors. An + accent recolours actions only, never app surfaces or error colours. + - On Linux, Omarchy's generated T3 palette is found with no launcher, + environment variable or broker, and an Omarchy theme switch is followed. + - **Choose theme file** follows that file through atomic replacements and + across restarts; **Use automatic colors** returns to detection. + - A missing or invalid file keeps the last valid palette from that source, + or native colours until one arrives. Files over 32 KiB, non-regular files + and symlinks to files are refused. + - A manually chosen theme survives file updates, a change of source, server + theme pushes, delayed startup responses, navigation and restarts. + Changing the source never replaces it. + - Web and mobile clients and the stock servers see no change; nothing the + desktop app sends a server changes. +- **Boundary exception:** `apps/server/src/environmentTheme.ts` only moves its + guarded file reader into `packages/shared/src/themeFile.ts` and re-exports + it, with no behaviour change. +- **Detail:** pingdotgg/t3code#17883 (pushed before fork PRs were required); + user guide in `docs/user/appearance.md`, Desktop colors. +- **Retire when:** upstream merges pingdotgg/t3code#17883. + +### Fork pipeline + +- **Commits:** `chore(fork): nightly rolling rebase and release pipeline`, + `fix(fork): revive the nightly schedule GitHub dropped`, + `chore(fork): dispatch the nightly from the owner's server` +- **Must still be true:** + - On each dispatch, the newest upstream nightly gets `custom` replayed onto + it, checked and built; it ships only when every step is green. GitHub + never starts it on a schedule: the owner's server dispatches it once a + night, or by day after the owner approves. + - A changed `ORCHESTRATION_PROTOCOL_VERSION` never ships without + `allow_protocol_change`. + - A release carries the upstream version unchanged, and fork builds update + from this repository's releases. + - Any failure ships nothing and opens or updates one `rebase-hold` issue; + the next success closes it. + - The boundary check runs on every pull request and nightly and refuses the + refused zone except listed exceptions. + - Upstream's own workflows stay disabled. + - The push key lives only in the two push steps, which run no repository + code; write access stays out of jobs that run repository code, + and `custom` is replaced only under a lease on the tip that was rebased. +- **Tests:** `node --test .github/fork/*.test.cjs` (Check job), one test per + promise above that can be checked without running Actions. +- **Detail:** this file and `.github/workflows/fork-nightly.yml`; + not-compromised/t3code#6 (nightly schedule revival), + not-compromised/t3code#12 (dispatch from the owner's server). +- **Retire when:** never. + +### Dependency security fixes + +- **Commits:** `fix(fork): update dependencies with released security fixes` +- **Must still be true:** + - The workspace lockfile resolves the dependencies named in #8 to releases + that fix the reported advisories. + - Security overrides use compatible minimum-version ranges, allowing newer + patch/minor releases within the chosen compatibility line. Remove them + when upstream raises its dependency requirements to fixed versions. + - The desktop build, markdown rendering, D-Bus integration and bundled + provider SDK consumers remain compatible. Navigation keeps query-string + 7's namespace API while its decoder resolves to a fixed ESM release. +- **Boundary exception:** the owner's dependency-update request permits + security overrides in `pnpm-workspace.yaml` and regenerated `pnpm-lock.yaml`. + Stock npm servers and the TestFlight app still receive dependencies from + upstream; this fork update changes their bundled fork counterparts only. +- **Detail:** not-compromised/t3code#8, not-compromised/t3code#10. +- **Retire when:** upstream's dependency requirements exclude the vulnerable versions. + +### Sidebar render leak + +- **Commits:** `fix(web): stop the sidebar keeping every past render alive` +- **Must still be true:** + - With agents running, the desktop window's Sidebar keeps a constant + handful of render scopes alive, not one per render. Renderer memory + stops growing from Sidebar re-renders and doesn't climb toward the V8 + heap limit. + - The thread, draft and multi-select context menus, the project settings + shortcut and the row action sweep act on the capabilities and projects + current when they run. +- **Detail:** not-compromised/t3code#14; upstream crash report + pingdotgg/t3code#17295. +- **Retire when:** upstream ships an equivalent fix to + `apps/web/src/components/Sidebar.tsx`. + +## Verification + +A run is good when the release exists with `T3-Code-*.AppImage` and +`nightly-linux.yml`, and an installed fork build finds it through +Settings → Updates. Before moving `custom` by hand, run +`node --test .github/fork/*.test.cjs`, the boundary check against +`upstream-mirror`, and the client-side tests from the workflow. + +## Rollback + +Every release stays published, and its tag points at the exact stack it +shipped. Roll a desktop back by installing an older release's AppImage; the +next release moves it forward again. Servers are never moved backward +automatically. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8e736a7d54105..8cd891a1508a9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", + "culori": "^4.0.2", "dbus-next": "0.10.2", "effect": "catalog:", "electron": "44.4.5", @@ -35,6 +36,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/culori": "^4.0.1", "@types/node": "catalog:", "acorn": "8.16.0", "cross-env": "^10.1.0", diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 5caa18a53aabf..2df9e42ce7bc0 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -56,6 +56,7 @@ function layerElectronApp( const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), + systemTheme: Effect.succeed({ appearance: "light" as const, accent: null }), setSource: () => Effect.void, onUpdated: () => Effect.void, }); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index b4d0a06265547..e7be26ded6195 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -260,6 +260,7 @@ describe("DesktopServerExposure", () => { setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), + setLocalThemeFile: () => Effect.die("unexpected local theme file change"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index b4028930af661..a3281b0c90961 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -1,18 +1,43 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { beforeEach, vi } from "vite-plus/test"; -const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ +const { + onMock, + removeListenerMock, + accentOnMock, + accentRemoveListenerMock, + subscribeMock, + unsubscribeMock, + themeState, +} = vi.hoisted(() => ({ onMock: vi.fn(), removeListenerMock: vi.fn(), + accentOnMock: vi.fn(), + accentRemoveListenerMock: vi.fn(), + subscribeMock: vi.fn(), + unsubscribeMock: vi.fn(), themeState: { shouldUseDarkColors: true, themeSource: "system", setSourceError: null as unknown, + accent: "aabbccff", + accentError: null as unknown, }, })); vi.mock("electron", () => ({ + systemPreferences: { + getAccentColor: () => { + if (themeState.accentError !== null) throw themeState.accentError; + return themeState.accent; + }, + on: accentOnMock, + removeListener: accentRemoveListenerMock, + subscribeNotification: subscribeMock, + unsubscribeNotification: unsubscribeMock, + }, nativeTheme: { get shouldUseDarkColors() { return themeState.shouldUseDarkColors; @@ -34,9 +59,15 @@ describe("ElectronTheme", () => { beforeEach(() => { onMock.mockClear(); removeListenerMock.mockClear(); + accentOnMock.mockClear(); + accentRemoveListenerMock.mockClear(); + subscribeMock.mockReset(); + unsubscribeMock.mockClear(); themeState.shouldUseDarkColors = true; themeState.themeSource = "system"; themeState.setSourceError = null; + themeState.accent = "aabbccff"; + themeState.accentError = null; }); it.effect("scopes native theme update listeners", () => @@ -52,9 +83,59 @@ describe("ElectronTheme", () => { assert.deepEqual(onMock.mock.calls, [["updated", listener]]); assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); + assert.deepEqual(accentOnMock.mock.calls, [["accent-color-changed", listener]]); + assert.deepEqual(accentRemoveListenerMock.mock.calls, [["accent-color-changed", listener]]); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); + + it.effect("reads native appearance and normalizes the system RGBA accent", () => + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + themeState.accent = "12AbCDef"; + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: "#12abcd" }); + themeState.shouldUseDarkColors = false; + assert.deepEqual(yield* electronTheme.systemTheme, { + appearance: "light", + accent: "#12abcd", + }); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); + + it.effect("keeps native appearance when the desktop exposes no usable accent", () => + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + themeState.accentError = new Error("not supported"); + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: null }); + themeState.accentError = null; + themeState.accent = "not a color"; + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: null }); + themeState.accent = "ffffff00"; + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: null }); }).pipe(Effect.provide(ElectronTheme.layer)), ); + it.effect("observes macOS accent notifications and unsubscribes when its scope closes", () => + Effect.gen(function* () { + const listener = vi.fn(); + subscribeMock.mockReturnValueOnce(101).mockReturnValueOnce(102); + yield* Effect.scoped( + Effect.gen(function* () { + yield* (yield* ElectronTheme.ElectronTheme).onUpdated(listener); + assert.deepEqual( + subscribeMock.mock.calls.map(([name]) => name), + ["AppleColorPreferencesChangedNotification", "AppleAquaColorVariantChanged"], + ); + subscribeMock.mock.calls[0]![1](); + assert.strictEqual(listener.mock.calls.length, 1); + }), + ); + assert.deepEqual(unsubscribeMock.mock.calls, [[101], [102]]); + }).pipe( + Effect.provide(ElectronTheme.layer), + Effect.provideService(HostProcess.Platform, "darwin"), + ), + ); + it.effect("preserves the requested source and cause when setting the theme fails", () => Effect.gen(function* () { const cause = new Error("theme source failed"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 42cac98d0f538..59a3909e3c942 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,4 +1,5 @@ import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -23,6 +24,7 @@ export class ElectronTheme extends Context.Service< ElectronTheme, { readonly shouldUseDarkColors: Effect.Effect; + readonly systemTheme: Effect.Effect<{ appearance: "light" | "dark"; accent: string | null }>; readonly setSource: (theme: DesktopTheme) => Effect.Effect; readonly onUpdated: (listener: () => void) => Effect.Effect; } @@ -31,6 +33,20 @@ export class ElectronTheme extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), + systemTheme: Effect.sync(() => { + let accent: string | null = null; + try { + const rgba = Electron.systemPreferences.getAccentColor(); + if (/^[a-f\d]{8}$/i.test(rgba) && rgba.slice(6).toLowerCase() !== "00") + accent = `#${rgba.slice(0, 6).toLowerCase()}`; + } catch { + // Desktop sessions without an accent preference retain the palette's accent. + } + return { + appearance: Electron.nativeTheme.shouldUseDarkColors ? "dark" : "light", + accent, + }; + }), setSource: (theme) => Effect.try({ try: () => { @@ -40,16 +56,28 @@ export const make = ElectronTheme.of({ }), onUpdated: (listener) => Effect.acquireRelease( - Effect.suspend(() => { + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; Electron.nativeTheme.on("updated", listener); - return Effect.void; + Electron.systemPreferences.on("accent-color-changed", listener); + const notifications = + platform === "darwin" + ? ["AppleColorPreferencesChangedNotification", "AppleAquaColorVariantChanged"].map( + (notification) => + Electron.systemPreferences.subscribeNotification(notification, listener), + ) + : []; + return notifications; }), - () => + (notifications) => Effect.suspend(() => { Electron.nativeTheme.removeListener("updated", listener); + Electron.systemPreferences.removeListener("accent-color-changed", listener); + for (const notification of notifications) + Electron.systemPreferences.unsubscribeNotification(notification); return Effect.void; }), - ), + ).pipe(Effect.asVoid), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e86..0d86b6f5297a3 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import { getLocalTheme, pickLocalThemeFile, setLocalThemeFile } from "./methods/localTheme.ts"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; @@ -101,6 +102,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(getLocalTheme); + yield* ipc.handle(setLocalThemeFile); + yield* ipc.handle(pickLocalThemeFile); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); yield* ipc.handle(getConnectionCatalog); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a6b617385b578..1b8986b466dce 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -35,6 +35,10 @@ export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environm export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; +export const GET_LOCAL_THEME_CHANNEL = "desktop:get-local-theme"; +export const SET_LOCAL_THEME_FILE_CHANNEL = "desktop:set-local-theme-file"; +export const PICK_LOCAL_THEME_FILE_CHANNEL = "desktop:pick-local-theme-file"; +export const LOCAL_THEME_CHANNEL = "desktop:local-theme"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; export const SETUP_SNAP_SHOT_CHANNEL = "desktop:setup-snap-shot"; diff --git a/apps/desktop/src/ipc/methods/localTheme.ts b/apps/desktop/src/ipc/methods/localTheme.ts new file mode 100644 index 0000000000000..4c0f8e458cfd2 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localTheme.ts @@ -0,0 +1,46 @@ +import { DesktopLocalThemeStateSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopLocalTheme from "../../theme/DesktopLocalTheme.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getLocalTheme = makeIpcMethod({ + channel: IpcChannels.GET_LOCAL_THEME_CHANNEL, + payload: Schema.Void, + result: DesktopLocalThemeStateSchema, + handler: Effect.fn("desktop.ipc.localTheme.get")(function* () { + return yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current; + }), +}); + +export const setLocalThemeFile = makeIpcMethod({ + channel: IpcChannels.SET_LOCAL_THEME_FILE_CHANNEL, + payload: Schema.NullOr(Schema.String), + result: DesktopLocalThemeStateSchema, + handler: Effect.fn("desktop.ipc.localTheme.setFile")(function* (path) { + return yield* (yield* DesktopLocalTheme.DesktopLocalTheme).setFile(path); + }), +}); + +export const pickLocalThemeFile = makeIpcMethod({ + channel: IpcChannels.PICK_LOCAL_THEME_FILE_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.localTheme.pickFile")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const windows = yield* ElectronWindow.ElectronWindow; + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + const state = yield* themes.current; + const paths = yield* dialog.pickFiles({ + owner: yield* windows.currentMainOrFirst, + defaultPath: Option.fromNullishOr(state.filePath), + filters: [{ name: "T3 Code theme", extensions: ["json"] }], + multiple: false, + }); + return paths[0] ?? null; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef00c4d66ed89..faa95ed9604d2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -52,6 +52,7 @@ import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; +import * as DesktopLocalTheme from "./theme/DesktopLocalTheme.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopSnapShot from "./snapShot/DesktopSnapShot.ts"; @@ -224,6 +225,7 @@ const layerDesktopApplication = Layer.mergeAll( ).pipe( Layer.provideMerge(layerDesktopSnapShot), Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(DesktopLocalTheme.layer), Layer.provideMerge(layerDesktopWslBackend), Layer.provideMerge(layerDesktopLocalEnvironmentAuth), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index efd49ffa38648..2c040bd58fd69 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -115,6 +115,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), + getLocalTheme: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_THEME_CHANNEL), + setLocalThemeFile: (path) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_THEME_FILE_CHANNEL, path), + pickLocalThemeFile: () => ipcRenderer.invoke(IpcChannels.PICK_LOCAL_THEME_FILE_CHANNEL), + onLocalTheme: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, theme: Parameters[0]) => + listener(theme); + ipcRenderer.on(IpcChannels.LOCAL_THEME_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.LOCAL_THEME_CHANNEL, handler); + }, getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index a98aa96079e67..cced1ccbc8174 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + localThemeFile: Schema.optionalKey(Schema.NullOr(Schema.String)), linuxPasswordStore: Schema.optionalKey( Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), ), @@ -91,6 +92,23 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect("remembers a local theme file across launches and can return to system colors", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + const before = yield* settings.get; + const themePath = "/home/alice/Themes/My palette.json"; + + assert.isTrue((yield* settings.setLocalThemeFile(themePath)).changed); + assert.deepEqual(yield* settings.load, { ...before, localThemeFile: themePath }); + assert.isFalse((yield* settings.setLocalThemeFile(themePath)).changed); + + assert.isTrue((yield* settings.setLocalThemeFile(null)).changed); + assert.deepEqual(yield* settings.load, before); + }), + ), + ); it.effect( "persists disabling and re-enabling local execution without clearing backend settings", () => @@ -125,6 +143,7 @@ describe("DesktopSettings", () => { { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -155,6 +174,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -263,6 +283,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -320,6 +341,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -397,6 +419,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -426,6 +449,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -454,6 +478,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index efd9fbfbfd3bd..87f7819fd62d2 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -27,6 +27,7 @@ import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly localEnvironmentEnabled: boolean; + readonly localThemeFile: string | null; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -76,6 +77,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { localEnvironmentEnabled: true, + localThemeFile: null, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -98,6 +100,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ const DesktopSettingsDocument = Schema.Struct({ localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), + localThemeFile: Schema.optionalKey(Schema.NullOr(Schema.String)), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -160,6 +163,9 @@ export class DesktopAppSettings extends Context.Service< readonly setLocalEnvironmentEnabled: ( enabled: boolean, ) => Effect.Effect; + readonly setLocalThemeFile: ( + path: string | null, + ) => Effect.Effect; readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, @@ -233,6 +239,7 @@ function normalizeDesktopSettingsDocument( return { localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false, + localThemeFile: parsed.localThemeFile ?? null, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -260,6 +267,10 @@ function toDesktopSettingsDocument( document.localEnvironmentEnabled = settings.localEnvironmentEnabled; } + if (settings.localThemeFile !== defaults.localThemeFile) { + document.localThemeFile = settings.localThemeFile; + } + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -389,6 +400,10 @@ function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean) : { ...settings, localEnvironmentEnabled: enabled }; } +function setLocalThemeFile(settings: DesktopSettings, path: string | null): DesktopSettings { + return settings.localThemeFile === path ? settings : { ...settings, localThemeFile: path }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -580,6 +595,10 @@ export const make = Effect.gen(function* () { persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), ), + setLocalThemeFile: (path) => + persist((settings) => setLocalThemeFile(settings, path)).pipe( + Effect.withSpan("desktop.settings.setLocalThemeFile"), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -623,6 +642,7 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), setLocalEnvironmentEnabled: (enabled) => update((settings) => setLocalEnvironmentEnabled(settings, enabled)), + setLocalThemeFile: (path) => update((settings) => setLocalThemeFile(settings, path)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/theme/DesktopLocalTheme.test.ts b/apps/desktop/src/theme/DesktopLocalTheme.test.ts new file mode 100644 index 0000000000000..3a35c789fd8d3 --- /dev/null +++ b/apps/desktop/src/theme/DesktopLocalTheme.test.ts @@ -0,0 +1,356 @@ +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopLocalTheme from "./DesktopLocalTheme.ts"; + +vi.mock("electron", () => ({})); + +const dark = { name: "Desktop", appearance: "dark", canvas: "#123456", accent: "#abcdef" } as const; +const light = { ...dark, appearance: "light", canvas: "#f0e8d0" } as const; +const native = { appearance: "light", accent: "#0055aa" } as const; + +const withDirectory = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "desktop-theme-test-" }); +}); + +function layer( + dir: string, + file: string | null, + send: (state: DesktopLocalThemeState) => Effect.Effect = () => Effect.void, + platform: NodeJS.Platform = "linux", + nativeSource: { + read: () => { appearance: "light" | "dark"; accent: string | null }; + listen: (listener: () => void) => void; + } = { read: () => native, listen: () => undefined }, +) { + const environment = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: dir, + platform, + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: dir })), + ), + ); + return DesktopLocalTheme.layer.pipe( + Layer.provide( + Layer.mergeAll( + environment, + NodeServices.layer, + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localThemeFile: file, + }), + Layer.mock(ElectronTheme.ElectronTheme)({ + systemTheme: Effect.sync(nativeSource.read), + onUpdated: (listener) => Effect.sync(() => nativeSource.listen(listener)), + }), + Layer.mock(ElectronWindow.ElectronWindow)({ + sendAll: (_channel, state) => send(state as DesktopLocalThemeState), + }), + ), + ), + ); +} + +const write = Effect.fnUntraced(function* (file: string, value: unknown) { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(`${file}.tmp`, JSON.stringify(value)); + yield* fs.rename(`${file}.tmp`, file); +}); + +function fileState(theme: typeof dark | typeof light, filePath: string): DesktopLocalThemeState { + return { + ...native, + appearance: theme.appearance, + source: "file", + filePath, + status: "ready", + theme, + }; +} + +describe("desktop system palette source", () => { + it.live("follows atomic replacements through notifications without a getter refresh", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const file = (yield* Path.Path).join(dir, "theme.json"); + yield* write(file, dark); + const updated = yield* Deferred.make(); + const send = (state: DesktopLocalThemeState) => + state.theme?.appearance === "light" + ? Deferred.succeed(updated, undefined).pipe(Effect.asVoid) + : Effect.void; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual(yield* themes.current, fileState(dark, file)); + yield* write(file, light); + yield* Deferred.await(updated); + assert.deepEqual(yield* themes.current, fileState(light, file)); + }).pipe(Effect.provide(layer(dir, file, send))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("provides native appearance and accent without configuration or servers", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + yield* Effect.gen(function* () { + assert.deepEqual(yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current, { + ...native, + source: "native", + filePath: null, + status: "ready", + theme: null, + }); + }).pipe(Effect.provide(layer(dir, null))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("publishes native appearance and accent changes", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const updated = yield* Deferred.make(); + let current: { appearance: "light" | "dark"; accent: string | null } = native; + let notify: () => void = () => undefined; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + current = { appearance: "dark", accent: "#fedcba" }; + notify(); + yield* Deferred.await(updated); + assert.deepEqual(yield* themes.current, { + ...current, + source: "native", + filePath: null, + status: "ready", + theme: null, + }); + }).pipe( + Effect.provide( + layer( + dir, + null, + () => Deferred.succeed(updated, undefined).pipe(Effect.asVoid), + "linux", + { + read: () => current, + listen: (listener) => { + notify = listener; + }, + }, + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("chooses a saved file and clears it back to automatic detection", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const file = (yield* Path.Path).join(dir, "theme.json"); + yield* write(file, dark); + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual(yield* themes.setFile(file), fileState(dark, file)); + const changedSource = yield* themes.setFile(`${file}.missing`); + assert.strictEqual(changedSource.source, "file"); + assert.strictEqual(changedSource.status, "missing"); + assert.strictEqual(changedSource.theme, null); + assert.deepEqual(yield* themes.setFile(null), { + ...native, + source: "native", + filePath: null, + status: "ready", + theme: null, + }); + }).pipe(Effect.provide(layer(dir, null))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("rejects invalid exported role colors and retains the last valid palette", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const file = (yield* Path.Path).join(dir, "theme.json"); + const exported = { + name: "Exported", + appearance: "dark", + colors: { canvas: "oklch(0.2 0.01 240)", accent: "rebeccapurple" }, + } as const; + yield* write(file, exported); + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual((yield* themes.current).theme, exported); + for (const colors of [{ canvas: "not a color" }, { futureUnknownRole: "#ffffff" }]) { + yield* write(file, { ...exported, colors }); + const state = yield* themes.setFile(file); + assert.strictEqual(state.status, "invalid"); + assert.deepEqual(state.theme, exported); + } + yield* (yield* FileSystem.FileSystem).writeFileString(file, "x".repeat(40_000)); + const state = yield* themes.setFile(file); + assert.strictEqual(state.status, "invalid"); + assert.deepEqual(state.theme, exported); + }).pipe(Effect.provide(layer(dir, file))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("recovers a missing configured directory without reading through the getter", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const subdir = (yield* Path.Path).join(dir, "later"); + const file = (yield* Path.Path).join(subdir, "theme.json"); + const updated = yield* Deferred.make(); + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.strictEqual((yield* themes.current).status, "missing"); + yield* (yield* FileSystem.FileSystem).makeDirectory(subdir); + yield* write(file, dark); + yield* TestClock.adjust("31 seconds"); + yield* Deferred.await(updated); + assert.deepEqual(yield* themes.current, fileState(dark, file)); + }).pipe( + Effect.provide( + layer(dir, file, (state) => + state.theme === null + ? Effect.void + : Deferred.succeed(updated, undefined).pipe(Effect.asVoid), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("detects an Omarchy file on Linux and ignores it on other platforms", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const fs = yield* FileSystem.FileSystem; + const file = (yield* Path.Path).join(dir, ".local/state/omarchy/current/theme/t3code.json"); + yield* fs.makeDirectory((yield* Path.Path).dirname(file), { recursive: true }); + yield* write(file, dark); + for (const platform of ["linux", "darwin", "win32"] as const) { + yield* Effect.gen(function* () { + const state = yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current; + assert.strictEqual(state.source, platform === "linux" ? "omarchy" : "native"); + assert.deepEqual(state.theme, platform === "linux" ? dark : null); + }).pipe(Effect.provide(layer(dir, null, undefined, platform))); + } + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("keeps the last Omarchy palette while its file is missing or invalid and recovers", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = path.join(dir, ".local/state/omarchy/current/theme/t3code.json"); + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* write(file, dark); + const notifications = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + yield* Queue.take(notifications); + yield* fs.remove(file); + const missing = yield* Queue.take(notifications); + assert.deepEqual(missing, { + ...fileState(dark, file), + source: "omarchy", + status: "missing", + }); + assert.deepEqual(yield* themes.current, missing); + yield* write(file, { + name: "Invalid", + appearance: "dark", + colors: { canvas: "not a color" }, + }); + const invalid = yield* Queue.take(notifications); + assert.deepEqual(invalid, { ...missing, status: "invalid" }); + yield* write(file, light); + const recovered = yield* Queue.take(notifications); + assert.deepEqual(recovered, { ...fileState(light, file), source: "omarchy" }); + assert.deepEqual(yield* themes.current, recovered); + }).pipe( + Effect.provide( + layer(dir, null, (state) => Queue.offer(notifications, state).pipe(Effect.asVoid)), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("follows Omarchy's theme directory symlink switches", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = path.join(dir, ".local/state/omarchy/current"); + const first = path.join(dir, "first"); + const second = path.join(dir, "second"); + yield* fs.makeDirectory(parent, { recursive: true }); + yield* fs.makeDirectory(first); + yield* fs.makeDirectory(second); + yield* write(path.join(first, "t3code.json"), dark); + yield* write(path.join(second, "t3code.json"), light); + yield* fs.symlink(first, path.join(parent, "theme")); + const updated = yield* Deferred.make(); + const recolored = yield* Deferred.make(); + const changed = { ...light, name: "Changed", canvas: "#ffeedd" }; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual((yield* themes.current).theme, dark); + yield* fs.symlink(second, path.join(parent, "next")); + yield* fs.rename(path.join(parent, "next"), path.join(parent, "theme")); + yield* Deferred.await(updated); + assert.deepEqual((yield* themes.current).theme, light); + yield* write(path.join(second, "t3code.json"), changed); + yield* Deferred.await(recolored); + assert.deepEqual((yield* themes.current).theme, changed); + }).pipe( + Effect.provide( + layer(dir, null, (state) => + state.theme?.name === "Changed" + ? Deferred.succeed(recolored, undefined).pipe(Effect.asVoid) + : state.theme?.appearance === "light" + ? Deferred.succeed(updated, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); +}); diff --git a/apps/desktop/src/theme/DesktopLocalTheme.ts b/apps/desktop/src/theme/DesktopLocalTheme.ts new file mode 100644 index 0000000000000..ba2389e62fa8d --- /dev/null +++ b/apps/desktop/src/theme/DesktopLocalTheme.ts @@ -0,0 +1,202 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Acquire watches before publishing snapshots, including after symlink switches. +import * as NodeFS from "node:fs"; +import { EnvironmentThemeFile, type DesktopLocalThemeState } from "@t3tools/contracts"; +import { MAX_THEME_FILE_BYTES, readThemeFileGuarded } from "@t3tools/shared/themeFile"; +import { THEME_COLOR_ROLES } from "@t3tools/shared/themePalettes"; +import "culori/css"; +import { converter, parse } from "culori/fn"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; + +export class DesktopLocalTheme extends Context.Service< + DesktopLocalTheme, + { + readonly current: Effect.Effect; + readonly setFile: ( + path: string | null, + ) => Effect.Effect; + } +>()("@t3tools/desktop/theme/DesktopLocalTheme") {} + +const decode = Schema.decodeUnknownExit(Schema.fromJsonString(EnvironmentThemeFile)); +const toRgb = converter("rgb"); +const knownRoles = new Set(THEME_COLOR_ROLES); + +function usableColor(value: string): boolean { + const parsed = parse(value); + const rgb = parsed && toRgb(parsed); + if (!rgb) return false; + return [rgb.r, rgb.g, rgb.b, rgb.alpha ?? 1].every(Number.isFinite); +} + +function usablePalette(theme: EnvironmentThemeFile): boolean { + if (theme.canvas && theme.accent && usableColor(theme.canvas) && usableColor(theme.accent)) + return true; + return [theme.colors, theme.variants?.[theme.appearance === "dark" ? "light" : "dark"]].some( + (colors) => + Object.entries(colors ?? {}).some( + ([role, color]) => knownRoles.has(role) && usableColor(color), + ), + ); +} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const native = yield* ElectronTheme.ElectronTheme; + const windows = yield* ElectronWindow.ElectronWindow; + const fs = yield* FileSystem.FileSystem; + const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); + const path = environment.path; + const nativeTheme = yield* native.systemTheme; + const state = yield* Ref.make({ + ...nativeTheme, + source: "native", + filePath: null, + status: "ready", + theme: null, + }); + const semaphore = yield* Semaphore.make(1); + const updates = yield* Queue.unbounded(); + const fileUpdates = yield* Queue.unbounded(); + yield* Effect.addFinalizer(() => Queue.shutdown(updates)); + yield* Effect.addFinalizer(() => Queue.shutdown(fileUpdates)); + const omarchyFiles = + environment.platform === "linux" + ? [ + path.join(environment.homeDirectory, ".local/state/omarchy/current/theme/t3code.json"), + path.join(environment.homeDirectory, ".config/omarchy/current/theme/t3code.json"), + ] + : []; + + const refresh = semaphore.withPermits(1)( + Effect.gen(function* () { + const nativeTheme = yield* native.systemTheme; + const explicitFile = (yield* settings.get).localThemeFile; + const previous = yield* Ref.get(state); + let file = explicitFile; + if (file === null) { + for (const candidate of omarchyFiles) { + if (yield* exists(candidate)) { + file = candidate; + break; + } + } + if (file === null && previous.source === "omarchy") file = previous.filePath; + } + let next: DesktopLocalThemeState = { + ...nativeTheme, + source: "native", + filePath: null, + status: "ready", + theme: null, + }; + if (file !== null) { + const source = explicitFile === null ? "omarchy" : "file"; + const raw = readThemeFileGuarded(file, MAX_THEME_FILE_BYTES); + const decoded = raw === null ? null : decode(raw); + const theme = + decoded?._tag === "Success" && usablePalette(decoded.value) ? decoded.value : null; + const retained = + previous.source === source && previous.filePath === file ? previous.theme : null; + const currentTheme = theme ?? retained; + next = { + appearance: currentTheme?.appearance ?? nativeTheme.appearance, + accent: nativeTheme.accent, + source, + filePath: file, + status: theme !== null ? "ready" : (yield* exists(file)) ? "invalid" : "missing", + theme: currentTheme, + }; + } + if (Equal.equals(previous, next)) return; + yield* Ref.set(state, next); + yield* windows + .sendAll(IpcChannels.LOCAL_THEME_CHANNEL, next) + .pipe(Effect.ignoreCause({ log: true })); + }), + ); + + let watchers: NodeFS.FSWatcher[] = []; + const closeWatches = () => { + for (const watcher of watchers) watcher.close(); + watchers = []; + }; + yield* Effect.addFinalizer(() => Effect.sync(closeWatches)); + const watchSemaphore = yield* Semaphore.make(1); + const watchDirectories = watchSemaphore.withPermits(1)( + Effect.gen(function* () { + const file = (yield* settings.get).localThemeFile; + const directories = new Set( + [...omarchyFiles, ...(file === null ? [] : [file])].flatMap((file) => [ + path.dirname(file), + // Omarchy changes the theme directory symlink, not just its contents. + path.dirname(path.dirname(file)), + ]), + ); + yield* Effect.sync(() => { + closeWatches(); + for (const directory of directories) { + try { + const watcher = NodeFS.watch(directory, { persistent: false }, () => { + Queue.offerUnsafe(fileUpdates, undefined); + }); + watcher.on("error", () => watcher.close()); + watchers.push(watcher); + } catch { + // The recovery tick retries directories that do not exist yet. + } + } + }); + }), + ); + + yield* watchDirectories; + yield* native.onUpdated(() => { + Queue.offerUnsafe(updates, undefined); + }); + yield* refresh; + yield* Stream.merge( + Stream.fromQueue(updates), + Stream.fromQueue(fileUpdates).pipe(Stream.debounce("100 millis")), + ).pipe( + Stream.runForEach(() => watchDirectories.pipe(Effect.andThen(refresh))), + Effect.forkScoped, + ); + // Recover unavailable directories and attach new watches after they appear. + yield* Stream.tick("30 seconds").pipe( + Stream.runForEach(() => watchDirectories.pipe(Effect.andThen(refresh))), + Effect.forkScoped, + ); + + return DesktopLocalTheme.of({ + current: Ref.get(state), + setFile: (file) => + Effect.gen(function* () { + const resolved = + file === null || file.trim() === "" + ? null + : path.resolve(environment.homeDirectory, file.trim()); + yield* settings.setLocalThemeFile(resolved); + yield* watchDirectories; + yield* refresh; + return yield* Ref.get(state); + }), + }); +}); + +export const layer = Layer.effect(DesktopLocalTheme, make); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 9d4556af42b47..fd0009d6af619 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -202,6 +202,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), + setLocalThemeFile: () => Effect.die("unexpected local theme file change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c9a49b1d735be..e27210d5915d3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -196,6 +196,7 @@ const layerElectronMenu = Layer.succeed(ElectronMenu.ElectronMenu, { const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), + systemTheme: Effect.succeed({ appearance: "light" as const, accent: null }), setSource: () => Effect.void, onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronTheme["Service"]); @@ -265,6 +266,7 @@ function layerTest(input: { setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), + setLocalThemeFile: () => Effect.die("unexpected local theme file change"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index b7eb11940cea8..d08d2fea6ae8a 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -1,5 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off - the guarded file read needs open -// flags (O_NOFOLLOW, O_NONBLOCK) the FileSystem service does not expose. /** * EnvironmentTheme - palettes this machine publishes for clients to follow. * @@ -14,7 +12,8 @@ * * @module EnvironmentTheme */ -import * as NodeFS from "node:fs"; +import { MAX_THEME_FILE_BYTES, readThemeFileGuarded } from "@t3tools/shared/themeFile"; +export { MAX_THEME_FILE_BYTES, readThemeFileGuarded } from "@t3tools/shared/themeFile"; import { EnvironmentTheme, @@ -54,8 +53,7 @@ const THEME_FILE_SUFFIX = ".json"; * cost of a mistake is capped rather than unbounded. */ const MAX_THEME_FILES = 32; -/** Exported so the publish path cannot accept a file the watcher will skip. */ -export const MAX_THEME_FILE_BYTES = 32 * 1024; + /** * The set travels whole in a websocket event to every subscriber, so the sum * matters more than any single file. An exported theme runs a few KB, leaving @@ -88,52 +86,6 @@ export class EnvironmentThemeService extends Context.Service< } >()("t3/environmentTheme/EnvironmentThemeService") {} -/** - * Reads a theme file through one opened handle, so every check binds to the - * file actually read rather than to a path that may have been swapped since: - * O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays - * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from - * blocking the open, and the fstat type and size gate examines the open - * descriptor. Returns null for anything that is not a small regular file. - * - * Windows has neither flag (the constants are undefined, and OR-ing them in - * is a no-op), so the symlink check there is an lstat before the open. That - * leaves a window a swap could slip through, which the descriptor-bound - * checks below then narrow to "a regular file at that path". - */ -export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { - let fd: number; - try { - if (NodeFS.constants.O_NOFOLLOW === undefined && NodeFS.lstatSync(filePath).isSymbolicLink()) { - return null; - } - fd = NodeFS.openSync( - filePath, - NodeFS.constants.O_RDONLY | - (NodeFS.constants.O_NOFOLLOW ?? 0) | - (NodeFS.constants.O_NONBLOCK ?? 0), - ); - } catch { - return null; - } - try { - const info = NodeFS.fstatSync(fd); - if (!info.isFile() || info.size > maxBytes) return null; - const contents = Buffer.alloc(info.size); - let offset = 0; - while (offset < contents.length) { - const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset); - if (read <= 0) break; - offset += read; - } - return contents.subarray(0, offset).toString("utf8"); - } catch { - return null; - } finally { - NodeFS.closeSync(fd); - } -}; - /** * Every theme the directory actually publishes. A file that is missing, * unreadable, malformed, colorless, or misnamed is simply skipped; the rest of diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6596e451e3fc6..8d16132ea02e8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -88,6 +88,7 @@ import { useReducer, useRef, useState, + useSyncExternalStore, type KeyboardEvent, type ReactNode, } from "react"; @@ -109,7 +110,13 @@ import { useTheme } from "../hooks/useTheme"; import { useCustomThemes } from "../hooks/useCustomThemes"; import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; -import { getThemeDefinition } from "../themePalette"; +import { + DESKTOP_LOCAL_THEME_ID, + getDesktopThemeState, + getDesktopLocalTheme, + subscribeToCustomThemes, + getThemeDefinition, +} from "../themePalette"; import { STANDARD_THEME_CARDS, getThemeCardDefinition, @@ -711,11 +718,21 @@ function OpenCommandPaletteDialog(props: { } = useTheme(); const customThemes = useCustomThemes(); const environmentThemes = useEnvironmentThemeDefinitions(); + const desktopThemeState = useSyncExternalStore( + subscribeToCustomThemes, + getDesktopThemeState, + () => null, + ); const themeCards = useMemo(() => { const seen = new Set(); return [ ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })), - ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes] + ...[ + ...(desktopThemeState === null ? [] : [getDesktopLocalTheme()]), + ...BUILT_IN_THEMES, + ...customThemes, + ...environmentThemes, + ] .filter((definition) => { if (seen.has(definition.id)) return false; seen.add(definition.id); @@ -723,7 +740,7 @@ function OpenCommandPaletteDialog(props: { }) .map(getThemeCardDefinition), ]; - }, [customThemes, environmentThemes]); + }, [customThemes, environmentThemes, desktopThemeState]); const providers = useAtomValue(primaryServerProvidersAtom); const providerEntryByEnvironmentAndInstanceId = useMemo(() => { const map = new Map(); @@ -2134,7 +2151,8 @@ function OpenCommandPaletteDialog(props: { previews.length === 1 && id !== null ? setThemeHalf(previews[0]!.mode, id) : setTheme(id ?? appearanceMode); - if (!saved) notifyThemeSaveFailure(); + if (!saved || (id === DESKTOP_LOCAL_THEME_ID && !setAppearanceMode("system"))) + notifyThemeSaveFailure(); }, })), }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0243fc502a444..938121cde758e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2599,6 +2599,16 @@ export default function Sidebar() { () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); + // Menu and gesture callbacks read these through refs. Every closure from one + // render shares that render's scope, so a callback memoized on server configs + // and another memoized on projects would each keep alive the render that last + // rebuilt the other, chaining every past render and its configs in memory. + const serverConfigsRef = useRef(serverConfigs); + serverConfigsRef.current = serverConfigs; + const projectsRef = useRef(projects); + projectsRef.current = projects; + const projectByKeyRef = useRef(projectByKey); + projectByKeyRef.current = projectByKey; const projectDisplayNameByKey = useMemo( () => new Map( @@ -2783,13 +2793,13 @@ export default function Sidebar() { projectKey: getSidebarProjectSettingsKey( projectGroup, projectGroupingSettings, - projects, + projectsRef.current, projectRef, ), }, }); }, - [isMobile, projectGroupingSettings, projects, router, setOpenMobile], + [isMobile, projectGroupingSettings, router, setOpenMobile], ); // Anchor for the scope popup: the header search field, not its icon trigger. const headerSearchRef = useRef(null); @@ -3634,7 +3644,7 @@ export default function Sidebar() { const canApply = (key: string) => { const thread = threadByKeyRef.current.get(key); const capabilities = - thread && serverConfigs.get(thread.environmentId)?.environment.capabilities; + thread && serverConfigsRef.current.get(thread.environmentId)?.environment.capabilities; return ( thread !== undefined && sectionByThreadKeyRef.current.get(key) === originSection && @@ -3686,7 +3696,7 @@ export default function Sidebar() { onAbort: () => {}, }); }, - [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads], + [attemptUnsettle, attemptUnsnooze, settleThreads], ); const pinnedKeys = useMemo( () => @@ -4309,6 +4319,7 @@ export default function Sidebar() { async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; + const configs = serverConfigsRef.current; // One exact actionable set: keys whose rows are actually rendered // right now. Selections can outlive their rows (settled-tail paging, // thread deletion elsewhere) and the menu labels must count only what @@ -4331,13 +4342,13 @@ export default function Sidebar() { ); const canSnoozeSelection = selectedThreads.every( (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && + configs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow.toISOString() }), ); const titleRegenerationThreads = selectedThreads.filter( (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true, + configs.get(thread.environmentId)?.environment.capabilities.threadTitleRegeneration === + true, ); const regeneratableTitleThreads = titleRegenerationThreads.filter( (thread) => thread.titleRegeneration == null, @@ -4351,8 +4362,8 @@ export default function Sidebar() { // is omitted entirely when nothing selected is pinned. const pinnedSelectedThreads = selectedThreads.filter( (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === - true && thread.pinnedAt != null, + configs.get(thread.environmentId)?.environment.capabilities.threadPinning === true && + thread.pinnedAt != null, ); const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, @@ -4573,7 +4584,6 @@ export default function Sidebar() { markThreadUnread, performSnooze, removeFromSelection, - serverConfigs, settleThreads, updateThreadMetadata, timestampFormat, @@ -4594,7 +4604,8 @@ export default function Sidebar() { ); const workspacePath = session.worktreePath ?? - projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot; + projectByKeyRef.current.get(`${session.environmentId}:${session.projectId}`) + ?.workspaceRoot; const clicked = await settlePromise(() => api.contextMenu.show( buildDraftActionMenuItems({ @@ -4630,7 +4641,7 @@ export default function Sidebar() { } })(); }, - [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey], + [copyBranchToClipboard, copyPathToClipboard, openProjectSettings], ); const handleThreadContextMenu = useCallback( @@ -4646,26 +4657,27 @@ export default function Sidebar() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; + const configs = serverConfigsRef.current; const threadWorkspacePath = thread.worktreePath ?? - projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ?? + projectByKeyRef.current.get(`${thread.environmentId}:${thread.projectId}`) + ?.workspaceRoot ?? null; // Un-settle pins the thread active until real activity clears the pin. // Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; + configs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + configs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const supportsPinning = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; + configs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; const supportsAutoSettleOptOut = - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadAutoSettleOptOut === true; + configs.get(thread.environmentId)?.environment.capabilities.threadAutoSettleOptOut === + true; const supportsTitleRegeneration = - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true; + configs.get(thread.environmentId)?.environment.capabilities.threadTitleRegeneration === + true; const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); @@ -4921,8 +4933,6 @@ export default function Sidebar() { markThreadUnread, openProjectSettings, projectScopeKey, - projectByKey, - serverConfigs, setProjectScopeKey, setThreadAutoSettle, startThreadRename, diff --git a/apps/web/src/components/settings/ThemeSettings.desktop.test.tsx b/apps/web/src/components/settings/ThemeSettings.desktop.test.tsx new file mode 100644 index 0000000000000..94b8dd458e7b0 --- /dev/null +++ b/apps/web/src/components/settings/ThemeSettings.desktop.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import type { DesktopBridge, DesktopLocalThemeState } from "@t3tools/contracts"; +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../../hooks/useEnvironmentTheme", () => ({ + useEnvironmentThemeDefinitions: () => [], +})); + +import { applyDesktopThemeState } from "../../desktopTheme"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { useTheme } from "../../hooks/useTheme"; +import { DESKTOP_LOCAL_THEME_ID, getDesktopThemeState } from "../../themePalette"; +import { ThemeLibrary } from "./ThemeSettings"; + +const native: DesktopLocalThemeState = { + appearance: "dark", + accent: "#6688aa", + source: "native", + filePath: null, + status: "ready", + theme: null, +}; +const file: DesktopLocalThemeState = { + ...native, + source: "file", + filePath: "/themes/current.json", + theme: { + name: "Personal palette", + appearance: "dark", + canvas: "#123456", + accent: "#6688aa", + }, +}; +const picker = vi.fn<() => Promise>(); +const saveSource = vi.fn<(path: string | null) => Promise>(); +let root: Root; +let container: HTMLDivElement; +let controls: ReturnType; + +function Harness() { + const current = useTheme(); + useEffect(() => { + controls = current; + }, [current]); + const customThemes = useCustomThemes(); + return ( + undefined} + /> + ); +} + +function button(label: string) { + const found = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + expect(found, `Button "${label}" should be available`).toBeDefined(); + return found!; +} + +async function click(label: string) { + await act(async () => button(label).click()); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + window.localStorage.clear(); + picker.mockReset().mockResolvedValue(null); + saveSource.mockReset().mockResolvedValue(native); + window.desktopBridge = { + pickLocalThemeFile: picker, + setLocalThemeFile: saveSource, + setTheme: vi.fn().mockResolvedValue(undefined), + } as unknown as DesktopBridge; + applyDesktopThemeState(native); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); + await act(async () => { + controls.setTheme("grove"); + controls.setAppearanceMode("light"); + controls.refreshTheme(); + }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + delete window.desktopBridge; + vi.unstubAllGlobals(); +}); + +describe("desktop theme settings", () => { + it("selects system colors and automatic appearance explicitly", async () => { + expect(container.textContent).toContain("Native system colors"); + expect(controls.theme).toBe("grove"); + + await click("System theme"); + + expect(controls.theme).toBe(DESKTOP_LOCAL_THEME_ID); + expect(controls.appearanceMode).toBe("system"); + expect(controls.resolvedTheme).toBe("dark"); + expect(document.documentElement.dataset.themeId).toBe(DESKTOP_LOCAL_THEME_ID); + }); + + it("leaves the source and manual theme untouched after picker cancellation", async () => { + await click("Choose theme file"); + + expect(picker).toHaveBeenCalledOnce(); + expect(saveSource).not.toHaveBeenCalled(); + expect(getDesktopThemeState()).toEqual(native); + expect(controls.theme).toBe("grove"); + expect(button("Choose theme file").disabled).toBe(false); + }); + + it("updates the file source without selecting it over a manual theme", async () => { + picker.mockResolvedValue(file.filePath); + saveSource.mockResolvedValue(file); + + await click("Choose theme file"); + + expect(saveSource).toHaveBeenCalledWith(file.filePath); + expect(container.textContent).toContain(file.filePath); + expect(container.textContent).toContain("Custom theme file"); + expect(controls.theme).toBe("grove"); + expect(controls.appearanceMode).toBe("light"); + }); + + it("shows a source failure and keeps the current palette", async () => { + picker.mockResolvedValue(file.filePath); + saveSource.mockRejectedValue(new Error("Cannot save settings")); + + await click("Choose theme file"); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Could not change the theme source", + ); + expect(getDesktopThemeState()).toEqual(native); + expect(controls.theme).toBe("grove"); + expect(button("Choose theme file").disabled).toBe(false); + }); + + it("disables source changes while saving and leaves the manual theme active", async () => { + let resolveSave!: (state: DesktopLocalThemeState) => void; + picker.mockResolvedValue(file.filePath); + saveSource.mockImplementation( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + + await click("Choose theme file"); + + expect(button("Saving source…").disabled).toBe(true); + expect(controls.theme).toBe("grove"); + await act(async () => resolveSave(file)); + expect(button("Change theme file").disabled).toBe(false); + expect(controls.theme).toBe("grove"); + }); + + it("returns to automatic colors while preserving the manual theme", async () => { + await act(async () => applyDesktopThemeState(file)); + + await click("Use automatic colors"); + + expect(saveSource).toHaveBeenCalledWith(null); + expect(container.textContent).toContain("Native system colors"); + expect(container.textContent).not.toContain(file.filePath); + expect(controls.theme).toBe("grove"); + expect(controls.appearanceMode).toBe("light"); + }); +}); diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 8934b1319b384..42945377f48fb 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -9,12 +9,17 @@ import { Trash2Icon, UploadIcon, } from "lucide-react"; -import { useCallback, useEffect, useState, type ReactElement } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore, type ReactElement } from "react"; +import { applyDesktopThemeState } from "../../desktopTheme"; import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme"; import { readThemeHalvesRaw } from "../../hooks/useTheme"; import { cn } from "../../lib/utils"; import { getThemeDefinition, + getDesktopThemeState, + getDesktopLocalTheme, + DESKTOP_LOCAL_THEME_ID, + subscribeToCustomThemes, singleAppearanceOf, getThemeModes, removeCustomThemes, @@ -101,6 +106,7 @@ function ThemeLibraryCard({ theme, isActive, onUse, + useDescription, onUseMode, activeModes, onEdit, @@ -112,6 +118,7 @@ function ThemeLibraryCard({ theme: ThemeCardDefinition; isActive: boolean; onUse: () => void; + useDescription?: string; onUseMode: (mode: ThemeMode) => void; activeModes: ReadonlyArray; onEdit?: () => void; @@ -411,11 +418,12 @@ function ThemeLibraryCard({ } /> - {variantNavigation - ? "Use the first variants for light and dark" - : cardModes.length > 1 - ? "Use for both light and dark" - : `Use for ${cardModes[0]} mode only`} + {useDescription ?? + (variantNavigation + ? "Use the first variants for light and dark" + : cardModes.length > 1 + ? "Use for both light and dark" + : `Use for ${cardModes[0]} mode only`)} ); @@ -521,7 +529,7 @@ export function ThemeLibrary({ setAppearanceMode: (mode: ThemeMode) => boolean; customThemes: ReadonlyArray; initialAppearance: ThemeAppearance; - refreshTheme: () => void; + refreshTheme: (options?: { preservePreview?: boolean }) => void; isImportOpen: boolean; onImportOpenChange: (open: boolean) => void; themeHalves: ThemeHalves | null; @@ -529,6 +537,14 @@ export function ThemeLibrary({ }) { const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); const environmentThemes = useEnvironmentThemeDefinitions(); + const desktopThemeState = useSyncExternalStore( + subscribeToCustomThemes, + getDesktopThemeState, + () => null, + ); + const desktopTheme = desktopThemeState === null ? null : getDesktopLocalTheme(); + const [isChangingDesktopSource, setIsChangingDesktopSource] = useState(false); + const [desktopSourceError, setDesktopSourceError] = useState(null); const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ theme: ThemeDefinition; collectionThemes: ReadonlyArray; @@ -686,6 +702,30 @@ export function ThemeLibrary({ if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); }; + const useDesktopTheme = () => { + if (!persistTheme(DESKTOP_LOCAL_THEME_ID)) return; + if (!setAppearanceMode("system")) notifyThemeSaveFailure(); + }; + + const changeDesktopSource = async (pickFile: boolean) => { + const bridge = window.desktopBridge; + const picker = bridge?.pickLocalThemeFile; + if (!bridge?.setLocalThemeFile || (pickFile && !picker)) return; + setIsChangingDesktopSource(true); + setDesktopSourceError(null); + try { + const filePath = pickFile && picker ? await picker() : null; + if (pickFile && filePath === null) return; + const next = await bridge.setLocalThemeFile(filePath); + applyDesktopThemeState(next); + refreshTheme({ preservePreview: true }); + } catch { + setDesktopSourceError("Could not change the theme source. Try again."); + } finally { + setIsChangingDesktopSource(false); + } + }; + // ----- Wireframe tiles on top, two-ball cards below -------------------- const handlePairPick = (cardId: string | null) => (mode: ThemeMode) => { if (mode === "system") return; @@ -916,6 +956,92 @@ export function ThemeLibrary({ + {desktopThemeState !== null && desktopTheme !== null ? ( +
+
+ + + openThemeEditor({ + editingThemeId: null, + seedThemeId: DESKTOP_LOCAL_THEME_ID, + seedName: `${desktopTheme.label} copy`, + initialAppearance, + }) + } + theme={{ ...getThemeCardDefinition(desktopTheme), label: "System theme" }} + /> + +
+
+

+ Match your desktop appearance and available colors. +

+

+ {desktopThemeState.source === "omarchy" + ? "Omarchy palette" + : desktopThemeState.source === "file" + ? "Custom theme file" + : "Native system colors"} +

+ {desktopThemeState.filePath ? ( +

+ {desktopThemeState.filePath} +

+ ) : null} + {desktopThemeState.status === "missing" || desktopThemeState.status === "invalid" ? ( +

+ {desktopThemeState.status === "missing" + ? "Theme file is missing. Keeping the last valid colors." + : "Theme file is invalid. Keeping the last valid colors."} +

+ ) : null} + {desktopThemeState.status === "unavailable" ? ( +

+ System colors are unavailable. Using T3 Code colors. +

+ ) : null} + {window.desktopBridge?.setLocalThemeFile ? ( +
+ {window.desktopBridge.pickLocalThemeFile ? ( + + ) : null} + {desktopThemeState.filePath ? ( + + ) : null} +
+ ) : null} + {desktopSourceError ? ( +

+ {desktopSourceError} +

+ ) : null} +
+
+ ) : null} {renderPairGrid()} { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0ee55ef3785b4..8d8df3618c456 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -245,7 +245,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "theme", title: "Themes", to: "/settings/appearance", - searchTerms: ["appearance colors palette custom import"], + searchTerms: [ + "appearance colors palette custom import system theme native accent desktop follow file", + ], // Theme cards live directly under the scheme tiles; the section is the // stable scroll destination for both. targetId: "appearance", diff --git a/apps/web/src/desktopTheme.test.ts b/apps/web/src/desktopTheme.test.ts new file mode 100644 index 0000000000000..4bd857cebe16f --- /dev/null +++ b/apps/web/src/desktopTheme.test.ts @@ -0,0 +1,42 @@ +import "culori/css"; +import { formatHex, parse } from "culori/fn"; +import { describe, expect, it } from "vite-plus/test"; +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import { applyDesktopThemeState } from "./desktopTheme"; +import { + getDesktopLocalTheme, + getStandardThemeColors, + getThemeColorsForMode, +} from "./themePalette"; + +const native: DesktopLocalThemeState = { + appearance: "dark", + accent: null, + source: "native", + filePath: null, + status: "ready", + theme: null, +}; +describe("native desktop palettes", () => { + it("uses standard T3 colors for both appearances when no accent is exposed", () => { + applyDesktopThemeState(native); + for (const appearance of ["light", "dark"] as const) { + expect(getThemeColorsForMode(getDesktopLocalTheme(), appearance)).toEqual( + getStandardThemeColors(appearance), + ); + } + }); + it("uses the native accent for actions without replacing app surfaces or error colors", () => { + applyDesktopThemeState({ ...native, accent: "#0055aa" }); + for (const appearance of ["light", "dark"] as const) { + const colors = getThemeColorsForMode(getDesktopLocalTheme(), appearance)!; + const defaults = getStandardThemeColors(appearance); + expect(formatHex(parse(colors.accent))).toBe("#0055aa"); + expect(formatHex(parse(colors.messageAction))).toBe("#0055aa"); + expect(colors.canvas).toBe(defaults.canvas); + expect(colors.sidebar).toBe(defaults.sidebar); + expect(colors.error).toBe(defaults.error); + expect(colors.accentForeground).not.toBe(colors.accent); + } + }); +}); diff --git a/apps/web/src/desktopTheme.ts b/apps/web/src/desktopTheme.ts new file mode 100644 index 0000000000000..ca0ada463728e --- /dev/null +++ b/apps/web/src/desktopTheme.ts @@ -0,0 +1,47 @@ +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import { environmentThemeDefinition } from "./publishedTheme"; +import { + DESKTOP_LOCAL_THEME_ID, + getStandardThemeColors, + updateThemeColorFamily, + setDesktopLocalTheme, + setDesktopThemeState, + type ThemeAppearance, + type ThemeColors, +} from "./themePalette"; + +/** Native desktops expose appearance and accent, rather than a full app palette. */ +export function applyDesktopThemeState(state: DesktopLocalThemeState): boolean { + const nativeColors = (appearance: ThemeAppearance): ThemeColors => { + const defaults = getStandardThemeColors(appearance); + return state.accent === null + ? defaults + : updateThemeColorFamily( + appearance, + updateThemeColorFamily(appearance, defaults, "accent", state.accent), + "messageAction", + state.accent, + ); + }; + const published = + state.theme === null + ? null + : environmentThemeDefinition({ ...state.theme, id: DESKTOP_LOCAL_THEME_ID }); + const light = + published?.appearance === "light" + ? published.colors + : (published?.variants?.light ?? nativeColors("light")); + const dark = + published?.appearance === "dark" + ? published.colors + : (published?.variants?.dark ?? nativeColors("dark")); + const paletteChanged = setDesktopLocalTheme({ + id: DESKTOP_LOCAL_THEME_ID, + label: "System theme", + appearance: state.appearance, + colors: state.appearance === "dark" ? dark : light, + variants: state.appearance === "dark" ? { light } : { dark }, + }); + const stateChanged = setDesktopThemeState(state); + return paletteChanged || stateChanged; +} diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index 01c908f736983..3f5477f5d29ee 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; import { primaryServerSettingsAtom } from "../state/server"; -import { getThemeDefinition, singleAppearanceOf } from "../themePalette"; +import { DESKTOP_LOCAL_THEME_ID, getThemeDefinition, singleAppearanceOf } from "../themePalette"; import { useEnvironmentThemeDefinitions } from "./useEnvironmentTheme"; import { useTheme } from "./useTheme"; @@ -74,7 +74,7 @@ export function useDefaultThemeAdoption(): void { const environmentId = useAtomValue(primaryEnvironmentIdAtom); const settings = useAtomValue(primaryServerSettingsAtom); const { defaultTheme, defaultThemeSetAt } = settings; - const { setTheme, setAppearanceMode } = useTheme(); + const { theme, themeHalves, setTheme, setAppearanceMode } = useTheme(); // Re-runs adoption when a late-arriving published theme makes the // requested id resolvable. const environmentThemes = useEnvironmentThemeDefinitions(); @@ -82,6 +82,18 @@ export function useDefaultThemeAdoption(): void { useEffect(() => { if (typeof window === "undefined" || environmentId === null) return; const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; + if ( + theme === DESKTOP_LOCAL_THEME_ID || + themeHalves?.light === DESKTOP_LOCAL_THEME_ID || + themeHalves?.dark === DESKTOP_LOCAL_THEME_ID + ) { + // Consume a server default ignored while following this desktop, so + // leaving System theme cannot replay it over a later manual choice. + if (defaultTheme.length > 0) { + writeAppliedGeneration(storageKey, defaultThemeGeneration(defaultTheme, defaultThemeSetAt)); + } + return; + } const definition = getThemeDefinition(defaultTheme); const generation = defaultThemeToApply({ environmentId, @@ -105,6 +117,8 @@ export function useDefaultThemeAdoption(): void { if (half !== null && !setAppearanceMode(half)) return; writeAppliedGeneration(storageKey, generation); }, [ + theme, + themeHalves, environmentId, defaultTheme, defaultThemeSetAt, diff --git a/apps/web/src/hooks/useDesktopLocalTheme.test.ts b/apps/web/src/hooks/useDesktopLocalTheme.test.ts new file mode 100644 index 0000000000000..4115cbcd5d9e9 --- /dev/null +++ b/apps/web/src/hooks/useDesktopLocalTheme.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { act, createElement, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { useDesktopLocalThemeSync } from "./useDesktopLocalTheme"; +import { useDefaultThemeAdoption } from "./useDefaultTheme"; +import { readThemeHalves, useTheme } from "./useTheme"; +import { + DESKTOP_LOCAL_THEME_ID, + getDefaultThemeColors, + getThemeColorVariable, + applyThemeColorPreview, + THEME_PREVIEW_ID, +} from "../themePalette"; + +const dark: DesktopLocalThemeState = { + appearance: "dark", + accent: "#abcdef", + source: "file", + filePath: "/themes/current.json", + status: "ready", + theme: { name: "Desktop", appearance: "dark", canvas: "#123456", accent: "#abcdef" }, +}; +const light: DesktopLocalThemeState = { + ...dark, + appearance: "light", + theme: { ...dark.theme!, appearance: "light", canvas: "#f0e8d0" }, +}; +let root: Root | undefined; +let controls: ReturnType; +let notify: (state: DesktopLocalThemeState) => void; +let resolveInitial: (state: DesktopLocalThemeState) => void; +const unsubscribe = vi.fn(); +vi.mock("../state/server", () => ({ primaryServerSettingsAtom: "server-settings" })); +vi.mock("../state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: "environment" })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: string) => + atom === "server-settings" + ? { defaultTheme: "iris", defaultThemeSetAt: "generation-one" } + : "remote-environment", +})); +function DefaultAdoptionHarness() { + useDefaultThemeAdoption(); + return createElement(Harness); +} + +function Harness() { + useDesktopLocalThemeSync(); + const current = useTheme(); + useEffect(() => { + controls = current; + }, [current]); + return null; +} +async function mount(adoptDefault = false) { + root = createRoot(document.createElement("div")); + await act(async () => + root!.render(createElement(adoptDefault ? DefaultAdoptionHarness : Harness)), + ); +} +async function setup(selected = true, deferred = false) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + window.localStorage.clear(); + document.documentElement.removeAttribute("data-theme-id"); + window.localStorage.setItem("t3code:theme", selected ? DESKTOP_LOCAL_THEME_ID : "iris"); + window.localStorage.setItem("t3code:theme-appearance-mode", "system"); + window.localStorage.setItem("draft", "unfinished message"); + const initial = deferred + ? new Promise((resolve) => { + resolveInitial = resolve; + }) + : Promise.resolve(dark); + Object.assign(window, { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + desktopBridge: { + getLocalTheme: () => initial, + onLocalTheme: (listener: typeof notify) => { + notify = listener; + return unsubscribe; + }, + setTheme: vi.fn().mockResolvedValue(undefined), + }, + }); + controls?.refreshTheme(); + await mount(); + return new Map(Object.entries(window.localStorage)); +} +async function publish(state: DesktopLocalThemeState) { + await act(async () => notify(state)); +} +afterEach(async () => { + await act(async () => root?.unmount()); + root = undefined; + unsubscribe.mockClear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("desktop theme selection", () => { + it("follows the selected source without changing saved preferences or drafts", async () => { + const saved = await setup(); + expect(document.documentElement.dataset.themeId).toBe(DESKTOP_LOCAL_THEME_ID); + expect(controls.resolvedTheme).toBe("dark"); + await publish(light); + expect(controls.resolvedTheme).toBe("light"); + expect(new Map(Object.entries(window.localStorage))).toEqual(saved); + }); + it("keeps native discovery opt-in", async () => { + await setup(false); + expect(document.documentElement.dataset.themeId).toBe("iris"); + await publish(light); + expect(document.documentElement.dataset.themeId).toBe("iris"); + }); + it("keeps a manual choice through updates and remounts", async () => { + await setup(); + await act(async () => { + controls.setTheme("iris"); + }); + await publish(light); + expect(document.documentElement.dataset.themeId).toBe("iris"); + await act(async () => root!.unmount()); + await mount(); + expect(document.documentElement.dataset.themeId).toBe("iris"); + }); + it("keeps per-appearance assignments and reset readers literal", async () => { + await setup(); + await act(async () => { + controls.setThemeHalf("light", "grove"); + controls.setThemeHalf("dark", "iris"); + }); + expect(readThemeHalves()).toEqual({ light: "grove", dark: "iris" }); + expect(controls.resolvedTheme).toBe("dark"); + expect(document.documentElement.dataset.themeId).toBe("iris"); + await act(async () => { + controls.setTheme("system"); + controls.clearThemeHalves(); + }); + expect(window.localStorage.getItem("t3code:theme-halves:v1")).toBeNull(); + }); + it("keeps a newer event over a delayed startup response", async () => { + await setup(true, true); + await publish(light); + await act(async () => resolveInitial(dark)); + expect(controls.resolvedTheme).toBe("light"); + }); + it("does not let a delayed startup response change a manual selection", async () => { + await setup(true, true); + await act(async () => { + controls.setTheme("grove"); + }); + await act(async () => resolveInitial(dark)); + expect(document.documentElement.dataset.themeId).toBe("grove"); + }); + it("does not replay a skipped server default after choosing a manual theme", async () => { + await setup(); + await act(async () => root!.unmount()); + await mount(true); + await act(async () => { + controls.setTheme("grove"); + }); + expect(document.documentElement.dataset.themeId).toBe("grove"); + }); + it("preserves editor previews while caching the newest source", async () => { + await setup(); + const colors = { ...getDefaultThemeColors("light"), canvas: "#fedcba" }; + applyThemeColorPreview(colors, "light"); + await publish(light); + expect(document.documentElement.dataset.themeId).toBe(THEME_PREVIEW_ID); + expect(document.documentElement.style.getPropertyValue(getThemeColorVariable("canvas"))).toBe( + colors.canvas, + ); + await act(async () => controls.refreshTheme()); + expect(document.documentElement.dataset.themeId).toBe(DESKTOP_LOCAL_THEME_ID); + expect(controls.resolvedTheme).toBe("light"); + }); +}); diff --git a/apps/web/src/hooks/useDesktopLocalTheme.ts b/apps/web/src/hooks/useDesktopLocalTheme.ts new file mode 100644 index 0000000000000..43cb1619cf27b --- /dev/null +++ b/apps/web/src/hooks/useDesktopLocalTheme.ts @@ -0,0 +1,33 @@ +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import { useEffect } from "react"; +import { applyDesktopThemeState } from "../desktopTheme"; +import { useTheme } from "./useTheme"; + +export function useDesktopLocalThemeSync(): void { + const { refreshTheme } = useTheme(); + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.getLocalTheme || !bridge.onLocalTheme) return; + let active = true; + let eventReceived = false; + const apply = (state: DesktopLocalThemeState) => { + if (active && applyDesktopThemeState(state)) refreshTheme({ preservePreview: true }); + }; + const unsubscribe = bridge.onLocalTheme((state) => { + eventReceived = true; + apply(state); + }); + void bridge + .getLocalTheme() + .then((state) => { + if (!eventReceived) apply(state); + }) + .catch(() => { + // Older or unavailable desktop bridges leave the saved palette intact. + }); + return () => { + active = false; + unsubscribe(); + }; + }, [refreshTheme]); +} diff --git a/apps/web/src/hooks/useEnvironmentTheme.ts b/apps/web/src/hooks/useEnvironmentTheme.ts index 53feabcf6a4f8..eced27df1ea97 100644 --- a/apps/web/src/hooks/useEnvironmentTheme.ts +++ b/apps/web/src/hooks/useEnvironmentTheme.ts @@ -5,93 +5,15 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; import { primaryServerEnvironmentThemesAtom } from "../state/server"; import { - createVividThemeColors, - getDefaultThemeColors, getEnvironmentThemes, - isReservedThemeId, - lenientThemeColorOverrides, setEnvironmentThemes, subscribeToCustomThemes, - type ThemeAppearance, - type ThemeColors, type ThemeDefinition, } from "../themePalette"; import { useTheme } from "./useTheme"; -function publishedThemeColors( - theme: EnvironmentTheme, - appearance: ThemeAppearance, - colors: Readonly> | undefined, -): ThemeColors { - // Seeds generate the base with the guided theme editor's generator, so a - // desktop theme arrives as a coherent T3 Code palette rather than a foreign - // one — but only for the appearance they describe. A variant builds on that - // appearance's stock defaults: the generator follows the seed canvas's - // luminance, so dark seeds would give a light variant unreadable colors. - const base = - appearance === theme.appearance && theme.canvas !== undefined && theme.accent !== undefined - ? createVividThemeColors(appearance, theme.canvas, theme.accent) - : getDefaultThemeColors(appearance); - return { ...base, ...lenientThemeColorOverrides(colors ?? {}) }; -} - -/** - * A published theme as the theme library renders it. Both published forms are - * accepted: the seeded short form a desktop generates, and the standard - * exported theme file the Download button produces — so any theme someone - * shared can be dropped into the machine's themes directory as-is. - */ -export function environmentThemeDefinition(theme: EnvironmentTheme): ThemeDefinition { - const variants: Partial> = {}; - for (const [variantAppearance, variantColors] of Object.entries(theme.variants ?? {})) { - if (variantAppearance === theme.appearance) continue; - variants[variantAppearance as ThemeAppearance] = publishedThemeColors( - theme, - variantAppearance as ThemeAppearance, - variantColors, - ); - } - - return { - id: theme.id, - label: theme.name, - appearance: theme.appearance, - colors: publishedThemeColors(theme, theme.appearance, theme.colors), - ...(Object.keys(variants).length > 0 ? { variants } : {}), - // Only the pure seeded form is guided-generator output. An exported file, - // or seeds carrying explicit role overrides, must open Duplicate in the - // advanced editor -- the guided one regenerates from canvas and accent and - // would discard whatever the machine hand-tuned. - ...(theme.canvas !== undefined && - theme.accent !== undefined && - theme.colors === undefined && - theme.variants === undefined - ? { managed: true } - : {}), - }; -} - -/** - * Published palettes this client can render. Reserved ids are dropped here rather - * than rendered: a published `t3-iris.json` would show this palette on its - * card while "Use" resolved the built-in, and a published `dark.json` would - * capture everyone whose stored preference is the stock `"dark"`. - */ -export function publishedThemeDefinitions( - themes: ReadonlyArray, -): ReadonlyArray { - return themes - .filter((theme) => { - if (isReservedThemeId(theme.id)) return false; - if (theme.canvas !== undefined && theme.accent !== undefined) return true; - const otherAppearance = theme.appearance === "dark" ? "light" : "dark"; - return [theme.colors, theme.variants?.[otherAppearance]].some( - (colors) => - colors !== undefined && Object.keys(lenientThemeColorOverrides(colors)).length > 0, - ); - }) - .map(environmentThemeDefinition); -} +export { environmentThemeDefinition, publishedThemeDefinitions } from "../publishedTheme"; +import { publishedThemeDefinitions } from "../publishedTheme"; /** The published themes as library entries; empty while none are published. */ export function useEnvironmentThemeDefinitions(): ReadonlyArray { diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 7a750fd7434a8..a0d639f25fe17 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -4,6 +4,8 @@ import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; import { applyThemePalette, + DESKTOP_LOCAL_THEME_ID, + getDesktopThemeState, CUSTOM_THEMES_STORAGE_KEY, invalidateCustomThemes, canonicalThemePreference, @@ -145,6 +147,10 @@ function emitChange() { } function getSystemDark() { + const desktop = getDesktopThemeState(); + if (desktop !== null && getStored() === DESKTOP_LOCAL_THEME_ID) { + return desktop.appearance === "dark"; + } return ( typeof window !== "undefined" && typeof window.matchMedia === "function" && diff --git a/apps/web/src/publishedTheme.ts b/apps/web/src/publishedTheme.ts new file mode 100644 index 0000000000000..2cad53ec0aae4 --- /dev/null +++ b/apps/web/src/publishedTheme.ts @@ -0,0 +1,85 @@ +import type { EnvironmentTheme } from "@t3tools/contracts"; +import { + createVividThemeColors, + getDefaultThemeColors, + isReservedThemeId, + lenientThemeColorOverrides, + type ThemeAppearance, + type ThemeColors, + type ThemeDefinition, +} from "./themePalette"; + +function publishedThemeColors( + theme: EnvironmentTheme, + appearance: ThemeAppearance, + colors: Readonly> | undefined, +): ThemeColors { + // Seeds generate the base with the guided theme editor's generator, so a + // desktop theme arrives as a coherent T3 Code palette rather than a foreign + // one — but only for the appearance they describe. A variant builds on that + // appearance's stock defaults: the generator follows the seed canvas's + // luminance, so dark seeds would give a light variant unreadable colors. + const base = + appearance === theme.appearance && theme.canvas !== undefined && theme.accent !== undefined + ? createVividThemeColors(appearance, theme.canvas, theme.accent) + : getDefaultThemeColors(appearance); + return { ...base, ...lenientThemeColorOverrides(colors ?? {}) }; +} + +/** + * A published theme as the theme library renders it. Both published forms are + * accepted: the seeded short form a desktop generates, and the standard + * exported theme file the Download button produces — so any theme someone + * shared can be dropped into the machine's themes directory as-is. + */ +export function environmentThemeDefinition(theme: EnvironmentTheme): ThemeDefinition { + const variants: Partial> = {}; + for (const [variantAppearance, variantColors] of Object.entries(theme.variants ?? {})) { + if (variantAppearance === theme.appearance) continue; + variants[variantAppearance as ThemeAppearance] = publishedThemeColors( + theme, + variantAppearance as ThemeAppearance, + variantColors, + ); + } + + return { + id: theme.id, + label: theme.name, + appearance: theme.appearance, + colors: publishedThemeColors(theme, theme.appearance, theme.colors), + ...(Object.keys(variants).length > 0 ? { variants } : {}), + // Only the pure seeded form is guided-generator output. An exported file, + // or seeds carrying explicit role overrides, must open Duplicate in the + // advanced editor -- the guided one regenerates from canvas and accent and + // would discard whatever the machine hand-tuned. + ...(theme.canvas !== undefined && + theme.accent !== undefined && + theme.colors === undefined && + theme.variants === undefined + ? { managed: true } + : {}), + }; +} + +/** + * Published palettes this client can render. Reserved ids are dropped here rather + * than rendered: a published `t3-iris.json` would show this palette on its + * card while "Use" resolved the built-in, and a published `dark.json` would + * capture everyone whose stored preference is the stock `"dark"`. + */ +export function publishedThemeDefinitions( + themes: ReadonlyArray, +): ReadonlyArray { + return themes + .filter((theme) => { + if (isReservedThemeId(theme.id)) return false; + if (theme.canvas !== undefined && theme.accent !== undefined) return true; + const otherAppearance = theme.appearance === "dark" ? "light" : "dark"; + return [theme.colors, theme.variants?.[otherAppearance]].some( + (colors) => + colors !== undefined && Object.keys(lenientThemeColorOverrides(colors)).length > 0, + ); + }) + .map(environmentThemeDefinition); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 91a0a15800785..528ba3f8379fa 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -42,6 +42,7 @@ import { ProviderAuthCallbackCoordinator } from "../components/settings/Provider import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; +import { useDesktopLocalThemeSync } from "../hooks/useDesktopLocalTheme"; import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme"; import { Button } from "../components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -141,6 +142,7 @@ function RootRouteNotFoundView() { } function RootRouteView() { + useDesktopLocalThemeSync(); useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []); const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index da9af454c5a25..4fafa3521588e 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,3 +1,4 @@ +import type { DesktopLocalThemeState } from "@t3tools/contracts"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import "culori/css"; @@ -247,6 +248,39 @@ export function getCustomThemes(): ReadonlyArray { return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES; } +export const DESKTOP_LOCAL_THEME_ID = "desktop-local-theme"; +// Resolve a saved desktop selection before its first asynchronous snapshot. +let desktopLocalTheme: ThemeDefinition = { + id: DESKTOP_LOCAL_THEME_ID, + label: "System theme", + appearance: "light", + colors: T3_CODE_LIGHT_THEME_COLORS, + variants: { dark: T3_CODE_DARK_THEME_COLORS }, +}; +let desktopThemeState: DesktopLocalThemeState | null = null; + +export function getDesktopThemeState(): DesktopLocalThemeState | null { + return desktopThemeState; +} + +export function setDesktopThemeState(state: DesktopLocalThemeState | null): boolean { + if (Equal.equals(desktopThemeState, state)) return false; + desktopThemeState = state; + notifyCustomThemeListeners(); + return true; +} + +export function getDesktopLocalTheme(): ThemeDefinition { + return desktopLocalTheme; +} + +export function setDesktopLocalTheme(theme: ThemeDefinition): boolean { + if (Equal.equals(desktopLocalTheme, theme)) return false; + desktopLocalTheme = theme; + notifyCustomThemeListeners(); + return true; +} + export function getEnvironmentThemes(): ReadonlyArray { return environmentThemeDefinitions; } @@ -1091,6 +1125,7 @@ const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = BUILT_IN_THEM export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null { const themeId = themeIdFromPreference(theme); + if (desktopLocalTheme?.id === themeId) return desktopLocalTheme; return ( BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ?? getCustomThemes().find((definition) => definition.id === themeId) ?? diff --git a/docs/user/appearance.md b/docs/user/appearance.md index ec02a28ed787a..cb20df6ef97f9 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -93,3 +93,27 @@ view. Write updates to a temporary file and rename it into place so clients never read a partial theme. Invalid files are not published. + +## Desktop colors + +On desktop, select **System theme** in **Settings → Appearance** to match your computer's +appearance and available accent color. Other surfaces keep T3 Code's standard colors. +If the desktop does not provide an accent, the standard accent is used. This works with local +environments disabled and remote server connections. Existing selections stay unchanged until +you choose System theme. + +Omarchy's generated T3 Code palette is detected automatically on Linux. When available, +System theme follows its full palette, including code and terminal colors. No launcher, +environment variable, or separate theme broker is required. + +For custom matching, import or create themes and assign their light and dark previews. +Choose **System** appearance to switch between them with your desktop. Alternatively, choose +**Choose theme file** beside System theme to follow JSON exported by T3 Code or produced by +another theme tool. The selected path and file status are shown in Settings; **Use automatic +colors** returns to desktop detection. Changing the source does not replace a manually selected +theme. Select System theme to use it. + +Replace files atomically. Missing or invalid files retain the last valid palette from that +source, or use native colors until a valid palette arrives. Files must be regular files no +larger than 32 KiB; symbolic links to files are not followed. Choosing another theme persists +through file updates, navigation, and app restarts. Web and mobile clients are unaffected. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e521c4c37271c..23a7dc117b51b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,4 +1,15 @@ import * as Schema from "effect/Schema"; +import { EnvironmentThemeFile } from "./server.ts"; + +export const DesktopLocalThemeStateSchema = Schema.Struct({ + appearance: Schema.Literals(["light", "dark"]), + accent: Schema.NullOr(Schema.String), + source: Schema.Literals(["native", "omarchy", "file"]), + filePath: Schema.NullOr(Schema.String), + status: Schema.Literals(["ready", "missing", "invalid", "unavailable"]), + theme: Schema.NullOr(EnvironmentThemeFile), +}); +export type DesktopLocalThemeState = typeof DesktopLocalThemeStateSchema.Type; import { SnapShotSource } from "./chatAttachment.ts"; import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -1116,6 +1127,10 @@ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { + getLocalTheme?: () => Promise; + setLocalThemeFile?: (path: string | null) => Promise; + pickLocalThemeFile?: () => Promise; + onLocalTheme?: (listener: (state: DesktopLocalThemeState) => void) => () => void; getAppBranding: () => DesktopAppBranding | null; /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ getPathForFile?: (file: File) => string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 98c9379527675..e56f158f02b85 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -486,6 +486,10 @@ "./PtyAdapter": { "types": "./src/PtyAdapter.ts", "import": "./src/PtyAdapter.ts" + }, + "./themeFile": { + "types": "./src/themeFile.ts", + "import": "./src/themeFile.ts" } }, "scripts": { diff --git a/packages/shared/src/themeFile.ts b/packages/shared/src/themeFile.ts new file mode 100644 index 0000000000000..866e92b08b052 --- /dev/null +++ b/packages/shared/src/themeFile.ts @@ -0,0 +1,51 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Guarded reads require descriptor flags unavailable in FileSystem. +import * as NodeFS from "node:fs"; + +export const MAX_THEME_FILE_BYTES = 32 * 1024; + +/** + * Reads a theme file through one opened handle, so every check binds to the + * file actually read rather than to a path that may have been swapped since: + * O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays + * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from + * blocking the open, and the fstat type and size gate examines the open + * descriptor. Returns null for anything that is not a small regular file. + * + * Windows has neither flag (the constants are undefined, and OR-ing them in + * is a no-op), so the symlink check there is an lstat before the open. That + * leaves a window a swap could slip through, which the descriptor-bound + * checks below then narrow to "a regular file at that path". + */ + +export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { + let fd: number; + try { + if (NodeFS.constants.O_NOFOLLOW === undefined && NodeFS.lstatSync(filePath).isSymbolicLink()) { + return null; + } + fd = NodeFS.openSync( + filePath, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), + ); + } catch { + return null; + } + try { + const info = NodeFS.fstatSync(fd); + if (!info.isFile() || info.size > maxBytes) return null; + const contents = Buffer.alloc(info.size); + let offset = 0; + while (offset < contents.length) { + const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset); + if (read <= 0) break; + offset += read; + } + return contents.subarray(0, offset).toString("utf8"); + } catch { + return null; + } finally { + NodeFS.closeSync(fd); + } +}; diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index d6d7979de5507..76496d24f9018 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -18,6 +18,7 @@ export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] */ export const RESERVED_THEME_IDS: ReadonlySet = new Set([ "system", + "desktop-local-theme", "light", "dark", ...BUILT_IN_THEME_IDS, diff --git a/patches/query-string@7.1.3.patch b/patches/query-string@7.1.3.patch new file mode 100644 index 0000000000000..916a641bf3d87 --- /dev/null +++ b/patches/query-string@7.1.3.patch @@ -0,0 +1,11 @@ +diff --git a/index.js b/index.js +index 4cf3c5e..3c4f9d3 100644 +--- a/index.js ++++ b/index.js +@@ -1,5 +1,5 @@ + 'use strict'; + const strictUriEncode = require('strict-uri-encode'); +-const decodeComponent = require('decode-uri-component'); ++const decodeComponent = require('decode-uri-component').default; + const splitOnFirst = require('split-on-first'); + const filterObject = require('filter-obj'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebcf31218b8c9..f9f33861488f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,18 @@ catalogs: version: 11.0.5 overrides: + '@modelcontextprotocol/sdk@1.29.0': ^1.31.0 + proxy-addr@2.0.7: ^2.0.8 + seroval@1.5.4: ^1.6.3 + shell-quote@1.10.0: ^1.11.0 + sharp@0.35.4: ^0.35.5 + source-map-js@1.2.1: ^1.2.2 + smol-toml@1.8.0: ^1.9.0 + uuid@7.0.3: ^11.1.1 + valibot@1.2.0: ^1.4.2 + xml2js@0.4.23: ^0.6.0 + katex@0.16.47: ^0.18.2 + decode-uri-component@0.2.2: ^0.5.0 '@exodus/bytes>@noble/hashes': 1.8.0 '@react-navigation/core': 7.21.13 dbus-next>usocket: '-' @@ -165,6 +177,7 @@ patchedDependencies: expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 + query-string@7.1.3: cfe5f429f2bd1e7155cd91c78734a8e474af385f5443ae87f8bff1a8511c4592 react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 @@ -230,6 +243,9 @@ importers: '@t3tools/tailscale': specifier: workspace:* version: link:../../packages/tailscale + culori: + specifier: ^4.0.2 + version: 4.0.2 dbus-next: specifier: 0.10.2 version: 0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4) @@ -264,6 +280,9 @@ importers: '@effect/vitest': specifier: 4.0.2 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -292,8 +311,8 @@ importers: specifier: ^7.3.5 version: 7.3.5(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@24.12.4) + specifier: ^0.35.5 + version: 0.35.5(@types/node@24.12.4) devDependencies: '@astrojs/check': specifier: ^0.9.10 @@ -531,7 +550,7 @@ importers: version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) react-native-enriched-markdown: specifier: file:deps/react-native-enriched-markdown-1.2.0-t3.11.tgz - version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.18.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-gesture-handler: specifier: ~3.2.1 version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -613,7 +632,7 @@ importers: dependencies: '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.276 - version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5) + version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5))(zod@4.6.5) '@cursor/sdk': specifier: 1.0.35 version: 1.0.35 @@ -712,8 +731,8 @@ importers: specifier: 4.0.2 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0(zod@4.6.5) + specifier: ^1.31.0 + version: 1.31.0(zod@4.6.5) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -1029,10 +1048,10 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.80 - version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ba3b2ceb33761bc36b3beb11002eb316) + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(49f496572593f908642e975f9dd4f1ba) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(da2d75dec181fbafaeb6986c8741457b) + version: 1.0.0-rc.5-ab785fc(59bf16287a03ba2888d338222244ec2f) effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) @@ -1864,8 +1883,8 @@ importers: specifier: 7.0.0 version: 7.0.0 sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@24.12.4) + specifier: ^0.35.5 + version: 0.35.5(@types/node@24.12.4) devDependencies: '@effect/vitest': specifier: 4.0.2 @@ -1958,7 +1977,7 @@ packages: engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' - '@modelcontextprotocol/sdk': ^1.29.0 + '@modelcontextprotocol/sdk': ^1.31.0 zod: ^4.0.0 '@anthropic-ai/sdk@0.93.0': @@ -3921,160 +3940,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -4239,8 +4258,8 @@ packages: '@mermaid-js/parser@1.2.1': resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.31.0': + resolution: {integrity: sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -8045,9 +8064,9 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - decode-uri-component@0.2.2: - resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} - engines: {node: '>=0.10'} + decode-uri-component@0.5.0: + resolution: {integrity: sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==} + engines: {node: '>=14.16'} decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} @@ -8223,7 +8242,7 @@ packages: sql.js: '>=1' sqlite3: '>=5' typebox: '>=1.2.0' - valibot: '>=1.0.0-beta.7' + valibot: ^1.4.2 zod: ^3.25.0 || ^4.0.0 peerDependenciesMeta: '@aws-sdk/client-rds-data': @@ -9527,8 +9546,8 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - katex@0.16.47: - resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + katex@0.18.2: + resolution: {integrity: sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==} hasBin: true keyv@4.5.4: @@ -10748,8 +10767,8 @@ packages: prosemirror-view@1.42.3: resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} pump@3.0.4: @@ -10846,7 +10865,7 @@ packages: resolution: {integrity: sha512-flSZglM98BMtQHffAu1HVq8Y295sff1aT9vMdUZMnSj/3QojV21DSlSITNNhPAArIsAr1rdUrLhipggO8TvfIg==, tarball: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz} version: 1.2.0-t3.11 peerDependencies: - katex: '>=0.16.0' + katex: ^0.18.2 react: '*' react-native: '*' peerDependenciesMeta: @@ -11212,10 +11231,10 @@ packages: resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} peerDependencies: - seroval: ^1.0 + seroval: ^1.6.3 - seroval@1.5.4: - resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + seroval@1.6.3: + resolution: {integrity: sha512-5PwLbVPpT4DEki1hYVSSsoxWY6xCNMVucAVLK/CH0mXSDfp/I4KaAex72q80GNt5hpn7CCMGdSpIg4YGqH/wXQ==} engines: {node: '>=10'} serve-static@1.16.3: @@ -11236,8 +11255,8 @@ packages: resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} engines: {node: '>=10'} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -11253,8 +11272,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.10.0: - resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + shell-quote@1.11.0: + resolution: {integrity: sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==} engines: {node: '>= 0.4'} shiki@4.2.0: @@ -11330,16 +11349,16 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.8.0: - resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} socks@2.8.9: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -11939,23 +11958,14 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@14.0.2: - resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true - uuid@7.0.3: - resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} hasBin: true - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -12275,10 +12285,6 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} - xml2js@0.4.23: - resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==} - engines: {node: '>=4.0.0'} - xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -12444,7 +12450,7 @@ snapshots: effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) magic-string: 0.30.21 mime: 4.1.0 - sharp: 0.35.4(@types/node@24.12.4) + sharp: 0.35.5(@types/node@24.12.4) unenv: 2.0.0-rc.24 workerd: 1.20260918.1 yauzl: 3.4.0 @@ -12476,10 +12482,10 @@ snapshots: package-manager-detector: 1.8.0 tinyexec: 1.3.1 - '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5)': + '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5))(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.6.5) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.31.0(zod@4.6.5) zod: 4.6.5 '@anthropic-ai/sdk@0.93.0(zod@4.6.5)': @@ -12575,7 +12581,7 @@ snapshots: picomatch: 4.0.7 retext-smartypants: 6.2.0 shiki: 4.2.0 - smol-toml: 1.8.0 + smol-toml: 1.9.0 unified: 11.0.5 '@astrojs/language-server@2.16.10(prettier@3.8.3)(typescript@6.0.3)': @@ -14840,108 +14846,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@inquirer/ansi@1.0.2': @@ -15120,7 +15126,7 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@modelcontextprotocol/sdk@1.29.0(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.31.0(zod@4.6.5)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 @@ -16078,7 +16084,7 @@ snapshots: escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.19 - query-string: 7.1.3 + query-string: 7.1.3(patch_hash=cfe5f429f2bd1e7155cd91c78734a8e474af385f5443ae87f8bff1a8511c4592) react: 19.3.0 react-is: 19.2.7 use-latest-callback: 0.2.6(react@19.3.0) @@ -16419,7 +16425,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.33.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -16525,8 +16531,8 @@ snapshots: dependencies: '@tanstack/history': 1.162.0 cookie-es: 3.1.1 - seroval: 1.5.4 - seroval-plugins: 1.5.4(seroval@1.5.4) + seroval: 1.6.3 + seroval-plugins: 1.5.4(seroval@1.6.3) '@tanstack/router-generator@1.167.12': dependencies: @@ -17610,7 +17616,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ba3b2ceb33761bc36b3beb11002eb316): + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(49f496572593f908642e975f9dd4f1ba): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)))(@effect/platform-node@4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)) @@ -17653,7 +17659,7 @@ snapshots: '@effect/sql-pg': 4.0.2(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)) '@effect/vitest': 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(da2d75dec181fbafaeb6986c8741457b) + drizzle-orm: 1.0.0-rc.5-ab785fc(59bf16287a03ba2888d338222244ec2f) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) pg: 8.23.0 vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' @@ -17819,7 +17825,7 @@ snapshots: piccolore: 0.1.3 picomatch: 4.0.7 shiki: 4.2.0 - smol-toml: 1.8.0 + smol-toml: 1.9.0 svgo: 4.1.0 tinyclip: 1.0.3 tinyexec: 1.3.1 @@ -17834,7 +17840,7 @@ snapshots: yargs-parser: 22.0.0 zod: 4.6.5 optionalDependencies: - sharp: 0.35.4(@types/node@24.12.4) + sharp: 0.35.5(@types/node@24.12.4) transitivePeerDependencies: - '@arethetypeswrong/core' - '@azure/app-configuration' @@ -18438,12 +18444,12 @@ snapshots: css-tree@2.2.1: dependencies: mdn-data: 2.0.28 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-what@6.2.2: {} @@ -18660,7 +18666,7 @@ snapshots: jsbi: 2.0.5 long: 4.0.0 safe-buffer: 5.2.1 - xml2js: 0.4.23 + xml2js: 0.6.0 debounce-fn@4.0.0: dependencies: @@ -18684,7 +18690,7 @@ snapshots: dependencies: character-entities: 2.0.2 - decode-uri-component@0.2.2: {} + decode-uri-component@0.5.0: {} decompress-response@6.0.0: dependencies: @@ -18809,7 +18815,7 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(da2d75dec181fbafaeb6986c8741457b): + drizzle-orm@1.0.0-rc.5-ab785fc(59bf16287a03ba2888d338222244ec2f): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 '@effect/sql-d1': 4.0.0(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)) @@ -18822,7 +18828,6 @@ snapshots: effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) expo-sqlite: 58.0.8(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) pg: 8.23.0 - valibot: 1.2.0(typescript@7.0.2) zod: 4.4.3 dset@3.1.4: {} @@ -19522,7 +19527,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0 @@ -20278,7 +20283,7 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 - katex@0.16.47: + katex@0.18.2: dependencies: commander: 8.3.0 @@ -20301,7 +20306,7 @@ snapshots: oxc-parser: 0.147.0 oxc-resolver: 11.24.2 picomatch: 4.0.7 - smol-toml: 1.8.0 + smol-toml: 1.9.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 unbash: 4.0.11 @@ -20464,7 +20469,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 makeerror@1.0.12: dependencies: @@ -20689,7 +20694,7 @@ snapshots: dompurify: 3.4.16 es-toolkit: 1.52.0 fastdom: 1.0.12 - katex: 0.16.47 + katex: 0.18.2 khroma: 2.1.0 marked: 16.4.2 roughjs: 4.6.6 @@ -21794,7 +21799,7 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: optional: true @@ -21949,7 +21954,7 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.1 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -21972,9 +21977,9 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 - query-string@7.1.3: + query-string@7.1.3(patch_hash=cfe5f429f2bd1e7155cd91c78734a8e474af385f5443ae87f8bff1a8511c4592): dependencies: - decode-uri-component: 0.2.2 + decode-uri-component: 0.5.0 filter-obj: 1.1.0 split-on-first: 1.1.0 strict-uri-encode: 2.0.0 @@ -22002,7 +22007,7 @@ snapshots: react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: - shell-quote: 1.10.0 + shell-quote: 1.11.0 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -22053,12 +22058,12 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.18.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) optionalDependencies: - katex: 0.16.47 + katex: 0.18.2 react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: @@ -22616,11 +22621,11 @@ snapshots: type-fest: 0.13.1 optional: true - seroval-plugins@1.5.4(seroval@1.5.4): + seroval-plugins@1.5.4(seroval@1.6.3): dependencies: - seroval: 1.5.4 + seroval: 1.6.3 - seroval@1.5.4: {} + seroval@1.6.3: {} serve-static@1.16.3: dependencies: @@ -22646,37 +22651,37 @@ snapshots: sf-symbols-typescript@2.2.0: {} - sharp@0.35.4(@types/node@24.12.4): + sharp@0.35.5(@types/node@24.12.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 24.12.4 shebang-command@2.0.0: @@ -22685,7 +22690,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.10.0: {} + shell-quote@1.11.0: {} shiki@4.2.0: dependencies: @@ -22785,7 +22790,7 @@ snapshots: smart-buffer@4.2.0: optional: true - smol-toml@1.8.0: {} + smol-toml@1.9.0: {} socks@2.8.9: dependencies: @@ -22793,7 +22798,7 @@ snapshots: smart-buffer: 4.2.0 optional: true - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.21: dependencies: @@ -23332,14 +23337,9 @@ snapshots: utils-merge@1.0.1: {} - uuid@14.0.2: {} - - uuid@7.0.3: {} + uuid@11.1.1: {} - valibot@1.2.0(typescript@7.0.2): - optionalDependencies: - typescript: 7.0.2 - optional: true + uuid@14.0.2: {} validate-npm-package-name@5.0.1: {} @@ -23728,18 +23728,13 @@ snapshots: xcode@3.0.1: dependencies: simple-plist: 1.3.1 - uuid: 7.0.3 + uuid: 11.1.1 xml-name-validator@5.0.0: {} xml-naming@0.1.0: optional: true - xml2js@0.4.23: - dependencies: - sax: 1.6.1 - xmlbuilder: 11.0.1 - xml2js@0.6.0: dependencies: sax: 1.6.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 86279b45106c2..24bfa46238206 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -193,6 +193,19 @@ minimumReleaseAgeExclude: - "@muse-code/sdk@1.4.6" overrides: + # Fixed minimum versions keep compatible upstream patch/minor updates available. + "@modelcontextprotocol/sdk@1.29.0": "^1.31.0" + "proxy-addr@2.0.7": "^2.0.8" + "seroval@1.5.4": "^1.6.3" + "shell-quote@1.10.0": "^1.11.0" + "sharp@0.35.4": "^0.35.5" + "source-map-js@1.2.1": "^1.2.2" + "smol-toml@1.8.0": "^1.9.0" + "uuid@7.0.3": "^11.1.1" + "valibot@1.2.0": "^1.4.2" + "xml2js@0.4.23": "^0.6.0" + "katex@0.16.47": "^0.18.2" + "decode-uri-component@0.2.2": "^0.5.0" # Keep jsdom's optional peer consistent so Vite+ has one test-runner instance. "@exodus/bytes>@noble/hashes": "1.8.0" # Earlier React Navigation declarations create a circular type alias in TypeScript 7. @@ -288,6 +301,8 @@ packageExtensions: vite-plus: "catalog:" patchedDependencies: + # Preserve React Navigation's query-string API while using its fixed ESM decoder. + query-string@7.1.3: patches/query-string@7.1.3.patch alchemy@2.0.0-beta.80: patches/alchemy@2.0.0-beta.80.patch expo-glass-effect@58.0.3: patches/expo-glass-effect@58.0.3.patch expo-blur@58.0.3: patches/expo-blur@58.0.3.patch